修改完成待测试

This commit is contained in:
2026-09-13 17:45:47 +08:00
parent 9ad572907b
commit bbc024f7f2
80 changed files with 3844 additions and 1672 deletions
+26 -7
View File
@@ -197,6 +197,8 @@
</view>
<!-- #endif -->
<LoginAdvertisement />
<view class="register-entry">
<text>还没有账号</text>
<button
@@ -278,6 +280,7 @@
import { ref } from "vue";
import { onBackPress, onShow, onUnload } from "@dcloudio/uni-app";
import AuthPageShell from "@/components/auth/PageShell.vue";
import LoginAdvertisement from "@/components/auth/LoginAdvertisement.vue";
import AppToast from "@/components/AppToast.vue";
import TacVerification from "@/components/auth/TacVerification.vue";
import {
@@ -777,11 +780,23 @@ const submitWechatLogin = async () => {
</script>
<style scoped lang="scss">
.login-page {
height: var(--app-viewport-height);
min-height: 0;
max-height: var(--app-viewport-height);
overflow: hidden;
}
.login-page :deep(.auth-shell__paper) {
min-height: 0;
overflow: hidden;
}
.login-content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
justify-content: flex-start;
box-sizing: border-box;
width: 100%;
max-width: 480px;
@@ -817,6 +832,7 @@ const submitWechatLogin = async () => {
display: flex;
align-items: flex-end;
min-height: var(--app-touch-min);
margin-top: 6rpx;
}
.login-tab {
@@ -825,7 +841,7 @@ const submitWechatLogin = async () => {
align-items: center;
justify-content: center;
min-height: 88rpx;
color: #80684f;
color: #5d493b;
font-size: clamp(19px, 34rpx, 24px);
letter-spacing: 2rpx;
}
@@ -893,7 +909,7 @@ const submitWechatLogin = async () => {
}
.input-placeholder {
color: #9f968d;
color: #766c63;
}
.password-toggle {
@@ -925,7 +941,7 @@ const submitWechatLogin = async () => {
}
.get-code--disabled {
color: #9f968d;
color: #766c63;
}
.form-secondary-row {
@@ -945,6 +961,7 @@ const submitWechatLogin = async () => {
.login-submit {
display: grid;
margin-top: 26rpx;
place-items: center;
}
@@ -973,6 +990,7 @@ const submitWechatLogin = async () => {
align-items: center;
justify-content: center;
min-height: var(--app-touch-min);
margin-top: 8rpx;
color: #493323;
font-size: clamp(16px, 28rpx, 20px);
}
@@ -987,8 +1005,9 @@ const submitWechatLogin = async () => {
.agreement-area {
position: relative;
margin-top: 2rpx;
margin-bottom: 48rpx;
color: #58483c;
color: #493323;
}
.agreement-row {
@@ -1041,7 +1060,7 @@ const submitWechatLogin = async () => {
}
.third-party-login__divider {
color: rgba(76, 57, 41, 0.62);
color: #5f4b3c;
font-size: clamp(12px, 21rpx, 15px);
}
@@ -1059,7 +1078,7 @@ const submitWechatLogin = async () => {
}
.wechat-login[disabled] {
opacity: 0.52;
opacity: 0.68;
}
.wechat-login__mark {
+6
View File
@@ -42,6 +42,11 @@
<text class="article-card__content">{{
article.contentProtected && !article.contentUnlocked ? "" : article.content || "作者暂未填写正文。"
}}</text>
<FamilyFeedMedia
v-if="!article.contentProtected || article.contentUnlocked"
:files="article.mediaFiles"
image-label="查看谱文正文图片"
/>
<text class="article-card__views">阅读 {{ article.viewCount }} </text>
<view
v-if="article.canEdit || article.canDelete || article.canManageProtection"
@@ -130,6 +135,7 @@ import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ContentPasswordRecoveryDialog from "@/components/ContentPasswordRecoveryDialog.vue";
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
+187 -8
View File
@@ -94,6 +94,35 @@
/>
</view>
</view>
<view class="editor-field editor-field--media">
<view>
<text class="editor-field__label">正文图片</text>
<text class="editor-field__hint">图片按添加顺序显示可单独移除</text>
</view>
<button
class="upload-button"
:disabled="uploading || isSubmitting"
@click="uploadArticleMedia"
>
{{ uploading ? "上传中…" : "添加正文图片" }}
</button>
<view v-if="mediaReceipts.length" class="body-media-list">
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="body-media-item"
>
<text>{{ index + 1 }}. {{ receipt.fileName || "正文图片" }}</text>
<button
class="remove-media-button"
:disabled="uploading || isSubmitting"
:aria-label="`移除第${index + 1}张正文图片`"
@click="removeArticleMedia(index)"
>移除</button>
</view>
</view>
<text v-if="mediaUploadError" class="editor-save-error">{{ mediaUploadError }}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">作者名称</text>
<view class="editor-control"
@@ -104,6 +133,30 @@
@input="submitError = ''"
/></view>
</view>
<view v-if="!isEdit" class="editor-field">
<text class="editor-field__label">内容密码选填</text>
<text class="editor-field__hint">填写后会先创建谱文再立即启用密码保护密码设置失败时会明确提示</text>
<view class="editor-control">
<input
v-model="contentPassword"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
placeholder-class="editor-placeholder"
@input="submitError = ''"
/>
</view>
<view class="editor-control editor-control--password-confirm">
<input
v-model="contentPasswordConfirm"
password
maxlength="128"
placeholder="请再次输入内容密码"
placeholder-class="editor-placeholder"
@input="submitError = ''"
/>
</view>
</view>
<text v-if="submitError" class="editor-save-error">{{
submitError
}}</text>
@@ -178,8 +231,14 @@ const uploading = ref(false);
const discardVisible = ref(false);
const submitError = ref("");
const uploadError = ref("");
const mediaUploadError = ref("");
const coverOssId = ref(null);
const coverFileName = ref("");
const mediaReceipts = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const passwordSetupError = ref("");
const passwordProtectionEnabled = ref(false);
const categoryOptionsState = ref("loading");
const categoryOptions = ref([]);
const preservedUpdateFields = ref({ sortOrder: null, status: "" });
@@ -194,7 +253,9 @@ const form = reactive({
const articleDetailController = createRequestController();
const articleCategoryController = createRequestController();
const articleCoverUploadController = createRequestController();
const articleMediaUploadController = createRequestController();
const articleSaveController = createRequestController();
const articlePasswordController = createRequestController();
const articleCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const categoryOptionLabels = computed(() => ["不设置分类", ...categoryOptions.value.map((item) => item.name)]);
@@ -204,13 +265,20 @@ const categoryOptionIndex = computed(() => {
});
const categoryOptionLabel = computed(() => categoryOptionLabels.value[categoryOptionIndex.value] || "不设置分类");
const isEdit = computed(() => mode.value === "edit");
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() =>
JSON.stringify({ ...form, coverOssId: coverOssId.value || "" }),
JSON.stringify({ ...form, coverOssId: coverOssId.value || "", mediaOssIds: mediaOssIds.value }),
);
const isDirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Boolean(Object.values(form).some((value) => value.trim()) || coverOssId.value),
: Boolean(
Object.values(form).some((value) => value.trim()) ||
coverOssId.value ||
mediaReceipts.value.length ||
contentPassword.value ||
contentPasswordConfirm.value
),
);
const hasValidContext = computed(
() =>
@@ -219,10 +287,19 @@ const hasValidContext = computed(
);
const resultCopy = computed(() =>
editorState.value === "success"
? {
? passwordSetupError.value
? {
eyebrow: "谱文已创建",
title: "密码保护尚未确认",
copy: passwordSetupError.value,
action: "打开谱文详情",
}
: {
eyebrow: "保存成功",
title: isEdit.value ? "谱文已更新" : "谱文已提交",
copy: "已保存,返回后会显示最新内容。",
copy: !isEdit.value && passwordProtectionEnabled.value
? "谱文已保存,并已启用内容密码。"
: "已保存,返回后会显示最新内容。",
action: isEdit.value ? "返回谱文详情" : "返回谱文列表",
}
: editorState.value === "error"
@@ -313,6 +390,10 @@ const loadArticleForEdit = async () => {
coverFileName.value = article.coverFile
? article.coverFile.fileName || "当前封面图片"
: "";
mediaReceipts.value = article.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName || "正文图片",
}));
preservedUpdateFields.value = {
sortOrder: article.sortOrder,
status: article.status,
@@ -352,6 +433,35 @@ const clearCover = () => {
uploadError.value = "";
};
const uploadArticleMedia = async () => {
if (uploading.value || isSubmitting.value) return;
uploading.value = true;
mediaUploadError.value = "";
try {
const receipt = await pickAndUploadImage({
requestController: articleMediaUploadController,
});
if (!pageActive) return;
if (mediaReceipts.value.some((item) => item.ossId === receipt.ossId)) {
mediaUploadError.value = "这张图片已经添加。";
return;
}
mediaReceipts.value = [...mediaReceipts.value, receipt];
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
mediaUploadError.value = getRequestErrorMessage(error, "正文图片上传失败,请稍后重试。");
}
} finally {
if (pageActive) uploading.value = false;
}
};
const removeArticleMedia = (index) => {
if (uploading.value || isSubmitting.value || !Number.isInteger(index)) return;
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
mediaUploadError.value = "";
};
const saveArticle = async () => {
if (isSubmitting.value || uploading.value || !hasValidContext.value) return;
if (!form.articleTitle.trim() || !form.articleContent.trim()) {
@@ -363,6 +473,7 @@ const saveArticle = async () => {
const payload = {
...form,
coverOssId: coverOssId.value,
mediaOssIds: mediaOssIds.value,
...(isEdit.value ? preservedUpdateFields.value : {}),
};
const createAttempt = isEdit.value ? null : articleCreateGuard.begin(payload);
@@ -371,18 +482,48 @@ const saveArticle = async () => {
"上次提交结果暂时无法确认,请先返回谱文列表检查,避免重复创建。";
return;
}
if (contentPassword.value || contentPasswordConfirm.value) {
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
submitError.value = "内容密码必须为8至128位。";
return;
}
if (contentPassword.value !== contentPasswordConfirm.value) {
submitError.value = "两次输入的内容密码不一致。";
return;
}
}
isSubmitting.value = true;
submitError.value = "";
passwordSetupError.value = "";
try {
if (isEdit.value) {
await familyArticleApi.updateArticle(genealogyId.value, articleId.value, payload, {
requestController: articleSaveController,
});
} else {
await familyArticleApi.createArticle(genealogyId.value, payload, {
const createdArticle = await familyArticleApi.createArticle(genealogyId.value, payload, {
requestController: articleSaveController,
});
if (!pageActive) return;
articleId.value = createdArticle.id;
if (contentPassword.value) {
try {
await familyArticleApi.setArticlePassword(
genealogyId.value,
createdArticle.id,
contentPassword.value,
{ requestController: articlePasswordController },
);
passwordProtectionEnabled.value = true;
} catch (passwordError) {
if (!pageActive || isRequestCancelled(passwordError)) return;
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
passwordSetupError.value = `谱文已经创建,但${failureCopy}。请进入详情重新设置;当前内容可能尚未受到密码保护。`;
}
contentPassword.value = "";
contentPasswordConfirm.value = "";
}
}
if (!pageActive) return;
editorState.value = "success";
@@ -412,7 +553,7 @@ const requestBack = () =>
});
const handleResultAction = () =>
editorState.value === "success"
? isEdit.value
? isEdit.value || passwordSetupError.value
? returnTo("F05", { genealogyId: genealogyId.value, articleId: articleId.value })
: returnTo("F04", { genealogyId: genealogyId.value })
: isEdit.value
@@ -424,7 +565,9 @@ onUnload(() => {
articleDetailController.abort();
articleCategoryController.abort();
articleCoverUploadController.abort();
articleMediaUploadController.abort();
articleSaveController.abort();
articlePasswordController.abort();
discardConfirmation.dispose();
});
</script>
@@ -532,7 +675,8 @@ onUnload(() => {
.editor-control--article textarea {
min-height: 180rpx;
}
.editor-field--cover {
.editor-field--cover,
.editor-field--media {
display: grid;
gap: 12rpx;
padding: 18rpx 22rpx;
@@ -540,7 +684,8 @@ onUnload(() => {
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
}
.editor-field--cover .editor-field__label {
.editor-field--cover .editor-field__label,
.editor-field--media .editor-field__label {
margin: 0;
}
.editor-field__hint {
@@ -565,6 +710,7 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.editor-control--password-confirm { margin-top: 14rpx; }
.remove-cover-button {
justify-self: start;
min-height: 72rpx;
@@ -577,6 +723,39 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
}
.remove-cover-button::after { border: 0; }
.body-media-list {
display: grid;
gap: 8rpx;
}
.body-media-item {
display: flex;
min-height: 66rpx;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 8rpx 12rpx 8rpx 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.22);
border-radius: 8rpx;
background: rgba(255, 253, 248, 0.58);
}
.body-media-item > text {
min-width: 0;
flex: 1;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.remove-media-button {
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin: 0;
padding: 0 14rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.remove-media-button::after { border: 0; }
.editor-placeholder {
color: #9e8e79;
}
+71 -1
View File
@@ -18,12 +18,32 @@
</picker>
</view>
<view v-if="listState === 'list'" class="article-list-items">
<BatchManagementBar
v-if="deletableArticles.length"
resource-name="谱文"
:active="articleBatch.selectionMode.value"
:selected-count="articleBatch.selectedCount.value"
:all-selected="articleBatch.allSelected.value"
:busy="articleBatch.deleting.value"
@start="articleBatch.enterSelectionMode"
@finish="articleBatch.exitSelectionMode"
@toggle-all="articleBatch.toggleAll"
@delete="articleBatch.requestDelete"
/>
<text v-if="articleBatch.notice.value" class="article-batch-notice" role="status">{{ articleBatch.notice.value }}</text>
<text v-if="articleBatch.error.value" class="article-batch-error" role="alert">{{ articleBatch.error.value }}</text>
<view
v-for="item in filteredArticles"
:key="item.id"
class="article-card"
@click="openArticle(item)"
@click="handleArticleClick(item)"
>
<BatchSelectionMark
v-if="articleBatch.selectionMode.value && item.canDelete"
:selected="articleBatch.isSelected(item)"
:label="`谱文:${item.title}`"
@toggle="articleBatch.toggleSelection(item)"
/>
<image
v-if="item.coverFile?.accessUrl"
class="article-card__cover"
@@ -56,6 +76,18 @@
/>
</view>
</view>
<AppDialog
:visible="articleBatch.confirmationVisible.value"
:close-on-mask="false"
eyebrow="批量删除"
title="将选中的谱文移入回收站?"
:message="articleBatch.confirmationMessage.value"
:confirm-text="articleBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
@confirm="articleBatch.confirmDelete"
@cancel="articleBatch.cancelDelete"
/>
</view>
</template>
@@ -63,6 +95,9 @@
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -71,6 +106,7 @@ import {
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { familyArticleApi } from "@/services/api/family-article-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import { goBack, openPage } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
@@ -82,6 +118,7 @@ const articleCategoryError = ref("");
const selectedCategoryId = ref("");
const articleListRequestController = createRequestController();
const articleCategoryRequestController = createRequestController();
const articleDeleteRequestController = createRequestController();
let pageActive = true;
let skipInitialShowRefresh = true;
const categoryOptions = computed(() => [
@@ -98,6 +135,20 @@ const filteredArticles = computed(() =>
? articles.value.filter((item) => item.categoryId === selectedCategoryId.value)
: articles.value,
);
const articleBatch = useBatchDeletion({
items: articles,
visibleItems: filteredArticles,
deleteOne: (article) =>
familyArticleApi.deleteArticle(genealogyId.value, article.id, {
requestController: articleDeleteRequestController,
}),
resourceName: "谱文",
isActive: () => pageActive,
onEmpty: () => {
listState.value = "empty";
},
});
const deletableArticles = articleBatch.deletableItems;
const stateCopy = computed(() =>
hasValidContext.value
? listState.value === "empty"
@@ -140,6 +191,7 @@ onUnload(() => {
pageActive = false;
articleListRequestController.abort();
articleCategoryRequestController.abort();
articleDeleteRequestController.abort();
});
const loadArticleCategories = async () => {
articleCategoryError.value = "";
@@ -169,6 +221,7 @@ const loadArticles = async () => {
]);
if (!pageActive) return;
articles.value = rows;
articleBatch.exitSelectionMode();
if (categoryRows) categories.value = categoryRows;
if (!categoryOptions.value.some((item) => item.id === selectedCategoryId.value)) {
selectedCategoryId.value = "";
@@ -181,6 +234,7 @@ const loadArticles = async () => {
};
const selectCategory = (event) => {
selectedCategoryId.value = categoryOptions.value[Number(event.detail.value)]?.id || "";
articleBatch.exitSelectionMode();
};
const createArticle = () =>
hasValidContext.value
@@ -188,6 +242,13 @@ const createArticle = () =>
: Promise.resolve(false);
const openArticle = (item) =>
openPage("F05", { genealogyId: genealogyId.value, articleId: item.id }, "F04");
const handleArticleClick = (item) => {
if (articleBatch.selectionMode.value && item?.canDelete) {
articleBatch.toggleSelection(item);
return;
}
openArticle(item);
};
const handleStateAction = () => {
if (!hasValidContext.value) return goBack();
if (listState.value === "error") return loadArticles();
@@ -214,6 +275,15 @@ const handleStateAction = () => {
.article-list-items {
margin-top: 24rpx;
}
.article-batch-notice,
.article-batch-error {
display: block;
margin-bottom: 16rpx;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.55;
}
.article-batch-notice { color: #426538; }
.article-batch-error { color: $brand-red; }
.article-category-error {
margin-top: 20rpx;
padding: 18rpx 22rpx;
+1 -1
View File
@@ -291,7 +291,7 @@ onUnload(() => {
const backToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
? returnTo("F12", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () => backToFamily();
const handleStateAction = () =>
+145 -8
View File
@@ -32,7 +32,7 @@
<view>
<text class="publish-field__label">动态配图</text>
<text class="publish-field__hint"
>图片上传成功后会随动态一起发布</text
>{{ isEdit ? "可新增、预览或移除图片,保存后生效。" : "图片上传成功后会随动态一起发布。" }}</text
>
</view>
<button
@@ -42,12 +42,49 @@
>
{{ isUploading ? "上传中…" : "添加图片" }}
</button>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
<template v-if="isEdit">
<view v-if="mediaReceipts.length" class="media-preview-grid">
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="media-preview-card"
>
<button
class="media-preview-card__preview"
:disabled="isUploading || isSubmitting || !getMediaPreviewUrl(receipt)"
:aria-label="`预览第${index + 1}张动态配图`"
@click="previewMedia(receipt)"
>
<image
v-if="getMediaPreviewUrl(receipt)"
class="media-preview-card__image"
:src="getMediaPreviewUrl(receipt)"
mode="aspectFill"
/>
<view v-else class="media-preview-card__fallback">
<text>图片暂不可预览</text>
</view>
</button>
<view class="media-preview-card__meta">
<text>{{ receipt.fileName || `动态配图${index + 1}` }}</text>
<button
class="media-preview-card__remove"
:disabled="isUploading || isSubmitting"
:aria-label="`移除第${index + 1}张动态配图`"
@click="removeMedia(index)"
>移除</button>
</view>
</view>
</view>
</template>
<template v-else>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
class="upload-receipt"
>已上传{{ receipt.fileName || "图片" }}</text
>
</template>
<text v-if="uploadError" class="publish-error">{{
uploadError
}}</text>
@@ -204,6 +241,7 @@ const loadEditFeed = async (feedId) => {
mediaReceipts.value = detail.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName,
accessUrl: file.accessUrl,
}));
editingFeed.value = {
id: detail.id,
@@ -254,6 +292,22 @@ const uploadImage = async () => {
}
};
const getMediaPreviewUrl = (receipt) =>
String(receipt?.thumbnailUrl || receipt?.accessUrl || receipt?.url || "").trim();
const previewMedia = (receipt) => {
const current = getMediaPreviewUrl(receipt);
if (!current) return;
const urls = mediaReceipts.value.map(getMediaPreviewUrl).filter(Boolean);
uni.previewImage({ current, urls });
};
const removeMedia = (index) => {
if (isUploading.value || isSubmitting.value || !Number.isInteger(index)) return;
mediaReceipts.value = mediaReceipts.value.filter((_, mediaIndex) => mediaIndex !== index);
uploadError.value = "";
};
const saveFeed = async () => {
if (isSubmitting.value || isUploading.value || !hasValidContext.value) return;
if (!form.feedContent.trim()) {
@@ -322,7 +376,7 @@ const requestBack = () =>
const returnToFamily = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
return returnTo("F12", { genealogyId: genealogyId.value });
};
const handleResultAction = () => {
if (publishState.value === "success") {
@@ -474,6 +528,89 @@ onUnload(() => {
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.media-preview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
}
.media-preview-card {
min-width: 0;
overflow: hidden;
border: 1rpx solid rgba(128, 89, 49, 0.24);
border-radius: 10rpx;
background: rgba(255, 253, 248, 0.62);
}
.media-preview-card__preview {
display: block;
width: 100%;
height: 176rpx;
margin: 0;
padding: 0;
border: 0;
border-radius: 0;
background: rgba(128, 89, 49, 0.1);
line-height: 1;
}
.media-preview-card__preview::after,
.media-preview-card__remove::after {
border: 0;
}
.media-preview-card__preview:focus-visible,
.media-preview-card__remove:focus-visible {
outline: 2rpx solid $brand-red;
outline-offset: -2rpx;
}
.media-preview-card__preview:active:not([disabled]) {
background: rgba(128, 89, 49, 0.16);
}
.media-preview-card__preview[disabled],
.media-preview-card__remove[disabled] {
opacity: 0.55;
}
.media-preview-card__image,
.media-preview-card__fallback {
display: block;
width: 100%;
height: 100%;
}
.media-preview-card__fallback {
display: flex;
align-items: center;
justify-content: center;
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
}
.media-preview-card__meta {
display: flex;
min-height: 72rpx;
align-items: center;
gap: 8rpx;
padding: 6rpx 6rpx 6rpx 14rpx;
}
.media-preview-card__meta > text {
min-width: 0;
flex: 1;
overflow: hidden;
color: $ink-muted;
font-size: clamp(12px, 19rpx, 15px);
text-overflow: ellipsis;
white-space: nowrap;
}
.media-preview-card__remove {
min-width: var(--app-touch-min);
min-height: var(--app-touch-min);
margin: 0;
padding: 0 10rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1;
white-space: nowrap;
}
.media-preview-card__remove:active:not([disabled]) {
background: rgba(159, 23, 15, 0.08);
}
.publish-placeholder {
color: #8e806e;
}
+14 -89
View File
@@ -3,25 +3,16 @@
<ModulePageBackground module="family" />
<view class="family-page__header"
><PageHeader
root
title="家族动态"
custom-back
:action="hasValidContext ? '发布' : ''"
@back="requestBack"
@action="toPublish"
/></view>
<view class="feed-content">
<view class="feed-heading"
><text>家族圈</text><text>家宴通知与共同记忆</text></view
>
<view v-if="hasValidContext" class="feed-shortcuts"
><button
v-for="item in shortcuts"
:key="item.key"
class="feed-shortcut"
:aria-label="`打开${item.label}`"
@click="openSection(item.key)"
><text>{{ item.label }}</text></button
></view
>
<AppLoading
v-if="feedState === 'loading'"
text="正在读取家族动态"
@@ -57,15 +48,13 @@
><text>{{ stateCopy.action }}</text></view
>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import FamilyFeedMedia from "@/components/family/FeedMedia.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -75,8 +64,7 @@ import {
} from "@/services/api/request-controller.js";
import { familyFeedApi } from "@/services/api/family-feed-service.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
import { goBack, handleBackPress, openPage } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const hasValidContext = ref(false);
@@ -108,17 +96,6 @@ const feedPreview = (content) => {
const text = String(content || "").trim();
return text.length > 96 ? `${text.slice(0, 96).trimEnd()}` : text;
};
const shortcuts = [
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "benefactors", label: "家族恩人" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
{ key: "videos", label: "家族视频" },
];
const stateCopy = computed(() =>
!hasValidContext.value
? {
@@ -205,13 +182,7 @@ const loadMoreFeeds = async () => {
}
};
onLoad((query) => {
const supplied = Object.prototype.hasOwnProperty.call(
query || {},
"genealogyId",
);
genealogyId.value = supplied
? String(query.genealogyId || "")
: String(genealogyContext.getCurrentGenealogyId() || "");
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
if (hasValidContext.value) void loadFeeds();
else feedState.value = "error";
@@ -230,33 +201,16 @@ onUnload(() => {
});
const toPublish = () =>
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F01")
: goRoot("G01");
? openPage("F02", { genealogyId: genealogyId.value, mode: "create" }, "F12")
: goBack();
const openFeed = (item) =>
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F01");
const openSection = (key) => {
const routes = {
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
benefactors: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
return openPage(
routes[key],
{
genealogyId: genealogyId.value,
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
},
"F01",
);
openPage("F03", { genealogyId: genealogyId.value, feedId: item.id }, "F12");
const handlePrimaryAction = () => {
if (!hasValidContext.value) return goBack();
return feedState.value === "error" ? loadFeeds() : toPublish();
};
const handlePrimaryAction = () =>
feedState.value === "error" ? loadFeeds() : toPublish();
const requestBack = () => goBack();
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@@ -273,7 +227,7 @@ const handlePrimaryAction = () =>
}
.feed-content {
flex: 1;
padding: 24rpx 24rpx calc(190rpx + env(safe-area-inset-bottom));
padding: 24rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.feed-heading text {
display: block;
@@ -289,29 +243,6 @@ const handlePrimaryAction = () =>
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
}
.feed-shortcuts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12rpx;
margin-top: 18rpx;
}
.feed-shortcut {
box-sizing: border-box;
min-height: 48px;
margin: 0;
padding: 0;
border: 1rpx solid rgba(181, 137, 63, 0.45);
border-radius: 8rpx;
background: rgba(255, 252, 244, 0.58);
display: flex;
align-items: center;
justify-content: center;
}
.feed-shortcut text {
color: $ink;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.feed-list {
margin-top: 22rpx;
}
@@ -347,12 +278,6 @@ const handlePrimaryAction = () =>
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.feed-shortcut::after {
border: 0;
}
.feed-shortcut:active {
background: rgba(181, 137, 63, 0.1);
}
.feed-card__reason {
margin-top: 8rpx;
color: #9a6555;
+25 -11
View File
@@ -40,12 +40,21 @@
/>
<view class="video-card__play" aria-hidden="true"></view>
</view>
<video
<view
v-else
:src="video.videoFile.accessUrl"
controls
class="video-card__player"
/>
class="video-card__cover-button video-card__cover-placeholder"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openVerticalViewer(video)"
>
<image
class="video-card__placeholder-seal"
src="/static/assets/foundation/transparent/brand-seal.png"
mode="aspectFit"
/>
<view class="video-card__play" aria-hidden="true"></view>
</view>
<text class="video-card__title">{{ video.title }}</text>
<text v-if="video.description" class="video-card__copy">{{ video.description }}</text>
<text v-if="video.startAt" class="video-card__meta">发布时间{{ video.startAt }}</text>
@@ -423,12 +432,6 @@ onUnload(() => {
.video-card {
padding: 28rpx;
}
.video-card__player {
width: 100%;
height: 360rpx;
border-radius: 10rpx;
background: #1f1b17;
}
.video-card__cover-button {
position: relative;
width: 100%;
@@ -437,6 +440,17 @@ onUnload(() => {
background: #1f1b17;
overflow: hidden;
}
.video-card__cover-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(145deg, #f4e5cd, #d8b77c);
}
.video-card__placeholder-seal {
width: 132rpx;
height: 152rpx;
opacity: .72;
}
.video-card__cover {
width: 100%;
height: 100%;
+7
View File
@@ -0,0 +1,7 @@
<template>
<SiteHome subpage-mode />
</template>
<script setup>
import SiteHome from "@/pages/family/site-home.vue";
</script>
+354
View File
@@ -0,0 +1,354 @@
<template>
<view class="site-home-page">
<ModulePageBackground module="family" />
<PageHeader :root="!subpageMode" :custom-back="subpageMode" :title="headerTitle" />
<scroll-view class="site-home-scroll" scroll-y>
<view class="site-home-content" :class="{ 'site-home-content--root': !subpageMode }">
<view class="site-home-heading">
<text>{{ pageTitle }}</text>
<text>{{ pageDescription }}</text>
</view>
<AppLoading
v-if="articleState === 'loading'"
text="正在整理资讯"
description="请稍候,正在读取最新内容。"
/>
<view v-else-if="articleState === 'list'" class="site-article-list">
<button
v-for="article in articles"
:key="article.id"
class="site-article-card"
:aria-label="`${article.title},查看文章`"
@click="openArticle(article)"
>
<view class="site-article-card__meta">
<text>{{ article.typeLabel }}</text>
<text v-if="article.publishTime">{{ formatArticleDate(article.publishTime) }}</text>
</view>
<text class="site-article-card__title">{{ article.title }}</text>
<text v-if="article.summary" class="site-article-card__summary">
{{ article.summary }}
</text>
<text class="site-article-card__action">
{{ article.externalUrl ? "前往阅读" : "阅读全文" }}
</text>
</button>
</view>
<view v-else class="site-home-state">
<text>{{ articleState === "empty" ? "暂时没有资讯" : "资讯暂时无法读取" }}</text>
<text>{{ articleState === "empty" ? "新内容发布后会在这里展示。" : articleError }}</text>
<button v-if="articleState === 'error'" @click="loadArticles">重新加载</button>
</view>
<text v-if="openError" class="site-home-error" role="alert">{{ openError }}</text>
<AppPromotionStrip placement="home_bottom" title="更多内容" />
</view>
</scroll-view>
<AppDialog
:visible="Boolean(selectedArticle)"
:eyebrow="selectedArticle?.typeLabel || '传承资讯'"
:title="selectedArticle?.title || '文章详情'"
:message="selectedArticleContent"
confirm-text="关闭"
@confirm="closeArticle"
@close="closeArticle"
/>
<AppTabbar v-if="!subpageMode" active="family" />
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled,
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { siteContentApi } from "@/services/api/site-content-service.js";
import { openSiteContentTarget } from "@/utils/navigation/gateway.js";
const props = defineProps({
subpageMode: { type: Boolean, default: false },
});
const subpageMode = computed(() => props.subpageMode);
const articleState = ref("loading");
const articleType = ref("");
const pageTitle = ref("传承资讯");
const articleError = ref("");
const openError = ref("");
const articles = ref([]);
const selectedArticle = ref(null);
const articleRequestController = createRequestController();
let pageActive = true;
const decodeRouteText = (value) => {
const text = String(value || "").trim();
if (!text) return "";
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const headerTitle = computed(() => (subpageMode.value ? pageTitle.value : "代代相传"));
const pageDescription = computed(() =>
articleType.value === "notice"
? "平台发布的网站公告与服务通知"
: articleType.value === "news"
? "家谱文化、平台动态与最新资讯"
: "家谱文化、平台动态与实用文章",
);
const decodeArticleEntities = (value) =>
value
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'");
const articlePlainText = (value) =>
decodeArticleEntities(
String(value || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(?:p|div|li|h[1-6])>/gi, "\n")
.replace(/<[^>]*>/g, ""),
)
.replace(/\n{3,}/g, "\n\n")
.trim();
const selectedArticleContent = computed(() =>
articlePlainText(selectedArticle.value?.content || selectedArticle.value?.summary) ||
"这篇文章暂时没有正文。",
);
const formatArticleDate = (value) => {
const text = String(value || "").trim();
return text.length >= 10 ? text.slice(0, 10) : text;
};
const loadArticles = async () => {
articleRequestController.abort();
articleState.value = "loading";
articleError.value = "";
try {
const rows = await siteContentApi.getSiteArticles({
limit: 20,
...(articleType.value ? { articleType: articleType.value } : {}),
requestController: articleRequestController,
});
if (!pageActive) return;
articles.value = rows;
articleState.value = rows.length ? "list" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
articleError.value = getRequestErrorMessage(error, "请稍后重新读取。");
articleState.value = "error";
}
};
const openArticle = async (article) => {
openError.value = "";
if (!article.externalUrl) {
selectedArticle.value = article;
return;
}
try {
await openSiteContentTarget(article.externalUrl, () => {
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
});
} catch {
if (pageActive) openError.value = "外部文章暂时打不开,请稍后再试。";
}
};
const closeArticle = () => {
selectedArticle.value = null;
};
onLoad((query) => {
articleType.value = subpageMode.value && ["news", "notice"].includes(String(query?.articleType || ""))
? String(query.articleType)
: "";
pageTitle.value = subpageMode.value
? decodeRouteText(query?.title) ||
(articleType.value === "notice" ? "网站公告" : articleType.value === "news" ? "家谱新闻" : "文化传承文章")
: "传承资讯";
void loadArticles();
});
onUnload(() => {
pageActive = false;
articleRequestController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.site-home-page {
display: flex;
height: 100vh;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: $paper;
}
.site-home-scroll {
z-index: 1;
width: 100%;
height: 0;
min-height: 0;
flex: 1;
}
.site-home-content {
padding: 22rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.site-home-content--root {
padding-bottom: calc(166rpx + env(safe-area-inset-bottom));
}
.site-home-heading text,
.site-article-card text,
.site-home-state text {
display: block;
}
.site-home-heading text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(20px, 36rpx, 25px);
font-weight: 700;
}
.site-home-heading text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
}
.site-article-list {
margin-top: 22rpx;
}
.site-article-card {
@include adaptive.adaptive-family-letter;
display: block;
width: 100%;
min-height: 210rpx;
margin: 0 0 18rpx;
padding: 28rpx 30rpx;
border: 0;
background-color: rgba($paper, 0.86);
box-sizing: border-box;
color: $ink;
line-height: 1.5;
text-align: left;
}
.site-article-card::after {
border: 0;
}
.site-article-card__meta {
display: flex;
justify-content: space-between;
gap: 20rpx;
color: #946c48;
font-size: clamp(12px, 20rpx, 15px);
}
.site-article-card__title {
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(18px, 31rpx, 22px);
font-weight: 700;
}
.site-article-card__summary {
display: -webkit-box;
margin-top: 9rpx;
overflow: hidden;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.site-article-card__action {
margin-top: 14rpx;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
font-weight: 700;
text-align: right;
}
.site-home-state {
display: flex;
min-height: 420rpx;
flex-direction: column;
align-items: center;
justify-content: center;
color: $ink-muted;
text-align: center;
}
.site-home-state text:first-child {
color: $ink;
font-size: clamp(18px, 32rpx, 23px);
font-weight: 700;
}
.site-home-state text + text {
margin-top: 12rpx;
}
.site-home-state button {
min-height: 72rpx;
margin-top: 24rpx;
padding: 0 30rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 36rpx;
background: rgba(255, 250, 240, 0.8);
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.site-home-state button::after {
border: 0;
}
.site-home-error {
display: block;
margin-top: 18rpx;
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
text-align: center;
}
.site-home-content :deep(.promotion-strip) {
margin-right: 0;
margin-left: 0;
}
</style>
+62 -4
View File
@@ -129,6 +129,20 @@
<AppButton block label="发布视频" @click="openPublishForm" />
</view>
<view v-else class="video-card-list">
<BatchManagementBar
v-if="deletableVideos.length"
resource-name="视频"
:active="videoBatch.selectionMode.value"
:selected-count="videoBatch.selectedCount.value"
:all-selected="videoBatch.allSelected.value"
:busy="videoBatch.deleting.value"
@start="videoBatch.enterSelectionMode"
@finish="videoBatch.exitSelectionMode"
@toggle-all="videoBatch.toggleAll"
@delete="videoBatch.requestDelete"
/>
<text v-if="videoBatch.notice.value" class="batch-notice" role="status">{{ videoBatch.notice.value }}</text>
<text v-if="videoBatch.error.value" class="field-error" role="alert">{{ videoBatch.error.value }}</text>
<AppButton
block
type="secondary"
@@ -136,10 +150,16 @@
@click="openVerticalViewer(videos[0])"
/>
<view v-for="video in videos" :key="video.id" class="video-card">
<BatchSelectionMark
v-if="videoBatch.selectionMode.value && video.canDelete"
:selected="videoBatch.isSelected(video)"
:label="`视频:${video.title}`"
@toggle="videoBatch.toggleSelection(video)"
/>
<button
class="video-card__cover-action"
:aria-label="`播放${video.title}`"
@click="openVerticalViewer(video)"
@click="videoBatch.selectionMode.value && video.canDelete ? videoBatch.toggleSelection(video) : openVerticalViewer(video)"
>
<image
class="video-card__cover"
@@ -156,7 +176,7 @@
video.publishTime || "刚刚发布"
}}</text>
<view
v-if="video.canEdit || video.canDelete"
v-if="!videoBatch.selectionMode.value && (video.canEdit || video.canDelete)"
class="video-card__actions"
>
<AppButton
@@ -175,7 +195,7 @@
@click="requestDeleteVideo(video)"
/>
</view>
<view class="video-card__actions">
<view v-if="!videoBatch.selectionMode.value" class="video-card__actions">
<AppButton compact type="secondary" label="沉浸观看" @click="openVerticalViewer(video)" />
<AppButton compact type="secondary" :disabled="videoActionKey === `like-${video.id}`" :label="video.likedByCurrentUser ? `已赞 ${video.likeCount || 0}` : `点赞 ${video.likeCount || 0}`" @click="toggleVideoLike(video)" />
<AppButton compact type="secondary" :disabled="videoActionKey === `comments-${video.id}`" label="查看评论" @click="openVideoComments(video)" />
@@ -192,6 +212,18 @@
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog
:visible="videoBatch.confirmationVisible.value"
eyebrow="批量删除"
title="将选中的视频移入回收站?"
:message="videoBatch.confirmationMessage.value"
:confirm-text="videoBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="videoBatch.confirmDelete"
@cancel="videoBatch.cancelDelete"
/>
<AppDialog
:visible="deleteConfirmVisible"
eyebrow="删除确认"
@@ -305,6 +337,8 @@ import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import VerticalVideoViewer from "@/components/family/VerticalVideoViewer.vue";
@@ -315,6 +349,7 @@ import {
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
isImagePickCancelled,
isVideoPickCancelled,
@@ -372,6 +407,19 @@ const discardConfirmation = createDiscardConfirmation((visible) => {
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const videoBatch = useBatchDeletion({
items: videos,
deleteOne: (video) =>
familyMediaApi.deleteVideo(genealogyId.value, video.id, {
requestController: videoDeletionRequestController,
}),
resourceName: "视频",
isActive: () => pageActive,
onEmpty: () => {
videoListState.value = "ready";
},
});
const deletableVideos = videoBatch.deletableItems;
const videoCommentPlaceholder = computed(() =>
replyTarget.value ? `回复 ${replyTarget.value.author}` : "说说你的看法",
);
@@ -599,6 +647,7 @@ const loadVideos = async () => {
});
if (!pageActive) return;
videos.value = videoRows;
videoBatch.exitSelectionMode();
videoListState.value = "ready";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -822,7 +871,7 @@ const submitVideo = async () => {
};
const returnToFamily = () =>
hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
? returnTo("G05", { genealogyId: genealogyId.value })
: goBack();
const returnToVideoList = () => {
pageState.value = "list";
@@ -833,6 +882,15 @@ const returnToVideoList = () => {
return true;
};
const requestBack = async () => {
if (videoBatch.deleting.value) return true;
if (videoBatch.confirmationVisible.value) {
videoBatch.cancelDelete();
return true;
}
if (videoBatch.selectionMode.value) {
videoBatch.exitSelectionMode();
return true;
}
if (verticalViewerVisible.value) {
closeVerticalViewer();
return true;
+45 -539
View File
@@ -3,7 +3,7 @@
class="page-shell genealogy-index"
:class="{ 'genealogy-index--split': isListLayout }"
>
<GenealogyPageBackground />
<GenealogyPageBackground tone="light" />
<PageHeader
root
notice
@@ -56,69 +56,6 @@
</view>
<template v-else-if="hasGenealogies">
<view class="genealogy-fixed-zone">
<view class="current-slip" @click="openSwitcher">
<view class="current-summary">
<view class="current-seal">
<text class="current-seal-title">家谱</text>
</view>
<text class="current-name">{{ currentGenealogy.name }}</text>
<text class="current-switch-copy">切换</text>
</view>
<view class="current-info-divider"></view>
<view class="current-meta">
<view class="current-meta-item current-meta-item--location">
<image
class="current-meta-icon"
src="/static/assets/foundation/transparent/meta-location.png"
mode="aspectFit"
/>
<text class="current-meta-item-text">{{
currentGenealogy.location
}}</text>
</view>
<view class="current-meta-item current-meta-item--members">
<image
class="current-meta-icon"
src="/static/assets/foundation/transparent/meta-member.png"
mode="aspectFit"
/>
<text class="current-meta-item-text"
>{{ currentGenealogy.memberCount }} 位成员</text
>
</view>
<view class="current-meta-item current-meta-item--role">
<image
class="current-meta-icon"
src="/static/assets/foundation/transparent/meta-admin.png"
mode="aspectFit"
/>
<text class="current-meta-item-text">{{
currentRoleLabel
}}</text>
</view>
</view>
</view>
<view class="shortcut-grid">
<view
v-for="shortcut in visibleShortcuts"
:key="shortcut.key"
class="shortcut-item"
@click="openShortcut(shortcut.key)"
>
<image class="shortcut-icon" :src="shortcut.icon" mode="aspectFit" />
<text class="shortcut-label">{{ shortcut.label }}</text>
</view>
</view>
<image
class="section-divider"
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
</view>
<scroll-view
class="genealogy-list-scroll"
scroll-y
@@ -126,8 +63,13 @@
@scroll="handleListScroll"
>
<view class="genealogy-lower">
<view class="genealogy-list-toolbar">
<view class="section-heading genealogy-list-heading">
<text>{{ managedGenealogies.length ? "我管理的" : "我加入的" }}</text>
</view>
</view>
<view v-if="managedGenealogies.length" class="list-section">
<view class="section-heading"><text>我管理的</text></view>
<GenealogyCard
v-for="genealogy in managedGenealogies"
:key="genealogy.id"
@@ -139,7 +81,7 @@
</view>
<view v-if="memberGenealogies.length" class="list-section">
<view class="section-heading"><text>我加入的</text></view>
<view v-if="managedGenealogies.length" class="section-heading"><text>我加入的</text></view>
<GenealogyCard
v-for="genealogy in memberGenealogies"
:key="genealogy.id"
@@ -171,85 +113,33 @@
/>
</view>
<view class="featured-media-section">
<view class="featured-media-heading">
<view>
<text class="featured-media-heading__title">宣传视频</text>
<text class="featured-media-heading__copy">家谱文化与使用介绍</text>
</view>
<button class="featured-media-more" @click="openPlatformVideos">
查看更多
</button>
</view>
<view v-if="featuredVideoState === 'loading'" class="featured-media-state">
<AppLoading text="正在读取宣传视频" />
</view>
<view v-else-if="featuredVideoState === 'error'" class="featured-media-state">
<text>宣传视频暂时无法显示</text>
<button class="featured-media-retry" @click="loadFeaturedVideos">重新加载</button>
</view>
<view v-else-if="featuredVideos.length" class="featured-media-grid">
<view
v-for="video in featuredVideos"
:key="video.id"
class="featured-media-card"
>
<view
v-if="video.coverFile?.accessUrl"
class="featured-media-cover-button"
role="button"
:aria-label="`播放${video.title}`"
hover-class="action-hover"
@click="openFeaturedVideo(video)"
>
<image
class="featured-media-cover"
:src="video.coverFile.accessUrl"
mode="aspectFill"
/>
<view class="featured-media-play" aria-hidden="true"></view>
</view>
<video
v-else
class="featured-media-video"
:src="video.videoFile.accessUrl"
controls
object-fit="cover"
/>
<button class="featured-media-title" @click="openFeaturedVideo(video)">
{{ video.title }}
</button>
</view>
</view>
<view v-else class="featured-media-state featured-media-state--empty">
<text>暂时没有推荐视频</text>
<button class="featured-media-more featured-media-more--empty" @click="openPlatformVideos">
查看全部视频
</button>
</view>
</view>
<view class="create-action" @click="openAddDialog">
<view class="create-action" hover-class="action-hover" @click="openAddDialog">
<image
class="create-cloud"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
<image
class="create-icon"
src="/static/assets/modules/genealogy/transparent/add.png"
mode="aspectFit"
aria-hidden="true"
/>
<text>添加家谱</text>
<image
class="create-cloud create-cloud--right"
src="/static/assets/modules/genealogy/transparent/create-cloud.png"
mode="aspectFit"
aria-hidden="true"
/>
</view>
<AppPromotionStrip placement="home_bottom" title="家谱服务推荐" />
</view>
</scroll-view>
<HomeAdvertisementPanel
class="home-content-fixed"
@open="openContentHome"
/>
</template>
<view v-else class="genealogy-empty-state">
@@ -284,12 +174,6 @@
>
<text class="empty-create-action__copy">创建家谱</text>
</view>
<AppButton
block
type="secondary"
label="观看宣传视频"
@click="openPlatformVideos"
/>
<text class="empty-create-note"
>确认没有现有家谱后再创建避免重复建谱</text
>
@@ -322,7 +206,7 @@
@saved="applySavedGenealogyOrder"
/>
<AppTabbar active="genealogy" />
<AppTabbar active="genealogy" tone="light" />
</view>
</template>
@@ -332,9 +216,9 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import AppButton from "@/components/AppButton.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import GenealogyAddDialog from "@/components/genealogy/AddDialog.vue";
import GenealogyCard from "@/components/genealogy/Card.vue";
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
import GenealogyOrderDialog from "@/components/genealogy/OrderDialog.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import GenealogySwitcherDialog from "@/components/genealogy/SwitcherDialog.vue";
@@ -345,12 +229,11 @@ import {
} from "@/services/api/request-controller.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { genealogyApi } from "@/services/api/genealogy-service.js";
import { PLATFORM_VIDEO_PLACEMENT } from "@/services/api/family-media-contract.js";
import { genealogyCapabilityApi } from "@/services/api/genealogy-capability-service.js";
import { notificationApi } from "@/services/api/notification-service.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
import {
consumeNavigationResult,
goRoot,
handleBackPress,
openPage,
runBackGuard,
@@ -372,18 +255,14 @@ const listScrollCommand = ref(0);
const currentListScrollTop = ref(0);
const unreadCount = ref(0);
const creationQuota = ref(null);
const featuredVideos = ref([]);
const featuredVideoState = ref("loading");
const genealogyListRequestController = createRequestController();
const unreadRequestController = createRequestController();
const quotaRequestController = createRequestController();
const featuredVideoRequestController = createRequestController();
// uni-app 的 abort 与成功回调可能在同一事件循环竞争。控制器负责取消任务,
// generation 再阻止已经迟到的旧响应覆盖新页面状态,两层保护不能互相替代。
let genealogyLoadGeneration = 0;
let unreadLoadGeneration = 0;
let quotaLoadGeneration = 0;
let featuredVideoLoadGeneration = 0;
let pageActive = true;
let skipNextShowRefresh = true;
@@ -485,37 +364,11 @@ const loadQuota = async () => {
}
};
const loadFeaturedVideos = async () => {
const generation = ++featuredVideoLoadGeneration;
featuredVideoRequestController.abort();
featuredVideoState.value = "loading";
try {
const rows = await genealogyCapabilityApi.getPlatformVideos(
PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
{ requestController: featuredVideoRequestController },
);
if (!pageActive || generation !== featuredVideoLoadGeneration) return;
featuredVideos.value = rows.slice(0, 2);
featuredVideoState.value = "ready";
} catch (error) {
if (
!pageActive ||
generation !== featuredVideoLoadGeneration ||
isRequestCancelled(error)
) {
return;
}
featuredVideos.value = [];
featuredVideoState.value = "error";
}
};
onLoad((query) => {
requestedGenealogyId.value = String(query?.genealogyId || "");
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onShow(() => {
@@ -527,7 +380,6 @@ onShow(() => {
) {
requestedGenealogyId.value = navigationResult.entityId;
loadGenealogies();
loadFeaturedVideos();
return;
}
if (skipNextShowRefresh) {
@@ -537,7 +389,6 @@ onShow(() => {
loadGenealogies();
loadUnreadCount();
loadQuota();
loadFeaturedVideos();
});
onUnload(() => {
@@ -545,11 +396,9 @@ onUnload(() => {
genealogyLoadGeneration += 1;
unreadLoadGeneration += 1;
quotaLoadGeneration += 1;
featuredVideoLoadGeneration += 1;
genealogyListRequestController.abort();
unreadRequestController.abort();
quotaRequestController.abort();
featuredVideoRequestController.abort();
});
const hasGenealogies = computed(() => genealogies.value.length > 0);
@@ -572,40 +421,6 @@ const currentGenealogy = computed(
(genealogy) => genealogy.id === selectedGenealogyId.value,
) || null,
);
const canManageCurrentGenealogy = computed(
() => currentGenealogy.value?.canManage === true,
);
const currentRoleLabel = computed(() =>
canManageCurrentGenealogy.value ? "管理员" : "成员",
);
const shortcuts = [
{
key: "tree",
label: "世系图",
icon: "/static/assets/modules/genealogy/transparent/shortcut-tree.png",
},
{
key: "members",
label: "成员",
icon: "/static/assets/modules/genealogy/transparent/shortcut-members.png",
},
{
key: "poem",
label: "字辈诗",
icon: "/static/assets/modules/genealogy/transparent/shortcut-generation-poem.png",
},
{
key: "applications",
label: "申请审核",
icon: "/static/assets/modules/genealogy/transparent/shortcut-application.png",
},
];
const visibleShortcuts = computed(() =>
canManageCurrentGenealogy.value
? shortcuts
: shortcuts.filter((shortcut) => shortcut.key !== "applications"),
);
const openGenealogy = (genealogy) =>
openPage("G05", { genealogyId: String(genealogy.id) }, "G01").then(
@@ -644,21 +459,7 @@ const openSwitcher = () => {
const closeSwitcher = () => {
switcherVisible.value = false;
};
const openPlatformVideos = () =>
openPage(
"F11",
{ placement: PLATFORM_VIDEO_PLACEMENT.VIDEO_CENTER },
"G01",
);
const openFeaturedVideo = (video) =>
openPage(
"F11",
{
placement: PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED,
videoId: String(video.id),
},
"G01",
);
const openContentHome = () => goRoot("F01");
const openOrderDialog = () => {
if (genealogies.value.length < 2) return;
orderDialogVisible.value = true;
@@ -714,17 +515,6 @@ const retryGenealogyLoad = () => {
loadGenealogies();
};
const openShortcut = (shortcutKey) => {
if (!currentGenealogy.value) return;
const genealogyId = String(currentGenealogy.value.id);
const actions = {
tree: () => openPage("T01", { genealogyId }, "G01"),
members: () => openPage("G13", { genealogyId }, "G01"),
poem: () => openPage("G12", { genealogyId }, "G01"),
applications: () => openPage("G10", { genealogyId }, "G01"),
};
return actions[shortcutKey]?.();
};
</script>
<style scoped lang="scss">
@@ -735,12 +525,12 @@ const openShortcut = (shortcutKey) => {
min-height: 100vh;
flex-direction: column;
overflow: hidden;
background: #f9f6ef;
background: #faf8f3;
}
.genealogy-content {
z-index: 1;
padding: 24rpx 32rpx calc(176rpx + env(safe-area-inset-bottom));
padding: 0 32rpx calc(176rpx + env(safe-area-inset-bottom));
}
.genealogy-index--split {
@@ -760,10 +550,6 @@ const openShortcut = (shortcutKey) => {
padding-bottom: 0;
}
.genealogy-fixed-zone {
flex: 0 0 auto;
}
.genealogy-list-scroll {
width: 100%;
height: 0;
@@ -771,151 +557,25 @@ const openShortcut = (shortcutKey) => {
flex: 1;
}
.current-slip {
@include adaptive-genealogy-current-slip;
display: block;
min-height: 300rpx;
padding: 30rpx 34rpx 28rpx;
.genealogy-list-toolbar {
display: flex;
align-items: center;
min-height: 70rpx;
padding: 0 4rpx 2rpx;
box-sizing: border-box;
}
.current-seal {
display: flex;
width: 80rpx;
height: 128rpx;
flex: 0 0 auto;
align-items: center;
justify-content: center;
margin-right: 24rpx;
background: url("/static/assets/modules/genealogy/transparent/current-seal-frame.png")
center / contain no-repeat;
color: #fff7e7;
}
.current-seal-title {
color: #fff7e7;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
letter-spacing: 2rpx;
writing-mode: vertical-rl;
}
.current-summary {
display: flex;
min-width: 0;
align-items: center;
}
.current-name {
min-width: 0;
.genealogy-list-heading {
flex: 1;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(18px, 34rpx, 24px);
font-weight: 700;
line-height: 1.25;
overflow: hidden;
overflow-wrap: anywhere;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.current-switch-copy {
flex: 0 0 auto;
margin-left: 16rpx;
padding: 4rpx 12rpx;
border: 1rpx solid rgba(159, 23, 15, 0.34);
border-radius: 999rpx;
color: $brand-red;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 600;
line-height: 1.3;
white-space: nowrap;
}
.current-info-divider {
width: 100%;
height: 1rpx;
margin: 18rpx 0 16rpx;
background: rgba(181, 138, 75, 0.48);
}
.current-meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 28rpx;
row-gap: 10rpx;
margin-top: 0;
color: #62584c;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 500;
line-height: 1.4;
}
.current-meta-item {
display: flex;
min-width: 0;
align-items: center;
margin: 0;
.current-meta-item-text {
min-width: 0;
overflow-wrap: anywhere;
}
}
.current-meta-item--location {
grid-column: 1 / -1;
}
.current-meta-item--role {
justify-self: end;
}
.current-meta-icon {
width: 34rpx;
height: 34rpx;
flex: 0 0 auto;
margin-right: 8rpx;
opacity: 1;
}
.shortcut-grid {
display: flex;
min-height: 168rpx;
align-items: center;
margin-top: 18rpx;
padding: 8rpx 0;
}
.shortcut-item {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
align-items: center;
}
.shortcut-icon {
width: 76rpx;
height: 76rpx;
}
.shortcut-label {
width: 100%;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 27rpx, 19px);
font-weight: 700;
text-align: center;
overflow-wrap: anywhere;
}
.section-divider {
display: block;
width: 100%;
height: 30rpx;
margin: 4rpx 0 0;
margin-bottom: 0;
}
.genealogy-lower {
padding: 12rpx 0 36rpx;
padding: 0 0 24rpx;
}
.list-section {
margin-top: 16rpx;
margin-top: 6rpx;
}
.section-heading {
@@ -951,7 +611,7 @@ const openShortcut = (shortcutKey) => {
padding: 24rpx 28rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
background: rgba(255, 253, 249, 0.84);
box-sizing: border-box;
}
@@ -978,164 +638,17 @@ const openShortcut = (shortcutKey) => {
flex: 0 0 auto;
}
.featured-media-section {
margin: 0 0 24rpx;
padding: 26rpx 24rpx;
border: 1rpx solid rgba(149, 103, 49, 0.24);
border-radius: 14rpx;
background: rgba(255, 250, 240, 0.72);
}
.featured-media-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.featured-media-heading__title,
.featured-media-heading__copy {
display: block;
}
.featured-media-heading__title {
color: #5c4330;
font-size: clamp(16px, 28rpx, 20px);
font-weight: 700;
}
.featured-media-heading__copy {
margin-top: 8rpx;
color: #8a7564;
font-size: clamp(14px, 23rpx, 17px);
}
.featured-media-more,
.featured-media-retry,
.featured-media-title {
min-height: var(--app-touch-min);
margin: 0;
padding: 0;
border: 0;
background: transparent;
line-height: 1.4;
}
.featured-media-more::after,
.featured-media-retry::after,
.featured-media-title::after {
border: 0;
}
.featured-media-more,
.featured-media-retry {
flex: 0 0 auto;
color: $brand-red;
font-size: clamp(13px, 22rpx, 16px);
}
.featured-media-state {
display: flex;
min-height: 176rpx;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
text-align: center;
}
.featured-media-state--empty {
min-height: 138rpx;
}
.featured-media-more--empty {
margin-top: 4rpx;
}
.featured-media-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16rpx;
margin-top: 22rpx;
}
.featured-media-card {
min-width: 0;
}
.featured-media-cover-button,
.featured-media-video {
width: 100%;
height: 176rpx;
border-radius: 10rpx;
background: #1f1b17;
overflow: hidden;
}
.featured-media-cover-button {
position: relative;
}
.featured-media-cover {
width: 100%;
height: 100%;
}
.featured-media-play {
position: absolute;
top: 50%;
left: 50%;
display: flex;
width: 58rpx;
height: 58rpx;
align-items: center;
justify-content: center;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 50%;
background: rgba(45, 29, 19, 0.62);
transform: translate(-50%, -50%);
}
.featured-media-play::after {
width: 0;
height: 0;
margin-left: 5rpx;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 16rpx solid #fff;
content: "";
}
.featured-media-title {
display: block;
width: 100%;
margin-top: 8rpx;
color: $ink;
font-size: clamp(14px, 24rpx, 17px);
font-weight: 600;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-action {
display: flex;
width: 350rpx;
min-height: 96rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: clamp(17px, 30rpx, 22px);
}
.create-action {
width: 350rpx;
margin: 32rpx auto 0;
margin: 8rpx auto 24rpx;
border: 2rpx solid $brand-red;
border-radius: 48rpx;
background: rgba(255, 249, 238, 0.66);
background: rgba(255, 253, 249, 0.8);
box-sizing: border-box;
color: $brand-red;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(17px, 32rpx, 22px);
@@ -1144,28 +657,31 @@ const openShortcut = (shortcutKey) => {
}
.create-icon {
width: 38rpx;
height: 38rpx;
margin-right: 12rpx;
}
.create-action .create-icon {
width: 40rpx;
height: 40rpx;
margin: 0 8rpx;
}
.create-cloud {
width: 54rpx;
height: 28rpx;
flex: 0 0 auto;
}
.create-action > text {
flex: 0 0 auto;
text-align: center;
}
.create-cloud--right {
transform: scaleX(-1);
}
.home-content-fixed {
flex: 0 0 auto;
margin-top: 8rpx;
}
.state-panel {
display: flex;
min-height: 540rpx;
@@ -1382,16 +898,6 @@ const openShortcut = (shortcutKey) => {
padding-right: 20rpx;
padding-left: 20rpx;
}
.current-name {
font-size: clamp(20px, 40rpx, 26px);
}
.shortcut-label {
font-size: clamp(14px, 23rpx, 17px);
}
.current-meta-icon {
width: 32rpx;
height: 32rpx;
}
.create-action {
width: 320rpx;
}
+118 -23
View File
@@ -198,15 +198,26 @@
</template>
</view>
<view class="overview-family" @click="toFamily">
<view>
<text>家族近况</text>
<text>进入家族圈查看动态</text>
<view class="overview-family-content">
<view class="overview-family-content__heading">
<text>家族内容</text>
<text>按用途进入动态影像与家族事务</text>
</view>
<view
v-for="group in familyContentGroups"
:key="group.key"
class="overview-family-group"
>
<text class="overview-family-group__title">{{ group.title }}</text>
<view class="overview-family-grid">
<button
v-for="item in group.items"
:key="item.key"
class="overview-family-link"
@click="openFamilySection(item.key)"
>{{ item.label }}</button>
</view>
</view>
<image
src="/static/assets/foundation/transparent/chevron-right.png"
mode="aspectFit"
/>
</view>
<view class="overview-note">
<text class="overview-note__title">成员资料按家谱访问规则保护</text>
@@ -280,7 +291,6 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
import { getGenealogyAccessPresetLabel } from "@/utils/genealogy/access-policy.js";
import {
goBack,
goRoot,
handleBackPress,
openPage,
returnTo,
@@ -378,7 +388,53 @@ onUnload(() => {
const reloadOverview = () => loadGenealogy({ genealogyId: genealogyId.value });
const toGenealogies = () => returnTo("G01", {});
const toTree = () => openPage("T01", { genealogyId: genealogyId.value }, "G05");
const toFamily = () => goRoot("F01", { genealogyId: genealogyId.value });
const familyContentGroups = [
{
key: "memories",
title: "家族记录",
items: [
{ key: "feed", label: "家族动态" },
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
{ key: "videos", label: "家族视频" },
],
},
{
key: "affairs",
title: "家族事务",
items: [
{ key: "rituals", label: "礼仪" },
{ key: "memos", label: "备忘" },
{ key: "benefactors", label: "家族恩人" },
{ key: "people", label: "人物录" },
{ key: "gifts", label: "贺礼簿" },
{ key: "merits", label: "功德录" },
],
},
];
const openFamilySection = (key) => {
if (!genealogyId.value) return;
if (key === "feed") return openPage("F12", { genealogyId: genealogyId.value }, "G05");
const routes = {
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
benefactors: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
return openPage(
routes[key],
{
genealogyId: genealogyId.value,
...(key === "benefactors" ? { memoType: "benefactor" } : {}),
},
"G05",
);
};
const toApplications = () =>
openPage("G10", { genealogyId: genealogyId.value }, "G05");
const toSettings = () =>
@@ -573,8 +629,7 @@ onBackPress((event) => handleBackPress(event, requestBack));
.overview-summary text {
display: block;
}
.overview-action__chevron,
.overview-family image {
.overview-action__chevron {
width: 26rpx;
height: 26rpx;
opacity: 0.68;
@@ -593,27 +648,67 @@ onBackPress((event) => handleBackPress(event, requestBack));
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.overview-family {
display: grid;
grid-template-columns: minmax(0, 1fr) 26rpx;
align-items: center;
gap: 20rpx;
.overview-family-content {
margin: 14rpx 42rpx 0;
padding: 20rpx 0;
padding: 24rpx 0 0;
border-top: 1rpx solid rgba(152, 119, 72, 0.28);
}
.overview-family-content__heading text {
display: block;
}
.overview-family-content__heading text:first-child {
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(18px, 31rpx, 22px);
font-weight: 700;
}
.overview-family-content__heading text:last-child {
margin-top: 5rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.5;
}
.overview-family text {
display: block;
.overview-family-group {
margin-top: 22rpx;
}
.overview-family text:first-child {
margin-bottom: 3rpx;
.overview-family-group__title {
display: block;
color: #7f4f16;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.overview-family-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
margin-top: 12rpx;
}
.overview-family-link {
display: flex;
min-height: 76rpx;
align-items: center;
justify-content: center;
margin: 0;
padding: 8rpx 10rpx;
border: 1rpx solid rgba(181, 137, 63, 0.34);
border-radius: 8rpx;
background: rgba(247, 237, 218, 0.76);
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(16px, 27rpx, 20px);
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
line-height: 1.35;
}
.overview-family-link::after {
border: 0;
}
.overview-note {
display: flex;
+60 -188
View File
@@ -79,36 +79,6 @@
</view>
</view>
<view class="profile-metadata" aria-label="个人资料摘要">
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-person.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>性别</text><text class="app-single-line">{{ sexText }}</text></view>
</view>
<view class="profile-metadata__item">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-calendar.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>生日</text><text class="app-single-line">{{ birthdayText }}</text></view>
</view>
<view class="profile-metadata__item profile-metadata__item--email">
<image
class="profile-metadata__icon"
src="/static/assets/modules/profile/transparent/icon-envelope.png"
mode="aspectFit"
aria-hidden="true"
/>
<view><text>邮箱</text><text class="app-long-value">{{ profile.email || "未设置" }}</text></view>
</view>
</view>
<view class="profile-preference" aria-label="个性化推荐设置">
<view>
<text>个性化推荐</text>
@@ -165,9 +135,12 @@
</view>
</view>
</view>
<HomeAdvertisementPanel
class="profile-home-advertisements"
@open="openContentHome"
/>
</template>
<AppPromotionStrip v-if="!loading && !profileHomeError" placement="profile_bottom" title="为你推荐" />
<AppTabbar active="profile" />
</view>
</template>
@@ -177,18 +150,16 @@ import { computed, reactive, ref } from "vue";
import { onPageScroll, onShow, onUnload } from "@dcloudio/uni-app";
import AppAvatar from "@/components/AppAvatar.vue";
import AppButton from "@/components/AppButton.vue";
import AppPromotionStrip from "@/components/AppPromotionStrip.vue";
import AppTabbar from "@/components/AppTabbar.vue";
import {
PROFILE_SEX_OPTIONS
} from "@/services/api/profile-contract.js";
import HomeAdvertisementPanel from "@/components/genealogy/HomeAdvertisementPanel.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { profileApi } from "@/services/api/profile-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { openPage } from "@/utils/navigation/gateway.js";
import { goRoot, openPage } from "@/utils/navigation/gateway.js";
import { genealogyContext } from "@/utils/genealogy/context.js";
const profile = reactive({
avatarFile: null,
@@ -211,84 +182,46 @@ const preferenceSaving = ref(false);
const preferenceError = ref("");
let isPageActive = true;
const serviceGroups = [
{
title: "账户与服务",
items: [
{
routeKey: "M03",
label: "账号与安全",
iconVariant: "security",
icon: "/static/assets/modules/profile/transparent/icon-security.png",
},
{
routeKey: "N01",
label: "消息中心",
iconVariant: "notifications",
icon: "/static/assets/modules/profile/transparent/icon-message.png",
},
{
routeKey: "M11",
label: "活动邀请",
iconVariant: "invitations",
icon: "/static/assets/modules/profile/transparent/icon-message.png",
},
{
routeKey: "M09",
label: "VIP 服务",
iconVariant: "vip",
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
},
{
routeKey: "M12",
label: "收益与提现",
iconVariant: "earnings",
icon: "/static/assets/modules/profile/transparent/icon-vip.png",
},
],
},
const profileIcons = Object.freeze({
security: "/static/assets/modules/profile/transparent/icon-security.png",
notifications: "/static/assets/modules/profile/transparent/icon-message.png",
vip: "/static/assets/modules/profile/transparent/icon-vip.png",
help: "/static/assets/modules/profile/transparent/icon-help.png",
feedback: "/static/assets/modules/profile/transparent/icon-feedback.png",
settings: "/static/assets/modules/profile/transparent/icon-settings.png",
});
const serviceItem = (routeKey, label, iconVariant, params = null) => ({
routeKey,
label,
iconVariant,
icon: profileIcons[iconVariant],
...(params ? { params } : {}),
});
const serviceGroups = Object.freeze([
{
title: "支持与其他",
items: [
serviceItem("M06", "帮助中心", "help"),
serviceItem("M07", "意见反馈", "feedback"),
serviceItem("M03", "账号与安全", "security"),
serviceItem("M10", "关于与设置", "settings"),
serviceItem("F13", "网站公告", "notifications", {
title: "网站公告",
articleType: "notice",
}),
serviceItem("F13", "家谱新闻列表(图文)", "feedback", {
title: "家谱新闻",
articleType: "news",
}),
{
routeKey: "M06",
label: "帮助中心",
iconVariant: "help",
icon: "/static/assets/modules/profile/transparent/icon-help.png",
},
{
routeKey: "M07",
label: "意见反馈",
iconVariant: "feedback",
icon: "/static/assets/modules/profile/transparent/icon-feedback.png",
},
{
routeKey: "M08",
label: "应用推广",
iconVariant: "promotion",
icon: "/static/assets/modules/profile/transparent/icon-promotion.png",
},
{
routeKey: "M10",
label: "关于与设置",
iconVariant: "settings",
icon: "/static/assets/modules/profile/transparent/icon-settings.png",
...serviceItem("F04", "家谱系列文章(图文)", "feedback"),
requiresGenealogy: true,
},
],
},
];
const profileSexLabels = Object.freeze(
Object.fromEntries(PROFILE_SEX_OPTIONS.map(({ value, label }) => [value, label])),
);
const sexText = computed(
() => profileSexLabels[String(profile.sex)] || "未设置",
);
const birthdayText = computed(() =>
/^\d{4}-\d{2}-\d{2}/.test(profile.birthday)
? profile.birthday.slice(0, 10)
: "未设置",
);
]);
const loadProfile = async () => {
if (loading.value) return;
@@ -341,9 +274,21 @@ const changeRecommendationPreference = async (event) => {
}
};
const openService = (item) => openPage(item.routeKey, {}, "M01");
const openService = (item) => {
const params = { ...(item.params || {}) };
if (item.requiresGenealogy) {
const genealogyId = genealogyContext.getCurrentGenealogyId();
if (!genealogyId) {
uni.showToast({ title: "请先在家谱页选择家谱", icon: "none" });
return Promise.resolve(false);
}
params.genealogyId = genealogyId;
}
return openPage(item.routeKey, params, "M01");
};
const openEdit = () => openPage("M02", {}, "M01");
const openSettings = () => openPage("M10", {}, "M01");
const openContentHome = () => goRoot("F01");
onShow(() => {
void loadProfile();
@@ -352,13 +297,14 @@ onShow(() => {
onPageScroll(({ scrollTop = 0 }) => {
compactHeader.value = scrollTop > 44;
});
onUnload(() => {
const disposeProfilePortal = () => {
isPageActive = false;
compactHeader.value = false;
profileReadController.abort();
preferenceReadController.abort();
preferenceSaveController.abort();
});
};
onUnload(disposeProfilePortal);
</script>
<style scoped lang="scss">
@@ -611,68 +557,6 @@ onUnload(() => {
border: 0;
}
.profile-metadata {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
z-index: 1;
margin: -36rpx 34rpx 0;
padding: 10rpx 12rpx 12rpx;
border: 1rpx solid rgba(117, 83, 52, 0.09);
border-radius: 18rpx;
background: rgba(239, 234, 224, 0.92);
}
.profile-metadata__item {
display: flex;
min-width: 0;
align-items: flex-start;
gap: 12rpx;
padding: 14rpx 12rpx;
border-right: 0;
}
.profile-metadata__item:first-child {
border-right: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: 1 / -1;
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__icon {
width: 56rpx;
height: 56rpx;
flex: 0 0 auto;
filter: sepia(0.7) saturate(0.8) hue-rotate(330deg) brightness(0.82);
}
.profile-metadata__item view {
min-width: 0;
}
.profile-metadata__item text {
display: block;
}
.profile-metadata__item text:first-child {
color: #857263;
font-size: clamp(13px, 20rpx, 16px);
}
.profile-metadata__item text:last-child {
margin-top: 4rpx;
color: #382c25;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.25;
overflow-wrap: anywhere;
}
.profile-metadata__item--email text:last-child {
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.35;
}
.profile-preference {
display: flex;
align-items: center;
@@ -717,6 +601,10 @@ onUnload(() => {
margin: 49rpx 58rpx 0 48rpx;
}
.profile-home-advertisements {
margin: 28rpx 24rpx 0;
}
.profile-service-group + .profile-service-group {
margin-top: 51rpx;
}
@@ -825,22 +713,6 @@ onUnload(() => {
height: 176rpx;
}
.profile-metadata {
grid-template-columns: minmax(0, 1fr);
}
.profile-metadata__item:first-child {
border-right: 0;
}
.profile-metadata__item + .profile-metadata__item {
border-top: 1rpx solid rgba(117, 83, 52, 0.16);
}
.profile-metadata__item--email {
grid-column: auto;
}
.profile-services {
margin-right: 38rpx;
margin-left: 38rpx;
+20 -3
View File
@@ -23,9 +23,15 @@
<AppButton compact type="secondary" label="重试" @click="loadReferralProfile" />
</view>
<view v-else-if="referralProfile.enabled" class="referral-card__content">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<view class="referral-card__details">
<ReferralQrCode :value="referralProfile.shareUrl" />
<view class="referral-card__copy">
<text class="referral-card__code">{{ referralProfile.referralCode }}</text>
<text>已成功邀请 {{ referralProfile.referredUserCount }} </text>
<text>{{ referralProfile.shareDescription || "家人通过此链接注册后,系统会记录推荐关系。" }}</text>
<text class="referral-card__qr-hint">请家人扫码打开注册链接</text>
</view>
</view>
<view class="referral-card__actions">
<AppButton compact type="secondary" label="复制推荐码" @click="copyReferralCode" />
<AppButton compact type="secondary" label="复制推荐链接" @click="copyReferralLink" />
@@ -83,6 +89,7 @@ import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import ReferralQrCode from "@/components/ReferralQrCode.vue";
import {
createRequestController,
isRequestCancelled
@@ -318,7 +325,12 @@ onUnload(() => {
.referral-card__state > text { margin-top: 8rpx; color: $ink-muted; font-size: clamp(13px, 22rpx, 16px); line-height: 1.55; }
.referral-card__content,
.referral-card__state { margin-top: 22rpx; }
.referral-card__details { display: flex; align-items: center; gap: 24rpx; }
.referral-card__copy { min-width: 0; flex: 1; }
.referral-card__copy > text { display: block; }
.referral-card__copy > text:not(:first-child) { margin-top: 8rpx; }
.referral-card__code { color: #9e251b; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: clamp(22px, 40rpx, 30px); font-weight: 700; letter-spacing: 3rpx; }
.referral-card__qr-hint { color: $brand-red; font-size: clamp(13px, 21rpx, 16px); }
.referral-card__actions { display: flex; flex-wrap: wrap; justify-content: flex-end; margin-top: 22rpx; gap: 12rpx; }
.referral-card__actions .app-button { width: auto; min-width: 150rpx; }
.promotion-state-card .app-button { margin-top: 28rpx; }
@@ -353,5 +365,10 @@ onUnload(() => {
min-width: 0;
flex: 1 1 220rpx;
}
.referral-card__details {
flex-direction: column;
align-items: flex-start;
}
}
</style>
+71 -4
View File
@@ -30,12 +30,32 @@
><AppButton block label="新建礼仪活动" @click="createCeremony"
/></view>
<view v-else class="ceremony-list"
><view
><BatchManagementBar
v-if="deletableCeremonies.length"
resource-name="礼仪活动"
:active="ceremonyBatch.selectionMode.value"
:selected-count="ceremonyBatch.selectedCount.value"
:all-selected="ceremonyBatch.allSelected.value"
:busy="ceremonyBatch.deleting.value"
@start="ceremonyBatch.enterSelectionMode"
@finish="ceremonyBatch.exitSelectionMode"
@toggle-all="ceremonyBatch.toggleAll"
@delete="ceremonyBatch.requestDelete"
/>
<text v-if="ceremonyBatch.notice.value" class="batch-notice" role="status">{{ ceremonyBatch.notice.value }}</text>
<text v-if="ceremonyBatch.error.value" class="batch-error" role="alert">{{ ceremonyBatch.error.value }}</text>
<view
v-for="item in ceremonies"
:key="item.id"
class="ceremony-card"
@click="openCeremony(item)"
><image
@click="handleCeremonyClick(item)"
><BatchSelectionMark
v-if="ceremonyBatch.selectionMode.value && item.canDelete"
:selected="ceremonyBatch.isSelected(item)"
:label="`礼仪活动:${item.title}`"
@toggle="ceremonyBatch.toggleSelection(item)"
/>
<image
v-if="item.coverFile?.accessUrl"
class="ceremony-card__cover"
:src="item.coverFile.accessUrl"
@@ -50,6 +70,18 @@
></view
>
</view>
<AppDialog
:visible="ceremonyBatch.confirmationVisible.value"
:close-on-mask="false"
eyebrow="批量删除"
title="将选中的礼仪活动移入回收站?"
:message="ceremonyBatch.confirmationMessage.value"
:confirm-text="ceremonyBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
@confirm="ceremonyBatch.confirmDelete"
@cancel="ceremonyBatch.cancelDelete"
/>
</view>
</template>
@@ -57,7 +89,10 @@
import { computed, ref } from "vue";
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -65,6 +100,7 @@ import {
isRequestCancelled
} from "@/services/api/request-controller.js";
import { ceremonyApi } from "@/services/api/ceremony-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { goBack, openPage, returnTo } from "@/utils/navigation/gateway.js";
@@ -72,8 +108,22 @@ const genealogyId = ref("");
const ceremonies = ref([]);
const ceremonyListState = ref("loading");
const ceremonyListRequestController = createRequestController();
const ceremonyDeleteRequestController = createRequestController();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const ceremonyBatch = useBatchDeletion({
items: ceremonies,
deleteOne: (ceremony) =>
ceremonyApi.deleteCeremony(genealogyId.value, ceremony.id, {
requestController: ceremonyDeleteRequestController,
}),
resourceName: "礼仪活动",
isActive: () => pageActive,
onEmpty: () => {
ceremonyListState.value = "empty";
},
});
const deletableCeremonies = ceremonyBatch.deletableItems;
const loadCeremonies = async () => {
if (!valid.value) return;
ceremonyListRequestController.abort();
@@ -84,6 +134,7 @@ const loadCeremonies = async () => {
});
if (!pageActive) return;
ceremonies.value = rows;
ceremonyBatch.exitSelectionMode();
ceremonyListState.value = ceremonies.value.length ? "ready" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
@@ -91,7 +142,7 @@ const loadCeremonies = async () => {
}
};
const returnToFamily = () =>
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const createCeremony = () =>
valid.value
? openPage("R07", { genealogyId: genealogyId.value, mode: "create" }, "R05")
@@ -102,6 +153,13 @@ const openCeremony = (item) =>
{ genealogyId: genealogyId.value, ceremonyId: item.id },
"R05",
);
const handleCeremonyClick = (item) => {
if (ceremonyBatch.selectionMode.value && item?.canDelete) {
ceremonyBatch.toggleSelection(item);
return;
}
openCeremony(item);
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (valid.value) loadCeremonies();
@@ -112,6 +170,7 @@ onShow(() => {
onUnload(() => {
pageActive = false;
ceremonyListRequestController.abort();
ceremonyDeleteRequestController.abort();
});
</script>
@@ -156,6 +215,14 @@ onUnload(() => {
flex-direction: column;
gap: 20rpx;
}
.batch-notice,
.batch-error {
display: block;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.55;
}
.batch-notice { color: #426538; }
.batch-error { color: $brand-red; }
.ceremony-card {
display: flex;
min-height: 138rpx;
+131 -13
View File
@@ -134,6 +134,24 @@
</view>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<view v-if="!isEdit" class="field field--password">
<text>内容密码选填</text>
<text class="person-option-note">填写后会先创建记录再立即启用密码保护设置失败时会保留记录并明确提示</text>
<input
v-model="contentPassword"
password
maxlength="128"
placeholder="请输入8至128位内容密码"
@input="error = ''"
/>
<input
v-model="contentPasswordConfirm"
password
maxlength="128"
placeholder="请再次输入内容密码"
@input="error = ''"
/>
</view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions"
><AppButton
@@ -167,9 +185,21 @@
><AppButton block label="新建成长记录" @click="openCreate"
/></view>
<view v-else class="record-list">
<text v-if="saveNotice" class="save-notice"
>成长记录已保存</text
>
<BatchManagementBar
v-if="deletableGrowthRecords.length"
resource-name="成长记录"
:active="growthBatch.selectionMode.value"
:selected-count="growthBatch.selectedCount.value"
:all-selected="growthBatch.allSelected.value"
:busy="growthBatch.deleting.value"
@start="growthBatch.enterSelectionMode"
@finish="growthBatch.exitSelectionMode"
@toggle-all="growthBatch.toggleAll"
@delete="growthBatch.requestDelete"
/>
<text v-if="growthBatch.notice.value" class="save-notice" role="status">{{ growthBatch.notice.value }}</text>
<text v-if="growthBatch.error.value" class="delete-error" role="alert">{{ growthBatch.error.value }}</text>
<text v-if="saveNotice" class="save-notice">{{ saveNotice }}</text>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view class="record-filters">
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
@@ -185,9 +215,15 @@
:key="item.id"
class="record-card"
role="button"
:aria-label="`查看${item.title}详情`"
@click="openRecordDetail(item)"
:aria-label="growthBatch.selectionMode.value && item.canDelete ? `${growthBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`"
@click="handleGrowthRecordClick(item)"
>
<BatchSelectionMark
v-if="growthBatch.selectionMode.value && item.canDelete"
:selected="growthBatch.isSelected(item)"
:label="`成长记录:${item.title}`"
@toggle="growthBatch.toggleSelection(item)"
/>
<view
><text>{{ item.title }}</text
><text
@@ -197,14 +233,14 @@
><text v-if="item.content">{{ item.content }}</text></view
>
<AppButton
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
v-if="!growthBatch.selectionMode.value && item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
compact
type="secondary"
label="编辑"
@click.stop="openEdit(item)"
/>
<AppButton
v-if="item.canDelete"
v-if="!growthBatch.selectionMode.value && item.canDelete"
compact
type="secondary"
label="删除"
@@ -219,6 +255,17 @@
@busy-change="detailBusy = $event"
@transient-change="detailTransientOpen = $event"
/>
<AppDialog
:visible="growthBatch.confirmationVisible.value"
title="将选中的成长记录移入回收站?"
:message="growthBatch.confirmationMessage.value"
:confirm-text="growthBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="growthBatch.confirmDelete"
@cancel="growthBatch.cancelDelete"
/>
<AppDialog
:visible="discardVisible"
title="放弃成长记录?"
@@ -250,6 +297,8 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
@@ -261,6 +310,7 @@ import { businessDictionaryApi } from "@/services/api/business-dictionary-servic
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
@@ -277,7 +327,7 @@ const personId = ref("");
const view = ref("list");
const listState = ref("loading");
const records = ref([]);
const saveNotice = ref(false);
const saveNotice = ref("");
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
@@ -299,6 +349,8 @@ const personOptions = ref([{ value: "", label: "不关联人物" }]);
const defaultLineagePersonId = ref("");
const growthTypeOptionsState = ref("loading");
const growthTypeOptions = ref([]);
const contentPassword = ref("");
const contentPasswordConfirm = ref("");
const form = reactive({
lineagePersonId: "",
recordType: "",
@@ -314,6 +366,7 @@ const recordListRequestController = createRequestController();
const growthEditorDetailRequestController = createRequestController();
const growthMediaUploadRequestController = createRequestController();
const growthRecordSaveRequestController = createRequestController();
const growthPasswordRequestController = createRequestController();
const personOptionsController = createRequestController();
const growthTypeRequestController = createRequestController();
const growthRecordDeleteController = createRequestController();
@@ -356,6 +409,20 @@ const filteredRecords = computed(() => records.value.filter((item) =>
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
(!filterType.value || item.type === filterType.value),
));
const growthBatch = useBatchDeletion({
items: records,
visibleItems: filteredRecords,
deleteOne: (record) =>
lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
requestController: growthRecordDeleteController,
}),
resourceName: "成长记录",
isActive: () => pageActive,
onEmpty: () => {
listState.value = "empty";
},
});
const deletableGrowthRecords = growthBatch.deletableItems;
const mediaOssIds = computed(() =>
mediaReceipts.value.map((item) => item.ossId).join(","),
);
@@ -376,7 +443,9 @@ const dirty = computed(() =>
field !== "lineagePersonId" && String(value).trim(),
) ||
form.lineagePersonId !== defaultLineagePersonId.value ||
mediaReceipts.value.length > 0,
mediaReceipts.value.length > 0 ||
contentPassword.value ||
contentPasswordConfirm.value,
);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
@@ -400,6 +469,8 @@ const resetForm = () => {
remindClock: "",
});
mediaReceipts.value = [];
contentPassword.value = "";
contentPasswordConfirm.value = "";
editingRecord.value = null;
formBaseline.value = "";
error.value = "";
@@ -456,6 +527,7 @@ const loadRecords = async () => {
});
if (!pageActive) return;
records.value = rows;
growthBatch.exitSelectionMode();
listState.value = records.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
@@ -464,7 +536,7 @@ const loadRecords = async () => {
};
const openCreate = () => {
if (!valid.value) return;
saveNotice.value = false;
saveNotice.value = "";
resetForm();
view.value = "form";
};
@@ -563,15 +635,24 @@ const selectGrowthType = (event) => {
};
const selectFilterPerson = (event) => {
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
growthBatch.exitSelectionMode();
};
const selectFilterType = (event) => {
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
growthBatch.exitSelectionMode();
};
const openRecordDetail = (record) =>
growthRecordDetailDialog.value?.open({
...record,
typeLabel: growthRecordTypeLabel(record),
});
const handleGrowthRecordClick = (record) => {
if (growthBatch.selectionMode.value && record?.canDelete) {
growthBatch.toggleSelection(record);
return;
}
openRecordDetail(record);
};
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
@@ -595,6 +676,16 @@ const saveGrowthRecord = async () => {
error.value = "请填写记录标题";
return;
}
if (contentPassword.value || contentPasswordConfirm.value) {
if (contentPassword.value.length < 8 || contentPassword.value.length > 128) {
error.value = "内容密码必须为8至128位。";
return;
}
if (contentPassword.value !== contentPasswordConfirm.value) {
error.value = "两次输入的内容密码不一致。";
return;
}
}
const { recordClock, remindDate, remindClock, ...recordForm } = form;
const payload = {
...recordForm,
@@ -619,6 +710,7 @@ const saveGrowthRecord = async () => {
submitting.value = true;
error.value = "";
let passwordSetupNotice = "";
try {
if (editingRecord.value) {
await lifeRecordApi.updateGrowthRecord(
@@ -628,14 +720,30 @@ const saveGrowthRecord = async () => {
{ requestController: growthRecordSaveRequestController },
);
} else {
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
const createdRecord = await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
requestController: growthRecordSaveRequestController,
});
if (!pageActive) return;
if (contentPassword.value) {
try {
await lifeRecordApi.setGrowthRecordPassword(
genealogyId.value,
createdRecord.id,
contentPassword.value,
{ requestController: growthPasswordRequestController },
);
passwordSetupNotice = "成长记录已保存,并已启用内容密码。";
} catch (passwordError) {
if (!pageActive || isRequestCancelled(passwordError)) return;
const failureCopy = getRequestErrorMessage(passwordError, "内容密码设置失败");
passwordSetupNotice = `成长记录已经创建,但${failureCopy}。请打开详情重新设置;当前内容可能尚未受到密码保护。`;
}
}
}
if (!pageActive) return;
resetForm();
view.value = "list";
saveNotice.value = true;
saveNotice.value = passwordSetupNotice || "成长记录已保存。";
await loadRecords();
} catch (cause) {
if (!pageActive) return;
@@ -708,7 +816,13 @@ const deleteRecord = async () => {
}
};
const requestBack = () =>
detailTransientOpen.value
growthBatch.deleting.value
? true
: growthBatch.confirmationVisible.value
? (growthBatch.cancelDelete(), true)
: growthBatch.selectionMode.value
? (growthBatch.exitSelectionMode(), true)
: detailTransientOpen.value
? (growthRecordDetailDialog.value?.closeTransient(), true)
: view.value !== "form"
? goBack()
@@ -745,6 +859,7 @@ onUnload(() => {
growthEditorDetailRequestController.abort();
growthMediaUploadRequestController.abort();
growthRecordSaveRequestController.abort();
growthPasswordRequestController.abort();
personOptionsController.abort();
growthTypeRequestController.abort();
growthRecordDeleteController.abort();
@@ -822,6 +937,9 @@ onUnload(() => {
.field textarea {
min-height: 150rpx;
}
.field--password input + input {
margin-top: 14rpx;
}
.field--picker picker {
display: block;
}
+106 -20
View File
@@ -94,18 +94,46 @@
><AppButton block :label="`新建${recordName}`" @click="openCreate"
/></view>
<view v-else class="memo-list">
<BatchManagementBar
v-if="deletableMemos.length"
:resource-name="recordName"
:active="memoBatch.selectionMode.value"
:selected-count="memoBatch.selectedCount.value"
:all-selected="memoBatch.allSelected.value"
:busy="memoBatch.deleting.value"
@start="memoBatch.enterSelectionMode"
@finish="memoBatch.exitSelectionMode"
@toggle-all="memoBatch.toggleAll"
@delete="memoBatch.requestDelete"
/>
<text v-if="memoBatch.notice.value" class="save-notice" role="status">{{ memoBatch.notice.value }}</text>
<text v-if="memoBatch.error.value" class="delete-error" role="alert">{{ memoBatch.error.value }}</text>
<text v-if="saveNotice" class="save-notice"
>{{ recordName }}已保存</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
<view class="memo-card__copy">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="memoBatch.selectionMode.value && item.canDelete ? `${memoBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMemoClick(item)">
<BatchSelectionMark
v-if="memoBatch.selectionMode.value && item.canDelete"
:selected="memoBatch.isSelected(item)"
:label="`${recordName}${item.title}`"
@toggle="memoBatch.toggleSelection(item)"
/>
<view class="memo-card__main">
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="memo-card__cover"
:src="item.mediaFiles[0].accessUrl"
mode="aspectFill"
/>
<view class="memo-card__copy">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
</view>
</view>
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
<view v-if="!memoBatch.selectionMode.value && (item.canEdit || item.canDelete)" class="memo-card__actions">
<AppButton
v-if="item.canEdit"
compact
@@ -124,6 +152,17 @@
</view>
</view>
</view>
<AppDialog
:visible="memoBatch.confirmationVisible.value"
:title="`将选中的${recordName}移入回收站?`"
:message="memoBatch.confirmationMessage.value"
:confirm-text="memoBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="memoBatch.confirmDelete"
@cancel="memoBatch.cancelDelete"
/>
<AppDialog
:visible="Boolean(detailTarget)"
:eyebrow="`${recordName}详情`"
@@ -178,8 +217,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -233,6 +275,19 @@ const memoDetailRequestController = createRequestController();
const memoCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const memoBatch = useBatchDeletion({
items: memos,
deleteOne: (memo) =>
lifeRecordApi.deleteMemo(genealogyId.value, memo.id, {
requestController: memoDeletionRequestController,
}),
resourceName: "记录",
isActive: () => pageActive,
onEmpty: () => {
listState.value = "empty";
},
});
const deletableMemos = memoBatch.deletableItems;
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
const pageTitle = computed(() => recordName.value);
@@ -285,6 +340,7 @@ const loadMemos = async () => {
});
if (!pageActive) return;
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
memoBatch.exitSelectionMode();
listState.value = memos.value.length ? "ready" : "empty";
if (pendingMemoId.value) {
const target = memos.value.find((item) => item.id === pendingMemoId.value);
@@ -372,6 +428,13 @@ const openMemoDetail = async (memo) => {
detailError.value = getRequestErrorMessage(cause, "完整备忘读取失败,请稍后重试。");
}
};
const handleMemoClick = (memo) => {
if (memoBatch.selectionMode.value && memo?.canDelete) {
memoBatch.toggleSelection(memo);
return;
}
openMemoDetail(memo);
};
const closeMemoDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -503,19 +566,30 @@ const deleteMemo = async () => {
if (pageActive) deleting.value = false;
}
};
const requestBack = () =>
detailTarget.value
? (closeMemoDetail(), true)
: view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const requestBack = () => {
if (memoBatch.deleting.value) return true;
if (memoBatch.confirmationVisible.value) {
memoBatch.cancelDelete();
return true;
}
if (memoBatch.selectionMode.value) {
memoBatch.exitSelectionMode();
return true;
}
if (detailTarget.value) {
closeMemoDetail();
return true;
}
if (view.value !== "form") return goBack();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -708,10 +782,22 @@ onUnload(() => {
min-height: 128rpx;
padding: 28rpx 32rpx;
}
.memo-card__main {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.memo-card__copy {
min-width: 0;
flex: 1;
}
.memo-card__cover {
width: 148rpx;
height: 148rpx;
flex: 0 0 148rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.memo-card__actions {
display: flex;
width: 100%;
+110 -24
View File
@@ -100,6 +100,20 @@
><AppButton block label="新建功德记录" @click="openCreate"
/></view>
<view v-else class="merit-list">
<BatchManagementBar
v-if="deletableMerits.length"
resource-name="功德记录"
:active="meritBatch.selectionMode.value"
:selected-count="meritBatch.selectedCount.value"
:all-selected="meritBatch.allSelected.value"
:busy="meritBatch.deleting.value"
@start="meritBatch.enterSelectionMode"
@finish="meritBatch.exitSelectionMode"
@toggle-all="meritBatch.toggleAll"
@delete="meritBatch.requestDelete"
/>
<text v-if="meritBatch.notice.value" class="save-notice" role="status">{{ meritBatch.notice.value }}</text>
<text v-if="meritBatch.error.value" class="delete-error" role="alert">{{ meritBatch.error.value }}</text>
<text v-if="saveNotice" class="save-notice"
>{{ saveNotice }}</text
>
@@ -107,15 +121,29 @@
> {{ merits.length }} 金额合计 ¥{{ totalAmount }}</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMeritDetail(item)">
<view>
<text>{{ item.title }}</text>
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
<text v-if="item.time">{{ item.time }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content">{{ item.content }}</text>
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="meritBatch.selectionMode.value && item.canDelete ? `${meritBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMeritClick(item)">
<BatchSelectionMark
v-if="meritBatch.selectionMode.value && item.canDelete"
:selected="meritBatch.isSelected(item)"
:label="`功德记录:${item.title}`"
@toggle="meritBatch.toggleSelection(item)"
/>
<view class="merit-card__main">
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="merit-card__cover"
:src="item.mediaFiles[0].accessUrl"
mode="aspectFill"
/>
<view class="merit-card__copy">
<text>{{ item.title }}</text>
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
<text v-if="item.time">{{ item.time }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content">{{ item.content }}</text>
</view>
</view>
<view class="merit-card__amount">
<view v-if="!meritBatch.selectionMode.value" class="merit-card__amount">
<text>¥{{ item.amount }}</text>
<AppButton
v-if="item.canEdit"
@@ -135,6 +163,17 @@
</view>
</view>
</view>
<AppDialog
:visible="meritBatch.confirmationVisible.value"
title="将选中的功德记录移入回收站?"
:message="meritBatch.confirmationMessage.value"
:confirm-text="meritBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="meritBatch.confirmDelete"
@cancel="meritBatch.cancelDelete"
/>
<AppDialog
:visible="Boolean(detailTarget)"
eyebrow="功德详情"
@@ -199,9 +238,12 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -263,6 +305,19 @@ const meritTypeRequestController = createRequestController();
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const meritBatch = useBatchDeletion({
items: merits,
deleteOne: (merit) =>
lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
requestController: meritDeletionRequestController,
}),
resourceName: "功德记录",
isActive: () => pageActive,
onEmpty: () => {
listState.value = "empty";
},
});
const deletableMerits = meritBatch.deletableItems;
const isEdit = computed(() => Boolean(editingMerit.value));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
@@ -338,6 +393,7 @@ const loadMerits = async () => {
});
if (!pageActive) return;
merits.value = rows;
meritBatch.exitSelectionMode();
listState.value = merits.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
@@ -437,6 +493,13 @@ const openMeritDetail = async (merit) => {
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const handleMeritClick = (merit) => {
if (meritBatch.selectionMode.value && merit?.canDelete) {
meritBatch.toggleSelection(merit);
return;
}
openMeritDetail(merit);
};
const closeMeritDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -591,19 +654,30 @@ const deleteMerit = async () => {
if (pageActive) deleting.value = false;
}
};
const requestBack = () =>
detailTarget.value
? (closeMeritDetail(), true)
: view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const requestBack = () => {
if (meritBatch.deleting.value) return true;
if (meritBatch.confirmationVisible.value) {
meritBatch.cancelDelete();
return true;
}
if (meritBatch.selectionMode.value) {
meritBatch.exitSelectionMode();
return true;
}
if (detailTarget.value) {
closeMeritDetail();
return true;
}
if (view.value !== "form") return goBack();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -782,21 +856,33 @@ onUnload(() => {
gap: 18rpx;
padding: 28rpx 32rpx;
}
.merit-card > view {
.merit-card__main {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.merit-card__copy {
min-width: 0;
flex: 1;
}
.merit-card__cover {
width: 148rpx;
height: 148rpx;
flex: 0 0 148rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.merit-card text {
display: block;
}
.merit-card > view text:first-child {
.merit-card__copy text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.merit-card > view text:not(:first-child) {
.merit-card__copy text:not(:first-child) {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
+67 -4
View File
@@ -30,8 +30,28 @@
><AppButton block label="新建往来记录" @click="createRelative"
/></view>
<view v-else class="record-list">
<BatchManagementBar
v-if="deletableRecords.length"
resource-name="往来记录"
:active="recordBatch.selectionMode.value"
:selected-count="recordBatch.selectedCount.value"
:all-selected="recordBatch.allSelected.value"
:busy="recordBatch.deleting.value"
@start="recordBatch.enterSelectionMode"
@finish="recordBatch.exitSelectionMode"
@toggle-all="recordBatch.toggleAll"
@delete="recordBatch.requestDelete"
/>
<text v-if="recordBatch.notice.value" class="batch-notice" role="status">{{ recordBatch.notice.value }}</text>
<text v-if="recordBatch.error.value" class="delete-error" role="alert">{{ recordBatch.error.value }}</text>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="`查看${item.name}的往来详情`" @click="openRecordDetail(item)">
<view v-for="item in records" :key="item.id" class="record-card" role="button" :aria-label="recordBatch.selectionMode.value && item.canDelete ? `${recordBatch.isSelected(item) ? '取消选择' : '选择'}${item.name}` : `查看${item.name}的往来详情`" @click="handleRecordClick(item)">
<BatchSelectionMark
v-if="recordBatch.selectionMode.value && item.canDelete"
:selected="recordBatch.isSelected(item)"
:label="`往来记录:${item.name}`"
@toggle="recordBatch.toggleSelection(item)"
/>
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="record-card__cover"
@@ -48,7 +68,7 @@
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
<view class="record-card__amount">
<view v-if="!recordBatch.selectionMode.value" class="record-card__amount">
<text v-if="item.amount">¥{{ item.amount }}</text>
<AppButton
v-if="item.canEdit"
@@ -68,6 +88,17 @@
</view>
</view>
</view>
<AppDialog
:visible="recordBatch.confirmationVisible.value"
title="将选中的往来记录移入回收站?"
:message="recordBatch.confirmationMessage.value"
:confirm-text="recordBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="recordBatch.confirmDelete"
@cancel="recordBatch.cancelDelete"
/>
<AppDialog
:visible="Boolean(detailTarget)"
eyebrow="往来详情"
@@ -113,8 +144,11 @@ import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useBatchDeletion } from "@/composables/use-batch-deletion.js";
import {
createRequestController,
isRequestCancelled
@@ -139,6 +173,19 @@ const detailError = ref("");
const relativeRecordDetailController = createRequestController();
let isPageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const recordBatch = useBatchDeletion({
items: records,
deleteOne: (record) =>
lifeRecordApi.deleteRelativeRecord(genealogyId.value, record.id, {
requestController: relativeRecordDeleteController,
}),
resourceName: "往来记录",
isActive: () => isPageActive,
onEmpty: () => {
relativeRecordListState.value = "empty";
},
});
const deletableRecords = recordBatch.deletableItems;
const loadRecords = async () => {
if (!valid.value) return;
relativeRecordListController.abort();
@@ -149,6 +196,7 @@ const loadRecords = async () => {
});
if (!isPageActive) return;
records.value = rows;
recordBatch.exitSelectionMode();
relativeRecordListState.value = records.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
@@ -156,7 +204,7 @@ const loadRecords = async () => {
}
};
const returnToFamily = () =>
valid.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
valid.value ? returnTo("G05", { genealogyId: genealogyId.value }) : goBack();
const createRelative = () =>
valid.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
@@ -188,6 +236,13 @@ const openRecordDetail = async (record) => {
detailError.value = getRequestErrorMessage(error, "完整记录读取失败,请稍后重试。");
}
};
const handleRecordClick = (record) => {
if (recordBatch.selectionMode.value && record?.canDelete) {
recordBatch.toggleSelection(record);
return;
}
openRecordDetail(record);
};
const closeRecordDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
@@ -211,7 +266,15 @@ const closeDeleteConfirmation = () => {
deleteTarget.value = null;
};
const requestBack = () => {
if (deleting.value || detailState.value === "loading") return true;
if (deleting.value || recordBatch.deleting.value || detailState.value === "loading") return true;
if (recordBatch.confirmationVisible.value) {
recordBatch.cancelDelete();
return true;
}
if (recordBatch.selectionMode.value) {
recordBatch.exitSelectionMode();
return true;
}
if (deleteConfirmationVisible.value) {
closeDeleteConfirmation();
return true;
+166 -44
View File
@@ -55,7 +55,7 @@
<picker
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, addForm.sex)"
@change="selectOption('sex', sexOptions, $event)"
@change="selectSex"
>
<view class="form-field form-field--picker">
<text>性别</text
@@ -168,19 +168,35 @@
type="number"
placeholder="可选正整数"
placeholder-class="form-placeholder"
@blur="loadRankOptions"
/>
<text v-else>首位成员固定为 1</text>
</view>
<picker
mode="date"
:value="addForm.birthDate"
@change="selectBirthDate"
v-if="rankOptions.length"
:range="rankPickerLabels"
:value="rankOptionIndex"
:disabled="rankOptionsState !== 'ready' || isSubmitting"
@change="selectRank"
>
<view class="form-field form-field--picker">
<text>出生日期</text
><text>{{ addForm.birthDate || "请选择" }}</text>
<text>排行称谓</text><text>{{ selectedRankName || "请选择" }}</text>
</view>
</picker>
<view v-else class="form-field form-field--disabled">
<text>排行称谓</text><text>{{ rankOptionStateLabel }}</text>
</view>
<text v-if="rankOptionsState === 'error'" class="field-error"
>排行配置暂未就绪请稍后重试</text
>
<view
class="form-field form-field--picker"
role="button"
aria-label="选择出生日期"
@click="openDatePicker('birthDate', '选择出生日期')"
>
<text>出生日期</text><text>{{ addForm.birthDate || "请选择" }}</text>
</view>
<picker
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, addForm.birthLunar)"
@@ -260,17 +276,15 @@
/>
</view>
<picker
<view
v-if="isDeceased"
mode="date"
:value="addForm.deathDate"
@change="selectDeathDate"
class="form-field form-field--picker"
role="button"
aria-label="选择逝世日期"
@click="openDatePicker('deathDate', '选择逝世日期')"
>
<view class="form-field form-field--picker">
<text>逝世日期</text
><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
</picker>
<text>逝世日期</text><text>{{ addForm.deathDate || "请选择" }}</text>
</view>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
@@ -338,17 +352,15 @@
<text>关系称谓</text><text>{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}</text>
</view>
</picker>
<picker
<view
v-if="isDeceased"
mode="date"
:value="addForm.burialDate"
@change="selectBurialDate"
class="form-field form-field--picker"
role="button"
aria-label="选择安葬日期"
@click="openDatePicker('burialDate', '选择安葬日期')"
>
<view class="form-field form-field--picker">
<text>安葬日期</text
><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
</picker>
<text>安葬日期</text><text>{{ addForm.burialDate || "请选择" }}</text>
</view>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -373,15 +385,6 @@
}}</text>
</view>
</picker>
<view class="form-field">
<text>排序值</text>
<input
v-model="addForm.sortOrder"
type="number"
placeholder="可选整数,值越小越靠前"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea
@@ -427,6 +430,14 @@
</view>
</view>
<MemberDatePickerSheet
:visible="datePickerVisible"
:title="datePickerTitle"
:value="datePickerValue"
@cancel="closeDatePicker"
@confirm="confirmDatePicker"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="尚未保存"
@@ -447,6 +458,7 @@ import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -486,10 +498,14 @@ const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const datePickerVisible = ref(false);
const datePickerField = ref("");
const datePickerTitle = ref("选择日期");
const currentMember = ref(null);
const errorMessage = ref("");
const memberDetailRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const rankOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberCreationRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
@@ -503,6 +519,8 @@ const dictionaryRequestControllers = {
const memberCreationGuard = createNonIdempotentWriteGuard();
let pageActive = true;
let loadSequence = 0;
let rankLoadSequence = 0;
let rankOptionsContext = "";
const relationIntents = Object.freeze(
Object.fromEntries(
@@ -526,6 +544,7 @@ const addForm = reactive({
sex: "",
generation: "",
generationName: "",
rankId: "",
avatarOssId: "",
birthDate: "",
birthLunar: "",
@@ -548,7 +567,6 @@ const addForm = reactive({
personStatus: "",
biography: "",
remark: "",
sortOrder: "",
});
const fieldErrors = reactive({
name: "",
@@ -560,6 +578,8 @@ const fieldErrors = reactive({
});
const memberOptionsState = ref("loading");
const memberOptions = ref([]);
const rankOptionsState = ref("idle");
const rankOptions = ref([]);
const zodiacOptions = ref([]);
const educationOptions = ref([]);
const deathExpressionOptions = ref([]);
@@ -568,6 +588,7 @@ const spouseRelationVariantOptions = ref([]);
const isFirstMember = computed(() => mode.value === "first");
const isDeceased = computed(() => addForm.personStatus === "1");
const datePickerValue = computed(() => addForm[datePickerField.value] || "");
const activeRelationIntent = computed(
() => relationIntents[relationType.value] || null,
);
@@ -617,6 +638,23 @@ const formCopy = computed(() =>
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
);
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
const rankPickerLabels = computed(() => [
"请选择排行称谓",
...rankOptions.value.map((item) => item.rankName),
]);
const rankOptionIndex = computed(() => {
const index = rankOptions.value.findIndex((item) => item.rankId === addForm.rankId);
return index < 0 ? 0 : index + 1;
});
const selectedRankName = computed(
() => rankOptions.value.find((item) => item.rankId === addForm.rankId)?.rankName || "",
);
const rankOptionStateLabel = computed(() => {
if (rankOptionsState.value === "loading") return "正在读取";
if (rankOptionsState.value === "error") return "暂时无法读取";
if (rankOptionsState.value === "idle") return "请先选择性别和世代";
return "当前条件暂无可选排行";
});
const resultCopy = computed(() => {
if (memberCreationOutcomeUnknown.value) {
return {
@@ -675,6 +713,7 @@ const loadCurrentMember = async () => {
});
if (!pageActive || activeLoad !== loadSequence) return;
currentMember.value = member;
applyRelationDefaults();
addState.value = "form";
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
@@ -753,6 +792,7 @@ onLoad((query) => {
void loadMemberOptions();
void loadBusinessOptions();
if (isFirstMember.value) {
applyRelationDefaults();
addState.value = "form";
return;
}
@@ -763,6 +803,7 @@ onUnload(() => {
loadSequence += 1;
memberDetailRequestController.abort();
memberOptionsRequestController.abort();
rankOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberCreationRequestController.abort();
sensitiveProfileRequestController.abort();
@@ -772,10 +813,13 @@ onUnload(() => {
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
transientOpen: datePickerVisible.value || discardDialogVisible.value,
dirty: addState.value === "form" && hasDraft.value,
submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard,
"close-transient": () => {
if (datePickerVisible.value) return closeDatePicker();
return cancelDiscard();
},
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
@@ -788,6 +832,7 @@ const clearError = (field) => {
const selectRelation = (event) => {
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
addForm.relationVariantCode = "";
applyRelationDefaults();
clearError("relation");
};
const optionIndex = findMemberOptionIndex;
@@ -795,6 +840,64 @@ const optionLabel = findMemberOptionLabel;
const selectOption = (field, options, event) => {
updateMemberFormOption(addForm, field, options, event);
};
const relationGeneration = () => {
const currentGeneration = Number(currentMember.value?.generation);
if (!Number.isSafeInteger(currentGeneration) || currentGeneration < 1) return "";
if ([memberRelationTypes.FATHER, memberRelationTypes.MOTHER].includes(selectedRelationType.value)) {
return String(Math.max(1, currentGeneration - 1));
}
if ([memberRelationTypes.SON, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
return String(currentGeneration + 1);
}
return String(currentGeneration);
};
const applyRelationDefaults = () => {
addForm.generation = isFirstMember.value ? "1" : relationGeneration();
if ([memberRelationTypes.FATHER, memberRelationTypes.SON].includes(selectedRelationType.value)) {
addForm.sex = "0";
} else if ([memberRelationTypes.MOTHER, memberRelationTypes.DAUGHTER].includes(selectedRelationType.value)) {
addForm.sex = "1";
}
rankOptionsContext = "";
addForm.rankId = "";
void loadRankOptions();
};
const loadRankOptions = async () => {
const generation = Number(isFirstMember.value ? 1 : addForm.generation);
const sex = String(addForm.sex || "");
const context = `${generation}:${sex}`;
const activeLoad = ++rankLoadSequence;
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
rankOptionsContext = "";
rankOptions.value = [];
rankOptionsState.value = "idle";
addForm.rankId = "";
return;
}
if (context !== rankOptionsContext) addForm.rankId = "";
rankOptionsContext = context;
rankOptionsState.value = "loading";
try {
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
requestController: rankOptionsRequestController,
});
if (!pageActive || activeLoad !== rankLoadSequence) return;
rankOptions.value = options;
rankOptionsState.value = "ready";
} catch (error) {
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
rankOptions.value = [];
rankOptionsState.value = "error";
}
};
const selectSex = (event) => {
selectOption("sex", sexOptions, event);
void loadRankOptions();
};
const selectRank = (event) => {
const selectedIndex = Number(event.detail.value) - 1;
addForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
};
const selectBindingMode = (event) => {
updateMemberBindingMode(addForm, event);
fieldErrors.bindingMode = "";
@@ -835,14 +938,26 @@ const uploadAvatar = async () => {
if (pageActive) isAvatarUploading.value = false;
}
};
const selectBirthDate = (event) => {
addForm.birthDate = event.detail.value || "";
const openDatePicker = (field, title) => {
datePickerField.value = field;
datePickerTitle.value = title;
datePickerVisible.value = true;
};
const selectDeathDate = (event) => {
addForm.deathDate = event.detail.value || "";
const closeDatePicker = () => {
datePickerVisible.value = false;
};
const selectBurialDate = (event) => {
addForm.burialDate = event.detail.value || "";
const confirmDatePicker = (value) => {
if (!datePickerField.value) return closeDatePicker();
addForm[datePickerField.value] = value;
fieldErrors.dates = "";
closeDatePicker();
};
const localToday = () => {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, "0");
const day = String(today.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
const validateAddForm = () => {
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
@@ -858,8 +973,15 @@ const validateAddForm = () => {
addForm.email.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(addForm.email.trim())
? "请填写有效的电子邮箱"
: "";
const today = localToday();
fieldErrors.dates =
addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
addForm.birthDate && addForm.birthDate > today
? "出生日期不能晚于今天"
: addForm.deathDate && addForm.deathDate > today
? "逝世日期不能晚于今天"
: addForm.burialDate && addForm.burialDate > today
? "安葬日期不能晚于今天"
: addForm.birthDate && addForm.deathDate && addForm.deathDate < addForm.birthDate
? "逝世日期不能早于出生日期"
: addForm.deathDate && addForm.burialDate && addForm.burialDate < addForm.deathDate
? "安葬日期不能早于逝世日期"
@@ -884,6 +1006,7 @@ const submitAdd = async () => {
aliasName: addForm.aliasName,
sex: addForm.sex,
generationName: addForm.generationName,
...(addForm.rankId ? { rankId: addForm.rankId } : {}),
avatarOssId: addForm.avatarOssId,
birthDate: addForm.birthDate,
birthLunar: addForm.birthLunar,
@@ -908,7 +1031,6 @@ const submitAdd = async () => {
personStatus: addForm.personStatus,
biography: addForm.biography,
remark: addForm.remark,
sortOrder: addForm.sortOrder,
...(isFirstMember.value
? { generation: 1 }
: {
+142 -42
View File
@@ -49,7 +49,7 @@
<picker
:range="sexOptions.map((item) => item.label)"
:value="optionIndex(sexOptions, editForm.sex)"
@change="selectOption('sex', sexOptions, $event)"
@change="selectSex"
>
<view class="form-field form-field--picker">
<text>性别</text
@@ -148,8 +148,28 @@
type="number"
placeholder="正整数"
placeholder-class="form-placeholder"
@blur="loadRankOptions"
/>
</view>
<picker
v-if="rankOptions.length"
:range="rankPickerLabels"
:value="rankOptionIndex"
:disabled="rankOptionsState !== 'ready'"
@change="selectRank"
>
<view class="form-field form-field--picker">
<text>排行称谓</text>
<text>{{ selectedRankName || "请选择" }}</text>
</view>
</picker>
<view v-else class="form-field form-field--disabled">
<text>排行称谓</text>
<text>{{ rankOptionStateLabel }}</text>
</view>
<text v-if="rankOptionsState === 'error'" class="field-error"
>排行配置暂未就绪请稍后重试</text
>
<picker
:range="personOptionLabels"
:value="personOptionIndex(editForm.fatherId)"
@@ -170,16 +190,15 @@
><text>{{ personOptionLabel(editForm.motherId) }}</text></view
>
</picker>
<picker
mode="date"
:value="editForm.birthDate"
@change="selectDate('birthDate', $event)"
<view
class="form-field form-field--picker"
role="button"
aria-label="选择出生日期"
@click="openDatePicker('birthDate', '选择出生日期')"
>
<view class="form-field form-field--picker"
><text>出生日期</text
><text>{{ editForm.birthDate || "未填写" }}</text></view
>
</picker>
<text>出生日期</text>
<text>{{ editForm.birthDate || "未填写" }}</text>
</view>
<picker
:range="lunarOptions.map((item) => item.label)"
:value="optionIndex(lunarOptions, editForm.birthLunar)"
@@ -257,17 +276,16 @@
placeholder-class="form-placeholder"
/>
</view>
<picker
<view
v-if="isDeceased"
mode="date"
:value="editForm.deathDate"
@change="selectDate('deathDate', $event)"
class="form-field form-field--picker"
role="button"
aria-label="选择离世日期"
@click="openDatePicker('deathDate', '选择离世日期')"
>
<view class="form-field form-field--picker"
><text>离世日期</text
><text>{{ editForm.deathDate || "在世或未填写" }}</text></view
>
</picker>
<text>离世日期</text>
<text>{{ editForm.deathDate || "在世或未填写" }}</text>
</view>
<picker
v-if="isDeceased"
:range="lunarOptions.map((item) => item.label)"
@@ -329,17 +347,16 @@
class="form-note"
>遗传病史属于敏感健康信息仅在已授权时读取和保存</text
>
<picker
<view
v-if="isDeceased"
mode="date"
:value="editForm.burialDate"
@change="selectDate('burialDate', $event)"
class="form-field form-field--picker"
role="button"
aria-label="选择安葬日期"
@click="openDatePicker('burialDate', '选择安葬日期')"
>
<view class="form-field form-field--picker"
><text>安葬日期</text
><text>{{ editForm.burialDate || "未填写" }}</text></view
>
</picker>
<text>安葬日期</text>
<text>{{ editForm.burialDate || "未填写" }}</text>
</view>
<view v-if="isDeceased" class="form-field">
<text>安葬地</text>
<input
@@ -361,15 +378,6 @@
}}</text>
</view>
</picker>
<view class="form-field">
<text>排序值</text>
<input
v-model="editForm.sortOrder"
type="number"
placeholder="可选整数,值越小越靠前"
placeholder-class="form-placeholder"
/>
</view>
<view class="form-field form-field--summary">
<text>人物简介</text>
<textarea
@@ -419,6 +427,14 @@
</view>
</view>
<MemberDatePickerSheet
:visible="datePickerVisible"
:title="datePickerTitle"
:value="datePickerValue"
@cancel="closeDatePicker"
@confirm="confirmDatePicker"
/>
<AppDialog
:visible="discardDialogVisible"
eyebrow="资料尚未保存"
@@ -439,6 +455,7 @@ import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import AppDialog from "@/components/AppDialog.vue";
import MemberDatePickerSheet from "@/components/tree/MemberDatePickerSheet.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
@@ -476,11 +493,15 @@ const isAvatarUploading = ref(false);
const avatarFileName = ref("");
const avatarUploadError = ref("");
const discardDialogVisible = ref(false);
const datePickerVisible = ref(false);
const datePickerField = ref("");
const datePickerTitle = ref("选择日期");
const errorMessage = ref("");
const failedAction = ref("load");
const memberDetailRequestController = createRequestController();
const personOptionsRequestController = createRequestController();
const memberOptionsRequestController = createRequestController();
const rankOptionsRequestController = createRequestController();
const avatarUploadRequestController = createRequestController();
const memberUpdateRequestController = createRequestController();
const sensitiveProfileRequestController = createRequestController();
@@ -491,6 +512,8 @@ const dictionaryRequestControllers = {
};
let pageActive = true;
let loadSequence = 0;
let rankLoadSequence = 0;
let rankOptionsContext = "";
const originalMember = ref(null);
const baseline = ref("");
@@ -504,6 +527,7 @@ const editForm = reactive({
sex: "",
generation: "",
generationName: "",
rankId: "",
fatherId: "",
motherId: "",
avatarOssId: "",
@@ -541,6 +565,8 @@ const lunarOptions = memberFormOptions.lunar;
const personStatusOptions = memberFormOptions.personStatus;
const bindingModeOptions = memberFormOptions.bindingMode;
const personOptions = ref([{ label: "不选择", value: "" }]);
const rankOptionsState = ref("idle");
const rankOptions = ref([]);
const personOptionLabels = computed(() =>
personOptions.value.map((item) => item.label),
);
@@ -565,6 +591,26 @@ const memberOptionLabels = computed(() =>
memberBindingOptions.value.map((item) => item.label),
);
const isDeceased = computed(() => editForm.personStatus === "1");
const datePickerValue = computed(() => editForm[datePickerField.value] || "");
const rankOptionIndex = computed(() => {
const index = rankOptions.value.findIndex((item) => item.rankId === editForm.rankId);
return index < 0 ? 0 : index + 1;
});
const rankPickerLabels = computed(() => [
"请选择排行称谓",
...rankOptions.value.map((item) => item.rankName),
]);
const selectedRankName = computed(
() =>
rankOptions.value.find((item) => item.rankId === editForm.rankId)?.rankName ||
(editForm.rankId === originalMember.value?.rankId ? originalMember.value?.rankName : "") ||
"",
);
const rankOptionStateLabel = computed(() => {
if (rankOptionsState.value === "loading") return "正在读取";
if (rankOptionsState.value === "error") return "暂时无法读取";
return selectedRankName.value || "暂无可选排行";
});
const formSnapshot = computed(() => JSON.stringify(editForm));
const hasValidContext = computed(() =>
@@ -654,6 +700,7 @@ const loadMember = async () => {
sex: member.sex || "",
generation: member.generation || "",
generationName: member.generationName || "",
rankId: member.rankId || "",
fatherId:
member.relatives.find((item) => item.relation === "父亲")?.id || "",
motherId:
@@ -696,6 +743,8 @@ const loadMember = async () => {
errorMessage.value = "";
failedAction.value = "load";
editState.value = "form";
rankOptionsContext = `${Number(editForm.generation)}:${String(editForm.sex || "")}`;
void loadRankOptions();
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
originalMember.value = null;
@@ -720,7 +769,7 @@ const loadPersonOptions = async () => {
return null;
}
const name = item.name;
return { value: personOptionId, label: `${name}${personOptionId}` };
return { value: personOptionId, label: name };
})
.filter(Boolean);
personOptions.value = [{ label: "不选择", value: "" }, ...options];
@@ -779,6 +828,32 @@ const loadMemberOptions = async () => {
memberOptionsState.value = "error";
}
};
const loadRankOptions = async () => {
const generation = Number(editForm.generation);
const sex = String(editForm.sex || "");
const context = `${generation}:${sex}`;
const activeLoad = ++rankLoadSequence;
if (!Number.isSafeInteger(generation) || generation < 1 || !["0", "1", "2"].includes(sex)) {
rankOptions.value = [];
rankOptionsState.value = "idle";
return;
}
if (context !== rankOptionsContext) editForm.rankId = "";
rankOptionsContext = context;
rankOptionsState.value = "loading";
try {
const options = await lineageApi.getRankOptions(genealogyId.value, generation, sex, {
requestController: rankOptionsRequestController,
});
if (!pageActive || activeLoad !== rankLoadSequence) return;
rankOptions.value = options;
rankOptionsState.value = "ready";
} catch (error) {
if (!pageActive || activeLoad !== rankLoadSequence || isRequestCancelled(error)) return;
rankOptions.value = [];
rankOptionsState.value = "error";
}
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
@@ -802,6 +877,7 @@ onUnload(() => {
memberDetailRequestController.abort();
personOptionsRequestController.abort();
memberOptionsRequestController.abort();
rankOptionsRequestController.abort();
avatarUploadRequestController.abort();
memberUpdateRequestController.abort();
sensitiveProfileRequestController.abort();
@@ -811,10 +887,13 @@ onUnload(() => {
const requestBack = () =>
runBackGuard({
transientOpen: discardDialogVisible.value,
transientOpen: datePickerVisible.value || discardDialogVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value || isAvatarUploading.value,
"close-transient": cancelDiscard,
"close-transient": () => {
if (datePickerVisible.value) return closeDatePicker();
return cancelDiscard();
},
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
@@ -829,6 +908,16 @@ const optionLabel = findMemberOptionLabel;
const selectOption = (field, options, event) => {
updateMemberFormOption(editForm, field, options, event);
};
const selectSex = (event) => {
const previousSex = editForm.sex;
selectOption("sex", sexOptions, event);
if (editForm.sex !== previousSex) editForm.rankId = "";
void loadRankOptions();
};
const selectRank = (event) => {
const selectedIndex = Number(event.detail.value) - 1;
editForm.rankId = rankOptions.value[selectedIndex]?.rankId || "";
};
const selectBindingMode = (event) => {
updateMemberBindingMode(editForm, event);
fieldErrors.bindingMode = "";
@@ -880,9 +969,19 @@ const uploadAvatar = async () => {
if (pageActive) isAvatarUploading.value = false;
}
};
const selectDate = (field, event) => {
editForm[field] = event.detail.value || "";
const openDatePicker = (field, title) => {
datePickerField.value = field;
datePickerTitle.value = title;
datePickerVisible.value = true;
};
const closeDatePicker = () => {
datePickerVisible.value = false;
};
const confirmDatePicker = (value) => {
if (!datePickerField.value) return closeDatePicker();
editForm[datePickerField.value] = value;
fieldErrors.dates = "";
closeDatePicker();
};
const validateEditForm = () => {
fieldErrors.name = editForm.name.trim() ? "" : "请填写成员姓名";
@@ -936,6 +1035,7 @@ const saveMember = async () => {
? Number(editForm.generation)
: undefined,
generationName: editForm.generationName,
rankId: editForm.rankId,
fatherId: editForm.fatherId,
motherId: editForm.motherId,
avatarOssId: editForm.avatarOssId,
+55 -309
View File
@@ -1,346 +1,92 @@
<template>
<view
class="rank-page"
:class="{
'rank-state--loading': rankState === 'loading',
'rank-state--form': rankState === 'form',
'rank-state--error': rankState === 'error',
}"
>
<view class="rank-redirect-page">
<ModulePageBackground module="tree" />
<view class="rank-page__header"
><PageHeader title="调整排行" custom-back @back="requestBack"
/></view>
<view class="rank-panel">
<PageHeader title="编辑成员" />
<view class="rank-redirect-panel">
<AppLoading
v-if="rankState === 'loading'"
text="正在读取成员资料"
description="请稍候,正在确认待调整人物。"
v-if="redirectState === 'loading'"
text="正在打开成员资料"
description="排行称谓已合并到成员编辑页面。"
/>
<view v-else-if="rankState === 'form' && member" class="rank-form">
<text class="form-eyebrow">同辈排行</text>
<text class="form-title">调整{{ member.name }}的排行</text>
<text class="form-copy"
>这里只调整这位成员的排行同辈成员的整体排序暂不能在这里修改</text
>
<view class="member-context">
<text>当前成员</text
><text> {{ member.generation }} · {{ member.branch }}</text>
</view>
<view class="rank-field">
<text>排序值</text>
<input
v-model="sortOrder"
type="number"
:disabled="savingRank"
placeholder="请输入整数,值越小越靠前"
placeholder-class="rank-placeholder"
@input="rankError = ''"
/>
</view>
<text v-if="rankError" class="rank-error">{{ rankError }}</text>
<view
class="form-action"
:class="{ 'form-action--disabled': savingRank }"
@click="saveRank"
>
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="scaleToFill"
/>
<text>{{ savingRank ? "正在保存…" : "保存排行" }}</text>
</view>
</view>
<view v-else class="rank-result">
<text class="form-eyebrow">暂时无法打开成员页面</text>
<text class="form-title">暂时无法读取成员资料</text>
<text class="form-copy">{{ errorMessage }}</text>
<view class="form-action" @click="goBack">
<image
src="/static/assets/foundation/transparent/scroll-primary.png"
mode="scaleToFill"
/>
<text>返回世系树</text>
<view v-else class="rank-redirect-error">
<text class="rank-redirect-error__title">暂时无法打开成员资料</text>
<text class="rank-redirect-error__copy">{{ errorMessage }}</text>
<view class="rank-redirect-error__action" @click="goBack">
<text>返回上一页</text>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
eyebrow="未保存修改"
title="放弃排行修改?"
message="当前排序值还没有保存。"
confirm-text="确认放弃"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppDialog from "@/components/AppDialog.vue";
import { ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { lineageApi } from "@/services/api/lineage-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
import { goBack, returnTo } from "@/utils/navigation/gateway.js";
const rankState = ref("loading");
const genealogyId = ref("");
const personId = ref("");
const member = ref(null);
const redirectState = ref("loading");
const errorMessage = ref("");
const sortOrder = ref("");
const savingRank = ref(false);
const rankError = ref("");
const committedSortOrder = ref("");
const sortOrderBaseline = ref("");
const discardVisible = ref(false);
const memberRankReadRequestController = createRequestController();
const memberRankSaveRequestController = createRequestController();
let pageActive = true;
let loadSequence = 0;
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const isDirty = computed(() =>
rankState.value === "form" && sortOrder.value !== sortOrderBaseline.value,
);
const loadMember = async () => {
const activeLoad = ++loadSequence;
rankState.value = "loading";
onLoad(async (query) => {
const genealogyId = String(query?.genealogyId || "");
const personId = String(query?.personId || "");
if (!/^[1-9]\d*$/.test(genealogyId) || !/^[1-9]\d*$/.test(personId)) {
redirectState.value = "error";
errorMessage.value = "页面参数已经失效,请从成员资料重新进入。";
return;
}
try {
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
requestController: memberRankReadRequestController,
});
if (!pageActive || activeLoad !== loadSequence) return;
member.value = personDetail;
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
? ""
: String(personDetail.sortOrder);
sortOrderBaseline.value = sortOrder.value;
committedSortOrder.value = "";
errorMessage.value = "";
rankState.value = "form";
await returnTo("T05", { genealogyId, personId });
} catch (error) {
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
member.value = null;
errorMessage.value =
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
rankState.value = "error";
redirectState.value = "error";
errorMessage.value = error?.message || "请返回后重新打开成员资料。";
}
};
const saveRank = async () => {
if (savingRank.value || !member.value) return;
const normalizedSortOrder = String(sortOrder.value).trim();
if (!/^-?\d+$/.test(normalizedSortOrder)) {
rankError.value = "请填写整数,例如 1、2、3。";
return;
}
const numericSortOrder = Number(normalizedSortOrder);
if (!Number.isSafeInteger(numericSortOrder)) {
rankError.value = "排序值超出可保存范围";
return;
}
savingRank.value = true;
rankError.value = "";
try {
if (committedSortOrder.value !== normalizedSortOrder) {
await lineageApi.updatePersonSortOrder(
genealogyId.value,
personId.value,
numericSortOrder,
{ requestController: memberRankSaveRequestController },
);
if (!pageActive) return;
committedSortOrder.value = normalizedSortOrder;
sortOrderBaseline.value = normalizedSortOrder;
}
await returnTo("T01", {
genealogyId: genealogyId.value,
selectedId: personId.value,
});
} catch (error) {
if (pageActive && !isRequestCancelled(error)) {
rankError.value = committedSortOrder.value === normalizedSortOrder
? "排行已经保存,但页面返回失败。请再次点击保存重试返回。"
: getRequestErrorMessage(error, "排行尚未保存,请稍后重试。");
}
} finally {
if (pageActive) savingRank.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: savingRank.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
if (
!/^[1-9]\d*$/.test(genealogyId.value) ||
!/^[1-9]\d*$/.test(personId.value) ||
query.mode !== "rank"
) {
rankState.value = "error";
errorMessage.value = "请从成员资料页重新进入排行调整。";
return;
}
void loadMember();
});
onUnload(() => {
pageActive = false;
loadSequence += 1;
memberRankReadRequestController.abort();
memberRankSaveRequestController.abort();
discardConfirmation.dispose();
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.rank-page {
display: flex;
.rank-redirect-page {
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.rank-page__header {
z-index: 3;
}
.rank-panel {
@include adaptive-tree-panel;
z-index: 2;
width: calc(100% - 32rpx);
margin: 18rpx auto calc(28rpx + env(safe-area-inset-bottom));
padding: 7.5% 8%;
}
.form-eyebrow {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
letter-spacing: 3rpx;
}
.form-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
line-height: 1.35;
}
.form-copy {
display: block;
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.member-context,
.rank-field {
@include adaptive-tree-field;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
min-height: 78rpx;
align-items: center;
gap: 20rpx;
margin-top: 16rpx;
padding: 12rpx 22rpx;
}
.member-context text:first-child,
.rank-field text {
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
}
.member-context text:last-child {
min-width: 0;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.45;
text-align: right;
}
.rank-field input {
min-width: 0;
min-height: var(--app-touch-min);
padding: 0 14rpx;
border: 1rpx solid rgba(143, 108, 63, 0.34);
border-radius: 8rpx;
background: rgba(255, 253, 247, 0.8);
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
}
.rank-placeholder { color: $ink-muted; }
.rank-error { display: block; margin-top: 12rpx; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
.form-action {
display: grid;
width: 100%;
min-height: var(--app-touch-min);
margin-top: 22rpx;
}
.form-action--disabled { opacity: 0.58; pointer-events: none; }
.form-action image,
.form-action text {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.form-action text {
.rank-redirect-panel {
position: relative;
z-index: 1;
padding: 48rpx 32rpx;
}
.rank-redirect-error {
display: flex;
align-items: center;
justify-content: center;
color: #fff9ed;
font-size: clamp(15px, 24rpx, 18px);
flex-direction: column;
gap: 24rpx;
padding: 64rpx 32rpx;
text-align: center;
}
.rank-redirect-error__title {
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.rank-result {
margin-top: 30%;
text-align: center;
.rank-redirect-error__copy {
color: $ink-muted;
font-size: 28rpx;
line-height: 1.7;
}
.rank-result .form-eyebrow,
.rank-result .form-copy {
text-align: center;
}
.rank-result .form-action {
width: 420rpx;
max-width: 100%;
margin-right: auto;
margin-left: auto;
}
@media (min-width: 400px) {
.rank-panel {
width: calc(100% - 48rpx);
}
.rank-redirect-error__action {
min-width: 240rpx;
padding: 22rpx 36rpx;
border: 1rpx solid rgba($brand-red, 0.4);
color: $brand-red;
font-size: 30rpx;
}
</style>
+53 -88
View File
@@ -26,10 +26,9 @@
aria-label="回到当前成员"
hover-class="tree-action--pressed"
@click="recenterSelectedMember"
>回到当前</text
>回到当前</text
>
<text @click="treeState = 'landscape'">阅读提示</text>
<text @click="toRank">调整排行</text>
</view>
</view>
@@ -105,19 +104,14 @@
>
<view class="member-node__surface">
<view class="member-node__portrait">
<AppAvatar :sex="member.sex" />
<AppAvatar
:sex="member.sex"
:src="member.avatarFile?.accessUrl || ''"
/>
<text class="node-relation">{{ member.relation }}</text>
</view>
<image
class="member-node__divider"
src="/static/assets/foundation/transparent/auth-divider-knot.png"
mode="aspectFit"
/>
<view class="member-node__copy">
<text class="node-name">{{ member.name }}</text>
<text class="node-relation">{{
nodeRelationText(member)
}}</text>
<text class="node-years">{{ member.treeYears || member.years }}</text>
</view>
</view>
</view>
@@ -243,13 +237,6 @@ const memberActions = Object.freeze([
routeKey: "T04",
relationType: memberRelationTypes.SIBLING,
},
{
key: "ADJUST_RANK",
label: "调整排行",
group: "MANAGEMENT",
routeKey: "T06",
mode: "rank",
},
{
key: "ADD_SON",
label: "添加儿子",
@@ -282,10 +269,6 @@ const managementActions = computed(() =>
memberActions.filter((action) => action.group === "MANAGEMENT"),
);
const nodeRelationText = (member) => {
const branch = String(member.branch || "").replace(/字辈$/, "");
return [member.relation, branch].filter(Boolean).join(" · ");
};
const treeLayout = computed(() => createTreeLayout(members.value));
const layoutMembers = computed(() => treeLayout.value.members);
const treeMetrics = computed(() => treeLayout.value.metrics);
@@ -493,18 +476,6 @@ const toDirectory = () =>
openPage("T07", { genealogyId: genealogyId.value }, "T01");
const toPedigree = () =>
openPage("T02", { genealogyId: genealogyId.value }, "T01");
const toRank = () =>
selectedMember.value
? openPage(
"T06",
{
genealogyId: genealogyId.value,
personId: String(selectedMember.value.id),
mode: "rank",
},
"T01",
)
: Promise.resolve(false);
const openMemberPanel = (member) => {
selectedMember.value = member;
memberActionPanelVisible.value = true;
@@ -521,8 +492,6 @@ const openMemberAction = (action) => {
};
if (action.routeKey === "T04") {
params.relationType = action.relationType;
} else if (action.routeKey === "T06") {
params.mode = action.mode;
}
return openPage(action.routeKey, params, "T01");
};
@@ -584,7 +553,7 @@ const openMemberAction = (action) => {
}
.tree-stage--lineage {
display: grid;
grid-template-columns: 72rpx minmax(0, 1fr);
grid-template-columns: 112rpx minmax(0, 1fr);
align-items: start;
}
.tree-scroll {
@@ -595,7 +564,7 @@ const openMemberAction = (action) => {
.generation-rail {
display: grid;
z-index: 4;
width: 72rpx;
width: 112rpx;
height: 100%;
box-sizing: border-box;
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
@@ -627,7 +596,7 @@ const openMemberAction = (action) => {
display: grid;
justify-self: center;
z-index: 3;
width: 62rpx;
width: 100rpx;
height: 104rpx;
color: #8f6c3f;
font-size: clamp(12px, 18rpx, 14px);
@@ -647,17 +616,17 @@ const openMemberAction = (action) => {
.generation-band__copy {
z-index: 1;
display: flex;
flex-direction: row;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 0;
justify-content: center;
gap: 4rpx;
height: 100%;
padding: 0 4rpx;
padding: 8rpx 4rpx;
box-sizing: border-box;
background: rgba(255, 252, 244, 0.9);
}
.generation-band__copy text {
writing-mode: vertical-rl;
text-orientation: upright;
line-height: 1.35;
white-space: nowrap;
}
.generation-band__copy text:last-child {
@@ -677,8 +646,8 @@ const openMemberAction = (action) => {
align-self: start;
justify-self: start;
z-index: 2;
width: 160rpx;
height: 224rpx;
width: 154rpx;
height: 200rpx;
transform: translate(-50%, -50%);
text-align: center;
}
@@ -689,85 +658,81 @@ const openMemberAction = (action) => {
flex-direction: column;
align-items: center;
box-sizing: border-box;
padding: 9rpx 8rpx 8rpx;
border: 1rpx solid #b78a42;
border-radius: 14rpx;
background: rgba(255, 252, 244, 0.96);
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
padding: 10rpx;
background: url("/static/assets/modules/tree/transparent/member-node-portrait.png") center / 100% 100% no-repeat;
}
.member-node__portrait {
position: relative;
display: flex;
width: 62rpx;
height: 62rpx;
width: 134rpx;
height: 126rpx;
align-items: center;
justify-content: center;
box-sizing: border-box;
border: 1rpx solid #c49a57;
border-radius: 50%;
border-radius: 2rpx;
background: #fffdf7;
overflow: hidden;
}
.member-node__divider {
width: 56rpx;
height: 14rpx;
margin-top: 3rpx;
}
.member-node__copy {
display: flex;
width: 100%;
margin-top: 1rpx;
min-height: 52rpx;
margin-top: 4rpx;
padding: 0;
align-items: center;
justify-content: center;
}
.node-name,
.node-relation,
.node-years {
.node-relation {
display: block;
}
.node-name {
display: -webkit-box;
color: #6b2419;
font-family: "STKaiti", "KaiTi", serif;
overflow: hidden;
font-size: clamp(15px, 24rpx, 18px);
font-weight: 700;
line-height: 1.1;
color: #d96529;
font-family: "STKaiti", "KaiTi", serif;
font-size: clamp(14px, 24rpx, 18px);
font-weight: 500;
line-height: 1.15;
word-break: break-all;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.node-relation {
position: absolute;
right: 50%;
bottom: 5rpx;
z-index: 1;
min-width: 54rpx;
max-width: 116rpx;
box-sizing: border-box;
overflow: hidden;
margin-top: 2rpx;
color: #644b2e;
font-size: clamp(12px, 18rpx, 14px);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-years {
overflow: hidden;
margin-top: 2rpx;
color: #695b4c;
font-size: clamp(12px, 17rpx, 14px);
line-height: 1.2;
padding: 1rpx 6rpx;
transform: translateX(50%);
border: 1rpx solid #e66d24;
background: rgba(255, 255, 255, 0.96);
color: #e66d24;
font-size: clamp(12px, 22rpx, 15px);
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-node--spouse .member-node__surface {
border-color: #c39a61;
background: rgba(255, 250, 241, 0.98);
background-image: url("/static/assets/modules/tree/transparent/member-node-portrait.png");
}
.member-node--selected {
z-index: 3;
}
.member-node--selected .member-node__surface {
border-color: #c55344;
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
filter: drop-shadow(0 4rpx 5rpx rgba(159, 23, 15, 0.2));
}
.member-node--selected .member-node__portrait {
border-color: #c55344;
}
.member-node--selected .node-name {
color: $brand-red;
color: #d96529;
}
.tree-state-card {
display: grid;
+18 -30
View File
@@ -31,7 +31,7 @@
:show-scrollbar="false"
:style="pedigreeScrollStyle"
>
<view class="pedigree-sheet" :style="pedigreeSheetStyle">
<view class="pedigree-sheet" :style="getPedigreeSheetStyle(page)">
<view class="pedigree-column pedigree-column--legend">
<view class="pedigree-column__relation"><text>身份</text></view>
<view class="pedigree-column__name"><text>{{ page.label }}</text></view>
@@ -40,15 +40,14 @@
<view
v-for="(member, memberIndex) in page.members"
:key="member?.id || `empty-${pageIndex}-${memberIndex}`"
:key="member.id || `member-${pageIndex}-${memberIndex}`"
class="member-node"
:class="{
'member-node--blank': !member,
'member-node--selected': selectedId === String(member?.id || ''),
'member-node--spouse': Boolean(member?.spouseOf),
'member-node--selected': selectedId === String(member.id),
'member-node--spouse': Boolean(member.spouseOf),
}"
>
<view v-if="member" class="member-node__surface">
<view class="member-node__surface">
<view class="member-node__relation"><text>{{ memberRelation(member) }}</text></view>
<view
class="member-node__name"
@@ -65,22 +64,16 @@
:aria-label="`查看${member.name}的生平详情`"
@click.stop="openMemberDetail(member)"
>
<text class="node-relation">{{ member.branch }}</text>
<text class="node-years">{{ memberDetail(member) }}</text>
<text>{{ memberDetailText(member) }}</text>
</view>
</view>
<view v-else class="member-node__surface">
<view class="member-node__relation"></view>
<view class="member-node__name"></view>
<view class="member-node__copy"></view>
</view>
</view>
</view>
</scroll-view>
</swiper-item>
</swiper>
<view class="generation-rail" :style="pedigreeSheetStyle">
<view class="generation-rail" :style="pedigreeHeightStyle">
<view class="generation-band">
<image
class="generation-band__arrow generation-band__arrow--previous"
@@ -152,7 +145,7 @@ import { getRequestErrorMessage } from "@/services/api/request-error-message.js"
import { genealogyContext } from "@/utils/genealogy/context.js";
import { handleBackPress, openPage, returnTo } from "@/utils/navigation/gateway.js";
const PEDIGREE_PAGE_SIZE = 5;
const PEDIGREE_PAGE_SIZE = 3;
const genealogyId = ref("");
const treeState = ref("loading");
const treeLoadError = ref("");
@@ -198,14 +191,13 @@ const pedigreePages = computed(() => {
const branch = row.members.find((member) => member.branch)?.branch;
for (let index = 0; index < row.members.length; index += PEDIGREE_PAGE_SIZE) {
const pageMembers = row.members.slice(index, index + PEDIGREE_PAGE_SIZE);
while (pageMembers.length < PEDIGREE_PAGE_SIZE) pageMembers.push(null);
pages.push({
generation: row.generation,
label: row.label,
memberCount: row.members.length,
summary: [branch, `${row.members.length} 位成员`]
summary: [branch, `${row.members.length}位成员`]
.filter(Boolean)
.join(" · "),
.join("·"),
members: pageMembers,
});
}
@@ -219,14 +211,18 @@ const detailLength = computed(() =>
Math.max(
0,
...members.value.map((member) =>
Array.from(`${member.branch || ""}${memberDetail(member)}`).length,
Array.from(memberDetailText(member)).length,
),
),
);
const sheetHeight = computed(() => Math.max(1100, 720 + detailLength.value * 20));
const pedigreeSheetStyle = computed(() => ({
const pedigreeHeightStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
const getPedigreeSheetStyle = (page) => ({
...pedigreeHeightStyle.value,
gridTemplateColumns: `104rpx repeat(${page.members.length}, minmax(0, 1fr))`,
});
const pedigreeScrollStyle = computed(() => ({
height: `${sheetHeight.value}rpx`,
}));
@@ -254,6 +250,8 @@ const handlePedigreePageChange = (event) => {
const memberRelation = (member) =>
member.spouseOf ? "配偶" : member.relation || "家谱成员";
const memberDetail = (member) => member.treeYears || member.years || "生卒待补";
const memberDetailText = (member) =>
[member.branch, memberDetail(member)].filter(Boolean).join("·");
const openMemberProfile = (member) =>
openPage(
"T03",
@@ -463,9 +461,6 @@ onUnload(() => {
.member-node__surface {
display: contents;
}
.member-node--blank {
pointer-events: none;
}
.member-node--selected .member-node__relation,
.member-node--selected .member-node__name,
.member-node--selected .member-node__copy {
@@ -477,13 +472,6 @@ onUnload(() => {
.member-node--spouse .member-node__relation {
color: #7a4d43;
}
.node-relation {
color: #675f57;
}
.node-years {
color: #3f3934;
text-combine-upright: all;
}
.generation-rail {
width: 92rpx;
border-left: 1rpx solid #e2e2e2;