diff --git a/.gitignore b/.gitignore
index 99a1ebe..1daac0d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,8 +57,11 @@ sitemap.xml
/tmp/
/unpackage/
/.vite/
+/.audit/
/design-pipeline/generated/
/tmp-g01-icon-audit.png
/tmp-mumu-current.png
/artifacts/
/docs/audit-*/
+/docs/audits/
+/book-style-*.png
diff --git a/components/AppPromotionStrip.vue b/components/AppPromotionStrip.vue
index 1d783be..ced9794 100644
--- a/components/AppPromotionStrip.vue
+++ b/components/AppPromotionStrip.vue
@@ -1,6 +1,6 @@
@@ -8,10 +8,16 @@
{{ title }}
-
+
+ 正在加载广告内容…
+
+
推荐内容暂时没有显示
+
+ 暂无广告内容
+
{
font-size: clamp(13px, 21rpx, 16px);
}
+.promotion-strip__state {
+ padding: 12rpx 22rpx 18rpx;
+ color: #766252;
+ font-size: clamp(13px, 21rpx, 16px);
+ text-align: center;
+}
+
.promotion-strip__error button {
margin: 0;
padding: 8rpx 18rpx;
diff --git a/components/AppTabbar.vue b/components/AppTabbar.vue
index 655dee2..9aaba7f 100644
--- a/components/AppTabbar.vue
+++ b/components/AppTabbar.vue
@@ -1,5 +1,10 @@
-
+
@@ -22,6 +22,7 @@ import { computed } from "vue";
const props = defineProps({
files: { type: Array, default: () => [] },
+ imageLabel: { type: String, default: "查看动态图片" },
});
const visibleFiles = computed(() =>
diff --git a/components/genealogy/Card.vue b/components/genealogy/Card.vue
index e155f06..eb13b46 100644
--- a/components/genealogy/Card.vue
+++ b/components/genealogy/Card.vue
@@ -12,14 +12,21 @@
src="/static/assets/modules/genealogy/transparent/list-slip-frame.png"
mode="scaleToFill"
/>
-
+
-
- 家谱
+
+ {{ character }}
@@ -51,8 +58,8 @@
{{ role }}
- 更新于 {{ genealogy.updatedAt }}创建于 {{ formatGenealogyDate(genealogy.createTime) }}
@@ -70,9 +77,16 @@ const props = defineProps({
});
defineEmits(["select"]);
+const formatGenealogyDate = (value) => {
+ const text = String(value || "").trim();
+ return text.length >= 10 ? text.slice(0, 10) : text;
+};
+const getBookSpineTitle = (value) =>
+ Array.from(`${String(value || "").trim()}氏家谱`);
+
const accessibleLabel = computed(
() =>
- `${props.genealogy.name},${props.role}${props.selected ? ',当前家谱' : ''},${props.genealogy.location},${props.genealogy.memberCount} 位成员`,
+ `${props.genealogy.name},${props.role}${props.selected ? ',当前家谱' : ''},${props.genealogy.location},${props.genealogy.memberCount} 位成员${props.genealogy.createTime ? `,创建于 ${formatGenealogyDate(props.genealogy.createTime)}` : ''}`,
);
@@ -81,7 +95,7 @@ const accessibleLabel = computed(
--card-padding-x: 24rpx;
--card-padding-y: 20rpx;
display: grid;
- grid-template-columns: 68rpx minmax(0, 1fr);
+ grid-template-columns: 112rpx minmax(0, 1fr);
min-height: 204rpx;
align-items: center;
column-gap: 20rpx;
@@ -106,47 +120,37 @@ const accessibleLabel = computed(
pointer-events: none;
}
-.surname-seal {
- display: grid;
+.genealogy-book {
+ position: relative;
grid-column: 1;
grid-row: 1;
z-index: 2;
- width: 68rpx;
- height: 106rpx;
- place-items: center;
- color: #fff5df;
+ width: 112rpx;
+ height: 153rpx;
}
-
-.surname-seal-frame {
- z-index: 0;
+.genealogy-book__cover {
+ display: block;
width: 100%;
height: 100%;
}
-.surname-seal-frame,
-.surname-seal-copy {
- grid-area: 1 / 1;
-}
-.surname-seal-copy {
- z-index: 1;
+.genealogy-book__spine-title {
+ position: absolute;
+ top: 5rpx;
+ left: 7rpx;
display: flex;
- height: 78rpx;
+ width: 23rpx;
+ height: 91rpx;
align-items: center;
justify-content: center;
+ flex-direction: column;
+ color: #2f2925;
+ font-family: "STKaiti", "KaiTi", "SimSun", serif;
}
-.seal-title {
- color: #fff5df;
- font-family: "STKaiti", "KaiTi", serif;
- font-size: clamp(17px, 30rpx, 22px);
- font-weight: 700;
- letter-spacing: 2rpx;
- line-height: 1;
- writing-mode: vertical-rl;
-}
-.genealogy-card:not(.genealogy-card--current) .surname-seal-frame {
- opacity: 0.32;
-}
-.genealogy-card:not(.genealogy-card--current) .seal-title {
- color: $ink-muted;
+.genealogy-book__spine-character {
+ display: block;
+ font-size: clamp(9.5px, 17rpx, 12px);
+ font-weight: 400;
+ line-height: 1.05;
}
.card-main {
@@ -258,15 +262,21 @@ const accessibleLabel = computed(
@media screen and (max-width: 340px) {
.genealogy-card {
--card-padding-x: 18rpx;
- grid-template-columns: 62rpx minmax(0, 1fr);
+ grid-template-columns: 100rpx minmax(0, 1fr);
column-gap: 16rpx;
min-height: 176rpx;
padding-right: 18rpx;
padding-left: 18rpx;
}
- .surname-seal {
- width: 62rpx;
- height: 96rpx;
+ .genealogy-book {
+ width: 100rpx;
+ height: 136rpx;
+ }
+ .genealogy-book__spine-title {
+ top: 4rpx;
+ left: 6rpx;
+ width: 21rpx;
+ height: 81rpx;
}
.card-name {
font-size: clamp(17px, 32rpx, 22px);
diff --git a/components/genealogy/HomeAdvertisementPanel.vue b/components/genealogy/HomeAdvertisementPanel.vue
new file mode 100644
index 0000000..7d53b17
--- /dev/null
+++ b/components/genealogy/HomeAdvertisementPanel.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+ 点击查看更多内容
+
+
+
+
+
+
+
diff --git a/components/genealogy/PageBackground.vue b/components/genealogy/PageBackground.vue
index 4870cd7..6291994 100644
--- a/components/genealogy/PageBackground.vue
+++ b/components/genealogy/PageBackground.vue
@@ -1,5 +1,8 @@
-
+
+
+
diff --git a/components/tree/MemberDatePickerSheet.vue b/components/tree/MemberDatePickerSheet.vue
new file mode 100644
index 0000000..49e547d
--- /dev/null
+++ b/components/tree/MemberDatePickerSheet.vue
@@ -0,0 +1,423 @@
+
+
+
+
+
+ {{ title }}
+ 点击年份可直接切换,左右按钮切换月份
+
+
+
+
+
+
+
+
+
+ 选择年份
+ {{ selectedYear }}年
+
+
+
+
+ 选择月份
+ {{ selectedMonth }}月
+
+
+
+
+
+
+
+
+
+ {{ weekday }}
+
+
+
+ {{ calendarDay.day }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/components/tree/PersonDocumentDialog.vue b/components/tree/PersonDocumentDialog.vue
index ff3b45e..dc72fbe 100644
--- a/components/tree/PersonDocumentDialog.vue
+++ b/components/tree/PersonDocumentDialog.vue
@@ -22,15 +22,35 @@
label="新建证件档案"
@click="openDocumentCreate"
/>
+
+ {{ documentBatch.notice.value }}
+ {{ documentBatch.error.value }}
{{ personId ? "这位成员还没有可查看的重要证件" : "当前家谱还没有可查看的重要证件" }}
+
{{ documentSummary.documentTitle }}
{{ documentUploadReceipt ? `已选择:${documentUploadReceipt.fileName || '证件图片'}` : '添加证件图片(选填)' }}
+
+ 内容密码选填;填写后会先创建档案,再立即启用密码保护。
+
+
+
{{ documentError }}
@@ -129,6 +154,18 @@
+
document?.documentId,
+ deleteOne: (document) =>
+ personDocumentApi.deletePersonDocument(props.genealogyId, document.documentId, {
+ requestController: documentDeletionRequestController,
+ }),
+ resourceName: "证件档案",
+ isActive: () => isActive && documentDialogVisible.value,
+});
+const deletableDocuments = documentBatch.deletableItems;
const documentDialogTitle = computed(() => {
switch (documentDialogMode.value) {
@@ -340,6 +391,7 @@ const resetDocumentWorkflow = () => {
documentAccessToken.value = "";
documentError.value = "";
documentUploadReceipt.value = null;
+ documentBatch.exitSelectionMode();
};
const open = async () => {
@@ -367,6 +419,7 @@ const open = async () => {
]);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
documents.value = documentRows;
+ documentBatch.exitSelectionMode();
documentTypeOptions.value = typeOptions;
documentState.value = "ready";
} catch (error) {
@@ -396,6 +449,8 @@ const openDocumentCreate = () => {
description: "",
});
documentUploadReceipt.value = null;
+ documentPassword.value = "";
+ documentPasswordConfirm.value = "";
documentDialogMode.value = "create";
documentState.value = "ready";
documentError.value = "";
@@ -454,6 +509,16 @@ const createDocument = async () => {
documentError.value = "请填写证件名称。";
return;
}
+ if (documentPassword.value || documentPasswordConfirm.value) {
+ if (documentPassword.value.length < 8 || documentPassword.value.length > 128) {
+ documentError.value = "内容密码必须为8至128位。";
+ return;
+ }
+ if (documentPassword.value !== documentPasswordConfirm.value) {
+ documentError.value = "两次输入的内容密码不一致。";
+ return;
+ }
+ }
const documentPersonId = props.personId;
const createPayload = { lineagePersonId: documentPersonId, ...documentForm };
const createAttempt = documentCreateGuard.begin(createPayload);
@@ -467,6 +532,7 @@ const createDocument = async () => {
const activeWorkflow = workflowVersion;
const uploadReceipt = documentUploadReceipt.value;
let createdDocumentId = "";
+ let postCreateStage = "creating";
try {
const createdDocument = await personDocumentApi.createPersonDocument(
props.genealogyId,
@@ -475,6 +541,7 @@ const createDocument = async () => {
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
createdDocumentId = createdDocument.documentId;
+ postCreateStage = "linking-file";
if (uploadReceipt?.ossId) {
await personDocumentApi.addPersonDocumentResource(
props.genealogyId,
@@ -487,6 +554,19 @@ const createDocument = async () => {
);
if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
}
+ postCreateStage = "setting-password";
+ if (documentPassword.value) {
+ await personDocumentApi.setPersonDocumentPassword(
+ props.genealogyId,
+ createdDocument.documentId,
+ documentPassword.value,
+ { requestController: documentSaveRequestController },
+ );
+ if (!isCurrentPersonContext(documentPersonId, activeWorkflow)) return;
+ }
+ postCreateStage = "complete";
+ documentPassword.value = "";
+ documentPasswordConfirm.value = "";
await open();
} catch (error) {
if (
@@ -494,6 +574,8 @@ const createDocument = async () => {
isCurrentPersonContext(documentPersonId, activeWorkflow) &&
!isRequestCancelled(error)
) {
+ documentPassword.value = "";
+ documentPasswordConfirm.value = "";
await loadDocumentDetail(createdDocumentId, "");
if (
isCurrentDocumentContext(
@@ -502,12 +584,14 @@ const createDocument = async () => {
activeWorkflow,
)
) {
- documentError.value =
- "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
+ documentError.value = postCreateStage === "setting-password"
+ ? "证件档案已创建,图片也已处理,但内容密码设置结果暂时无法确认。请在详情中重新设置,当前档案可能尚未受到密码保护。"
+ : "证件档案已创建,但图片关联结果暂时无法确认。请检查文件列表后再添加,避免重复创建档案。";
} else if (isCurrentPersonContext(documentPersonId, activeWorkflow)) {
documentState.value = "error";
- documentError.value =
- "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
+ documentError.value = postCreateStage === "setting-password"
+ ? "证件档案已创建,但内容密码设置结果暂时无法确认。请重新打开详情检查,避免重复创建档案。"
+ : "证件档案已创建,但暂时无法确认图片是否关联。请关闭后重新打开证件列表,避免重复创建档案。";
}
return;
}
@@ -600,6 +684,13 @@ const openDocument = async (documentSummary) => {
}
await loadDocumentDetail(documentSummary.documentId, "");
};
+const handleDocumentSummaryClick = (documentSummary) => {
+ if (documentBatch.selectionMode.value && documentSummary?.canDelete) {
+ documentBatch.toggleSelection(documentSummary);
+ return;
+ }
+ openDocument(documentSummary);
+};
const unlockDocument = async () => {
if (documentState.value === "submitting" || !selectedDocument.value) return;
documentError.value = "";
@@ -1001,6 +1092,7 @@ onUnmounted(() => {
font-size: clamp(14px, 22rpx, 17px);
}
.document-form-field--textarea { min-height: 132rpx; }
+.document-form-hint { display: block; margin-top: 16rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; }
.document-upload-button {
width: 100%;
min-height: 76rpx;
@@ -1068,4 +1160,5 @@ onUnmounted(() => {
.document-dialog-empty,
.document-dialog-error { padding: 24rpx 10rpx; text-align: center; }
.document-dialog-error { color: $brand-red; }
+.document-batch-notice { display: block; padding: 14rpx 10rpx; color: #426538; font-size: clamp(13px, 21rpx, 16px); line-height: 1.55; }
diff --git a/composables/use-batch-deletion.js b/composables/use-batch-deletion.js
new file mode 100644
index 0000000..c0961f8
--- /dev/null
+++ b/composables/use-batch-deletion.js
@@ -0,0 +1,135 @@
+import { computed, ref } from "vue";
+import { isRequestCancelled } from "@/services/api/request-controller.js";
+import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
+
+export const useBatchDeletion = ({
+ items,
+ visibleItems = items,
+ getId = (item) => item?.id,
+ canDelete = (item) => item?.canDelete === true,
+ deleteOne,
+ resourceName,
+ isActive = () => true,
+ onEmpty = () => {},
+}) => {
+ const selectionMode = ref(false);
+ const selectedIds = ref([]);
+ const confirmationVisible = ref(false);
+ const deleting = ref(false);
+ const notice = ref("");
+ const error = ref("");
+
+ const deletableItems = computed(() => visibleItems.value.filter(canDelete));
+ const allSelected = computed(
+ () =>
+ deletableItems.value.length > 0 &&
+ deletableItems.value.every((item) => selectedIds.value.includes(getId(item))),
+ );
+ const selectedCount = computed(() => selectedIds.value.length);
+ const confirmationMessage = computed(
+ () =>
+ `将选中的 ${selectedCount.value} 条${resourceName}移入回收站;管理员可在保留期内恢复。`,
+ );
+
+ const clearFeedback = () => {
+ notice.value = "";
+ error.value = "";
+ };
+ const exitSelectionMode = () => {
+ selectionMode.value = false;
+ selectedIds.value = [];
+ confirmationVisible.value = false;
+ };
+ const enterSelectionMode = () => {
+ if (!deletableItems.value.length || deleting.value) return;
+ clearFeedback();
+ selectedIds.value = [];
+ selectionMode.value = true;
+ };
+ const isSelected = (item) => selectedIds.value.includes(getId(item));
+ const toggleSelection = (item) => {
+ if (!selectionMode.value || !canDelete(item) || deleting.value) return;
+ const id = getId(item);
+ selectedIds.value = isSelected(item)
+ ? selectedIds.value.filter((selectedId) => selectedId !== id)
+ : [...selectedIds.value, id];
+ clearFeedback();
+ };
+ const toggleAll = () => {
+ if (!selectionMode.value || deleting.value) return;
+ selectedIds.value = allSelected.value ? [] : deletableItems.value.map(getId);
+ clearFeedback();
+ };
+ const requestDelete = () => {
+ if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
+ clearFeedback();
+ confirmationVisible.value = true;
+ };
+ const cancelDelete = () => {
+ if (!deleting.value) confirmationVisible.value = false;
+ };
+ const confirmDelete = async () => {
+ if (!selectionMode.value || !selectedIds.value.length || deleting.value) return;
+ const pendingIds = selectedIds.value.slice();
+ deleting.value = true;
+ clearFeedback();
+ let deletedCount = 0;
+ let failedRequest = null;
+ try {
+ for (const id of pendingIds) {
+ const target = items.value.find((item) => getId(item) === id);
+ if (!target || !canDelete(target)) continue;
+ try {
+ await deleteOne(target);
+ } catch (cause) {
+ failedRequest = cause;
+ break;
+ }
+ if (!isActive()) return;
+ deletedCount += 1;
+ items.value = items.value.filter((item) => getId(item) !== id);
+ selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id);
+ }
+ if (!isActive()) return;
+ confirmationVisible.value = false;
+ if (failedRequest && !isRequestCancelled(failedRequest)) {
+ const failureCopy = getRequestErrorMessage(
+ failedRequest,
+ `剩余${resourceName}删除失败,请稍后重试。`,
+ );
+ error.value = deletedCount ? `已删除 ${deletedCount} 条;${failureCopy}` : failureCopy;
+ } else if (deletedCount) {
+ notice.value = `已删除 ${deletedCount} 条${resourceName}。`;
+ }
+ if (!items.value.length) {
+ exitSelectionMode();
+ onEmpty();
+ } else if (!selectedIds.value.length) {
+ selectionMode.value = false;
+ }
+ } finally {
+ if (isActive()) deleting.value = false;
+ }
+ };
+
+ return {
+ selectionMode,
+ selectedIds,
+ selectedCount,
+ deletableItems,
+ allSelected,
+ confirmationVisible,
+ confirmationMessage,
+ deleting,
+ notice,
+ error,
+ enterSelectionMode,
+ exitSelectionMode,
+ isSelected,
+ toggleSelection,
+ toggleAll,
+ requestDelete,
+ cancelDelete,
+ confirmDelete,
+ };
+};
diff --git a/design-pipeline/manifests/application-runtime-assets.json b/design-pipeline/manifests/application-runtime-assets.json
index 098a5e5..f1e4d54 100644
--- a/design-pipeline/manifests/application-runtime-assets.json
+++ b/design-pipeline/manifests/application-runtime-assets.json
@@ -20,6 +20,8 @@
{ "id": "app-modules-family-transparent-family-letter-card", "output": "static/assets/modules/family/transparent/family-letter-card.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-content-frame", "output": "static/assets/modules/family/transparent/module-content-frame.png", "width": 2003, "height": 581, "alpha": true, "bytes": 1513491, "sha256": "a3686f99e32cdc255c0fc130294aab1974392d66bc8a7991daeb8f67849015ee", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-family-transparent-module-field-frame", "output": "static/assets/modules/family/transparent/module-field-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
+ { "id": "app-modules-genealogy-opaque-home-ad-family-tree", "output": "static/assets/modules/genealogy/opaque/home-ad-family-tree.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2701295, "sha256": "d333924d07569491809211508302c064bba6eee52b4af489a526bf53ba066be4", "provenance": "committed-binary", "rebuildable": false },
+ { "id": "app-modules-genealogy-opaque-home-ad-heritage-hall", "output": "static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png", "width": 1862, "height": 845, "alpha": false, "bytes": 2554759, "sha256": "c64fbb4fc965a084a7b1184c924673f6df25a3504858df3a790973664d886b6e", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-overview-surface", "output": "static/assets/modules/genealogy/opaque/overview-surface.png", "width": 1122, "height": 1506, "alpha": false, "bytes": 2537499, "sha256": "2d908f53c7edb637c7ced7500822bd33d354410ad8e671871d1da3a4cbd0f4db", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-search-button", "output": "static/assets/modules/genealogy/opaque/search-button.png", "width": 300, "height": 132, "alpha": false, "bytes": 74664, "sha256": "bf0abc0a036c07322dd834fe5a9797413afd453b69d2ff8a895f3999fb8846cd", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-opaque-search-input-wide", "output": "static/assets/modules/genealogy/opaque/search-input-wide.png", "width": 1120, "height": 248, "alpha": false, "bytes": 319364, "sha256": "2a575eeb5582c8458cbdd04cd328afbbecbdaabf4db46e8f2133bfe2614faa80", "provenance": "committed-binary", "rebuildable": false },
@@ -46,9 +48,11 @@
{ "id": "app-modules-records-transparent-person-name-card", "output": "static/assets/modules/records/transparent/person-name-card.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-node-selected", "output": "static/assets/modules/tree/transparent/member-node-selected.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-member-node-standard", "output": "static/assets/modules/tree/transparent/member-node-standard.png", "width": 720, "height": 272, "alpha": true, "bytes": 18568, "sha256": "47e519768830062a2363bd4bd10fb9ffc82c6a94638d3400a1e4c943a0ca6e66", "provenance": "committed-binary", "rebuildable": false },
+ { "id": "app-modules-tree-transparent-member-node-portrait", "output": "static/assets/modules/tree/transparent/member-node-portrait.png", "width": 81, "height": 105, "alpha": true, "bytes": 1141, "sha256": "e0e1609ae3f70ef9ab0fec82f2ef9bdb8af1ad7966a2cfefa3aae41d9684212f", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-state-panel", "output": "static/assets/modules/tree/transparent/state-panel.png", "width": 2139, "height": 675, "alpha": true, "bytes": 254885, "sha256": "01cf572ed3f20d99e10a21fee6834dced385e71d6b14693b04c0cb49560a6fd3", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-tree-transparent-search-input-frame", "output": "static/assets/modules/tree/transparent/search-input-frame.png", "width": 2132, "height": 443, "alpha": true, "bytes": 1065770, "sha256": "bdc65f33e1dbe05ae75562519b0080e2e5cdbe2dba720a6ac49b56b99be75ceb", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-book-png", "output": "static/assets/foundation/transparent/book.png", "width": 154, "height": 210, "alpha": false, "bytes": 41283, "sha256": "d565a2ad4f90c07046e9c60ebd6658426cacdd1f2af79e0a28ce9fe59d670630", "provenance": "committed-binary", "rebuildable": false },
+ { "id": "app-foundation-opaque-book-blank", "output": "static/assets/foundation/opaque/book-blank.png", "width": 154, "height": 210, "alpha": false, "bytes": 46607, "sha256": "500441f7631b45e1f4367a0b4f7f27031d1585097a458af599e8c7b922314454", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-default-avatar-female", "output": "static/assets/foundation/transparent/default-avatar-female.png", "width": 128, "height": 128, "alpha": true, "bytes": 38629, "sha256": "a85cf941e531e84217f37b42fe8e8aa9ab6a32457480d45aa3d6fa33da360d2c", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-foundation-transparent-default-avatar-male", "output": "static/assets/foundation/transparent/default-avatar-male.png", "width": 128, "height": 128, "alpha": true, "bytes": 43055, "sha256": "b00cb59f440aa87215432afc64f1463a61f0d9806ba1655ed6f8888a9d04c100", "provenance": "committed-binary", "rebuildable": false },
{ "id": "app-modules-genealogy-transparent-settings-gear", "output": "static/assets/modules/genealogy/transparent/settings-gear.png", "width": 1254, "height": 1254, "alpha": true, "bytes": 1305800, "sha256": "0c3f064878a865af57ea3e7de450703a07bbb59b81ef0c44c4a041fa35a5dd1c", "provenance": "committed-binary", "rebuildable": false },
diff --git a/docs/后端线上联调故障与数据准备清单.md b/docs/后端线上联调故障与数据准备清单.md
index 1826f95..e1245b7 100644
--- a/docs/后端线上联调故障与数据准备清单.md
+++ b/docs/后端线上联调故障与数据准备清单.md
@@ -1,120 +1,239 @@
# 后端线上联调故障与数据准备清单
-收件人:后端开发、运维、测试负责人
-整理日期:2026-08-24
-联调环境:正式接口 `https://backend-api.ddxcjp.cn`,租户 `000000`
+收件人:后端开发、运维、测试负责人
-## 一、结论
+整理日期:2026-09-09
-前端已按最新 App OpenAPI 和后端提交 `de4cc9a` 完成适配,并已移除预览数据回退。当前页面中的“无内容”分为两类:
+前端项目:`jiapuapp`
-| 类型 | 数量 | 结论 |
-| --- | ---: | --- |
-| 线上接口或配置故障 | 3 项 | 需要后端、运维处理 |
-| 接口成功但测试数据为空 | 8 类 | 不是前端故障,需要后端测试环境准备数据 |
-| 本轮确认的前端问题 | 2 项 | 已修复并通过自动检查 |
+后端源码:`C:\Users\Rain\Desktop\job\Genealogy`
-本轮前端检查结果:60 个页面与 60 条路由一致;参考项目 78 条功能路由均已有对应关系;导航恢复、契约回归和 12 项运行时资产测试全部通过。
+参考项目:`C:\Users\Rain\Desktop\job\Jiapu-App`
-## 二、必须处理的线上问题
+本文只汇总当前仍需要后端、运维或测试环境处理的有效问题。已经由前端完成的问题不再重复要求后端开发,历史文档与本文冲突时以本文为准。
-### 问题一:我的家谱接口无法完成首页读取
+## 问题一(P0):线上世系排行接口成功但返回空数组
-接口:`GET /genealogy/app/genealogies/mine`
+### 2026-09-09 后端更新后复验
-现象:2026-08-24 22:17 将本轮新构建运行到 MuMu 后,已登录账号点击“重新加载”,家谱首页仍稳定进入“暂时无法读取家谱”状态,具体提示为“家谱服务暂时无法读取,请稍后重试”。该登录态访问个人资料和其他家族业务接口能够成功,因此不能归因于模拟器断网或整体登录失效。
+前端拉取后端最新更新、重新编译并同步到 MuMu 后,该问题仍未解决:
-前端已确认:
+- 测试成员:`MANUALCHILD01`;
+- 成员条件:第 2 世、男性(前端请求参数为 `generation=2&sex=0`);
+- 编辑成员页“排行称谓”显示“暂无可选排行”;
+- 返回树状图后,该成员节点仍显示“排行待补”;
+- 同一页面的成员详情、父亲关系等数据均能正常读取,因此不是整个成员详情页或登录会话失效。
-- 正式包请求地址和接口路径正确;
-- 请求失败和成功空数组使用不同页面状态;
-- 前端没有家谱预览数据或假数据回退;
-- 本轮已增加错误类型显示。重新编译后,若后端响应结构不符合契约,会显示“服务返回的家谱数据不完整”;网络、登录、权限和服务异常也会分别提示。
+前端状态逻辑会把接口异常显示为“暂时无法读取”,把成功返回的空列表显示为“暂无可选排行”。本次 MuMu 实际显示的是后者,说明请求流程已经完成,但当前条件没有取得任何可用排行记录。该判断来自页面状态;下方 2026-09-04 的四组 JSON 是此前直接请求接口取得的响应证据。
-本次实际命中的是服务异常提示,不是网络异常、登录失效、无权限或前端响应结构校验异常。因此排查重点应放在线上接口业务异常、SQL 异常和部署版本,不需要前端放宽字段校验来掩盖故障。
+请后端本次不要只确认“接口代码已存在”,需要在**已部署环境、当前租户和当前测试家谱**下实际请求并回传脱敏响应。至少验证:
-后端最新代码中,该接口会读取家谱实体并返回 `AppGenealogyVo`。2026-08-24 迁移又为 `gen_genealogy` 增加了 `create_request_id`、`first_ancestor_name`、`root_person_id`。因此请优先排查以下三项:
-
-1. 线上是否确实部署了提交 `de4cc9a`,而不是只合并到代码仓库;
-2. 是否按顺序执行 `2026-08-24-app-legacy-parity-closure-precheck.sql`、迁移脚本和后置检查;
-3. 后置检查的 `app_legacy_parity_postcheck_blocking_count` 和 `blocking_count` 是否都为 `0`。
-
-同时请用当前测试账号直接调用接口并保存完整响应及服务端异常日志,重点核对每条家谱是否稳定返回:
-
-- `genealogyId`:正整数;
-- `genealogyName`:非空;
-- `memberCount`:非负整数;
-- `personCount`:空或非负整数;
-- `visibility`、`joinMode`、`lifecycleStatus`:合法枚举;
-- `canManage`、`canEditContent`、`canArchive`、`canRestore`:布尔值。
-
-验收标准:同一账号连续调用三次均返回 `code=200`,`data` 为合法数组;MuMu 点击“重新加载”后正常显示家谱卡片或真实空状态。
-
-### 问题二:用户协议和隐私政策免登录接口返回 500
-
-接口:
-
-- `GET /genealogy/app/compliance/documents/user_agreement`
-- `GET /genealogy/app/compliance/documents/privacy_policy`
-
-2026-08-24 复测原始响应,两条接口均为:
-
-```json
-{"code":500,"msg":"发生未知异常,请联系管理员","data":null}
+```text
+GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=1&sex=0
+GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=0
+GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation=2&sex=1
```
-这是不携带登录令牌即可复现的后端问题,与前端页面、登录态和 MuMu 无关。后端当前实现预期在未发布时返回“合规文档尚未发布”,线上却变成未知异常,说明仍需检查线上表结构、发布数据、租户数据隔离或异常处理。
+其中第二世男性结果必须包含“长子、次子”等,第二世女性结果必须包含“长女、次女”等;只返回 `code=200` 但 `data=[]` 仍视为未解决。
-请执行并提供以下检查结果:
+### 当前现象
-1. 合规文档首次发布及换行修复 SQL 的后置检查结果,所有 `blocking_count` 必须为 `0`;
-2. 租户 `000000` 下两份文档、当前版本、版本状态和内容摘要的查询结果;
-3. 两条免登录请求对应的后端异常堆栈;
-4. 确认请求只需要正确的 `clientid` 和租户头,不应依赖登录令牌。
+前端已接入正式接口:
-验收标准:两条接口均返回 `code=200`、非空标题、版本号、正文和内容摘要;不携带 Authorization 仍可读取。
+`GET /genealogy/app/genealogies/{genealogyId}/lineage/ranks?generation={generation}&sex={sex}`
-### 问题三:应用推广资料读取失败
+2026-09-04 使用 MuMu 当前有效登录会话,针对联调家谱 `2086296260134936577`、租户 `000000` 直接请求线上环境。接口没有报错,所有请求均返回业务成功,但 `data` 是空数组:
-接口:`GET /genealogy/app/referrals/me`
+```text
+generation=1&sex=0 -> {"code":200,"msg":"操作成功","data":[]}
+generation=2&sex=0 -> {"code":200,"msg":"操作成功","data":[]}
+generation=2&sex=1 -> {"code":200,"msg":"操作成功","data":[]}
+generation=2&sex=2 -> {"code":200,"msg":"操作成功","data":[]}
+```
-现象:2026-08-24 22:21 MuMu 点击进入“应用推广”,推广内容列表能够正常展示,但顶部推荐资料卡明确显示“推荐码暂时无法读取”。这说明页面和推广内容接口正常,故障集中在推荐资料接口。前端已使用后端返回的 `shareUrl`,没有自行拼接内部用户编号。
+这不是前端未发请求、鉴权失败或响应解析失败。普通成员因此无法选择“长子、次子、三子”等排行,人物保存后也没有可回显的 `rankId`、`rankName`。世系树只能显示“排行待补”,无法准确显示其与上一辈的排行关系。配偶不使用排行选项,仍由世系树的 `spouses` 结构显示“配偶”。
-后端 `ReferralService.buildShareUrl` 明确依赖当前租户品牌配置中的 `h5Domain`,配置为空、格式错误或不是 HTTPS 都会直接抛出业务异常。请核对租户 `000000` 的启用品牌配置,并确保 `h5Domain` 是可访问的 HTTPS H5 注册地址。
+### 已核对的后端逻辑
-验收标准:接口返回 `code=200`,包含稳定推荐码、推荐人数、分享标题、分享文案和 HTTPS `shareUrl`;链接不暴露内部用户编号,打开后能进入注册流程并携带推荐凭据。
+后端接口与查询实现均已存在:
-## 三、需要准备的联调数据
+- `AppLineagePersonController.rankOptions` 接收 `genealogyId`、`generation`、`sex`;
+- `AppLineagePersonServiceImpl.rankOptions` 完成家谱查看权限校验后查询排行配置;
+- `LineageRankConfigServiceImpl.queryOptions` 只返回 `status=0`、性别适用且排行类型匹配的数据;
+- 第一世查询 `rank_type=ANCESTOR`,其他世代查询 `rank_type=GENERATION`;
+- 查询表为 `gen_lineage_rank_config`。
-以下页面已确认能够区分“读取失败”和“成功但为空”。当前显示无内容,是接口成功返回空数组或零条记录,不属于前端渲染故障。请在测试环境为当前测试家谱准备最小可点击数据:
+当前代码中的 `genealogyId` 只用于权限校验,实际排行查询没有按家谱 ID 过滤,而是读取当前租户下的公共排行配置。因此本问题优先检查租户 `000000` 的全局排行配置和租户拦截条件,不要只检查联调家谱本身。
-| 数据类别 | 当前结果 | 最小验收数据 |
-| --- | --- | --- |
-| 家族动态 | 成功,空列表 | 1 条带图片动态、1 条纯文字动态,可进入详情 |
-| 谱文 | 分类接口可读,谱文为空列表 | 1 篇带封面谱文、1 篇设有内容密码的谱文 |
-| 礼仪活动 | 成功,空列表 | 1 条带封面活动,可进入详情并读取献礼列表 |
-| 家族备忘 | 成功,空列表 | `general` 和 `benefactor` 各 1 条 |
-| 亲友往来 | 成功,空列表 | 1 条带图片记录,可查看完整详情 |
-| 功德记录 | 成功,空列表 | 1 条带金额和图片记录 |
-| 家族视频与平台宣传视频 | 家族视频为空;首页平台视频因家谱首页故障暂不能完成内容态验收 | 各 1 条带封面且视频地址可播放的数据,并正确配置平台视频投放位 |
-| 相册照片 | 已有相册 `CHECKDELETE01`,照片数为 0 | 在该相册中加入至少 2 张当前账号可访问的图片 |
+仓库中的 `script/sql/update/2026-07-29-genealogy-lineage-generation-rank.sql` 已定义 37 条默认数据:1 条“始祖”、18 条男性排行、18 条女性排行。现有 2026-08-03 数据库备份中该表结构存在,但记录区为空。这与线上接口返回空数组一致,需由后端或运维核对线上库,不能由前端硬编码排行数据。
-这些数据的文件字段必须返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 文件编号。
+### 后端/测试数据处理要求
-## 四、本轮前端已处理
+1. 先在**线上实际租户库**执行以下只读检查,确认数据是缺失、停用还是被逻辑删除:
-1. 家谱首页不再把所有异常统一显示成“网络或服务不可用”,现在会区分网络、登录、权限、服务失败和响应结构不完整。
-2. 谱文分类接口失败时不再静默转换为空分类;页面会明确提示“分类读取失败”,同时保留已成功读取的全部谱文。
-3. 已增加对应回归检查,防止以后再次把请求失败伪装为空数据。
+ ```sql
+ select rank_id, tenant_id, rank_code, rank_name, rank_type,
+ gender_scope, rank_order, sort_order, status, del_flag
+ from gen_lineage_rank_config
+ where tenant_id = '000000'
+ order by rank_type, gender_scope, rank_order;
+ ```
-## 五、后端回传材料
+2. 若记录不存在,先备份线上表,再按发布流程执行并核验 `2026-07-29-genealogy-lineage-generation-rank.sql`;不要直接在前端写死“长子、次子”等数据;
+3. 若记录存在,检查 `status='0'`、`del_flag='0'`、`rank_type` 和 `gender_scope` 是否满足查询条件,并检查租户隔离是否错误过滤 `000000` 数据;
+4. 第一世至少返回“始祖”,其他世代按性别返回“长子、次子、长女、次女”等可用选项;
+5. 保存人物 `rankId` 后,人物详情和世系树必须回显一致的 `rankId`、`rankName`;
+6. 已停用、已删除或不适用当前性别的排行不得出现在可选列表中;
+7. 增加线上同结构数据库的集成测试,防止只验证 Mock 数据而没有验证迁移结果。
-完成后请一次性提供:
+### 验收标准
-1. 线上实际部署提交编号和部署时间;
-2. 2026-08-24 迁移及所有相关后置检查结果,最终 `blocking_count=0`;
-3. 上述三项故障接口的完整响应示例和对应服务端日志结论;
-4. 测试数据所属家谱编号、数据编号和账号权限;
-5. 更新后的正式 App OpenAPI(如接口字段、枚举或错误码发生变化)。
+- 第二世男性请求返回至少一个男性排行选项,且包含稳定的 `rankId` 和非空 `rankName`;
+- 第一世请求返回“始祖”,第二世女性请求返回“长女、次女”等女性排行;
+- 前端选择并保存后,重新进入调整排行页面仍选中原值;
+- 返回世系树后节点显示保存的排行名称,不再出现“后代”兜底文案。
-仅提供“代码已提交”“自动测试通过”或“域名可以访问”不能作为线上联调完成依据,最终以同一部署环境中的接口响应和 MuMu 点击回归为准。
+## 问题二(待部署验收):我的家谱真实创建时间
+
+### 当前现象
+
+新版家谱首页需要在家谱卡片中展示“创建于”。此前以下两个正式接口只返回当前用户的加入时间 `joinTime`,没有返回家谱创建时间 `createTime`:
+
+- `GET /genealogy/app/genealogies/mine`
+- `GET /genealogy/app/genealogies/{genealogyId}`
+
+`joinTime` 表示用户加入家谱的时间,不能替代家谱创建时间。
+
+2026-09-05 更新:后端提交 `0e8d094` 已增加只读 `createTime`,前端也已完成响应保留和“创建于”展示。当前仅等待后端部署后进行真实接口验收;线上网关现阶段返回 502,尚不能判定线上完成。
+
+### 后端处理要求
+
+1. 由家谱响应模型统一拥有只读字段 `createTime`,我的家谱列表和家谱详情使用同一字段定义;
+2. 字段取家谱记录的真实创建时间,不取成员关系创建时间或 `joinTime`;
+3. 创建时间由服务端生成,创建后不可被客户端修改;
+4. 已有家谱需要从现有家谱数据的创建时间完整回读,不允许只对新建家谱生效;
+5. 同步更新正式 App OpenAPI、响应示例和接口契约测试。
+
+### 验收标准
+
+- 两个接口均稳定返回非空 `createTime`;
+- 同一家谱在列表和详情中的 `createTime` 完全一致;
+- 不同时间加入同一家谱的成员读取到相同的 `createTime`,但各自的 `joinTime` 可以不同;
+- 编辑家谱名称、地区或简介后,`createTime` 不发生变化;
+- 前端接入后将卡片文案由“加入于”调整为“创建于”。
+
+## 当前待办总览
+
+本清单已按后端最新源码重新核对,旧文档中的以下事项已经不再列为后端待办:
+
+- `AppGenealogyVo` 已有稳定家谱编号 `genealogyNo`,前端已接入保留;
+- 功德记录已经具备请求字段 `mediaOssIds` 和响应字段 `mediaFiles`;
+- 谱文、成长记录、重要证件已经分别具备内容密码设置、解除、验证和短信找回接口;
+- 前端可以用现有单条删除接口完成批量管理,不要求后端新增批量删除接口;
+- 先前的“我的家谱无法读取”和合规文档接口异常已不再作为本轮待办。
+
+当前需要后端、运维或测试环境处理的事项共五类:
+
+| 优先级 | 事项 | 责任方 | 是否阻塞对应功能验收 |
+| --- | --- | --- | --- |
+| P0 | 世系排行接口返回空数组 | 后端/运维 | 是 |
+| 待部署 | 家谱真实创建时间 | 后端/运维 | 是 |
+| 待部署 | 谱文正文多图片保存与回显 | 后端/运维 | 是 |
+| P1 | 应用推广 HTTPS 分享地址配置 | 后端/运维 | 是 |
+| P1 | 联调环境缺少可点击业务数据 | 后端/测试/运营 | 是 |
+
+## 问题三(待部署验收):谱文正文多图片保存与回显
+
+### 1. 参考项目行为
+
+参考项目 `pages/index/puwen/add.vue` 的谱文新增和编辑表单使用图片上传组件,维护 `form.imgs` 图片数组;谱文不仅有文字正文,也允许上传、删除和回显多张正文图片。
+
+### 2. 最新实现状态
+
+2026-09-05 更新:后端提交 `0e8d094` 已增加 `mediaOssIds`、`mediaFiles` 及文件引用和回收站处理;前端已完成正文多图添加、编辑回显、单图移除、顺序提交和详情预览。当前剩余事项是执行后端三段迁移、部署并使用真实 OSS 数据验收。
+
+后端其他内容类型已经采用统一媒体契约,可直接沿用该方式。例如 `AppMeritRecordBody.mediaOssIds` 使用英文逗号分隔的正整数 OSS 编号,`AppMeritRecordVo.mediaFiles` 返回授权后的文件访问对象列表。
+
+### 3. 后端处理要求
+
+1. 数据库为谱文增加正文媒体 OSS 编号字段,名称和类型与现有内容媒体模型保持一致;
+2. `Article`、创建/更新 DTO、查询 VO、Mapper 和 Service 同步增加对应字段;
+3. 请求字段使用 `mediaOssIds`,格式与现有内容接口保持一致:空字符串表示清空,多项为英文逗号分隔的正整数 OSS 编号;
+4. 响应字段使用 `mediaFiles`,类型为 `List`,不得把数据库中的 OSS 编号直接暴露成可访问地址;
+5. 封面 `coverFile` 与正文图片 `mediaFiles` 分开管理:封面用于列表缩略图,正文图片用于详情展示;
+6. 创建、编辑、移入回收站、恢复和永久删除时,同步维护业务文件引用,避免文件被误删或形成无主引用;
+7. 同步更新正式 App OpenAPI、字段格式校验、HTTP 契约测试和数据库事务测试。
+
+涉及接口:
+
+- `POST /genealogy/app/genealogies/{genealogyId}/articles`
+- `PUT /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`
+- `GET /genealogy/app/genealogies/{genealogyId}/articles`
+- `GET /genealogy/app/genealogies/{genealogyId}/articles/{articleId}`
+
+### 4. 验收标准
+
+- 新建谱文上传至少三张正文图片,保存后列表封面正常,详情按提交顺序显示三张正文图片;
+- 编辑时删除一张、保留一张、新增一张,重新读取结果准确且顺序稳定;
+- 提交 `mediaOssIds=""` 后正文图片清空,但 `coverFile` 不受影响;
+- 无文件访问权限时不得返回可用下载地址;
+- 谱文进入回收站和恢复后,正文图片引用保持一致;永久删除后按现有文件生命周期规则清理引用。
+
+## 问题四(P1):应用推广缺少可用的 HTTPS 分享地址
+
+涉及接口:`GET /genealogy/app/referrals/me`
+
+后端 `ReferralService.buildShareUrl` 依赖当前租户启用品牌配置中的 `h5Domain`。请核对联调租户的品牌配置,确保:
+
+- `h5Domain` 非空;
+- 使用可公开访问的 HTTPS 地址;
+- 地址指向真实 H5 注册流程;
+- 服务端生成的 `shareUrl` 携带推荐凭据,但不直接暴露内部用户编号。
+
+验收标准:接口返回 `code=200`,并包含稳定的 `referralCode`、邀请人数、分享标题、分享文案和 HTTPS `shareUrl`;前端可据此生成二维码、复制链接并进入带推荐关系的注册流程。
+
+## 问题五(P1):联调环境缺少完整可点击业务数据
+
+以下项目已有前端页面和接口调用,但仅有空数据时无法完成图片预览、视频播放、内容密码、编辑和删除等点击回归。请为同一测试账号、同一家谱准备最小数据:
+
+| 数据类型 | 最小验收数据 |
+| --- | --- |
+| 家族动态 | 一条纯文字动态、一条带图片动态 |
+| 谱文 | 一篇带封面和正文多图的普通谱文、一篇已设置内容密码的谱文 |
+| 礼仪活动 | 一条带封面活动,并包含可查看的献礼记录 |
+| 家族视频 | 一条带封面且视频文件可播放的数据 |
+| 平台宣传视频 | `home_featured`、`video_center` 各至少一条带封面且可播放的数据 |
+| 功德记录 | 一条带金额和至少两张图片的数据 |
+| 家族备忘 | `general`、`benefactor` 各至少一条带图片的数据 |
+| 亲友往来 | 一条带图片的记录 |
+| 成长记录 | 一条带图片的普通记录、一条已设置内容密码的记录 |
+| 重要证件 | 一份带图片的普通证件、一份已设置内容密码的证件 |
+| 家族相册 | 一个至少包含两张可预览图片的相册 |
+
+所有文件响应必须通过 `BusinessFileAccessVo` 返回当前账号可访问的 HTTPS 地址,不能只返回 OSS 编号。
+
+## 前端已经完成,不需要后端重复开发
+
+1. 谱文、成长记录、重要证件的新建表单已支持选填 8 至 128 位内容密码;前端先创建内容,再调用现有内容保护接口。第二步失败时会明确提示“内容已创建、当前可能尚未受密码保护”,不会重复创建;
+2. 谱文、家族视频、礼仪、功德记录、亲友往来、家族备忘、成长记录和重要证件列表已补批量管理;只允许选择具有删除权限的内容,逐条调用现有删除接口,失败项保留选择;
+3. 我的家谱响应已保留后端返回的 `genealogyNo`;
+4. 宣传视频无封面时使用可点击占位封面,点击后进入播放器,不再在首页直接铺开播放器;
+5. 首页结构、浅色页面底色、固定双图内容区、底部导航名称和家族内容归位均已在前端完成;
+6. 出生日期选择器已经限制未来年份和未来月份,不需要后端修改日期接口;
+7. 世系树头像框、姓名、排行标签、配偶标签和节点布局已经按参考项目调整;后端只需解决排行选项及 `rankId`、`rankName` 回显;
+8. 前端已通过项目页面、审计回归、导航恢复、参考功能映射和运行时资产检查。
+
+## 后端完成后请回传
+
+1. 后端提交编号和联调环境部署时间;
+2. `gen_lineage_rank_config` 的线上检查结果、补数或迁移记录,以及四组排行请求的脱敏响应;
+3. 家谱列表和详情接口新增 `createTime` 后的脱敏响应;
+4. 谱文媒体字段的数据库迁移名称及后置检查结果;
+5. 更新后的正式 App OpenAPI;
+6. 谱文多图创建、更新、清空、回收站和恢复的自动化测试结果;
+7. `/referrals/me` 的脱敏响应示例;
+8. 测试数据所属家谱编号、各资源编号和测试账号权限说明。
+
+建议后端按“问题一 → 问题二 → 问题三 → 问题四 → 问题五”的顺序处理。问题一是当前世系树排行功能的直接阻塞项,补齐数据后即可由前端在 MuMu 上复验;问题二和问题三涉及正式响应契约,需要同时更新运行时代码、OpenAPI 和测试;问题四、问题五主要是线上配置与联调数据准备。
+
+最终以同一部署环境中的实际接口响应和 MuMu 点击回归为准,不能只以“代码已提交”或“自动测试通过”作为联调完成依据。
diff --git a/genealogy-app-openapi.yaml b/genealogy-app-openapi.yaml
index e82808a..c605ff5 100644
--- a/genealogy-app-openapi.yaml
+++ b/genealogy-app-openapi.yaml
@@ -4857,6 +4857,7 @@ components:
deletePermanentlyDisabledReasons: { type: array, items: { type: string } }
verifiedMobileMasked: { type: string, nullable: true }
memberStatus: { type: string }
+ createTime: { type: string, format: date-time, nullable: true, readOnly: true, description: 家谱记录的真实创建时间;不同成员读取同一家谱时一致 }
joinTime: { type: string, format: date-time, nullable: true }
AppGrowthRecord:
type: object
@@ -5000,7 +5001,8 @@ components:
articleTitle: { type: string }
articleSummary: { type: string }
coverFile: { $ref: '#/components/schemas/BusinessFileAccess' }
- articleContent: { type: string }
+ mediaFiles: { type: array, description: 有序正文图片;未解锁时为空数组;不含永久公开地址, items: { $ref: '#/components/schemas/BusinessFileAccess' } }
+ articleContent: { type: string, nullable: true, description: 未解锁时为 null }
authorName: { type: string }
publishTime: { type: string, format: date-time, nullable: true }
viewCount: { type: integer, format: int64 }
@@ -6277,6 +6279,11 @@ components:
articleContent:
description: 文章内容
type: string
+ mediaOssIds:
+ type: string
+ nullable: true
+ description: 正文图片OSS ID用英文逗号分隔;创建缺失或空值为无图;更新省略保持,null或空白清空;非空整体替换,去除两侧空格并按首次出现顺序去重;仅允许正整数且规范值总长度不超过1000字符
+ example: '2060000000000000002,2060000000000000003'
authorName:
description: 作者名称
type: string
diff --git a/package-lock.json b/package-lock.json
index e767f93..fe8f089 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,10 +5,19 @@
"packages": {
"": {
"name": "jiapuapp",
+ "dependencies": {
+ "qrcode-generator": "^1.4.4"
+ },
"devDependencies": {
"yaml": "^2.8.1"
}
},
+ "node_modules/qrcode-generator": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-1.4.4.tgz",
+ "integrity": "sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==",
+ "license": "MIT"
+ },
"node_modules/yaml": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
diff --git a/package.json b/package.json
index c68923c..c5a91a0 100644
--- a/package.json
+++ b/package.json
@@ -10,5 +10,8 @@
},
"devDependencies": {
"yaml": "^2.8.1"
+ },
+ "dependencies": {
+ "qrcode-generator": "^1.4.4"
}
}
diff --git a/pages.json b/pages.json
index 4604541..b536718 100644
--- a/pages.json
+++ b/pages.json
@@ -136,6 +136,18 @@
"navigationStyle": "custom"
}
},
+ {
+ "path": "pages/family/site-home",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
+ {
+ "path": "pages/family/site-article-list",
+ "style": {
+ "navigationStyle": "custom"
+ }
+ },
{
"path": "pages/family/feed",
"style": {
diff --git a/pages/auth/sign-in.vue b/pages/auth/sign-in.vue
index 6b897bf..d12ac68 100644
--- a/pages/auth/sign-in.vue
+++ b/pages/auth/sign-in.vue
@@ -197,6 +197,8 @@
+
+
还没有账号?
{
diff --git a/pages/family/videos.vue b/pages/family/videos.vue
index 4a46486..9b2becb 100644
--- a/pages/family/videos.vue
+++ b/pages/family/videos.vue
@@ -129,6 +129,20 @@
+
+ {{ videoBatch.notice.value }}
+ {{ videoBatch.error.value }}
+
-
+
@@ -192,6 +212,18 @@
+
{
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;
diff --git a/pages/genealogy/my-genealogies.vue b/pages/genealogy/my-genealogies.vue
index b80b8d5..55268d7 100644
--- a/pages/genealogy/my-genealogies.vue
+++ b/pages/genealogy/my-genealogies.vue
@@ -3,7 +3,7 @@
class="page-shell genealogy-index"
:class="{ 'genealogy-index--split': isListLayout }"
>
-
+
-
-
-
-
- 家谱
-
- {{ currentGenealogy.name }}
- 切换
-
-
-
-
-
- {{
- currentGenealogy.location
- }}
-
-
-
- {{ currentGenealogy.memberCount }} 位成员
-
-
-
- {{
- currentRoleLabel
- }}
-
-
-
-
-
-
-
- {{ shortcut.label }}
-
-
-
-
-
-
+
+
+ {{ managedGenealogies.length ? "我管理的" : "我加入的" }}
+
+
+
- 我管理的
- 我加入的
+ 我加入的
-
-
-
- 宣传视频
- 家谱文化与使用介绍
-
-
- 查看更多
-
-
-
-
-
-
- 宣传视频暂时无法显示
- 重新加载
-
-
-
-
-
-
-
-
-
- {{ video.title }}
-
-
-
-
- 暂时没有推荐视频
-
- 查看全部视频
-
-
-
-
-
+
添加家谱
-
+
@@ -284,12 +174,6 @@
>
创建家谱
-
确认没有现有家谱后再创建,避免重复建谱
@@ -322,7 +206,7 @@
@saved="applySavedGenealogyOrder"
/>
-
+
@@ -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]?.();
-};
diff --git a/pages/records/ceremonies.vue b/pages/records/ceremonies.vue
index 4fc50b3..f7b6ced 100644
--- a/pages/records/ceremonies.vue
+++ b/pages/records/ceremonies.vue
@@ -30,12 +30,32 @@
>
+ {{ ceremonyBatch.notice.value }}
+ {{ ceremonyBatch.error.value }}
+
+
+
@@ -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();
});
@@ -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;
diff --git a/pages/records/growth-journal.vue b/pages/records/growth-journal.vue
index 5f52db7..e6a35fc 100644
--- a/pages/records/growth-journal.vue
+++ b/pages/records/growth-journal.vue
@@ -134,6 +134,24 @@
{{ uploadError }}
+
+ 内容密码(选填)
+ 填写后会先创建记录,再立即启用密码保护;设置失败时会保留记录并明确提示。
+
+
+
{{ error }}
- 成长记录已保存。
+
+ {{ growthBatch.notice.value }}
+ {{ growthBatch.error.value }}
+ {{ saveNotice }}
{{ deleteError }}
@@ -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)"
>
+
{{ item.title }}{{ item.content }}
+
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;
}
diff --git a/pages/records/memos.vue b/pages/records/memos.vue
index 66c70f8..6d55bbc 100644
--- a/pages/records/memos.vue
+++ b/pages/records/memos.vue
@@ -94,18 +94,46 @@
>
+
+ {{ memoBatch.notice.value }}
+ {{ memoBatch.error.value }}
{{ recordName }}已保存。
{{ deleteError }}
-
-
- {{ item.title }}
- {{ item.remindTime }}
- 创建于 {{ formatMinuteTimestamp(item.createTime) }}
- {{ item.content }}
+
+
+
+
+
+ {{ item.title }}
+ {{ item.remindTime }}
+ 创建于 {{ formatMinuteTimestamp(item.createTime) }}
+ {{ item.content }}
+
-
+
+
/^[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%;
diff --git a/pages/records/merit-records.vue b/pages/records/merit-records.vue
index d6d966f..48d925c 100644
--- a/pages/records/merit-records.vue
+++ b/pages/records/merit-records.vue
@@ -100,6 +100,20 @@
>
+
+ {{ meritBatch.notice.value }}
+ {{ meritBatch.error.value }}
{{ saveNotice }}
@@ -107,15 +121,29 @@
>共 {{ merits.length }} 笔,金额合计 ¥{{ totalAmount }}
{{ deleteError }}
-
-
- {{ item.title }}
- {{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}
- {{ item.time }}
- 创建于 {{ formatMinuteTimestamp(item.createTime) }}
- {{ item.content }}
+
+
+
+
+
+ {{ item.title }}
+ {{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}
+ {{ item.time }}
+ 创建于 {{ formatMinuteTimestamp(item.createTime) }}
+ {{ item.content }}
+
-
+
¥{{ item.amount }}
+
/^[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);
diff --git a/pages/records/relative-records.vue b/pages/records/relative-records.vue
index 0708351..c2c8600 100644
--- a/pages/records/relative-records.vue
+++ b/pages/records/relative-records.vue
@@ -30,8 +30,28 @@
>
+
+ {{ recordBatch.notice.value }}
+ {{ recordBatch.error.value }}
{{ deleteError }}
-
+
+
创建于 {{ formatMinuteTimestamp(item.createTime) }}{{ item.content }}
-
+
¥{{ item.amount }}
+
/^[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;
diff --git a/pages/tree/add-relative.vue b/pages/tree/add-relative.vue
index 1e988f8..40ddf0e 100644
--- a/pages/tree/add-relative.vue
+++ b/pages/tree/add-relative.vue
@@ -55,7 +55,7 @@
性别
首位成员固定为 1
- 出生日期{{ addForm.birthDate || "请选择" }}
+ 排行称谓{{ selectedRankName || "请选择" }}
+
+ 排行称谓{{ rankOptionStateLabel }}
+
+ 排行配置暂未就绪,请稍后重试。
+
+ 出生日期{{ addForm.birthDate || "请选择" }}
+
-
-
- 逝世日期{{ addForm.deathDate || "请选择" }}
-
-
+ 逝世日期{{ addForm.deathDate || "请选择" }}
+
关系称谓{{ optionLabel(relationVariantOptions, addForm.relationVariantCode) || "请选择" }}
-
-
- 安葬日期{{ addForm.burialDate || "请选择" }}
-
-
+ 安葬日期{{ addForm.burialDate || "请选择" }}
+
安葬地
-
- 排序值
-
-
人物简介
+
+
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 }
: {
diff --git a/pages/tree/edit-member.vue b/pages/tree/edit-member.vue
index c9e46d1..76e107c 100644
--- a/pages/tree/edit-member.vue
+++ b/pages/tree/edit-member.vue
@@ -49,7 +49,7 @@
性别
+
+
+ 排行称谓
+ {{ selectedRankName || "请选择" }}
+
+
+
+ 排行称谓
+ {{ rankOptionStateLabel }}
+
+ 排行配置暂未就绪,请稍后重试。
{{ personOptionLabel(editForm.motherId) }}
-
- 出生日期{{ editForm.birthDate || "未填写" }}
-
+ 出生日期
+ {{ editForm.birthDate || "未填写" }}
+
-
- 离世日期{{ editForm.deathDate || "在世或未填写" }}
-
+ 离世日期
+ {{ editForm.deathDate || "在世或未填写" }}
+
遗传病史属于敏感健康信息,仅在已授权时读取和保存。
-
- 安葬日期{{ editForm.burialDate || "未填写" }}
-
+ 安葬日期
+ {{ editForm.burialDate || "未填写" }}
+
安葬地
-
- 排序值
-
-
人物简介
+
+
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,
diff --git a/pages/tree/member-rank.vue b/pages/tree/member-rank.vue
index 546d04c..6eb72eb 100644
--- a/pages/tree/member-rank.vue
+++ b/pages/tree/member-rank.vue
@@ -1,346 +1,92 @@
-
+
-
-
+
+
-
-
- 同辈排行
- 调整{{ member.name }}的排行
- 这里只调整这位成员的排行;同辈成员的整体排序暂不能在这里修改。
-
-
- 当前成员第 {{ member.generation }} 世 · {{ member.branch }}
-
-
- 排序值
-
-
- {{ rankError }}
-
-
- {{ savingRank ? "正在保存…" : "保存排行" }}
-
-
-
-
- 暂时无法打开成员页面
- 暂时无法读取成员资料
- {{ errorMessage }}
-
-
- 返回世系树
+
+ 暂时无法打开成员资料
+ {{ errorMessage }}
+
+ 返回上一页
-
diff --git a/pages/tree/overview.vue b/pages/tree/overview.vue
index c87b212..56e8bea 100644
--- a/pages/tree/overview.vue
+++ b/pages/tree/overview.vue
@@ -26,10 +26,9 @@
aria-label="回到当前成员"
hover-class="tree-action--pressed"
@click="recenterSelectedMember"
- >回到当前回到当前
阅读提示
- 调整排行
@@ -105,19 +104,14 @@
>
-
+
+ {{ member.relation }}
-
{{ member.name }}
- {{
- nodeRelationText(member)
- }}
- {{ member.treeYears || member.years }}
@@ -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;
diff --git a/pages/tree/pedigree.vue b/pages/tree/pedigree.vue
index 5ba394e..10458a8 100644
--- a/pages/tree/pedigree.vue
+++ b/pages/tree/pedigree.vue
@@ -31,7 +31,7 @@
:show-scrollbar="false"
:style="pedigreeScrollStyle"
>
-
+
身份
{{ page.label }}
@@ -40,15 +40,14 @@
-
+
{{ memberRelation(member) }}
- {{ member.branch }}
- {{ memberDetail(member) }}
+ {{ memberDetailText(member) }}
-
-
-
-
-
-
+
{
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;
diff --git a/scripts/check-audit-regressions.mjs b/scripts/check-audit-regressions.mjs
index 91d3275..dcfca64 100644
--- a/scripts/check-audit-regressions.mjs
+++ b/scripts/check-audit-regressions.mjs
@@ -16,9 +16,17 @@ const listPageSources = (directory = 'pages') => fs.readdirSync(path.join(worksp
})
const signIn = read('pages/auth/sign-in.vue')
+const loginAdvertisement = read('components/auth/LoginAdvertisement.vue')
const register = read('pages/auth/register.vue')
const routes = read('utils/navigation/routes.js')
const genealogyHome = read('pages/genealogy/my-genealogies.vue')
+const profileHomePage = read('pages/profile/home.vue')
+const homeAdvertisementPanel = read('components/genealogy/HomeAdvertisementPanel.vue')
+const genealogyOverview = read('pages/genealogy/overview.vue')
+const genealogyCard = read('components/genealogy/Card.vue')
+const familyFeedPage = read('pages/family/feed.vue')
+const siteHomePage = read('pages/family/site-home.vue')
+const siteContentContract = read('services/api/site-content-contract.js')
const invitationManager = read('components/genealogy/InvitationManager.vue')
const pedigree = read('pages/tree/pedigree.vue')
const treeOverview = read('pages/tree/overview.vue')
@@ -26,6 +34,7 @@ const moduleBackground = read('components/ModulePageBackground.vue')
const genealogyBackground = read('components/genealogy/PageBackground.vue')
const vipPage = read('pages/profile/vip.vue')
const promotionsPage = read('pages/profile/promotions.vue')
+const referralQrCode = read('components/ReferralQrCode.vue')
const helpPage = read('pages/profile/help.vue')
const editProfilePage = read('pages/profile/edit-profile.vue')
const genealogySettingsPage = read('pages/genealogy/settings.vue')
@@ -45,6 +54,12 @@ const changePhonePage = read('pages/profile/change-phone.vue')
const changePasswordPage = read('pages/profile/change-password.vue')
const platformVideosPage = read('pages/family/platform-videos.vue')
const familyMediaContract = read('services/api/family-media-contract.js')
+const batchDeletionComposable = read('composables/use-batch-deletion.js')
+const batchManagementBar = read('components/BatchManagementBar.vue')
+const batchSelectionMark = read('components/BatchSelectionMark.vue')
+const familyArticleService = read('services/api/family-article-service.js')
+const familyArticleContract = read('services/api/family-article-contract.js')
+const lifeRecordService = read('services/api/life-record-service.js')
const dialogPages = [
'pages/tree/pedigree.vue',
'pages/family/album-detail.vue',
@@ -63,7 +78,6 @@ const memberDirectoryPage = read('pages/tree/member-directory.vue')
const peoplePage = read('pages/records/people.vue')
const idValidatedPages = [
'pages/tree/add-relative.vue',
- 'pages/tree/member-rank.vue',
'pages/tree/member-profile.vue',
'pages/tree/member-states.vue',
'pages/genealogy/generation-poems.vue',
@@ -92,7 +106,6 @@ const genealogyContract = read('services/api/genealogy-contract.js')
const genealogySearchPage = read('pages/genealogy/search.vue')
const generationPoemService = read('services/api/generation-poem-service.js')
const genealogyMemberService = read('services/api/genealogy-member-service.js')
-const lifeRecordService = read('services/api/life-record-service.js')
const personDocumentService = read('services/api/person-document-service.js')
const mediaUpload = read('utils/media-upload.js')
const businessFileContract = read('services/api/business-file-contract.js')
@@ -119,6 +132,29 @@ expect(!signIn.includes('协议页面准备中'), '登录页仍使用协议占
expect(!register.includes('协议页面准备中'), '注册页仍使用协议占位提示')
expect(signIn.includes('openComplianceDocument'), '登录页没有接入协议正文导航')
expect(register.includes('openComplianceDocument'), '注册页没有接入协议正文导航')
+expect(
+ signIn.includes('\s*openPage\("G13"/.test(genealogyHome),
- '家谱首页“成员”没有直达成员列表',
+ genealogyOverview.includes('openPage("G13"') &&
+ !genealogyHome.includes('shortcut-grid') &&
+ !genealogyHome.includes('openShortcut'),
+ '家谱快捷导航没有完整迁移到家谱详情页',
)
const platformVideosRoute = routes.match(/F11: defineRoute\(\{[\s\S]*?^\s{2}\}\),/m)?.[0] || ''
-expect(platformVideosRoute.includes('"G01"'), '宣传视频路由不允许从家谱首页进入')
+expect(!platformVideosRoute.includes('"G01"') && platformVideosRoute.includes('"F10"'), '宣传视频没有迁回原视频导航')
expect(platformVideosRoute.includes('"videoId"'), '宣传视频路由不能携带指定视频 ID')
expect(
familyMediaContract.includes("HOME_FEATURED: 'home_featured'") &&
@@ -142,10 +180,72 @@ expect(
familyMediaContract.includes("PROFILE_FEATURED: 'profile_featured'"),
'平台视频契约没有完整覆盖后端三个投放位',
)
-expect(genealogyHome.includes('featured-media-grid'), '家谱首页没有宣传视频封面预览')
expect(
- genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
- '家谱首页没有读取首页推荐视频投放位',
+ !genealogyHome.includes('featured-media-grid') &&
+ !genealogyHome.includes('PLATFORM_VIDEO_PLACEMENT.HOME_FEATURED'),
+ '家谱首页仍保留已经迁出的宣传视频',
+)
+expect(
+ !genealogyHome.includes('genealogy-create-button') &&
+ !genealogyHome.includes('新建家谱') &&
+ genealogyHome.includes('class="create-action"') &&
+ genealogyHome.includes('添加家谱'),
+ '家谱首页仍在顶部显示误加的新建按钮,或列表末尾缺少原有添加入口',
+)
+expect(
+ genealogyHome.indexOf('class="genealogy-list-scroll"') <
+ genealogyHome.indexOf('class="genealogy-list-toolbar"') &&
+ !genealogyHome.includes('genealogy-fixed-zone'),
+ '家谱分组标题仍被固定在第 7 条标注的滚动区域之外',
+)
+expect(
+ siteContentContract.includes("news: '资讯'") &&
+ siteContentContract.includes("notice: '公告'") &&
+ siteHomePage.includes('article.typeLabel'),
+ '官网文章类型仍直接暴露后端英文编码',
+)
+expect(
+ genealogyHome.includes(']*@click\.stop/.test(promotionsPage),
'推广操作按钮会继续触发卡片跳转',
)
+expect(
+ promotionsPage.includes('排序值'), '独立排行页仍要求普通用户手填排序值')
+expect(!memberRankPage.includes('updatePersonSortOrder'), '独立排行页仍会提交手工排序值')
expect(editProfilePage.includes('original[field] = payload[field]'), '编辑资料保存成功后没有更新草稿基准')
expect(meritRecordsPage.includes('/^(?:0|[1-9]\\d{0,9})(?:\\.\\d{1,2})?$/'), '功德记录页面没有限制金额精度和范围')
expect(lifeRecordContract.includes("normalizeOptionalCurrencyNumber(payload.amount, '功德金额')"), '功德记录 API 契约没有限制金额精度和范围')
@@ -369,6 +486,21 @@ expect(
editMemberPage.match(/\/\^\[1-9\]\\d\*\$\/.test/g)?.length >= 4,
'修改成员页没有完整校验家谱和人物 ID',
)
+expect(editMemberPage.includes('lineageApi.getRankOptions'), '修改成员页没有读取排行称谓选项')
+expect(editMemberPage.includes('rankId: editForm.rankId'), '修改成员页没有提交所选排行称谓')
+expect(editMemberPage.includes('暂无可选排行'), '修改成员页没有展示排行配置缺失状态')
+expect(!editMemberPage.includes('排序值'), '修改成员页仍要求普通用户手填排序值')
+expect(addRelativePage.includes('lineageApi.getRankOptions'), '新增亲属页没有读取排行称谓选项')
+expect(addRelativePage.includes('{ rankId: addForm.rankId }'), '新增亲属页没有提交所选排行称谓')
+expect(!addRelativePage.includes('排序值'), '新增亲属页仍要求普通用户手填排序值')
+expect(addRelativePage.includes('MemberDatePickerSheet'), '新增亲属页没有复用成员日期选择器')
+expect(!addRelativePage.includes('mode="date"'), '新增亲属页仍在使用可选择未来年份的系统日期控件')
+expect(addRelativePage.includes('不能晚于今天'), '新增亲属页没有在提交前拦截未来日期')
+expect(!treeOverview.includes('writing-mode: vertical-rl'), '树状图世代信息仍被强制逐字竖排')
+expect(treeOverview.includes('-webkit-line-clamp: 2'), '树状图长姓名仍会被单行省略号截断')
+expect(!editMemberPage.includes('`${name}(${personOptionId})`'), '修改成员页仍向用户暴露人物内部 ID')
+expect(editMemberPage.includes('sortOrder: editForm.sortOrder'), '隐藏排序值后修改成员会把原顺序重置为零')
+expect(!treeOverview.includes('@click="toRank"') && !treeOverview.includes('ADJUST_RANK'), '树状图仍保留重复的独立排行入口')
expect(
/v-if="feed\.canDelete"[\s\S]*?label="删除动态"/.test(feedDetailPage),
'动态详情向无删除权限用户显示删除按钮',
@@ -420,6 +552,38 @@ expect(memoPage.includes('memo.memoType === memoType.value'), '家族恩人列
for (const contractSource of [lifeRecordContract, familyMediaContract, read('services/api/ceremony-contract.js')]) {
expect(contractSource.includes('createTime:'), '资源响应契约没有保留后端 createTime')
}
+expect(batchDeletionComposable.includes('for (const id of pendingIds)') && batchDeletionComposable.includes('break'), '批量删除没有按顺序执行或遇到失败后停止')
+expect(batchDeletionComposable.includes('selectedIds.value = selectedIds.value.filter'), '批量删除成功后没有移除已完成选择')
+expect(batchManagementBar.includes('仅可选择有删除权限的内容'), '批量管理没有说明权限边界')
+expect(batchSelectionMark.includes('role="checkbox"') && batchSelectionMark.includes(':aria-checked="selected"'), '批量选择控件缺少复选语义')
+for (const [pageSource, resourceName] of [
+ [articleListPage, '谱文'],
+ [familyVideosPage, '视频'],
+ [ceremonyListPage, '礼仪活动'],
+ [meritRecordsPage, '功德记录'],
+ [relativeRecordsPage, '往来记录'],
+ [memoPage, '家族备忘/恩人'],
+ [growthJournalPage, '成长记录'],
+ [personDocumentDialog, '重要证件'],
+]) {
+ expect(pageSource.includes('useBatchDeletion'), `${resourceName}没有接入统一批量删除状态`)
+ expect(pageSource.includes('BatchManagementBar') && pageSource.includes('BatchSelectionMark'), `${resourceName}缺少批量管理入口或选择控件`)
+}
+expect(/createArticle[\s\S]*?normalizeAppArticle\(article, normalizedGenealogyId\)/.test(familyArticleService), '谱文创建响应没有规范化,无法可靠继续设置密码')
+expect(/createGrowthRecord[\s\S]*?normalizeAppGrowthRecord\(growthRecord, normalizedGenealogyId\)/.test(lifeRecordService), '成长记录创建响应没有规范化,无法可靠继续设置密码')
+expect(/genealogyNo:\s*normalizeGenealogyResponseText\(item\.genealogyNo/.test(genealogyContract), '我的家谱响应没有保留后端返回的稳定家谱编号')
+expect(genealogyContract.includes("createTime: normalizeGenealogyResponseText(item.createTime, 'createTime')"), '我的家谱响应没有保留后端真实创建时间')
+expect(genealogyContract.includes('createTime: genealogy.createTime'), '我的家谱列表规范化时丢弃了真实创建时间')
+expect(familyArticleContract.includes("'mediaOssIds'") && familyArticleContract.includes('normalizedPayload.mediaOssIds'), '谱文请求契约没有提交正文图片标识')
+expect(familyArticleContract.includes('mediaFiles: normalizeBusinessFileAccessRows'), '谱文响应契约没有保留正文图片')
+expect(articleEditorPage.includes('mediaReceipts') && articleEditorPage.includes('mediaOssIds') && articleEditorPage.includes('removeArticleMedia'), '谱文编辑页没有完整支持正文多图新增、回显和移除')
+expect(articleDetailPage.includes(':files="article.mediaFiles"'), '谱文详情没有按后端顺序显示正文图片')
+expect(/createArticle[\s\S]*?setArticlePassword/.test(articleEditorPage), '新建谱文没有在创建成功后继续设置内容密码')
+expect(/createGrowthRecord[\s\S]*?setGrowthRecordPassword/.test(growthJournalPage), '新建成长记录没有在创建成功后继续设置内容密码')
+expect(/createPersonDocument[\s\S]*?setPersonDocumentPassword/.test(personDocumentDialog), '新建重要证件没有在创建成功后继续设置内容密码')
+for (const protectedCreateSource of [articleEditorPage, growthJournalPage, personDocumentDialog]) {
+ expect(protectedCreateSource.includes('可能尚未受到密码保护'), '两阶段创建没有明确提示密码设置失败后的未保护风险')
+}
expect(permissionContract.includes('item.code'), '权限目录仍读取旧版 permissionCode 字段')
expect(permissionContract.includes('item.groupName'), '权限目录没有接入后端权限分组')
expect(genealogyCapabilityService.includes('/comments/page`'), '家族视频根评论仍调用旧列表路径')
diff --git a/services/api/family-article-contract.js b/services/api/family-article-contract.js
index 05a1a25..1f545f1 100644
--- a/services/api/family-article-contract.js
+++ b/services/api/family-article-contract.js
@@ -8,6 +8,7 @@ import {
import {
assertPlainPayload,
normalizeOptionalNormalDisableStatus,
+ normalizeOptionalOssIdList,
normalizeOptionalSafeInteger,
normalizeOptionalText,
normalizeOssIdString,
@@ -15,6 +16,7 @@ import {
} from './request-normalizers.js'
import {
normalizeBusinessFileAccess,
+ normalizeBusinessFileAccessRows,
normalizeContentProtectionCapabilities,
normalizeResourceCapabilities
} from './business-file-contract.js'
@@ -67,6 +69,7 @@ export const normalizeAppArticle = (value, expectedGenealogyId, expectedArticleI
categoryId: normalizeOptionalNumericId(value.categoryId, '谱文分类标识', 'ARTICLE_RESPONSE_INVALID'),
category: normalizeResponseText(value.categoryName, 'categoryName'),
coverFile: normalizeBusinessFileAccess(value.coverFile, '谱文封面', 'ARTICLE_RESPONSE_INVALID'),
+ mediaFiles: normalizeBusinessFileAccessRows(value.mediaFiles, '谱文正文图片', 'ARTICLE_RESPONSE_INVALID'),
viewCount: normalizeOptionalNonnegativeInteger(value.viewCount, '谱文阅读数', 'ARTICLE_RESPONSE_INVALID'),
sortOrder: normalizeOptionalNonnegativeInteger(value.sortOrder, '谱文排序值', 'ARTICLE_RESPONSE_INVALID'),
status: normalizeNormalDisableResponseStatus(value.status, '谱文状态', 'ARTICLE_RESPONSE_INVALID'),
@@ -90,6 +93,7 @@ export const normalizeArticleCreatePayload = (payload) => {
'articleTitle',
'articleSummary',
'coverOssId',
+ 'mediaOssIds',
'articleContent',
'authorName',
'sortOrder',
@@ -116,5 +120,12 @@ export const normalizeArticleCreatePayload = (payload) => {
if (payload.coverOssId === null) normalizedPayload.coverOssId = null
else if (payload.coverOssId !== '') normalizedPayload.coverOssId = normalizeOssIdString(payload.coverOssId, 'coverOssId')
}
+ if (Object.prototype.hasOwnProperty.call(payload, 'mediaOssIds')) {
+ const mediaOssIds = normalizeOptionalOssIdList(payload.mediaOssIds)
+ if (mediaOssIds && mediaOssIds.length > 1000) {
+ throw new TypeError('谱文正文图片标识总长度不能超过1000个字符')
+ }
+ normalizedPayload.mediaOssIds = mediaOssIds || ''
+ }
return normalizedPayload
}
diff --git a/services/api/family-article-service.js b/services/api/family-article-service.js
index 75e8ab2..554b91c 100644
--- a/services/api/family-article-service.js
+++ b/services/api/family-article-service.js
@@ -33,13 +33,14 @@ const requestArticleContentProtection = async ({
export const familyArticleApi = {
async createArticle(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
- return requestStrict({
+ const article = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/articles`,
method: 'POST',
data: normalizeArticleCreatePayload(payload)
}, {
requestController: requestOptions.requestController ?? null
})
+ return normalizeAppArticle(article, normalizedGenealogyId)
},
async getArticles(genealogyId, requestOptions = {}) {
diff --git a/services/api/genealogy-contract.js b/services/api/genealogy-contract.js
index 8279cf6..f7d9e4e 100644
--- a/services/api/genealogy-contract.js
+++ b/services/api/genealogy-contract.js
@@ -8,6 +8,7 @@ import { createRequestError } from './request-client.js'
import { assertPlainPayload, normalizeOssIdString } from './request-normalizers.js'
import {
normalizeOptionalNonnegativeInteger,
+ normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
@@ -227,6 +228,7 @@ export const normalizeAppGenealogy = (item) => {
)
return {
id: normalizeMyGenealogyId(item.genealogyId),
+ genealogyNo: normalizeGenealogyResponseText(item.genealogyNo, 'genealogyNo'),
name,
firstAncestorName: normalizeGenealogyResponseText(item.firstAncestorName, 'firstAncestorName'),
rootPersonId: normalizeOptionalNumericId(item.rootPersonId, '始迁祖人物标识', 'GENEALOGY_RESPONSE_INVALID'),
@@ -248,6 +250,7 @@ export const normalizeAppGenealogy = (item) => {
),
archivedAt: normalizeGenealogyResponseText(item.archivedAt, 'archivedAt'),
intro: normalizeGenealogyResponseText(item.intro, 'intro'),
+ createTime: normalizeGenealogyResponseText(item.createTime, 'createTime'),
joinTime: normalizeGenealogyResponseText(item.joinTime, 'joinTime')
}
}
@@ -267,6 +270,8 @@ export const normalizeMyGenealogies = (value) => {
memberCount: genealogy.memberCount,
canManage: genealogy.canManage,
canEditContent: genealogy.canEditContent,
+ createTime: genealogy.createTime,
+ joinTime: genealogy.joinTime,
lifecycleStatus: genealogy.lifecycleStatus,
archivedAt: genealogy.archivedAt,
canArchive: genealogy.canArchive,
diff --git a/services/api/life-record-service.js b/services/api/life-record-service.js
index 4881af9..13adf7f 100644
--- a/services/api/life-record-service.js
+++ b/services/api/life-record-service.js
@@ -89,11 +89,12 @@ export const lifeRecordApi = {
async createGrowthRecord(genealogyId, payload, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
- return requestStrict({
+ const growthRecord = await requestStrict({
url: `/genealogy/app/genealogies/${normalizedGenealogyId}/growth-records`,
method: 'POST',
data: normalizeGrowthRecordCreatePayload(payload)
}, { requestController: requestOptions.requestController ?? null })
+ return normalizeAppGrowthRecord(growthRecord, normalizedGenealogyId)
},
async updateGrowthRecord(genealogyId, recordId, payload, requestOptions = {}) {
diff --git a/services/api/lineage-person-contract.js b/services/api/lineage-person-contract.js
index d5437bc..052f506 100644
--- a/services/api/lineage-person-contract.js
+++ b/services/api/lineage-person-contract.js
@@ -1,5 +1,6 @@
import { normalizeOptionalSafeInteger } from './request-normalizers.js'
import { createRequestError } from './request-client.js'
+import { normalizeOptionalNumericId } from './response-normalizers.js'
import { LINEAGE_PERSON_OPTIONS } from './lineage-person-options.js'
import { normalizeBusinessOptionProjection } from './business-dictionary-contract.js'
@@ -10,6 +11,16 @@ const optionLabels = (options) => Object.freeze(
const lineagePersonError = (message) =>
createRequestError(message, 'LINEAGE_PERSON_RESPONSE_INVALID')
+const lineageRankError = (message) =>
+ createRequestError(message, 'LINEAGE_RANK_RESPONSE_INVALID')
+
+const normalizeLineageRankText = (value, label) => {
+ if (typeof value !== 'string' || !value.trim()) {
+ throw lineageRankError(`世系排行选项缺少${label}`)
+ }
+ return value.trim()
+}
+
export const normalizeLineagePersonIdentity = (value, label) => {
if (typeof value === 'string' && /^[1-9]\d*$/.test(value)) return value
if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return String(value)
@@ -146,6 +157,12 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
throw lineagePersonError('成员详情敏感病史权限无效')
}
const relationName = normalizeLineagePersonText(value.relationName, '关系显示名称')
+ const rankId = normalizeOptionalNumericId(
+ value.rankId,
+ '成员排行标识',
+ 'LINEAGE_PERSON_RESPONSE_INVALID'
+ )
+ const rankName = normalizeLineagePersonText(value.rankName, '排行名称')
for (const field of ['canDisable', 'canCreateDocument', 'canManageDocuments']) {
if (value[field] !== undefined && typeof value[field] !== 'boolean') {
throw lineagePersonError(`成员详情权限字段 ${field} 无效`)
@@ -221,6 +238,8 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
remark,
canManageSensitiveMedicalHistory,
relationName,
+ rankId,
+ rankName,
sortOrder,
status: personStatus === '1' ? 'deceased' : 'normal',
canDisable: value.canDisable === true,
@@ -231,6 +250,40 @@ export const normalizeLineagePersonDetail = (value, expectedGenealogyId, expecte
}
}
+export const normalizeLineageRankOptions = (value) => {
+ if (!Array.isArray(value)) throw lineageRankError('世系排行选项响应不是列表')
+ const rankIds = new Set()
+ return value.map((option) => {
+ if (!option || typeof option !== 'object' || Array.isArray(option)) {
+ throw lineageRankError('世系排行选项包含无效记录')
+ }
+ const rankId = normalizeOptionalNumericId(
+ option.rankId,
+ '世系排行标识',
+ 'LINEAGE_RANK_RESPONSE_INVALID'
+ )
+ if (!rankId || rankIds.has(rankId)) {
+ throw lineageRankError(rankId ? '世系排行选项包含重复标识' : '世系排行选项缺少标识')
+ }
+ rankIds.add(rankId)
+ const rankCode = normalizeLineageRankText(option.rankCode, '排行编码')
+ const rankName = normalizeLineageRankText(option.rankName, '排行名称')
+ const rankType = normalizeLineageRankText(option.rankType, '排行类型')
+ const genderScope = normalizeLineageRankText(option.genderScope, '排行适用性别')
+ if (!['ANCESTOR', 'GENERATION'].includes(rankType)) {
+ throw lineageRankError('世系排行选项类型无效')
+ }
+ if (!['0', '1', '2'].includes(genderScope)) {
+ throw lineageRankError('世系排行选项适用性别无效')
+ }
+ const rankOrder = normalizeOptionalSafeInteger(option.rankOrder, '排行顺序')
+ if (rankOrder !== undefined && rankOrder < 0) {
+ throw lineageRankError('世系排行选项顺序无效')
+ }
+ return { rankId, rankCode, rankName, rankType, genderScope, rankOrder }
+ })
+}
+
export const normalizeLineageSensitiveProfile = (value, expectedPersonId) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw lineagePersonError('成员敏感健康资料响应不是对象')
diff --git a/services/api/lineage-service.js b/services/api/lineage-service.js
index 22918ff..2ee7c11 100644
--- a/services/api/lineage-service.js
+++ b/services/api/lineage-service.js
@@ -4,6 +4,7 @@ import {
normalizeLineagePersonIdentity,
normalizeLineagePersonOptions,
normalizeLineagePersonPage,
+ normalizeLineageRankOptions,
normalizeLineageSensitiveProfile
} from './lineage-person-contract.js'
import { normalizeLineageTree } from './lineage-tree-contract.js'
@@ -26,6 +27,24 @@ export const lineageApi = {
return normalizeLineageTree(tree)
},
+ async getRankOptions(genealogyId, generation, sex, requestOptions = {}) {
+ const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
+ if (!Number.isSafeInteger(generation) || generation < 1) {
+ throw new TypeError('排行选项世代必须是正安全整数')
+ }
+ if (!['0', '1', '2'].includes(sex)) {
+ throw new TypeError('排行选项性别必须是 0、1 或 2')
+ }
+ const options = await requestStrict({
+ url: `/genealogy/app/genealogies/${normalizedGenealogyId}/lineage/ranks`,
+ method: 'GET',
+ data: { generation, sex }
+ }, {
+ requestController: requestOptions.requestController ?? null
+ })
+ return normalizeLineageRankOptions(options)
+ },
+
async getPerson(genealogyId, personId, requestOptions = {}) {
const normalizedGenealogyId = normalizeGenealogyPathId(genealogyId)
const normalizedPersonId = normalizeLineagePersonIdentity(personId, '人物标识')
diff --git a/services/api/lineage-tree-contract.js b/services/api/lineage-tree-contract.js
index ed1d82a..f0e049e 100644
--- a/services/api/lineage-tree-contract.js
+++ b/services/api/lineage-tree-contract.js
@@ -1,4 +1,5 @@
import { createRequestError } from './request-client.js'
+import { normalizeBusinessFileAccess } from './business-file-contract.js'
const lineageTreeError = (message) =>
createRequestError(message, 'LINEAGE_TREE_RESPONSE_INVALID')
@@ -60,6 +61,7 @@ export const normalizeLineageTree = (value) => {
throw lineageTreeError('世系树人物世代无效')
}
const generationName = normalizeLineageText(node.generationName, '字辈')
+ const rankName = normalizeLineageText(node.rankName, '排行名称')
const relationName = normalizeLineageText(node.relationName, '人物关系')
const birthDate = lineageDatePart(node.birthDate, '出生日期')
const deathDate = lineageDatePart(node.deathDate, '逝世日期')
@@ -70,15 +72,22 @@ export const normalizeLineageTree = (value) => {
name: normalizeLineageText(node.name, '人物姓名', { required: true }),
relation:
relationOverride ||
+ rankName ||
(parentId
- ? (relationName && relationName !== '配偶' ? relationName : '后代')
+ ? (relationName && relationName !== '配偶' ? relationName : '排行待补')
: relationName || '始祖'),
+ rankName,
generation: node.generation,
branch: generationName
? (generationName.endsWith('字辈') ? generationName : `${generationName}字辈`)
: '字辈待补',
years: birthDate || deathDate ? `${birthDate}—${deathDate}` : '生卒待补',
treeYears: compactLineageYears(birthDate, deathDate),
+ avatarFile: normalizeBusinessFileAccess(
+ node.avatarFile,
+ '世系人物头像',
+ 'LINEAGE_TREE_RESPONSE_INVALID'
+ ),
sex: normalizeLineageText(node.sex, '性别'),
personStatus: normalizeLineageText(node.personStatus, '人物状态')
}
diff --git a/services/api/lineage-write-contract.js b/services/api/lineage-write-contract.js
index 3362e9e..13cbb3a 100644
--- a/services/api/lineage-write-contract.js
+++ b/services/api/lineage-write-contract.js
@@ -27,6 +27,7 @@ export const normalizeLineageWritePayload = (payload) => {
'aliasName',
'sex',
'generationName',
+ 'rankId',
'fatherId',
'motherId',
'avatarOssId',
@@ -77,6 +78,11 @@ export const normalizeLineageWritePayload = (payload) => {
if (payload[field] === undefined || payload[field] === null || payload[field] === '') continue
normalizedPayload[field] = normalizeLineagePersonIdentity(payload[field], label)
}
+ if (Object.prototype.hasOwnProperty.call(payload, 'rankId')) {
+ normalizedPayload.rankId = payload.rankId === null || payload.rankId === ''
+ ? null
+ : normalizeLineagePersonIdentity(payload.rankId, '排行标识')
+ }
if (payload.avatarOssId !== undefined && payload.avatarOssId !== null && payload.avatarOssId !== '') {
normalizedPayload.avatarOssId = normalizeOssIdString(payload.avatarOssId, '头像文件标识')
}
diff --git a/services/api/site-content-contract.js b/services/api/site-content-contract.js
index 0b7d0ea..28e82e3 100644
--- a/services/api/site-content-contract.js
+++ b/services/api/site-content-contract.js
@@ -1,11 +1,16 @@
import { createRequestError } from './request-client.js'
import {
+ normalizeOptionalNonnegativeInteger,
normalizeOptionalNumericId,
normalizeResponseText
} from './response-normalizers.js'
import { normalizeBusinessFileAccess } from './business-file-contract.js'
const promotionPlacements = new Set(['home_banner', 'home_bottom', 'message_bottom', 'profile_bottom'])
+const siteArticleTypeLabels = Object.freeze({
+ news: '资讯',
+ notice: '公告'
+})
export const normalizePromotionPlacement = (value) => {
if (!promotionPlacements.has(value)) throw new TypeError('推广位无效')
@@ -50,6 +55,64 @@ export const normalizeAppPromotions = (value, expectedPlacement) => {
return promotions
}
+export const normalizeSiteArticles = (value) => {
+ if (!Array.isArray(value)) {
+ throw createRequestError('官网文章响应不是列表', 'SITE_ARTICLE_RESPONSE_INVALID')
+ }
+ const articles = value.map((item) => {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
+ throw createRequestError('官网文章响应包含无效条目', 'SITE_ARTICLE_RESPONSE_INVALID')
+ }
+ const id = normalizeOptionalNumericId(item.articleId, '官网文章标识', 'SITE_ARTICLE_RESPONSE_INVALID')
+ const title = normalizeResponseText(item.articleTitle, 'articleTitle', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ })
+ if (!id || !title) {
+ throw createRequestError('官网文章响应缺少稳定字段', 'SITE_ARTICLE_RESPONSE_INVALID')
+ }
+ const externalUrl = normalizeResponseText(item.externalUrl, 'externalUrl', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ })
+ if (externalUrl && !/^https:\/\/[^\s/?#]+(?:[/?#][^\s]*)?$/.test(externalUrl)) {
+ throw createRequestError('官网文章外部链接无效', 'SITE_ARTICLE_RESPONSE_INVALID')
+ }
+ const type = normalizeResponseText(item.articleType, 'articleType', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ })
+ return {
+ id,
+ title,
+ type,
+ typeLabel: siteArticleTypeLabels[type] || '资讯',
+ summary: normalizeResponseText(item.articleSummary, 'articleSummary', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ }),
+ content: normalizeResponseText(item.articleContent, 'articleContent', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ }),
+ publishTime: normalizeResponseText(item.publishTime, 'publishTime', {
+ code: 'SITE_ARTICLE_RESPONSE_INVALID',
+ subject: '官网文章响应'
+ }),
+ externalUrl,
+ sortOrder: normalizeOptionalNonnegativeInteger(
+ item.sortOrder,
+ '官网文章排序值',
+ 'SITE_ARTICLE_RESPONSE_INVALID'
+ )
+ }
+ })
+ if (new Set(articles.map((article) => article.id)).size !== articles.length) {
+ throw createRequestError('官网文章响应包含重复标识', 'SITE_ARTICLE_RESPONSE_INVALID')
+ }
+ return articles
+}
+
const normalizeHelpArticleText = (value, field) => {
if (typeof value !== 'string' || !value.trim()) {
throw createRequestError(`帮助文章缺少 ${field}`, 'HELP_ARTICLE_RESPONSE_INVALID')
diff --git a/services/api/site-content-service.js b/services/api/site-content-service.js
index a6d5db9..aec421d 100644
--- a/services/api/site-content-service.js
+++ b/services/api/site-content-service.js
@@ -3,11 +3,33 @@ import {
normalizeComplianceDocument,
normalizeComplianceDocumentKey,
normalizeHelpArticles,
- normalizePromotionPlacement
+ normalizePromotionPlacement,
+ normalizeSiteArticles
} from './site-content-contract.js'
import { requestStrict } from './request-client.js'
export const siteContentApi = {
+ async getSiteArticles(requestOptions = {}) {
+ const limit = requestOptions.limit ?? 20
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
+ throw new TypeError('官网文章数量上限无效')
+ }
+ const articleType = typeof requestOptions.articleType === 'string'
+ ? requestOptions.articleType.trim()
+ : ''
+ const articles = await requestStrict({
+ url: '/genealogy/app/site/articles',
+ method: 'GET',
+ data: {
+ limit,
+ ...(articleType ? { articleType } : {})
+ }
+ }, {
+ requestController: requestOptions.requestController ?? null
+ })
+ return normalizeSiteArticles(articles)
+ },
+
async getHelpArticles(requestOptions = {}) {
const helpArticles = await requestStrict({
url: '/genealogy/app/help-articles',
@@ -25,6 +47,7 @@ export const siteContentApi = {
method: 'GET',
data: { platform: 'app', placement }
}, {
+ authenticated: false,
requestController: requestOptions.requestController ?? null
})
return normalizeAppPromotions(promotions, placement)
diff --git a/static/app-icons/1024x1024.png b/static/app-icons/1024x1024.png
index d506e84..69fbb67 100644
Binary files a/static/app-icons/1024x1024.png and b/static/app-icons/1024x1024.png differ
diff --git a/static/app-icons/120x120.png b/static/app-icons/120x120.png
index 90f8cb7..3d3d899 100644
Binary files a/static/app-icons/120x120.png and b/static/app-icons/120x120.png differ
diff --git a/static/app-icons/144x144.png b/static/app-icons/144x144.png
index ef3b43c..87d6951 100644
Binary files a/static/app-icons/144x144.png and b/static/app-icons/144x144.png differ
diff --git a/static/app-icons/152x152.png b/static/app-icons/152x152.png
index 0da7ed5..52ca64e 100644
Binary files a/static/app-icons/152x152.png and b/static/app-icons/152x152.png differ
diff --git a/static/app-icons/167x167.png b/static/app-icons/167x167.png
index 55c6ddd..95869f7 100644
Binary files a/static/app-icons/167x167.png and b/static/app-icons/167x167.png differ
diff --git a/static/app-icons/180x180.png b/static/app-icons/180x180.png
index da0985a..5a49636 100644
Binary files a/static/app-icons/180x180.png and b/static/app-icons/180x180.png differ
diff --git a/static/app-icons/192x192.png b/static/app-icons/192x192.png
index d7ba165..c63a9f1 100644
Binary files a/static/app-icons/192x192.png and b/static/app-icons/192x192.png differ
diff --git a/static/app-icons/20x20.png b/static/app-icons/20x20.png
index cdec32a..9e3495d 100644
Binary files a/static/app-icons/20x20.png and b/static/app-icons/20x20.png differ
diff --git a/static/app-icons/29x29.png b/static/app-icons/29x29.png
index 061d603..c036790 100644
Binary files a/static/app-icons/29x29.png and b/static/app-icons/29x29.png differ
diff --git a/static/app-icons/40x40.png b/static/app-icons/40x40.png
index 3ad7649..0043ba8 100644
Binary files a/static/app-icons/40x40.png and b/static/app-icons/40x40.png differ
diff --git a/static/app-icons/58x58.png b/static/app-icons/58x58.png
index db6f1a2..6848a59 100644
Binary files a/static/app-icons/58x58.png and b/static/app-icons/58x58.png differ
diff --git a/static/app-icons/60x60.png b/static/app-icons/60x60.png
index 47d1424..7b1f6dd 100644
Binary files a/static/app-icons/60x60.png and b/static/app-icons/60x60.png differ
diff --git a/static/app-icons/72x72.png b/static/app-icons/72x72.png
index e536a07..9e0029a 100644
Binary files a/static/app-icons/72x72.png and b/static/app-icons/72x72.png differ
diff --git a/static/app-icons/76x76.png b/static/app-icons/76x76.png
index 6554714..64b1914 100644
Binary files a/static/app-icons/76x76.png and b/static/app-icons/76x76.png differ
diff --git a/static/app-icons/80x80.png b/static/app-icons/80x80.png
index 9dda176..03c63ac 100644
Binary files a/static/app-icons/80x80.png and b/static/app-icons/80x80.png differ
diff --git a/static/app-icons/87x87.png b/static/app-icons/87x87.png
index 8358058..8cde37d 100644
Binary files a/static/app-icons/87x87.png and b/static/app-icons/87x87.png differ
diff --git a/static/app-icons/96x96.png b/static/app-icons/96x96.png
index d919373..46f3c2d 100644
Binary files a/static/app-icons/96x96.png and b/static/app-icons/96x96.png differ
diff --git a/static/assets/foundation/opaque/book-blank.png b/static/assets/foundation/opaque/book-blank.png
new file mode 100644
index 0000000..972a5c8
Binary files /dev/null and b/static/assets/foundation/opaque/book-blank.png differ
diff --git a/static/assets/modules/genealogy/opaque/home-ad-family-tree.png b/static/assets/modules/genealogy/opaque/home-ad-family-tree.png
new file mode 100644
index 0000000..f1775d8
Binary files /dev/null and b/static/assets/modules/genealogy/opaque/home-ad-family-tree.png differ
diff --git a/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png b/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png
new file mode 100644
index 0000000..ed75e28
Binary files /dev/null and b/static/assets/modules/genealogy/opaque/home-ad-heritage-hall.png differ
diff --git a/static/assets/modules/tree/transparent/member-node-portrait.png b/static/assets/modules/tree/transparent/member-node-portrait.png
new file mode 100644
index 0000000..58fec1a
Binary files /dev/null and b/static/assets/modules/tree/transparent/member-node-portrait.png differ
diff --git a/utils/navigation/routes.js b/utils/navigation/routes.js
index 1b7ec5f..4c3a7b0 100644
--- a/utils/navigation/routes.js
+++ b/utils/navigation/routes.js
@@ -87,7 +87,7 @@ export const ROUTES = Object.freeze({
kind: "page",
parent: "G01",
requiredParams: ["genealogyId"],
- allowedSources: ["G01", "G05", "N01", "N02"],
+ allowedSources: ["G05", "N01", "N02"],
}),
G11: defineRoute({
path: "/pages/genealogy/settings",
@@ -101,14 +101,14 @@ export const ROUTES = Object.freeze({
kind: "flow",
parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["G01", "G05"],
+ allowedSources: ["G05"],
}),
G13: defineRoute({
path: "/pages/genealogy/members",
kind: "page",
parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["G01", "G05"],
+ allowedSources: ["G05"],
}),
G14: defineRoute({
path: "/pages/genealogy/capability-center",
@@ -123,7 +123,7 @@ export const ROUTES = Object.freeze({
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["selectedId"],
- allowedSources: ["G01", "G05", "T02", "T04", "T06", "T07"],
+ allowedSources: ["G05", "T02", "T04", "T06", "T07"],
}),
T02: defineRoute({
path: "/pages/tree/pedigree",
@@ -131,7 +131,7 @@ export const ROUTES = Object.freeze({
parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["selectedId"],
- allowedSources: ["G01", "G05", "T01"],
+ allowedSources: ["G05", "T01"],
}),
T03: defineRoute({
path: "/pages/tree/member-profile",
@@ -182,32 +182,38 @@ export const ROUTES = Object.freeze({
allowedSources: ["T03"],
}),
F01: defineRoute({
- path: "/pages/family/feed",
+ path: "/pages/family/site-home",
kind: "root",
parent: null,
- optionalParams: ["genealogyId"],
}),
F02: defineRoute({
path: "/pages/family/feed-editor",
kind: "flow",
- parent: "F01",
+ parent: "F12",
requiredParams: ["genealogyId", "mode"],
optionalParams: ["feedId"],
- allowedSources: ["F01", "F03"],
+ allowedSources: ["F12", "F03"],
}),
F03: defineRoute({
path: "/pages/family/feed-detail",
kind: "page",
- parent: "F01",
+ parent: "F12",
requiredParams: ["genealogyId", "feedId"],
- allowedSources: ["F01", "F02", "N02"],
+ allowedSources: ["F12", "F02", "N02"],
+ }),
+ F12: defineRoute({
+ path: "/pages/family/feed",
+ kind: "page",
+ parent: "G05",
+ requiredParams: ["genealogyId"],
+ allowedSources: ["G05"],
}),
F04: defineRoute({
path: "/pages/family/articles",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05", "M01"],
}),
F05: defineRoute({
path: "/pages/family/article-detail",
@@ -227,9 +233,9 @@ export const ROUTES = Object.freeze({
F07: defineRoute({
path: "/pages/family/albums",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
}),
F08: defineRoute({
path: "/pages/family/album-detail",
@@ -248,16 +254,23 @@ export const ROUTES = Object.freeze({
F10: defineRoute({
path: "/pages/family/videos",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
+ }),
+ F13: defineRoute({
+ path: "/pages/family/site-article-list",
+ kind: "page",
+ parent: "F01",
+ optionalParams: ["articleType", "title"],
+ allowedSources: ["F01", "M01"],
}),
R01: defineRoute({
path: "/pages/records/people",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
}),
R02: defineRoute({
path: "/pages/records/person-detail",
@@ -270,9 +283,9 @@ export const ROUTES = Object.freeze({
R03: defineRoute({
path: "/pages/records/relative-records",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
}),
R04: defineRoute({
path: "/pages/records/relative-record-editor",
@@ -285,9 +298,9 @@ export const ROUTES = Object.freeze({
R05: defineRoute({
path: "/pages/records/ceremonies",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
}),
R06: defineRoute({
path: "/pages/records/ceremony-detail",
@@ -321,17 +334,17 @@ export const ROUTES = Object.freeze({
R10: defineRoute({
path: "/pages/records/memos",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
optionalParams: ["memoId", "memoType"],
- allowedSources: ["F01", "N02"],
+ allowedSources: ["G05", "N02"],
}),
R11: defineRoute({
path: "/pages/records/merit-records",
kind: "page",
- parent: "F01",
+ parent: "G05",
requiredParams: ["genealogyId"],
- allowedSources: ["F01"],
+ allowedSources: ["G05"],
}),
R12: defineRoute({
path: "/pages/records/person-documents",
@@ -345,7 +358,7 @@ export const ROUTES = Object.freeze({
kind: "page",
parent: "G01",
optionalParams: ["genealogyId"],
- allowedSources: ["G01", "M01"],
+ allowedSources: ["G01", "F01", "M01"],
}),
N02: defineRoute({
path: "/pages/notification/message-detail",
@@ -363,7 +376,7 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/edit-profile",
kind: "flow",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
M03: defineRoute({
path: "/pages/profile/security",
@@ -387,7 +400,7 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/help",
kind: "page",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
M07: defineRoute({
path: "/pages/profile/feedback",
@@ -399,38 +412,38 @@ export const ROUTES = Object.freeze({
path: "/pages/profile/promotions",
kind: "page",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
M09: defineRoute({
path: "/pages/profile/vip",
kind: "page",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
M10: defineRoute({
path: "/pages/profile/settings",
kind: "page",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
F11: defineRoute({
path: "/pages/family/platform-videos",
kind: "page",
- parent: "F01",
+ parent: "F10",
optionalParams: ["placement", "videoId"],
- allowedSources: ["F10", "G01"],
+ allowedSources: ["F10"],
}),
M11: defineRoute({
path: "/pages/profile/ceremony-invitations",
kind: "page",
parent: "M01",
- allowedSources: ["M01", "N02"],
+ allowedSources: ["F01", "M01", "N02"],
}),
M12: defineRoute({
path: "/pages/profile/earnings",
kind: "page",
parent: "M01",
- allowedSources: ["M01"],
+ allowedSources: ["F01", "M01"],
}),
M13: defineRoute({
path: "/pages/profile/compliance-document",
diff --git a/utils/tree/layout.js b/utils/tree/layout.js
index 26a3b0d..52ad001 100644
--- a/utils/tree/layout.js
+++ b/utils/tree/layout.js
@@ -1,6 +1,6 @@
const GRID_UNIT = 5;
-const NODE_HALF_WIDTH = 80;
-const NODE_HALF_HEIGHT = 112;
+const NODE_HALF_WIDTH = 77;
+const NODE_HALF_HEIGHT = 100;
const FAMILY_LINK_OFFSET = 18;
const MEMBER_GAP = 178;
const GENERATION_GAP = 296;