diff --git a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue index bd80f88..d35c652 100644 --- a/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue +++ b/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue @@ -11,9 +11,12 @@ import type { LoadedTreeNode } from "@/components/family-tree-chart/types"; import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData"; import type { AdminRow } from "@/admin/admin-types"; +import type { ExchangeScope } from "@/composables/useFamilyTreeExchange"; +import type { FamilyTreeExchangeFileInfo } from "@/data/auto/familyTreeExchangeFileInfo"; import { useLocale } from "uview-pro"; import { computed, onMounted, ref } from "vue"; import { useThemeGradient, useThemeTokens } from "@/composables"; +import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange"; import { usePersonPickConfig } from "@/composables/usePickerConfigs"; import { usePolicyOptions } from "@/composables/usePolicyOptions"; import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity"; @@ -1035,6 +1038,298 @@ async function rejectPermission(record: { PersonId: string; UserName: string }): load(); } +// ===== 交换文件(.fm 导入导出,加密信封见 utils/familyTreeExchange)===== +const { exchangeBusy, exportTree, listFiles, decryptPayload, readAndDecrypt, importTree, importSubTree, listBackups, restoreBackup } = useFamilyTreeExchange(); +const exportVisible = ref(false); +const importVisible = ref(false); +const exScope = ref("ALL"); +const exPassword = ref(""); +const exSaveLocal = ref(true); +const exUploadServer = ref(true); +const importFiles = ref([]); +const importFilesLoading = ref(false); +const pickedFileName = ref(""); +const imPassword = ref(""); +// 二期:导入模式(ALL=整谱替换 / SUB=支系替换,与所选文件范围必须同构) +const imScope = ref("ALL"); +/** 小系导出闸:管理员任意锚点(D8),成员只限可编支系内的当前族员 */ +const canSubExport = computed(() => data.value.IsManager || (data.value.IsCanAmend && !!currentPerson.value?.PersonId)); +const canExport = computed(() => data.value.IsManager || data.value.IsMember); + +function openExchangeExport(): void { + exScope.value = "ALL"; + exPassword.value = ""; + exSaveLocal.value = true; + exUploadServer.value = true; + exportVisible.value = true; +} + +function onExSaveChange(value: boolean): void { + exSaveLocal.value = value; +} + +function onExUploadChange(value: boolean): void { + exUploadServer.value = value; +} + +/** 导出全链:拉明文→加密→落盘/上传(composable 内部完成),成功报文件名 */ +async function runExchangeExport(): Promise { + if (exPassword.value.length < 4) { + toast(t("FamilyTreePage.exchangePwdShort")); + return; + } + if (!exSaveLocal.value && !exUploadServer.value) { + toast(t("FamilyTreePage.exchangeTargetTip")); + return; + } + if (exScope.value === "SUB" && !canSubExport.value) { + toast(t("FamilyTreePage.exchangeSubDenied")); + return; + } + try { + const fileName = await exportTree({ + treeId: treeId.value, + scope: exScope.value, + anchorPersonId: currentPerson.value?.PersonId, + password: exPassword.value, + saveLocal: exSaveLocal.value, + uploadServer: exUploadServer.value, + }); + exportVisible.value = false; + toast(t("FamilyTreePage.exchangeExportDone").replace("{name}", fileName)); + } + catch (error: any) { + toast(error?.message === "exchange-export-empty" + ? t("FamilyTreePage.exchangeExportEmpty") + : error?.message || t("FamilyTreePage.exchangeFailed")); + } +} + +async function openExchangeImport(): Promise { + // 非管理员看不到整谱替换 chip(D4 管理员限),默认落在小系替换 + imScope.value = data.value.IsManager ? "ALL" : "SUB"; + pickedFileName.value = ""; + imPassword.value = ""; + bkMode.value = false; + pickedBackup.value = ""; + clearLocalFile(); + importVisible.value = true; + await loadImportFiles(); +} + +/** 模式切换:换范围重拉列表,已选文件清空防串模式 */ +async function onImScopeChange(scope: ExchangeScope): Promise { + if (imScope.value === scope) { + return; + } + imScope.value = scope; + pickedFileName.value = ""; + await loadImportFiles(); +} + +/** 交换目录里本谱对应模式的文件(后端已降序,composable 再按 Scope 过滤) */ +async function loadImportFiles(): Promise { + importFilesLoading.value = true; + try { + importFiles.value = await listFiles(treeId.value, imScope.value); + } + catch { + importFiles.value = []; + } + finally { + importFilesLoading.value = false; + } +} + +// 三期备份还原(R4):导入弹窗第三个 chips;BK 明文列表不需密码,选中即待还原回滚点 +const bkMode = ref(false); +const backupFiles = ref([]); +const backupLoading = ref(false); +const pickedBackup = ref(""); + +/** 回文件模式(ALL/SUB):退 bkMode,选中文件保留(同模式切回不丢进度) */ +function pickImScope(scope: ExchangeScope): void { + bkMode.value = false; + void onImScopeChange(scope); +} + +async function enterBkMode(): Promise { + if (bkMode.value) { + return; + } + bkMode.value = true; + pickedFileName.value = ""; + clearLocalFile(); + if (!backupFiles.value.length) { + await fetchBackups(); + } +} + +async function fetchBackups(): Promise { + backupLoading.value = true; + try { + backupFiles.value = await listBackups(treeId.value); + } + catch { + backupFiles.value = []; + } + finally { + backupLoading.value = false; + } +} + +/** 还原执行:二次确认(整谱回滚+重编号+清绑定说清)→ 后端回放 BK(还原前自动再备份现谱) */ +async function runRestoreBackup(): Promise { + if (!pickedBackup.value) { + toast(t("FamilyTreePage.exchangePickBackupTip")); + return; + } + const bk = backupFiles.value.find(x => x.FileName === pickedBackup.value); + const confirmResult = await new Promise((resolve) => { + uni.showModal({ + title: t("FamilyTreePage.exchangeRestoreConfirmTitle"), + content: t("FamilyTreePage.exchangeRestoreConfirm").replace("{time}", formatFileTime(bk?.LastModified ?? "")), + success: res => resolve(!!res.confirm), + fail: () => resolve(false), + }); + }); + if (!confirmResult) { + return; + } + try { + const count = await restoreBackup(treeId.value, pickedBackup.value); + importVisible.value = false; + toast(t("FamilyTreePage.exchangeRestoreDone").replace("{count}", String(count))); + data.value = new FamilyTreeData(); // 还原后全员重编号,清旧上下文再重拉 + await load(); + } + catch (error: any) { + toast(error?.message || t("FamilyTreePage.exchangeFailed")); + } +} + +// 本地上传通道:A 谱导出的 .fm 在任意谱界面直接选中导入(跨谱嫁接,不经服务器交换目录) +const localFileText = ref(""); +const localFileName = ref(""); + +function clearLocalFile(): void { + localFileText.value = ""; + localFileName.value = ""; +} + +/** uni.chooseFile 选本地 .fm 并读文本(H5 原生 File.text / App FileSystemManager),与服务器列表通道互斥 */ +async function pickLocalFile(): Promise { + try { + const picked: any = await new Promise((resolve, reject) => { + uni.chooseFile({ count: 1, extension: [".fm"], success: resolve, fail: reject }); + }); + const file = picked.tempFiles?.[0]; + const name = String(file?.name || picked.tempFilePaths?.[0]?.split("/").pop() || "exchange.fm"); + let text = ""; + // #ifdef H5 + text = await file.text(); + // #endif + // #ifndef H5 + const res: any = await new Promise((resolve, reject) => { + uni.getFileSystemManager().readFile({ + filePath: picked.tempFilePaths[0], + encoding: "utf8", + success: resolve, + fail: reject, + }); + }); + text = String(res.data ?? ""); + // #endif + if (!text.trim()) { + toast(t("FamilyTreePage.exchangeLocalBad")); + return; + } + localFileText.value = text; + localFileName.value = name; + pickedFileName.value = ""; + } + catch (e: any) { + // 用户取消不算错(errMsg 含 cancel),其余给统一读失败提示 + if (!String(e?.errMsg ?? e ?? "").includes("cancel")) { + toast(t("FamilyTreePage.exchangeLocalBad")); + console.warn("[familyTreeExchange] choose local file failed:", e); + } + } +} + +/** 导入三步合一:验文件(服务器/本地两路)+验密码 → 破坏性行为二次确认 → 按模式执行(整谱替换/支系替换) */ +async function runExchangeImport(): Promise { + if (!pickedFileName.value && !localFileName.value) { + toast(t("FamilyTreePage.exchangePickFileTip")); + return; + } + if (imPassword.value.length < 4) { + toast(t("FamilyTreePage.exchangePwdShort")); + return; + } + let payload; + let preview; + let scope; + try { + if (localFileName.value) { + // 本地文件:模式自动识别(payload.Scope 说了算,支持跨谱嫁接),chips 跟随文件 + ({ payload, preview, scope } = await decryptPayload(localFileText.value, imPassword.value, null)); + imScope.value = scope; + } + else { + ({ payload, preview } = await readAndDecrypt(pickedFileName.value, imPassword.value, imScope.value)); + scope = imScope.value; + } + } + catch (error: any) { + toast(error?.message === "exchange-scope-mismatch" + ? t("FamilyTreePage.exchangeScopeMismatch") + : error?.message === "exchange-legacy-sub" + ? t("FamilyTreePage.exchangeLegacySub") + : t("FamilyTreePage.exchangeDecryptFailed")); + return; + } + // 支系替换的锚点口径(P2):与当前族员对比,姓+谱名同一人才同意;本地 SUB 文件解密后才知道要锚点 + if (scope === "SUB" && !currentPerson.value?.PersonId) { + toast(t("FamilyTreePage.exchangeSubAnchorMissing")); + return; + } + // 二次确认:ALL 按同谱/跨谱区分——同谱(含本谱服务器文件)编号与绑定原样保留,跨谱嫁接才清绑定重编号;SUB 说清只动哪根枝(备份都在后端自动落盘可回滚) + const sameTree = String(payload.FamilyTree?.TreeId ?? "").toUpperCase() === String(treeId.value).toUpperCase(); + const confirmResult = await new Promise((resolve) => { + uni.showModal({ + title: t(scope === "SUB" ? "FamilyTreePage.exchangeImportConfirmSubTitle" : "FamilyTreePage.exchangeImportConfirmTitle"), + content: scope === "SUB" + ? t("FamilyTreePage.exchangeImportConfirmSub") + .replace("{tree}", preview.TreeName) + .replace("{count}", String(preview.PersonCount)) + .replace("{anchor}", currentPersonName.value || "-") + : t(sameTree ? "FamilyTreePage.exchangeImportConfirmSame" : "FamilyTreePage.exchangeImportConfirm") + .replace("{tree}", preview.TreeName) + .replace("{count}", String(preview.PersonCount)) + .replace("{time}", preview.ExportedAt), + success: res => resolve(!!res.confirm), + fail: () => resolve(false), + }); + }); + if (!confirmResult) { + return; + } + try { + const count = scope === "SUB" + ? await importSubTree(treeId.value, currentPerson.value!.PersonId, payload) + : await importTree(treeId.value, payload); + importVisible.value = false; + toast(t("FamilyTreePage.exchangeImportDone").replace("{count}", String(count))); + // 导入/还原后全员重编号,旧 currentPerson 已失效:先清上下文再重拉,避免带旧 Id 查询 + data.value = new FamilyTreeData(); + await load(); + } + catch (error: any) { + toast(error?.message || t("FamilyTreePage.exchangeFailed")); + } +} + // ===== 只读文案小工具 ===== function genderText(gender?: string): string { return getPolicyText("GenderPolicy", gender ?? ""); @@ -1160,18 +1455,24 @@ defineExpose({ - {{ t("FamilyTreePage.chipPublic") }} + {{ + t("FamilyTreePage.chipPublic") }} {{ t("FamilyTreePage.chipPrivate") }} - - {{ t("FamilyTreePage.managerTag") }} + + {{ t("FamilyTreePage.managerTag") + }} - {{ memberCount }}{{ t("FamilyTreePage.statMembers") }} - {{ managerPermissions.length }}{{ t("FamilyTreePage.statManagers") }} - {{ pendingPermissions.length }}{{ t("FamilyTreePage.statPending") }} + {{ memberCount }}{{ + t("FamilyTreePage.statMembers") }} + {{ managerPermissions.length }}{{ t("FamilyTreePage.statManagers") }} + {{ pendingPermissions.length }}{{ t("FamilyTreePage.statPending") }} @@ -1181,12 +1482,14 @@ defineExpose({ {{ t("FamilyTreePage.tabOverview") }} - + {{ t("FamilyTreePage.tabMembers") }} {{ memberCount }} - + {{ t("FamilyTreePage.tabPerms") }} @@ -1206,19 +1509,24 @@ defineExpose({ {{ t("FamilyTreePage.ancestorInfo") }} - {{ t("FamilyTreePage.edit") }} + {{ t("FamilyTreePage.edit") }} - {{ t("FamilyTreePage.addAncestor") }} + {{ t("FamilyTreePage.addAncestor") }} {{ t("FamilyTreePage.noAncestor") }} - {{ t("FamilyTreePage.quickActions") }} + {{ t("FamilyTreePage.quickActions") }} + - {{ t("FamilyTreePage.editProfile") }} + {{ t("FamilyTreePage.editProfile") }} - {{ t("FamilyTreePage.applyManage") }} + {{ + t("FamilyTreePage.applyManage") }} + + {{ t("FamilyTreePage.exchangeExportEntry") }} + {{ t("FamilyTreePage.exchangeImportEntry") }} @@ -1249,10 +1568,13 @@ defineExpose({ - {{ t("FamilyTreePage.currentPerson") }} + {{ t("FamilyTreePage.currentPerson") + }} - - + + @@ -1261,108 +1583,197 @@ defineExpose({ {{ genderText(currentPerson?.Gender) || "-" }} - {{ personChipText(currentPerson) }} + {{ personChipText(currentPerson) + }} - {{ t("FamilyPersonPage.editBut") }} - {{ t("FamilyTreePage.personSearch") }} - {{ t("FamilyTreePage.pedigreeChart") }} + {{ t("FamilyPersonPage.editBut") }} + {{ t("FamilyTreePage.personSearch") }} + + + {{ + t("FamilyTreePage.pedigreeChart") }} + - {{ t("FamilyTreePage.unbindAccount") }} - {{ t("FamilyTreePage.bindAccount") }} + {{ + t("FamilyTreePage.unbindAccount") }} + {{ t("FamilyTreePage.bindAccount") + }} - + + - {{ t("FamilyTreePage.filterAll") }} - {{ t("FamilyTreePage.filterMale") }} - {{ t("FamilyTreePage.filterFemale") }} + {{ + t("FamilyTreePage.filterAll") }} + {{ + t("FamilyTreePage.filterMale") }} + {{ + t("FamilyTreePage.filterFemale") }} - {{ t("FamilyTreePage.spouseTitle") }}{{ spouseMaps.length }}{{ t("FamilyTreePage.addSpouse") }} - {{ t("FamilyTreePage.spouseEmpty") }} + + {{ t("FamilyTreePage.spouseTitle") + }}{{ spouseMaps.length }} + {{ t("FamilyTreePage.addSpouse") }} + + {{ t("FamilyTreePage.spouseEmpty") + }} - - - + + + - {{ `${record.SpouseSurname ?? ""}${record.SpouseGenealogyName ?? ""}` }}{{ genderText(personGender(record.SpouseId, record.Gender)) }} + {{ `${record.SpouseSurname ?? + ""}${record.SpouseGenealogyName ?? ""}` }}{{ + genderText(personGender(record.SpouseId, record.Gender)) }} {{ personCommonName(record.SpouseId) || record.SpouseId }} - - - - + + + + + + + + - {{ t("FamilyTreePage.childTitle") }}{{ childMaps.length }}{{ t("FamilyTreePage.addChildBtn") }} - {{ t("FamilyTreePage.childEmpty") }} + + {{ t("FamilyTreePage.childTitle") + }}{{ childMaps.length }} + {{ t("FamilyTreePage.addChildBtn") }} + + + {{ t("FamilyTreePage.childEmpty") + }} - - - + + + - {{ `${record.ChildrenSurname ?? ""}${record.ChildrenGenealogyName ?? ""}` }}{{ genderText(personGender(record.ChildrenId, record.Gender)) }} + {{ `${record.ChildrenSurname ?? + ""}${record.ChildrenGenealogyName ?? ""}` }}{{ + genderText(personGender(record.ChildrenId, record.Gender)) }} {{ personCommonName(record.ChildrenId) || record.ChildrenId }} - - - - + + + + + - {{ t("FamilyTreePage.viewFullChart") }} + + {{ t("FamilyTreePage.viewFullChart") }} + - {{ t("FamilyTreePage.permPending") }}{{ pendingPermissions.length }} + + {{ t("FamilyTreePage.permPending") + }}{{ pendingPermissions.length }} + - {{ record.UserLocalName || record.UserName }}{{ record.PersonLocalName }} + {{ record.UserLocalName || + record.UserName + }}{{ record.PersonLocalName + }} + - {{ t("FamilyTreePage.approve") }} - {{ t("FamilyTreePage.setManager") }} - {{ t("FamilyTreePage.reject") }} + {{ + t("FamilyTreePage.approve") }} + + {{ + t("FamilyTreePage.setManager") }} + + {{ t("FamilyTreePage.reject") }} - {{ t("FamilyTreePage.permManager") }}{{ managerPermissions.length }} - {{ t("FamilyTreePage.permEmpty") }} + + {{ t("FamilyTreePage.permManager") + }}{{ managerPermissions.length }} + + {{ t("FamilyTreePage.permEmpty") + }} - {{ record.UserLocalName || record.UserName }}{{ record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag") }} - {{ t("FamilyTreePage.remove") }} + {{ record.UserLocalName || + record.UserName + }}{{ record.ManagerIndc === "Y" ? + t("FamilyTreePage.managerTag") : + t("FamilyTreePage.memberTag") }} + + {{ t("FamilyTreePage.remove") }} + - {{ t("FamilyTreePage.applyManage") }} - {{ t("FamilyTreePage.alreadyManagerTip") }} - {{ t("FamilyTreePage.applyManage") }} + {{ t("FamilyTreePage.applyManage") }} + + {{ t("FamilyTreePage.alreadyManagerTip") }} + + {{ + t("FamilyTreePage.applyManage") }} - {{ t("FamilyTreePage.deleteTree") }}{{ t("FamilyTreePage.deleteTreeHint") }} + {{ + t("FamilyTreePage.deleteTree") }}{{ + t("FamilyTreePage.deleteTreeHint") + }} @@ -1374,10 +1785,16 @@ defineExpose({ - {{ t("FamilyTreePage.selectMember") }} + {{ + t("FamilyTreePage.selectMember") }} - - + + + + + + @@ -1828,10 +2245,9 @@ defineExpose({ + :columns="personPickConfig.columns.value" :loader="personPickConfig.loader" :row-key="personPickConfig.rowKey" + :title-key="personPickConfig.titleKey" :fixed-filters="personPickConfig.fixedFilters.value" + @confirm="onPersonPicked" @close="personPickVisible = false">