This commit is contained in:
owenchen committed 2026-10-20 11:11:55 +08:00
1 parent 27284040cc
commit 9b01c4a57a
11 files changed
+1827 -77

No files matched your search

@@ -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<ExchangeScope>("ALL");
const exPassword = ref("");
const exSaveLocal = ref(true);
const exUploadServer = ref(true);
const importFiles = ref<FamilyTreeExchangeFileInfo[]>([]);
const importFilesLoading = ref(false);
const pickedFileName = ref("");
const imPassword = ref("");
// 二期:导入模式(ALL=整谱替换 / SUB=支系替换,与所选文件范围必须同构)
const imScope = ref<ExchangeScope>("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<void> {
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<void> {
// 非管理员看不到整谱替换 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<void> {
if (imScope.value === scope) {
return;
}
imScope.value = scope;
pickedFileName.value = "";
await loadImportFiles();
}
/** 交换目录里本谱对应模式的文件(后端已降序,composable 再按 Scope 过滤) */
async function loadImportFiles(): Promise<void> {
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<FamilyTreeExchangeFileInfo[]>([]);
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<void> {
if (bkMode.value) {
return;
}
bkMode.value = true;
pickedFileName.value = "";
clearLocalFile();
if (!backupFiles.value.length) {
await fetchBackups();
}
}
async function fetchBackups(): Promise<void> {
backupLoading.value = true;
try {
backupFiles.value = await listBackups(treeId.value);
}
catch {
backupFiles.value = [];
}
finally {
backupLoading.value = false;
}
}
/** 还原执行:二次确认(整谱回滚+重编号+清绑定说清)→ 后端回放 BK(还原前自动再备份现谱) */
async function runRestoreBackup(): Promise<void> {
if (!pickedBackup.value) {
toast(t("FamilyTreePage.exchangePickBackupTip"));
return;
}
const bk = backupFiles.value.find(x => x.FileName === pickedBackup.value);
const confirmResult = await new Promise<boolean>((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<void> {
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<void> {
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<boolean>((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({
<u-icon name="file-text" size="22" color="#9a8f82" />
</view>
<view class="ft-hero__badges">
<text v-if="tree.PublicIndc === 'Y'" class="ft-badge ft-badge--pub">{{ t("FamilyTreePage.chipPublic") }}</text>
<text v-if="tree.PublicIndc === 'Y'" class="ft-badge ft-badge--pub">{{
t("FamilyTreePage.chipPublic") }}</text>
<text v-else class="ft-badge ft-badge--priv">{{ t("FamilyTreePage.chipPrivate") }}</text>
<!-- 公开开关属谱级设置:仅管理员可见可操作,成员/普通用户只读徽章 -->
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40" :active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag") }}</text>
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40"
:active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag")
}}</text>
</view>
</view>
</view>
<view class="ft-hero__stats">
<view class="ft-stat"><text class="ft-stat__n">{{ memberCount }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statMembers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ managerPermissions.length }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statManagers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ pendingPermissions.length }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statPending") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ memberCount }}</text><text class="ft-stat__l">{{
t("FamilyTreePage.statMembers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ managerPermissions.length }}</text><text
class="ft-stat__l">{{ t("FamilyTreePage.statManagers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ pendingPermissions.length }}</text><text
class="ft-stat__l">{{ t("FamilyTreePage.statPending") }}</text></view>
</view>
</view>
@@ -1181,12 +1482,14 @@ defineExpose({
<text>{{ t("FamilyTreePage.tabOverview") }}</text>
</view>
<!-- 成员 tab:管理员与已绑定成员可见;普通用户仅概览(查看公开信息 + 两谱图) -->
<view v-if="data.IsManager || data.IsMember" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'members' }" @tap="switchTab('members')">
<view v-if="data.IsManager || data.IsMember" class="ft-tab"
:class="{ 'ft-tab--on': activeTab === 'members' }" @tap="switchTab('members')">
<text>{{ t("FamilyTreePage.tabMembers") }}</text>
<text class="ft-tab__cnt">{{ memberCount }}</text>
</view>
<!-- 权限 tab:审批/管理名单属全谱人事权,仅管理员可见 -->
<view v-if="data.IsManager" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'perms' }" @tap="switchTab('perms')">
<view v-if="data.IsManager" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'perms' }"
@tap="switchTab('perms')">
<text>{{ t("FamilyTreePage.tabPerms") }}</text>
<view v-if="pendingPermissions.length" class="ft-tab__dot" />
</view>
@@ -1206,19 +1509,24 @@ defineExpose({
<view v-if="loaded" class="ft-card">
<view class="ft-card__head">
<text class="ft-card__title">{{ t("FamilyTreePage.ancestorInfo") }}</text>
<text v-if="ancestorPerson && data.IsCanAmend" class="ft-card__link" @tap="openEditPerson(ancestorPerson.PersonId)">{{ t("FamilyTreePage.edit") }}</text>
<text v-if="ancestorPerson && data.IsCanAmend" class="ft-card__link"
@tap="openEditPerson(ancestorPerson.PersonId)">{{ t("FamilyTreePage.edit") }}</text>
<!-- 没始祖时补设属谱级动作:挂始祖只跟管理员,成员支系内可编辑已有始祖档案但不可改挂 -->
<text v-else-if="!ancestorPerson && data.IsManager" class="ft-card__link" @tap="openQuickCreate('ancestor')">{{ t("FamilyTreePage.addAncestor") }}</text>
<text v-else-if="!ancestorPerson && data.IsManager" class="ft-card__link"
@tap="openQuickCreate('ancestor')">{{ t("FamilyTreePage.addAncestor") }}</text>
</view>
<template v-if="ancestorPerson">
<view class="person-hero">
<view class="person-hero__avatar" :style="avatarFallbackStyle(!!ancestorPerson.AvatarUrl, ancestorPerson.Gender)">
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img" :src="ancestorPerson.AvatarUrl" mode="aspectFill" />
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!ancestorPerson.AvatarUrl, ancestorPerson.Gender)">
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img" :src="ancestorPerson.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(ancestorPerson.Gender)" />
</view>
<view class="person-hero__main">
<view class="person-hero__line">
<text class="person-hero__name">{{ `${ancestorPerson.Surname ?? ""}${ancestorPerson.GenealogyName ?? ""}` || "-" }}</text>
<text class="person-hero__name">{{ `${ancestorPerson.Surname ??
""}${ancestorPerson.GenealogyName ?? ""}` || "-" }}</text>
<text class="person-hero__gender">{{ genderText(ancestorPerson.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
@@ -1227,20 +1535,31 @@ defineExpose({
</view>
</view>
<view class="ft-rows">
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.genealogyName") }}</text><text class="ft-row__v">{{ ancestorPerson.GenealogyName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.commonName") }}</text><text class="ft-row__v">{{ ancestorPerson.CommonName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.genealogyName") }}</text><text
class="ft-row__v">{{ ancestorPerson.GenealogyName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.commonName") }}</text><text
class="ft-row__v">{{ ancestorPerson.CommonName || "-" }}</text></view>
</view>
</template>
<view v-else class="ft-empty"><text>{{ t("FamilyTreePage.noAncestor") }}</text></view>
</view>
<view class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.quickActions") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.quickActions") }}</text>
</view>
<view class="ft-quick">
<!-- 编辑资料改的是族谱本体(谱级):仅管理员;普通用户/成员不可见 -->
<view v-if="data.IsManager" class="ft-qa" @tap="openTreeEditor"><u-icon name="edit-pen" size="30" :color="primary" /><text>{{ t("FamilyTreePage.editProfile") }}</text></view>
<view v-if="data.IsManager" class="ft-qa" @tap="openTreeEditor"><u-icon name="edit-pen" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.editProfile") }}</text></view>
<!-- 申请管理三档都可用(已是管理员时点击只提示不重复发) -->
<view class="ft-qa" @tap="requestManager"><u-icon name="server-man" size="30" :color="primary" /><text>{{ t("FamilyTreePage.applyManage") }}</text></view>
<view class="ft-qa" @tap="requestManager"><u-icon name="server-man" size="30"
:color="primary" /><text>{{
t("FamilyTreePage.applyManage") }}</text></view>
<!-- 交换文件:导出需成员及以上(D8 管理员不限);导入入口同样成员可见(小系替换 P6 / 备份还原 R1 均已开成员闸),仅整谱替换留管理员 -->
<view v-if="canExport" class="ft-qa" @tap="openExchangeExport"><u-icon name="download" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.exchangeExportEntry") }}</text></view>
<view v-if="canExport" class="ft-qa" @tap="openExchangeImport"><u-icon name="upload" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.exchangeImportEntry") }}</text></view>
</view>
</view>
</view>
@@ -1249,10 +1568,13 @@ defineExpose({
<view v-show="activeTab === 'members'" class="ft-pane">
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
<view v-if="loaded && currentPerson" class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
}}</text></view>
<view class="person-hero">
<view class="person-hero__avatar" :style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl" mode="aspectFill" />
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
</view>
<view class="person-hero__main">
@@ -1261,108 +1583,197 @@ defineExpose({
<text class="person-hero__gender">{{ genderText(currentPerson?.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson) }}</text>
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
}}</text>
</view>
</view>
</view>
<view class="op-btns">
<view v-if="currentPerson?.PersonId && data.IsCanAmend" class="op-btn" @tap="openEditPerson(currentPerson.PersonId)"><u-icon name="edit-pen" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyPersonPage.editBut") }}</text></view>
<view class="op-btn" @tap="pickCurrentPerson"><u-icon name="search" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.personSearch") }}</text></view>
<view class="op-btn" @tap="subChartVisible = true"><view class="ft-glyph ft-glyph--sub" /><text class="op-btn-text">{{ t("FamilyTreePage.pedigreeChart") }}</text></view>
<view v-if="currentPerson?.PersonId && data.IsCanAmend" class="op-btn"
@tap="openEditPerson(currentPerson.PersonId)"><u-icon name="edit-pen" size="26"
:color="primary" /><text class="op-btn-text">{{ t("FamilyPersonPage.editBut") }}</text></view>
<view class="op-btn" @tap="pickCurrentPerson"><u-icon name="search" size="26"
:color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.personSearch") }}</text>
</view>
<view class="op-btn" @tap="subChartVisible = true">
<view class="ft-glyph ft-glyph--sub" /><text class="op-btn-text">{{
t("FamilyTreePage.pedigreeChart") }}</text>
</view>
<!-- 绑定/解绑开关:当前族员已绑到自己权限行时显「解除绑定」,否则显「绑定账号」(需已过审且同名;申请入口仍在概览快捷区/底部条) -->
<view v-if="!data.IsManager && isSelfBound" class="op-btn" @tap="unbindCurrentPerson"><u-icon name="close" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.unbindAccount") }}</text></view>
<view v-else-if="!data.IsManager" class="op-btn" @tap="bindCurrentPerson"><u-icon name="scan" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.bindAccount") }}</text></view>
<view v-if="!data.IsManager && isSelfBound" class="op-btn" @tap="unbindCurrentPerson"><u-icon
name="close" size="26" :color="primary" /><text class="op-btn-text">{{
t("FamilyTreePage.unbindAccount") }}</text></view>
<view v-else-if="!data.IsManager" class="op-btn" @tap="bindCurrentPerson"><u-icon name="scan"
size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.bindAccount")
}}</text></view>
</view>
</view>
<view class="ft-search"><search-input v-model="memberKeyword" :placeholder="t('FamilyTreePage.searchMember')" /></view>
<view class="ft-search"><search-input v-model="memberKeyword"
:placeholder="t('FamilyTreePage.searchMember')" />
</view>
<view class="ft-chips">
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'all' }" @tap="memberFilter = 'all'">{{ t("FamilyTreePage.filterAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'male' }" @tap="memberFilter = 'male'">{{ t("FamilyTreePage.filterMale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'female' }" @tap="memberFilter = 'female'">{{ t("FamilyTreePage.filterFemale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'all' }"
@tap="memberFilter = 'all'">{{
t("FamilyTreePage.filterAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'male' }"
@tap="memberFilter = 'male'">{{
t("FamilyTreePage.filterMale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'female' }"
@tap="memberFilter = 'female'">{{
t("FamilyTreePage.filterFemale") }}</text>
</view>
<view class="ft-card">
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle") }}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view><view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view></view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle")
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view>
</view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty")
}}</text></view>
<view v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
<view class="ft-av" :style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="ft-av__img" :src="personAvatar(record.SpouseId)" mode="aspectFill" />
<u-icon v-else name="account" size="40" :color="genderTheme(personGender(record.SpouseId, record.Gender))" />
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="ft-av__img"
:src="personAvatar(record.SpouseId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.SpouseId, record.Gender))" />
</view>
<view class="ft-mrow__main">
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.SpouseSurname ?? ""}${record.SpouseGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{ genderText(personGender(record.SpouseId, record.Gender)) }}</text></view>
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.SpouseSurname ??
""}${record.SpouseGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{
genderText(personGender(record.SpouseId, record.Gender)) }}</text></view>
<text class="ft-mrow__code">{{ personCommonName(record.SpouseId) || record.SpouseId }}</text>
</view>
<!-- 行尾直达动作(旧编辑态式):↑↓ 调序属锚点关系列表维护留锚点级;编辑/解除按目标人逐个判,旁支(如兄弟)不显示 -->
<view v-if="data.IsCanAmend" class="ft-mrow__ops" @tap.stop>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, -1)"><u-icon name="arrow-up" size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, 1)"><u-icon name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op" @tap.stop="openEditPerson(record.SpouseId)"><u-icon name="edit-pen" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op" @tap.stop="onRelationRemove('spouse', record)"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, -1)"><u-icon name="arrow-up"
size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, 1)"><u-icon
name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op"
@tap.stop="openEditPerson(record.SpouseId)">
<u-icon name="edit-pen" size="28" :color="primary" />
</view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op"
@tap.stop="onRelationRemove('spouse', record)">
<u-icon name="trash" size="28" color="#f0413e" />
</view>
</view>
</view>
</view>
<view class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle") }}</text><text class="ft-card__count">{{ childMaps.length }}</text></view><view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text></view></view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle")
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text>
</view>
</view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty")
}}</text></view>
<view v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
<view class="ft-av" :style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="ft-av__img" :src="personAvatar(record.ChildrenId)" mode="aspectFill" />
<u-icon v-else name="account" size="40" :color="genderTheme(personGender(record.ChildrenId, record.Gender))" />
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="ft-av__img"
:src="personAvatar(record.ChildrenId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.ChildrenId, record.Gender))" />
</view>
<view class="ft-mrow__main">
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.ChildrenSurname ?? ""}${record.ChildrenGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{ genderText(personGender(record.ChildrenId, record.Gender)) }}</text></view>
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.ChildrenSurname ??
""}${record.ChildrenGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{
genderText(personGender(record.ChildrenId, record.Gender)) }}</text></view>
<text class="ft-mrow__code">{{ personCommonName(record.ChildrenId) || record.ChildrenId }}</text>
</view>
<view v-if="data.IsCanAmend" class="ft-mrow__ops" @tap.stop>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, -1)"><u-icon name="arrow-up" size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, 1)"><u-icon name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op" @tap.stop="openEditPerson(record.ChildrenId)"><u-icon name="edit-pen" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op" @tap.stop="onRelationRemove('child', record)"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, -1)"><u-icon name="arrow-up"
size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, 1)"><u-icon name="arrow-down"
size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op"
@tap.stop="openEditPerson(record.ChildrenId)"><u-icon name="edit-pen" size="28"
:color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op"
@tap.stop="onRelationRemove('child', record)"><u-icon name="trash" size="28" color="#f0413e" />
</view>
</view>
</view>
</view>
<view class="ft-ghost" @tap="fullChartVisible = true"><view class="ft-glyph ft-glyph--full" /><text>{{ t("FamilyTreePage.viewFullChart") }}</text></view>
<view class="ft-ghost" @tap="fullChartVisible = true">
<view class="ft-glyph ft-glyph--full" /><text>{{ t("FamilyTreePage.viewFullChart") }}</text>
</view>
</view>
<!-- ===== 权限 ===== -->
<view v-show="activeTab === 'perms'" class="ft-pane">
<view v-if="data.IsManager && pendingPermissions.length" class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending") }}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending")
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
</view>
<view v-for="record in pendingPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName || record.UserName }}</text><text v-if="record.PersonLocalName" class="ft-perm-row__sub">{{ record.PersonLocalName }}</text></view>
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
}}</text><text v-if="record.PersonLocalName" class="ft-perm-row__sub">{{ record.PersonLocalName
}}</text>
</view>
<view class="ft-perm-ops">
<view class="ft-perm-op" @tap="approvePermission(record, 'N')"><text>{{ t("FamilyTreePage.approve") }}</text></view>
<view class="ft-perm-op" @tap="approvePermission(record, 'Y')"><text>{{ t("FamilyTreePage.setManager") }}</text></view>
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text class="ft-perm-op__t--danger">{{ t("FamilyTreePage.reject") }}</text></view>
<view class="ft-perm-op" @tap="approvePermission(record, 'N')"><text>{{
t("FamilyTreePage.approve") }}</text>
</view>
<view class="ft-perm-op" @tap="approvePermission(record, 'Y')"><text>{{
t("FamilyTreePage.setManager") }}</text>
</view>
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text
class="ft-perm-op__t--danger">{{ t("FamilyTreePage.reject") }}</text></view>
</view>
</view>
</view>
<view v-if="data.IsManager" class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager") }}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view></view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager")
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
</view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty")
}}</text></view>
<view v-for="record in managerPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName || record.UserName }}</text><text class="ft-perm-row__sub">{{ record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag") }}</text></view>
<view class="ft-perm-ops"><view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text class="ft-perm-op__t--danger">{{ t("FamilyTreePage.remove") }}</text></view></view>
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
}}</text><text class="ft-perm-row__sub">{{ record.ManagerIndc === "Y" ?
t("FamilyTreePage.managerTag") :
t("FamilyTreePage.memberTag") }}</text></view>
<view class="ft-perm-ops">
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text
class="ft-perm-op__t--danger">{{ t("FamilyTreePage.remove") }}</text></view>
</view>
</view>
</view>
<!-- 申请管理(旧系统同款):常驻卡——非管理员点按钮发申请,管理员显示当前状态;原邀请占位卡无后端直接取消 -->
<view class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.applyManage") }}</text></view>
<view v-if="data.IsManager" class="ft-empty"><text>{{ t("FamilyTreePage.alreadyManagerTip") }}</text></view>
<view v-else class="ft-apply" @tap="requestManager"><u-icon name="server-man" size="28" color="#ffffff" /><text>{{ t("FamilyTreePage.applyManage") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.applyManage") }}</text>
</view>
<view v-if="data.IsManager" class="ft-empty"><text>{{ t("FamilyTreePage.alreadyManagerTip") }}</text>
</view>
<view v-else class="ft-apply" @tap="requestManager"><u-icon name="server-man" size="28"
color="#ffffff" /><text>{{
t("FamilyTreePage.applyManage") }}</text></view>
</view>
<!-- 删除族谱:原「...」更多菜单整体取消后,唯一入口放权限 tab 底部(仅有修改权) -->
<view v-if="data.IsCanAmend" class="ft-card">
<view class="ft-menu-item ft-menu-item--danger" @tap="onDeleteTree">
<view class="ft-menu-item__ic"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-menu-item__main"><text class="ft-menu-item__t ft-menu-item__t--danger">{{ t("FamilyTreePage.deleteTree") }}</text><text class="ft-menu-item__d">{{ t("FamilyTreePage.deleteTreeHint") }}</text></view>
<view class="ft-menu-item__main"><text class="ft-menu-item__t ft-menu-item__t--danger">{{
t("FamilyTreePage.deleteTree") }}</text><text class="ft-menu-item__d">{{
t("FamilyTreePage.deleteTreeHint")
}}</text></view>
</view>
</view>
</view>
@@ -1374,10 +1785,16 @@ defineExpose({
<!-- 底部主操作条 -->
<view class="ft-bottombar">
<!-- 主按钮:选定当前族员(添加配偶/子女都以它为基准;建员入口在概览快捷区与关系卡头) -->
<view class="ft-bottombar__main" @tap="pickCurrentPerson"><u-icon name="account" size="30" color="#ffffff" /><text>{{ t("FamilyTreePage.selectMember") }}</text></view>
<view class="ft-bottombar__main" @tap="pickCurrentPerson"><u-icon name="account" size="30"
color="#ffffff" /><text>{{
t("FamilyTreePage.selectMember") }}</text></view>
<!-- 小系图/全系图:定制 SVG glyph(mask 随主题着色);原分享入口随邀请占位弹层一并取消 -->
<view class="ft-bottombar__icon" @tap="subChartVisible = true"><view class="ft-glyph ft-glyph--bar ft-glyph--sub" /></view>
<view class="ft-bottombar__icon" @tap="fullChartVisible = true"><view class="ft-glyph ft-glyph--bar ft-glyph--full" /></view>
<view class="ft-bottombar__icon" @tap="subChartVisible = true">
<view class="ft-glyph ft-glyph--bar ft-glyph--sub" />
</view>
<view class="ft-bottombar__icon" @tap="fullChartVisible = true">
<view class="ft-glyph ft-glyph--bar ft-glyph--full" />
</view>
</view>
</template>
@@ -1828,10 +2245,9 @@ defineExpose({
<!-- 族员选择弹窗(族员搜索 / 选配偶共用):与邀请/编辑弹层同套底部 sheet,取消/确定齐全;行样式复用成员列表同款 -->
<admin-pick-popup v-model:visible="personPickVisible"
:title="personPickMode === 'current' ? t('FamilyTreePage.personSearch') : t('FamilyTreePage.pickSpouse')"
: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">
: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">
<template #row="{ row }">
<view class="pick-person">
<view class="ft-av" :style="avatarFallbackStyle(!!row.AvatarUrl, row.Gender)">
@@ -1868,6 +2284,144 @@ defineExpose({
<!-- 族人档案弹层已取消:始祖卡单击不再弹窗,编辑走卡头「编辑资料」文字链 -->
<!-- ===== 编辑族谱信息弹层(仅名称/始祖谱名/简介/公开四字段,关系与成员分开维护) ===== -->
<!-- ===== 交换文件导出弹层(信封加密在前端,密码只当密钥材料、后端不校验)===== -->
<u-popup :model-value="exportVisible" mode="bottom" round="24" @close="exportVisible = false">
<view class="ft-sheet">
<view class="ft-sheet__head">
<text class="ft-sheet__title">{{ t("FamilyTreePage.exchangeExportTitle") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="exportVisible = false" />
</view>
<view class="ft-sheet__form">
<view class="ft-chips">
<text class="ft-chip" :class="{ 'ft-chip--on': exScope === 'ALL' }" @tap="exScope = 'ALL'">{{
t("FamilyTreePage.exchangeScopeAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': exScope === 'SUB' }" @tap="exScope = 'SUB'">{{
t("FamilyTreePage.exchangeScopeSub") }}</text>
</view>
<text v-if="exScope === 'SUB'" class="ft-exchange__sub">{{ t("FamilyTreePage.exchangeAnchorTip") }}:{{
currentPersonName || "-" }}</text>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.exchangePwd") }}</text>
<input v-model="exPassword" class="ft-field__input" :password="true" :maxlength="50"
:placeholder="t('FamilyTreePage.exchangePwdHint')">
</view>
<text class="ft-exchange__warn">{{ t("FamilyTreePage.exchangePwdLostTip") }}</text>
<view class="ft-switch-card">
<view class="ft-switch-card__main">
<text class="ft-switch-card__t">{{ t("FamilyTreePage.exchangeSaveLocal") }}</text>
<text class="ft-switch-card__d">{{ t("FamilyTreePage.exchangeSaveLocalTip") }}</text>
</view>
<u-switch :model-value="exSaveLocal" :active-color="primary" @change="onExSaveChange" />
</view>
<view class="ft-switch-card">
<view class="ft-switch-card__main">
<text class="ft-switch-card__t">{{ t("FamilyTreePage.exchangeUploadServer") }}</text>
<text class="ft-switch-card__d">{{ t("FamilyTreePage.exchangeUploadServerTip") }}</text>
</view>
<u-switch :model-value="exUploadServer" :active-color="primary" @change="onExUploadChange" />
</view>
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="exportVisible = false"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="runExchangeExport"><text>{{ exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeExportBut") }}</text></view>
</view>
</view>
</u-popup>
<!-- ===== 交换文件导入弹层(一期仅全系整谱替换,二次确认里说清破坏性行为)===== -->
<u-popup :model-value="importVisible" mode="bottom" round="24" @close="importVisible = false">
<view class="ft-sheet">
<view class="ft-sheet__head">
<text class="ft-sheet__title">{{ t("FamilyTreePage.exchangeImportTitle") }}</text>
<view class="ft-exchange__head-ops">
<u-icon name="reload" size="32" color="#8f959e" @click="bkMode ? fetchBackups() : loadImportFiles()" />
<u-icon name="close" size="32" color="#8f959e" @click="importVisible = false" />
</view>
</view>
<view class="ft-sheet__form">
<!-- 导入模式:整谱替换(ALL)/ 支系替换(SUB)/ 备份还原(BK 明文列表,不需密码) -->
<view class="ft-chips">
<text v-if="data.IsManager" class="ft-chip" :class="{ 'ft-chip--on': imScope === 'ALL' && !bkMode }"
@tap="pickImScope('ALL')">{{ t("FamilyTreePage.exchangeScopeAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': imScope === 'SUB' && !bkMode }"
@tap="pickImScope('SUB')">{{
t("FamilyTreePage.exchangeScopeSub") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': bkMode }" @tap="enterBkMode">{{
t("FamilyTreePage.exchangeScopeBk") }}</text>
</view>
<template v-if="!bkMode">
<text v-if="imScope === 'SUB' && !localFileName" class="ft-exchange__sub">{{
t("FamilyTreePage.exchangeSubAnchorRow") }}:{{ currentPersonName || "-" }}</text>
<!-- 本地上传通道:选中后暂时遮住服务器列表(两路互斥),点本地行可移除 -->
<view v-if="!localFileName" class="ft-exchange__row" @tap="pickLocalFile">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ t("FamilyTreePage.exchangeLocalPick") }}</text>
<text class="ft-exchange__row-sub">{{ t("FamilyTreePage.exchangeLocalPickSub") }}</text>
</view>
<u-icon name="plus" size="28" :color="primary" />
</view>
<view v-else class="ft-exchange__row ft-exchange__row--on" @tap="clearLocalFile">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ localFileName }}</text>
<text class="ft-exchange__row-sub">{{ t("FamilyTreePage.exchangeLocalTag") }}</text>
</view>
<u-icon name="close" size="28" color="#8f959e" />
</view>
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
}}</text></view>
<view v-else-if="!importFiles.length" class="ft-empty"><text>{{ t(imScope === "SUB" ?
"FamilyTreePage.exchangeFileEmptySub" : "FamilyTreePage.exchangeFileEmpty") }}</text></view>
<view v-for="file in importFiles" :key="file.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedFileName === file.FileName }"
@tap="pickedFileName = file.FileName">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ file.FileName }}</text>
<text class="ft-exchange__row-sub">{{ formatFileSize(file.Size) }} · {{
formatFileTime(file.LastModified) }}</text>
</view>
<u-icon v-if="pickedFileName === file.FileName" name="checkbox-mark" size="28" :color="primary" />
</view>
</scroll-view>
</template>
<!-- 备份还原模式:本谱 BK 列表(一年内、时刻降序),选中即待还原回滚点;限高滚动防备份多了撑爆弹层 -->
<template v-else>
<scroll-view scroll-y class="ft-exchange__list">
<view v-if="backupLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading") }}</text>
</view>
<view v-else-if="!backupFiles.length" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeBackupEmpty")
}}</text></view>
<view v-for="bk in backupFiles" :key="bk.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedBackup === bk.FileName }" @tap="pickedBackup = bk.FileName">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ formatFileTime(bk.LastModified) }}</text>
<text class="ft-exchange__row-sub">{{ bk.FileName }} · {{ formatFileSize(bk.Size) }}</text>
</view>
<u-icon v-if="pickedBackup === bk.FileName" name="checkbox-mark" size="28" :color="primary" />
</view>
</scroll-view>
</template>
<view v-if="!bkMode" class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.exchangePwd") }}</text>
<input v-model="imPassword" class="ft-field__input" :password="true" :maxlength="50"
:placeholder="t('FamilyTreePage.exchangePwdHint')">
</view>
<text class="ft-exchange__warn">{{ t(bkMode ? "FamilyTreePage.exchangeRestoreWarn" :
"FamilyTreePage.exchangeImportWarn") }}</text>
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="importVisible = false"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view v-if="!bkMode" class="ft-sheet__btn ft-sheet__btn--primary" @tap="runExchangeImport"><text>{{
exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeImportBut") }}</text></view>
<view v-else class="ft-sheet__btn ft-sheet__btn--primary" @tap="runRestoreBackup"><text>{{ exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeRestoreBut") }}</text></view>
</view>
</view>
</u-popup>
<u-popup :model-value="treeEditVisible" mode="bottom" round="24" @close="cancelTreeEdit">
<view class="ft-sheet">
<view class="ft-sheet__head">
@@ -1877,16 +2431,19 @@ defineExpose({
<view class="ft-sheet__form">
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.treeName") }}</text>
<input v-model="tree.TreeName" class="ft-field__input" :maxlength="50" :placeholder="t('FamilyTreePage.treeNameRequired')">
<input v-model="tree.TreeName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.treeNameRequired')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.ancestorGenealogyName") }}</text>
<input v-model="tree.AncestorPersonGenealogyName" class="ft-field__input" :maxlength="50" :placeholder="t('FamilyTreePage.ancestorGenealogyName')">
<input v-model="tree.AncestorPersonGenealogyName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.ancestorGenealogyName')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.brief") }}</text>
<view class="ft-field__area">
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="60" :placeholder="t('FamilyTreePage.noBrief')" />
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="60"
:placeholder="t('FamilyTreePage.noBrief')" />
<text class="ft-field__counter">{{ (tree.Brief || "").length }}/60</text>
</view>
</view>
@@ -1900,7 +2457,9 @@ defineExpose({
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="cancelTreeEdit"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut")
}}</text>
</view>
</view>
</view>
</u-popup>
@@ -2783,7 +3342,7 @@ defineExpose({
align-items: center;
gap: 4rpx;
& + &::before {
&+&::before {
content: "";
position: absolute;
top: 6rpx;
@@ -3406,6 +3965,87 @@ defineExpose({
}
}
/* ===== 交换文件弹层专属:说明小字/警示/文件行(选中态跟主题 soft 底) ===== */
.ft-exchange__sub {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
color: #8f959e;
}
.ft-exchange__warn {
display: block;
margin: 8rpx 0 16rpx;
font-size: 22rpx;
line-height: 1.6;
color: #f0413e;
}
.ft-exchange__head-ops {
display: flex;
align-items: center;
gap: 24rpx;
}
/* 导入/还原弹层内的文件与备份列表:限高滚动,条目再多也不撑爆弹层(密码框/警告/按钮始终可见) */
.ft-exchange__list {
max-height: 42vh;
}
/* H5 端给滚动条一个可见的细条样式(默认 overlay 条在桌面浏览器不可见,用户会以为列表被截断) */
/* #ifdef H5 */
.ft-exchange__list ::v-deep ::-webkit-scrollbar {
width: 6px;
appearance: none;
-webkit-appearance: none;
}
.ft-exchange__list ::v-deep ::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgba(128, 128, 128, 0.35);
}
.ft-exchange__list ::v-deep ::-webkit-scrollbar-track {
background: transparent;
}
/* #endif */
.ft-exchange__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 24rpx;
margin-bottom: 12rpx;
border-radius: 20rpx;
background: var(--fg-soft, rgba(0, 0, 0, 0.03));
}
.ft-exchange__row--on {
border: 2rpx solid var(--fg-primary, #12b35d);
}
.ft-exchange__row-main {
display: flex;
flex-direction: column;
gap: 6rpx;
overflow: hidden;
}
.ft-exchange__row-name {
font-size: 26rpx;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ft-exchange__row-sub {
font-size: 22rpx;
color: #8f959e;
}
.ft-switch-card {
display: flex;
align-items: center;
@@ -0,0 +1,278 @@
/**
* 族谱 .fm 导入导出流程编排(配合 utils/familyTreeExchange.ts 的信封加解密):
* - 导出:ExportFamilyTree 拉后端权限闸裁剪后的明文 payload → 信封加密 →
* 设备落盘(saveFileBytes)和/上传交换目录(SaveExchangeFile,base64 通道);
* - 导入:ListExchangeFiles 选文件 → ReadExchangeFile 拿 base64 → 解密验 MAC →
* 校验 Scope==='ALL' → ImportFamilyTree 整谱替换(后端自动备份回滚点)。
* 文件名按 D1 口径由前端拼装(A{treeId}_{ts}.fm / P{treeId}-{personId}_{ts}.fm),
* 与后端白名单正则同源;加解密密钥 = PBKDF2(弹窗输入的密码 ⊕ GetExchangeFactor 因子)。
* 密码只当密钥材料使用,后端不校验,忘了只能重新导出(提示语在 UI 词条里)。
*/
import type { FamilyTreeExchangeData } from "@/data/auto/familyTreeExchangeData";
import type { FamilyTreeExchangeFileInfo } from "@/data/auto/familyTreeExchangeFileInfo";
import { ref } from "vue";
import familyTreeService from "@/service/master/familyTreeService";
import { saveFileBytes } from "@/utils/dmsBytes";
import {
bytesToString,
decryptEnvelope,
encryptEnvelope,
stringToBytes,
} from "@/utils/familyTreeExchange";
/** 导出范围(与后端 FamilyTreeExchangeScope 常量同值) */
export type ExchangeScope = "ALL" | "SUB";
/** 解密后的载荷预览(导入确认弹窗展示用) */
export interface ExchangePreview {
TreeName: string;
Scope: string;
PersonCount: number;
ExportedAt: string;
ExportedBy: string;
}
/** 14 位时间戳(yyyyMMddHHmmss,D1 文件名同款,后端正则依赖它) */
function fileTimestamp(): string {
const d = new Date();
const pad = (n: number): string => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
/** 按 D1 口径拼 .fm 文件名(全系 A 前缀 / 小系 P 前缀带锚点) */
export function buildExportFileName(
scope: ExchangeScope,
treeId: string,
anchorPersonId?: string,
): string {
const ts = fileTimestamp();
return scope === "ALL"
? `A${treeId}_${ts}.fm`
: `P${treeId}-${anchorPersonId ?? ""}_${ts}.fm`;
}
/** 文件字节数友好显示(列表行小字) */
export function formatFileSize(size: number): string {
if (size >= 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}
if (size >= 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${size} B`;
}
/** 时间显示归一(服务端 Date/ISO 字符串都吃,取到分钟) */
export function formatFileTime(value?: Date | string): string {
return String(value ?? "")
.replace("T", " ")
.slice(0, 16);
}
/** 从 payload 抽确认弹窗要用的预览信息(防御式取值,坏数据交给校验拒掉) */
function previewOf(payload: FamilyTreeExchangeData): ExchangePreview {
return {
TreeName: payload.FamilyTree?.TreeName ?? "-",
Scope: payload.Scope ?? "-",
PersonCount: payload.Persons?.length ?? 0,
ExportedAt: formatFileTime(payload.ExportedAt as unknown as Date | string),
ExportedBy: payload.ExportedBy ?? "-",
};
}
export function useFamilyTreeExchange() {
/** 流程互斥闸:导出/导入都是多步链(拉明文→加密→落盘/上传),期间禁用重入 */
const exchangeBusy = ref(false);
/**
* 导出全链:返回落盘/上传用的文件名;任何一步失败都抛错由调用方 toast。
* saveLocal / uploadServer 至少给一个 true(UI 层已做勾选兜底校验)。
*/
async function exportTree(opts: {
treeId: string;
scope: ExchangeScope;
anchorPersonId?: string;
password: string;
saveLocal: boolean;
uploadServer: boolean;
}): Promise<string> {
if (exchangeBusy.value) {
return Promise.reject(new Error("exchange-busy"));
}
exchangeBusy.value = true;
try {
const factor = await familyTreeService.GetExchangeFactor();
const payload = await familyTreeService.ExportFamilyTree({
treeId: opts.treeId,
scope: opts.scope,
anchorPersonId: opts.scope === "SUB" ? (opts.anchorPersonId ?? "") : "",
});
if (!payload || !payload.Persons?.length) {
throw new Error("exchange-export-empty");
}
const fileName = buildExportFileName(
opts.scope,
opts.treeId,
opts.anchorPersonId,
);
const fileText = encryptEnvelope(
JSON.stringify(payload),
opts.password,
factor,
);
const bytes = stringToBytes(fileText);
if (opts.saveLocal) {
await saveFileBytes({ bytes, name: fileName });
}
if (opts.uploadServer) {
await familyTreeService.SaveExchangeFile({
fileName,
contentBase64: uni.arrayBufferToBase64(bytes),
});
}
return fileName;
} finally {
exchangeBusy.value = false;
}
}
/** 交换目录文件列表(后端已按 LastModified 降序):按导入模式过滤 Scope,全系/小系各看各的文件 */
async function listFiles(
treeId: string,
scope: ExchangeScope,
): Promise<FamilyTreeExchangeFileInfo[]> {
const files = await familyTreeService.ListExchangeFiles(treeId);
return (files ?? []).filter(
(file: FamilyTreeExchangeFileInfo) => file.Scope === scope,
);
}
/**
* 解密校验 .fm 信封文本(服务器回读与本地选文件两路共用):
* expectScope=null 时自动采纳文件自身范围(本地文件跨谱嫁接场景,模式跟着 payload 走);
* 密码错/文件坏/范围不符/旧版小系无锚点都抛错,不落入替换环节。
*/
async function decryptPayload(
fileText: string,
password: string,
expectScope: ExchangeScope | null,
): Promise<{
payload: FamilyTreeExchangeData;
preview: ExchangePreview;
scope: ExchangeScope;
}> {
const factor = await familyTreeService.GetExchangeFactor();
const plainText = decryptEnvelope(fileText, password, factor);
const payload = JSON.parse(plainText) as FamilyTreeExchangeData;
const scope = (expectScope ??
(payload.Scope === "SUB" ? "SUB" : "ALL")) as ExchangeScope;
// 指定了模式则文件范围必须同构;自动模式(本地文件)取文件自身
if (payload.Scope !== scope) {
throw new Error("exchange-scope-mismatch");
}
// 二期:小系文件验锚点标记(一期旧导出文件无此字段,后端也会拒,这里早一步给友好提示)
if (scope === "SUB" && !payload.AnchorPersonId) {
throw new Error("exchange-legacy-sub");
}
if (!payload.Persons?.length || !payload.FamilyTree) {
throw new Error("exchange-file-bad");
}
return { payload, preview: previewOf(payload), scope };
}
/**
* 读服务器交换目录文件并解密校验(导入第一步):
* 成功返回 { payload, preview };密码错/文件坏/范围与模式不符都抛错,不落入替换环节。
* payload 留在前端内存里,确认后原样交给 importTree/importSubTree(替换是二次确认后的独立动作)。
*/
async function readAndDecrypt(
fileName: string,
password: string,
expectScope: ExchangeScope,
): Promise<{ payload: FamilyTreeExchangeData; preview: ExchangePreview }> {
const contentBase64 = await familyTreeService.ReadExchangeFile(fileName);
if (!contentBase64) {
throw new Error("exchange-file-bad");
}
const fileText = bytesToString(uni.base64ToArrayBuffer(contentBase64));
return decryptPayload(fileText, password, expectScope);
}
/** 整谱替换执行(readAndDecrypt 通过并经 UI 二次确认后调用):返回导入人数 */
async function importTree(
targetTreeId: string,
payload: FamilyTreeExchangeData,
): Promise<number> {
if (exchangeBusy.value) {
return Promise.reject(new Error("exchange-busy"));
}
exchangeBusy.value = true;
try {
return await familyTreeService.ImportFamilyTree({
targetTreeId,
data: payload,
});
} finally {
exchangeBusy.value = false;
}
}
/** 二期支系替换执行(P1=B):锚点用目标谱当前族员的 PersonId,后端验同人与权限闸;返回新插人数 */
async function importSubTree(
targetTreeId: string,
anchorPersonId: string,
payload: FamilyTreeExchangeData,
): Promise<number> {
if (exchangeBusy.value) {
return Promise.reject(new Error("exchange-busy"));
}
exchangeBusy.value = true;
try {
return await familyTreeService.ImportSubTree({
targetTreeId,
anchorPersonId,
data: payload,
});
} finally {
exchangeBusy.value = false;
}
}
/** 三期备份列表(R1 成员可见 / R5 只有一年内):后端按备份时刻降序,Scope 固定回 "BK" */
async function listBackups(
treeId: string,
): Promise<FamilyTreeExchangeFileInfo[]> {
return (await familyTreeService.ListBackups(treeId)) ?? [];
}
/** 备份还原(R2 导坏了回滚点):后端直接读 BK 明文回放整谱替换,还原前自动再备份现谱(R3);返回还原人数 */
async function restoreBackup(
targetTreeId: string,
backupFileName: string,
): Promise<number> {
if (exchangeBusy.value) {
return Promise.reject(new Error("exchange-busy"));
}
exchangeBusy.value = true;
try {
return await familyTreeService.RestoreBackup({
targetTreeId,
backupFileName,
});
} finally {
exchangeBusy.value = false;
}
}
return {
exchangeBusy,
exportTree,
listFiles,
decryptPayload,
readAndDecrypt,
importTree,
importSubTree,
listBackups,
restoreBackup,
};
}
@@ -0,0 +1,16 @@
import { FamilyTreeEntity } from "./familyTreeEntity";
import { FamilyPersonEntity } from "./familyPersonEntity";
import { FamilyTreeSpouseMapEntity } from "./familyTreeSpouseMapEntity";
import { FamilyTreeChildrenMapEntity } from "./familyTreeChildrenMapEntity";
export class FamilyTreeExchangeData {
public Scope: string = "";
public FamilyTree: FamilyTreeEntity = new FamilyTreeEntity();
public Persons: FamilyPersonEntity[] = [];
public SpouseMaps: FamilyTreeSpouseMapEntity[] = [];
public ChildrenMaps: FamilyTreeChildrenMapEntity[] = [];
public PersonCount: number = 0;
// 二期:小系导出锚点人号(支系替换导入验明同一人用;ALL 为空)
public AnchorPersonId: string = "";
public ExportedBy: string = "";
public ExportedAt: Date = new Date();
}
@@ -0,0 +1,8 @@
export class FamilyTreeExchangeFileInfo {
public FileName: string = "";
public Size: number = 0;
public LastModified: Date = new Date();
public Scope: string = "";
public TreeId: string = "";
public AnchorPersonId: string = "";
}
@@ -0,0 +1,46 @@
export class FamilyTreeFileLogEntity
{
public CreatedDatetime: Date = new Date();
public CreatedBy: string = "";
public UpdatedDatetime: Date = new Date();
public UpdatedBy: string = "";
public LogId: number = 0;
public TreeId: string = "";
public Action: string = "";
public Scope: string = "";
public AnchorPersonId: string = "";
public FileName: string = "";
public PersonCount: number = 0;
public Result: string = "";
public Message: string = "";
static readonly CREATED_DATETIME : string ='CreatedDatetime';
static readonly CREATED_BY : string ='CreatedBy';
static readonly UPDATED_DATETIME : string ='UpdatedDatetime';
static readonly UPDATED_BY : string ='UpdatedBy';
static readonly LOG_ID : string ='LogId';
static readonly TREE_ID : string ='TreeId';
static readonly ACTION : string ='Action';
static readonly SCOPE : string ='Scope';
static readonly ANCHOR_PERSON_ID : string ='AnchorPersonId';
static readonly FILE_NAME : string ='FileName';
static readonly PERSON_COUNT : string ='PersonCount';
static readonly RESULT : string ='Result';
static readonly MESSAGE : string ='Message';
static readonly DB_NAME_FAMILY_TREE_FILE_LOG : string ='family_tree_file_log';
static readonly DB_NAME_FIELDS : string ='created_datetime,created_by,updated_datetime,updated_by,log_id,tree_id,action,scope,anchor_person_id,file_name,person_count,result,message';
static readonly DB_CREATED_DATETIME : string ='created_datetime';
static readonly DB_CREATED_BY : string ='created_by';
static readonly DB_UPDATED_DATETIME : string ='updated_datetime';
static readonly DB_UPDATED_BY : string ='updated_by';
static readonly DB_LOG_ID : string ='log_id';
static readonly DB_TREE_ID : string ='tree_id';
static readonly DB_ACTION : string ='action';
static readonly DB_SCOPE : string ='scope';
static readonly DB_ANCHOR_PERSON_ID : string ='anchor_person_id';
static readonly DB_FILE_NAME : string ='file_name';
static readonly DB_PERSON_COUNT : string ='person_count';
static readonly DB_RESULT : string ='result';
static readonly DB_MESSAGE : string ='message';
}
@@ -165,6 +165,57 @@
"ancestryChart": "Full Chart",
"chartEmpty": "No family tree data",
"applyManage": "Apply for Manager",
"exchangeExportEntry": "Export",
"exchangeImportEntry": "Import",
"exchangeExportTitle": "Export Family Tree File",
"exchangeImportTitle": "Import Family Tree File",
"exchangeScopeAll": "Full Tree",
"exchangeScopeSub": "Sub Tree",
"exchangeAnchorTip": "Sub-tree export anchor (current member)",
"exchangePwd": "File Password",
"exchangePwdHint": "Set an encryption password (4+ characters)",
"exchangePwdShort": "Password must be at least 4 characters",
"exchangePwdLostTip": "This password is the only key for encryption/decryption and cannot be recovered by the server. Forget it and you must export again",
"exchangeSaveLocal": "Save to Device",
"exchangeSaveLocalTip": "Download the encrypted .fm file to this device",
"exchangeUploadServer": "Upload to Server",
"exchangeUploadServerTip": "Store in this tree's exchange folder for cross-device import",
"exchangeTargetTip": "Choose at least one export target",
"exchangeRunning": "Processing...",
"exchangeExportBut": "Start Export",
"exchangeExportDone": "Exported: {name}",
"exchangeExportEmpty": "No data to export (insufficient permission or empty tree)",
"exchangeFailed": "Operation failed",
"exchangeFileLoading": "Loading file list...",
"exchangeFileEmpty": "No full-tree file of this tree in the exchange folder yet",
"exchangeImportWarn": "Before importing: the file will completely replace the current tree; same-tree files keep member IDs and account bindings unchanged; cross-tree grafted files and sub-tree imports renumber everyone and unbind accounts, re-approval needed; an automatic backup allows rollback",
"exchangeImportBut": "Decrypt & Import",
"exchangePickFileTip": "Select a file to import first",
"exchangeSubDenied": "No permission to export the sub-tree of the current anchor",
"exchangeSubAnchorMissing": "Pick the replacement anchor in 'Select Member' before importing a sub-tree file",
"exchangeSubAnchorRow": "Sub-tree replacement anchor (current member)",
"exchangeFileEmptySub": "No sub-tree file of this tree in the exchange folder yet",
"exchangeLocalPick": "Upload a .fm file from this device",
"exchangeLocalPickSub": "Cross-tree grafting: pick a file exported by another tree; import mode is detected automatically",
"exchangeLocalTag": "Local file — tap to remove",
"exchangeLocalBad": "Cannot read this file, please pick again",
"exchangeScopeBk": "Restore",
"exchangeBackupEmpty": "No restorable backup for this tree yet",
"exchangePickBackupTip": "Select a backup to restore first",
"exchangeRestoreBut": "Restore This Backup",
"exchangeRestoreWarn": "Before restoring: the whole tree rolls back to the selected backup moment; member IDs and account bindings are kept unchanged, data is replayed 100%; the current tree is auto-backed-up first, so you can step back again",
"exchangeRestoreConfirmTitle": "Confirm Backup Restore",
"exchangeRestoreConfirm": "The whole tree will roll back to the backup taken at {time}: current data is replaced by that snapshot, member IDs and bindings are kept unchanged, data replayed 100%. The current tree is auto-backed-up first and can be stepped back again. Continue?",
"exchangeRestoreDone": "Restored {count} members",
"exchangeScopeMismatch": "The selected file does not match the current import mode. Switch mode or pick another file",
"exchangeLegacySub": "This legacy sub-tree file has no anchor marker. Please re-export and import again",
"exchangeImportConfirmSubTitle": "Confirm Sub-tree Replacement",
"exchangeImportConfirmSub": "Will replace everything of member \"{anchor}\". An automatic backup allows rollback. Continue?",
"exchangeDecryptFailed": "Wrong password or corrupted file",
"exchangeImportConfirmTitle": "Confirm Full Replacement",
"exchangeImportConfirm": "File \"{tree}\" (exported {time}, {count} members) comes from ANOTHER tree and will completely replace the current tree: everyone is renumbered, members unbound, permissions need reconfiguration. An automatic backup allows rollback. Continue?",
"exchangeImportConfirmSame": "File \"{tree}\" (exported {time}, {count} members) is from THIS tree and will be replayed whole: member IDs and account bindings are kept unchanged, data restored 100%. An automatic backup allows rollback. Continue?",
"exchangeImportDone": "Imported {count} members",
"bindAccount": "Bind Account",
"unbindAccount": "Unbind",
"unbindSuccessTip": "Binding with the member has been removed",
@@ -165,6 +165,57 @@
"ancestryChart": "全系图",
"chartEmpty": "暂无族谱数据",
"applyManage": "申请管理",
"exchangeExportEntry": "导出",
"exchangeImportEntry": "导入",
"exchangeExportTitle": "导出族谱文件",
"exchangeImportTitle": "导入族谱文件",
"exchangeScopeAll": "全系",
"exchangeScopeSub": "小系",
"exchangeAnchorTip": "小系导出锚点(当前族员)",
"exchangePwd": "文件密码",
"exchangePwdHint": "设置加密密码(至少 4 位)",
"exchangePwdShort": "密码至少 4 位",
"exchangePwdLostTip": "密码是加解密的唯一钥匙,服务器也无法找回:请牢记,忘了只能重新导出",
"exchangeSaveLocal": "保存到设备",
"exchangeSaveLocalTip": "生成 .fm 加密文件下载到本机",
"exchangeUploadServer": "上传到服务器",
"exchangeUploadServerTip": "存入本谱交换目录,换设备也能导入",
"exchangeTargetTip": "至少选择一个导出目标",
"exchangeRunning": "处理中…",
"exchangeExportBut": "开始导出",
"exchangeExportDone": "导出完成:{name}",
"exchangeExportEmpty": "没有可导出的数据(权限不足或族谱为空)",
"exchangeFailed": "操作失败",
"exchangeFileLoading": "文件列表加载中…",
"exchangeFileEmpty": "交换目录还没有本谱的全系文件",
"exchangeImportWarn": "导入前须知:文件内容将整体替换当前族谱;同谱文件保留人员编号与账号绑定不变;跨谱嫁接文件与小系导入会重编号并解除绑定,需重新审批;替换前自动备份可回滚",
"exchangeImportBut": "解密并导入",
"exchangePickFileTip": "先选择要导入的文件",
"exchangeSubDenied": "没有当前锚点小系的导出权限",
"exchangeSubAnchorMissing": "请先在「选择族员」里选好支系替换锚点再导入小系文件",
"exchangeSubAnchorRow": "支系替换锚点(当前族员)",
"exchangeFileEmptySub": "交换目录还没有本谱的小系文件",
"exchangeLocalPick": "从本机上传 .fm 文件",
"exchangeLocalPickSub": "跨谱嫁接:选其他谱导出的文件,导入模式按文件自动识别",
"exchangeLocalTag": "本地文件,点击可移除",
"exchangeLocalBad": "无法读取该文件,请重新选择",
"exchangeScopeBk": "备份还原",
"exchangeBackupEmpty": "本谱还没有可还原的备份",
"exchangePickBackupTip": "先选择要还原的备份",
"exchangeRestoreBut": "还原此备份",
"exchangeRestoreWarn": "还原前须知:整谱将回滚到所选备份时刻,人员编号与绑定原样保留、数据 100% 回放;还原前会自动备份当前谱,还能再退回去",
"exchangeRestoreConfirmTitle": "确认备份还原",
"exchangeRestoreConfirm": "整谱将回滚到备份时刻 {time}:当前数据全部替换为当时的快照,人员编号与绑定原样保留、数据 100% 回放;还原前会自动备份当前谱可再回退。确定继续吗?",
"exchangeRestoreDone": "还原完成,共 {count} 位族人",
"exchangeScopeMismatch": "所选文件与当前导入模式不匹配,请切换模式或更换文件",
"exchangeLegacySub": "旧版小系文件未标记锚点,请重新导出后再导入",
"exchangeImportConfirmSubTitle": "确认支系替换",
"exchangeImportConfirmSub": "将替换当前族员「{anchor}」的全部,自动备份可回滚。确定继续吗?",
"exchangeDecryptFailed": "密码错误或文件已损坏",
"exchangeImportConfirmTitle": "确认整谱替换",
"exchangeImportConfirm": "文件「{tree}」({time} 导出,{count} 人)来自其他族谱,将整体替换当前族谱:全员重新编号、族人解除绑定、成员权限需重配,替换前自动备份可回滚。确定继续吗?",
"exchangeImportConfirmSame": "文件「{tree}」({time} 导出,{count} 人)是本谱文件,将整谱回放:人员编号与账号绑定原样保留,数据 100% 恢复,替换前自动备份可回滚。确定继续吗?",
"exchangeImportDone": "导入完成,共 {count} 位族人",
"bindAccount": "绑定账号",
"unbindAccount": "解除绑定",
"unbindSuccessTip": "已解除与族员的绑定",
@@ -0,0 +1,288 @@
# 族谱导入导出闭环功能归档
> 适用范围:优嘉拉(youjiala)族谱 `.fm` 交换文件的**导出 / 导入 / 备份 / 还原**全链路。
> 本文是该特性的单一事实来源(single source of truth),涵盖设计决策、数据结构、加密、权限、后端算法、前端编排、测试矩阵与已知边界。
> 涉及工程:后端 `CloudBuilder`(.NET 8 / Furion)、前端 `uniapp youjiala`(Vue3 + crypto-js)。
---
## 1. 决策沿革(一句话版)
| 期次 | 能力 | 关键决策代号 |
|---|---|---|
| 一期 | 全系 `.fm` 导出/导入、备份还原 | D1~D8、R1~R6 |
| 二期 | 小系(支系)导出 + 锚点整家替换导入(方案 C) | P1~P7 |
| 三期 | 权限行随号迁移/清理、备份含权限快照、备份密文落盘 | 绝对闭环 |
核心口径演进:
- **方案 C**:小系导入时锚点本人资料也整体覆盖,锚点配偶"纯入配"随删随插(整家替换)。
- **同谱 100% 完整性**:`treeId` 相同(全系备份还原、服务器同谱文件导入)时,人员编号可保留(用户拍板"编号可以变"→ 实际同谱保留原号、跨谱才重编号),资料/绑定/关系 100% 回放,权限行不悬挂。
- **绝对闭环**:权限行进备份 JSON,还原时随谱回滚;备份整份以服务端 AES 密文落盘。
---
## 2. 名词与数据结构
### 2.1 `FamilyTreeExchangeData`(明文 payload / 备份快照载体)
`CloudBuilder.Master/Data/FamilyTreeExchangeData.cs`
| 字段 | 含义 | 备注 |
|---|---|---|
| `Scope` | `ALL` / `SUB` | 交换范围常量 `FamilyTreeExchangeScope` |
| `FamilyTree` | 谱信息行(含**源 TreeId**) | **同谱判定依据**:`data.FamilyTree.TreeId == targetTreeId` |
| `Persons[]` | 人员实体 | 全系=全谱;小系=家支闭包子集 |
| `SpouseMaps[]` | 夫妻关系行 | 两端都须在集合内 |
| `ChildrenMaps[]` | 父子关系行 | 两端都须在集合内 |
| `Permissions[]` | **权限行快照** | **仅备份 BK 写入**;`.fm` 恒空——防账号名外泄、防外部文件携权限行劫持本谱 |
| `AnchorPersonId` | 小系锚点人号 | ALL 为空;二期起写入 payload |
| `ExportedBy` | 发起人 `service.UserId` | 备份归属隔离依据 |
| `ExportedAt` | 导出时刻 | |
| `PersonCount` | 计算属性 `Persons.Length` | |
### 2.2 权限真源
真正的人员—账号绑定关系存 `family_tree_permission`(复合主键 `PersonId + TreeId + UserName`)。`family_person` 表的 `BindUserId / IsBindUser` 是遗留字段(无业务写入,但同谱回放会原样保留)。
---
## 3. 权限体系
### 3.1 三档身份(`FamilyTreePermissionHelper`)
- **普通用户**:与本谱无生效权限行。
- **成员**:有 `ValidIndc=YES` 且绑定到自己族员的权限行。可小系导出/导入(限自家支锚点)、还原自己的备份。
- **管理员**:有 `ManagerIndc=YES` 行。可全系导出/整谱替换导入、看全部备份、任意锚点小系。
- 判定 `IsManager` 按 `UserName + ManagerIndc`,**不看 PersonId**,故重编号不影响管理员身份。
### 3.2 绑定三重闸(`BindSelfPerson`)
1. 登录已验 `UserIsValidIndc=YES` + 未锁定(`AuthService.Login`);
2. 本谱审批行 `ValidIndc=YES`;
3. 同名闸:族员「姓+谱名」或「常用名」== `UserLocalName`。
### 3.3 家支可编集合 `GetAmendablePersonIds`
口径(`PackPersonIdByChildrenMaps`):本人 + 全部后代 + 配偶 + **向上最多两代(父、祖)及每代配偶**。小系导出/导入的锚点闸门用它。
---
## 4. 加密体系(两套,互不混用)
### 4.1 `.fm` 交换文件信封(前端,用户密码派生)
`src/utils/familyTreeExchange.ts`
- 方案 A / D2:`密钥 = PBKDF2(用户密码 ⊕ 系统因子, 随机盐, 10000 轮, SHA256, 512bit)`;
- 前 256bit → AES-256-CBC(Pkcs7),后 256bit → HMAC-SHA256,**encrypt-then-MAC**;
- MAC 覆盖 `salt+iv+ct`,先验完整性再解密(无 padding oracle);
- 盐与 IV 每次随机(同谱同密码两次导出密文不同);
- 系统因子 `YouJiaLa.FamilyTree.Exchange.v1` 由后端 `GetExchangeFactor` 下发(不进前端包体);
- 信封 JSON:`{V:"FM1", Iter, Salt, Iv, Ct, Mac}`(均 base64);
- **失败不区分**"密码错/文件坏",统一报 `exchange-file-bad`(不给攻击者预言机)。
### 4.2 `BK` 备份文件密文(后端,服务端密钥)
`CloudBuilder.Master.Service/FamilyTreeUtility/FamilyTreeExchangePolicy.cs`
- 备份含权限行(账号名隐私),整份用 `EncryptHelper.AesEncrypt`(AES-128-CBC,内置 Key/IV)落盘;
- 文本前缀魔数 `ENC:` 区分新老格式:**无此前缀=旧明文备份,直接透传**(向后兼容);
- 读取端 `DecryptBackup` / `ReadBackupOwner` / `RestoreBackup` 均先解密再解析;
- ⚠️ 编译陷阱:`DecryptBackup` 须写 `EncryptHelper.AesDecrpt(...)` 静态调用,`.AesDecrpt()` 零参扩展名不匹配(`EncryptHelper` 只有 `(string)` 静态与 `(string,string,string)` 扩展两个重载)。
- 因备份由后端自动生成、后端自回读(不经前端密码),故用服务端密钥而非 `.fm` 的用户密码派生方案。
---
## 5. 文件命名与目录
| 类型 | 规则 | 校验正则 |
|---|---|---|
| 全系 `.fm` | `A{treeId}_{yyyyMMddHHmmss}.fm` | `^AT\w+_\d{14}\.fm$` |
| 小系 `.fm` | `P{treeId}-{personId}_{ts}.fm` | `^PT\w+-P\w+_\d{14}\.fm$` |
| 备份 BK | `BK{treeId}_{ts}.json` | `^BKT\w+_\d{14}\.json$` |
- 目录:与身份证/头像同根(`FileServiceSettings:UploadFileDirectory`),挂 `FamilyTree/` 与 `FamilyTree/Backup/` 子目录,不新增配置节。
- `IsValidFileName` / `IsValidBackupFileName` 拒绝任何目录成分(防 `../` 路径穿越),且 `Path.GetFileName` 双检。
- **TreeId 来源**:交换/备份文件的 TreeId 从**文件名**反解析(`ParseFileName` / `ParseBackupFileName`),不依赖 JSON 内容或文件系统时间。
---
## 6. 导出链路
后端 `ExportFamilyTree`(`FamilyTreeService.auto.cs` L818):
1. D8 闸门:全系仅管理员;小系管理员任意锚点、成员仅限自家支锚点。
2. 全量取本谱 persons/spouseMaps/childrenMaps(关系按 `Orderby` 排序)。
3. 小系子集:`PackPersonIdByChildrenMaps`(管理员用传入锚点)或 `GetAmendablePersonIds`(成员)展开后过滤,关系行**两端都须在 ids 内**。
4. 审计 `OUT`,返回明文 `FamilyTreeExchangeData`(含 `FamilyTree` 谱信息)。
前端 `exportTree`(`useFamilyTreeExchange.ts` L91):
`GetExchangeFactor` → `ExportFamilyTree` → `encryptEnvelope` → **设备落盘**(`saveFileBytes`)和/或 **上传交换目录**(`SaveExchangeFile`,base64 通道,限 1B~50MB)。
---
## 7. 导入链路
### 7.1 全系整谱替换 `ImportFamilyTree` → `ReplaceTreeCore`
`ReplaceTreeCore`(L989,`ImportFamilyTree` 与 `RestoreBackup` 共用,调用方 `@UnitOfWork` 已开事务):
```
0) 完整性闸(宁拒不静默丢行,保落库与文件 100% 相同)
- PersonId 为空或重复 → 拒
- spouse/children 关系行两端不在文件内 → 拒
1) BackupTree 自动备份现谱(整谱快照,先落盘)
2) 清空当前谱:关系先行(spouse→children)、人后删
3) 编号策略 + SkipAudit
- sameTree:idMap[p]=原号(不耗号段)、保留 BindUserId/IsBindUser、回放文件 Created 印章
→ 插入段临时 dbCtx.SkipAudit=true(绕开框架对 Added 强盖 Created*),finally 复位
- 跨谱:generator.GetNo 重发 PersonId + 清绑定 + 框架盖当次 Created
4) 关系行双端 remap(完整性闸保证全量命中);同谱 Updated 补当次痕迹
5) 谱信息字段以数据为准(TreeName/Brief/Cover/PublicIndc;AncestorPersonId 经 idMap remap;主键 TreeId 保持目标谱)
6) 权限闭环(见 §8)→ 审计
```
- 全系导入仅管理员(D4)、仅 ALL scope、要求 `data.FamilyTree` 非空。
- 审计动码:`IN`=外部文件导入 / `RESTORE`=备份回滚回放;message 含 `sameTree=1/0`、`backup=`、权限统计。
### 7.2 小系锚点整家替换 `ImportSubTree`(方案 C)
`ImportSubTree`(L1147):
1. P6 权限闸:管理员任意锚点;成员限自家支锚点。
2. P2/P3 同一人验证:文件锚点与所选族人 **姓+谱名**全量一致才放行。
3. BackupTree 整谱备份(P7,回滚点)。
4. **1.5 方案 C**:锚点本人资料覆盖为文件锚点(`Clone`+`CopyFrom`),回保目标侧 `PersonId/TreeId/FatherId/BindUserId/IsBindUser/Created*`(防脱谱、防绑定劫持)。
5. **删除集**:`BranchClosure(anchor)` 后代闭包 + 锚点配偶"纯入配"(`delSpousePersons`:无绑定、无其它婚配/血缘、非始祖才随删重插),涉及锚点的 spouse 行全删。
6. **插入集**:文件侧同口径闭包(上溯段不插,P4)+ 文件锚点配偶(与目标侧删除对称);文件锚点映射到保留的目标锚点,其余 `generator` 重编号、清绑定。
7. 关系行双端 remap,引用集合外人的行丢弃防悬挂。
8. 权限行迁移 `MigrateSubTreePermissions`(§8)。
9. 审计 `MERGE`:`deleted/inserted/anchor=1/anchorSpouse=N/perm moved;dropped`。返回 `insSet.Count + 1`(含锚点本人)。
**BranchClosure**(L1127):后代 + 配偶迭代到不动点(防回头婚配漏拉),**锚点本人不进集合**;目标侧删除集与文件侧插入集共用此口径保证删/插对称。
### 7.3 前端解密校验 `decryptPayload` / `readAndDecrypt`
`useFamilyTreeExchange.ts` L155
- 服务器回读(`ReadExchangeFile` base64)与本地选文件两路共用解密;
- `expectScope=null`(本地跨谱嫁接)自动采纳文件自身范围;指定模式则文件范围必须同构;
- 小系无 `AnchorPersonId`(旧文件)→ `exchange-legacy-sub`;缺 Persons/FamilyTree → `exchange-file-bad`;
- payload 留前端内存,二次确认后原样交 `importTree` / `importSubTree`。
---
## 8. 权限行三方法(绝对闭环核心)
| 场景 | 方法 | 行为 |
|---|---|---|
| 小系导入 | `MigrateSubTreePermissions`(L386) | 被删后代/入配配偶的权限行经 `idMap`(文件旧号→新号)命中则 `RebindFamilyTreePermission` 删旧插新换绑;命中不了(跨谱/已删)删悬挂;**管理员行、PersonId 空行不动** |
| 跨谱全系导入 | `CleanDanglingPermissions`(L415) | 删本谱引用已删旧号的成员行(`ManagerIndc!=YES && PersonId!="" && 不在存活集`);管理员/空号保留 |
| 备份还原 | `RestorePermissions`(L430) | 本谱权限行整体删除→按快照重建,`PersonId` 经 idMap remap(还原恒同谱→原样落回);仅 `data.Permissions.Length>0` 触发(`.fm` 无快照不会走到) |
- 复合主键 `(PersonId,TreeId,UserName)` 改主键 EF 不可靠 → 统一走"删旧插新"(`RebindFamilyTreePermission` 先例)。
- 建谱时管理员行 `PersonId` 指向始祖且非空 → 清理/迁移必须 `ManagerIndc` 排除,防误杀。
`ReplaceTreeCore` 权限分支优先级:`有快照→Restore` > `跨谱→Clean` > `同谱→不碰`。
---
## 9. 备份与还原链路
### 9.1 备份 `BackupTree`(L960)
- 三条破坏性路径**动手前都先整谱备份**(全系导入 / 小系导入 / 还原),备份范围**恒为整谱**(即使只替换一根枝);
- 采集 persons/spouse/children + **本谱权限行**;
- **无 try-catch**:磁盘满/写失败直接抛出 → 整个事务回滚 = "备份落盘不成功,删除就不执行";
- 整份 `EncryptBackup`(`ENC:` 密文)落 `Backup/` 子目录。
### 9.2 备份列表 `ListBackups`(R1 收紧)(L1288)
- 成员只见自己创建的(`ReadBackupOwner` 解密取 `ExportedBy`),管理员不限;
- R5 保留窗口一年;按备份时刻降序;解析不出归属的旧文件对成员隐藏。
### 9.3 还原 `RestoreBackup`(L1341)
- R1 成员也可还原(走 IsMember);R6 文件名严格 BK + 谱号==目标谱(防跨谱灌数据)+ 超窗口拒;
- 解密反序列化(`PropertyNameCaseInsensitive`)→ 内容完整性校验 → R1 归属校验(成员只能还原自己的);
- 复用 `ReplaceTreeCore(..., "RESTORE")`:**还原前先把当前谱再备份一份**(链式可回退),同谱编号/绑定原样回放、权限随快照闭环。
---
## 10. 同谱 / 跨谱 语义矩阵
| 场景 | 编号 | 绑定 | Created 印章 | 权限行 | 完整性闸 |
|---|---|---|---|---|---|
| 同谱全系导入 / 备份还原 | 保留原号 | 保留 | 回放文件值(SkipAudit) | 无损 / 快照回放 | 拒悬挂/重复 |
| 跨谱全系嫁接 | 重发新号 | 清空 | 当次操作人/时间 | Clean 清悬挂 | 拒悬挂/重复 |
| 小系导入 | 后代重编号 | 锚点保留 / 后代清 | 当次 | Migrate 迁移 | — |
同谱判定:`data.FamilyTree.TreeId == targetTreeId`(还原与服务器同谱文件导入恒同谱;跨谱全系只能来自本地上传)。
---
## 11. 前端 UI / 交互 / 文案
入口在 `components/family-tree-home/family-tree-home.vue`:
- 导入/导出入口 `v-if="canExport"`(`canExport = IsManager || IsMember`),**成员也有导入入口**(不再仅管理员);
- `openExchangeImport`:非管理员默认 scope=`SUB`,`ALL` 选项 chip `v-if IsManager`;
- 二次确认按 **同谱/跨谱** 区分风险文案:同谱文件(含本谱服务器文件)`exchangeImportConfirmSame`(保留编号/绑定,数据 100% 恢复);跨谱 `exchangeImportConfirm`(重编号+解绑需重审批);
- 小系确认 `exchangeImportConfirmSub` 极简版:"将替换当前族员「{anchor}」的全部,自动备份可回滚。确定继续吗?";
- 还原 `exchangeRestoreConfirm` / 警告 `exchangeRestoreWarn`(恒同谱,保留编号绑定 100% 回放);
- **导入/还原弹层列表**用 `<scroll-view scroll-y class="ft-exchange__list">`(`max-height:42vh` + H5 可见 6px 细滚动条),保证密码框/红色警告/按钮永不被长列表挤出。
i18n:`src/locale/lang/zh-CN.json` 与 `en-US.json` 双语同步。
---
## 12. 审计日志 `family_tree_file_log`
`WriteExchangeLog`(L1378):失败不阻断主流程(`try-catch` 吞并 Console 打印)。
| Action | 来源 | Message 关键字 |
|---|---|---|
| `OUT` | 导出 | 文件名、人数 |
| `IN` | 全系导入 | `backup; sameTree; permDropped` |
| `MERGE` | 小系导入 | `backup; deleted; inserted; anchor=1; anchorSpouse=N; perm moved;dropped` |
| `RESTORE` | 备份还原 | `backup; sameTree; permRestored` |
---
## 13. 后端接口清单
| 方法 | 权限 | 入参绑定 | 说明 |
|---|---|---|---|
| `GetExchangeFactor` | Find | — | 下发系统因子 |
| `ExportFamilyTree` | Find | `(treeId,scope,anchorPersonId)` | 返回明文 payload |
| `SaveExchangeFile` | Find | `(fileName,contentBase64)` | 上传交换目录 |
| `ListExchangeFiles` | Find | `?treeId=`(query) | 列 `.fm`,时间降序 |
| `ReadExchangeFile` | Find | `?fileName=`(query) | base64 回读 |
| `ImportFamilyTree` | Update+事务 | body `{targetTreeId,data}` | 整谱替换(仅管理员/ALL) |
| `ImportSubTree` | Update+事务 | body `{targetTreeId,anchorPersonId,data}` | 小系替换 |
| `ListBackups` | Find | `?treeId=`(query) | 列 BK(按归属过滤) |
| `RestoreBackup` | Update+事务 | body `{targetTreeId,backupFileName}` | 备份还原 |
> 简单类型入参走 query string,复杂对象走 JSON body;业务异常统一 HTTP 200 + `ValidatedException` 文本。
---
## 14. 构建链与部署(易踩坑)
- `CloudBuilder.Web.Entry.csproj` 对子项目是 `<Reference>+<HintPath>` 而非 `<ProjectReference>`:**必须先 `dotnet build` 子项目 csproj(更新其 bin dll),再 build Entry**,否则 Entry 输出目录仍是旧 dll,改动不生效。
- 改了 `CloudBuilder.Master`(如 DTO)→ 顺序:`Master → Master.Service → Web.Entry`。
- 构建前需释放文件锁:VS 调试进程(端口 5235)与 PostBuild 的 `CodeGenerator.exe` 会锁 dll;构建后核对 `bin\Debug\net8.0\CloudBuilder.Master.Service.dll` 时间戳。
- `Controller` 与前端 `service/*.ts` 由 CodeGenerator 自动生成,**不可手改**;手写业务逻辑放 `*.auto.cs`(允许)。
---
## 15. 自动化测试矩阵(已实测通过)
| 用例 | 断言要点 | 结果 |
|---|---|---|
| 同谱全系 round-trip | 编号/绑定/字段/关系全等,Created 印章回放 | 12/12 PASS |
| 还原旧明文 BK | 向后兼容透传不炸,生成新密文 BK | PASS |
| 新 BK 密文 | `ENC:` 前缀 + AES-128-CBC 解密回明文 | PASS |
| 权限快照 | 密文含 `Permissions`,条数==库内权限行;含 UserName/ManagerIndc | PASS |
| 小系权限迁移 | 后代绑定随人换绑新号;管理员/空号行不动 | 14/14 PASS |
| 跨谱清理悬挂 | 引用已删旧号的成员行删除,管理员行保留 | PASS |
| 完整性负闸 | 空/重复编号、悬挂关系行拒绝 | PASS |
测试脚本要点(见记忆《CloudBuilder 后端自动化测试登录与接口调用要点》):
- `POST /Auth/Login` 体字段 `UserMobile` + `RsaUserPassword`(RSA: `guid+md5(pwd)`)+ `Guid`,`Culture` 必须**小写 `zh-cn`**(传 `zh-CN` 会让 `ComposeMessage` 抛空 Message 异常);
- token 在 `Token.AccessToken`;
- 结果落盘 `fs.writeFileSync` + Read 工具直读(PowerShell stdout 滞后不可靠)。
---
## 16. 已知边界与遗留
- **备份密钥**:`BK` 用 `EncryptHelper` 内置固定 Key/IV(代码硬编码于 `CloudBuilder.Core`);如需更强隔离可改走配置密钥。
- **father_id 字段 vs children_map 行一致性**:曾发现陈国国 `father_id` 为 null 但 children_map 存在父行(保存链路潜在不一致),属独立 bug,未在本闭环处理。
- **JWT 无状态窗口**:解绑/重绑在 token 有效期内对已登录会话的影响为窗口期问题。
- `.fm` 交换文件的 `Permissions` 恒空:恶意文件携带权限行会被忽略(不回放),符合防劫持设计。
---
*文档生成于 2026-10-20,对应代码版本:Entry 输出 `CloudBuilder.Master.Service.dll` 时间戳 09:14:41(含同谱完整性 + 权限迁移/清理 + 备份权限快照 + 密文落盘全部改动)。*
@@ -0,0 +1,128 @@
# 族谱权限体系(完整流程)
> 定稿 2026-10(v5):三档角色 + 中文姓名同名绑定 + 家支两代上溯 + 逐目标人可编集合。前后端口径一致,后端为最终裁决。
## 一、三档角色定义
| 角色 | 判定条件 | 能力范围 |
|---|---|---|
| **普通用户** | 无生效权限行(或仅有待审行) | 只读:查看公开信息、小/全系图;「申请管理」可用;编辑、编辑资料、顶部公开开关、成员/权限选项卡全部不可见 |
| **成员** | 有 `UserName=自己 且 ValidIndc=Y` 的权限行(`IsMember=true`) | 可见成员选项卡;可编辑**以自己绑定族员为锚点的家支主线**(见第三节);权限选项卡、谱级操作(编辑资料/挂始祖/公开开关/删谱)不可见且后端拦截 |
| **管理员** | 权限行 `ManagerIndc=Y 且 ValidIndc=Y`(`IsManager=true`) | 全部权限:审批/任免、族谱本体修改、任意族员编辑删除(`HasPermission` 内短路为真) |
建谱人(Insert 时 CreatedBy)自动成为管理员。
后端 `FamilyTreeData` 恒定下发三个标志,前端一切显隐只认它们:
- `IsManager`:是否管理员(谱级操作闸门);
- `IsMember`:是否已生效成员(成员 tab 闸门);
- `IsCanAmend`:**当前锚点族员**是否可编(随「选择族员」切换而变),个人编辑/关系维护入口闸门。
## 二、完整流程:从陌生人到成员
```
注册登录 → 浏览族谱(普通用户档)
│
▼
[申请管理](快捷区 / 权限tab常驻卡 / 底部条,三档均可见入口)
│ 后端 RequestFamilyTreePermission:
│ 插入权限行 TreeId+UserName,PersonId=""(不绑任何族员!
│ 旧版把申请锚点——往往是始祖——写入,导致成员误得大支系,已修)
│ 查重按 TreeId+UserName(一人一谱一行)
▼
管理员权限tab「待审批」看到申请行(视图 LEFT JOIN 保证空绑定行可见)
│
▼
[通过](成员档) 后端 ApproveFamilyTreePermission:
│ ValidIndc=Y;不绑始祖;
│ 自动绑定判定:取申请人中文姓名 user_local_name,全谱内
│ 「姓+谱名」组合或常用名与之相同且唯一 → 自动绑该族员;
│ 否则 PersonId 留空,由用户自行绑定
│
├─或→ [设为管理员]:ManagerIndc=Y,不做绑定(管理员全谱短路可编)
│
▼
未自动绑定的成员 → [绑定账号](成员tab「当前族员」卡第4钮)
│ 先用「族员搜索」把当前族员切到目标,再点绑定
│ 后端 BindSelfPerson(treeId, personId):
│ ① 校验自己有 ValidIndc=Y 行,否则「您还未通过审批,不能绑定账号」
│ ② 校验该族员「姓+谱名」组合或常用名 == 登录中文姓名(user_local_name),
│ 否则「只能绑定与您的中文姓名同名的族员」(与审批自动绑定同一把尺;
│ 登录账号 user_name 只是标识,不参与名字比较)
│ ③ 换绑:PersonId 属复合主键 (PersonId,TreeId,UserName),
│ EF 分离实体改主键列不可靠 → 删旧行插新行(RebindFamilyTreePermission)
│ ④ 返回 FindByCurrentPerson(treeId, personId),锚点即切到刚绑族员
│
▼
[解除绑定](同一按钮位:当前族员==自己绑定时显「解除绑定」)
│ 后端 UnbindSelfPerson(treeId):自己 Valid=Y 行 Rebind 到 PersonId="",
│ 锚点退回始祖;解绑后可重新绑定其他同名族员
▼
成员生效:家支主线可编(第三节)
```
驳回(Reject)/移出:按 TreeId+UserName 定位删行(不再依赖 PersonId)。
## 三、成员可编范围:家支主线(两代上溯口径)
`FamilyTreePermissionHelper.HasPermission(treeId, personId)` 判定,
支系集合由绑定族员(锚点)按下列规则展开:
| 关系 | 是否在集合 | 说明 |
|---|---|---|
| 本人(绑定的族员) | ✅ | |
| 全部后代(递归子链) | ✅ | 含后代的配偶 |
| 父(第 1 代上溯)、祖父(第 2 代上溯) | ✅ | **最多两代**,含他们的配偶(母亲/祖母辈) |
| 曾祖及更远直系祖先(含始祖) | ❌ | 超出两代即出支系 |
| 本人配偶 | ✅ | |
| 兄弟姐妹 | ❌ | 旁支 |
| **伯/叔(父亲的兄弟)及其子女** | ❌ | 上溯只收直系父链+每代配偶,不从祖父向下展开 |
环保护:`ps.Contains(father)` 即停,父链成环不会死循环。
**逐目标人可编集合**:详情接口同时下发 `CanAmendPersonIds`(`GetAmendablePersonIds`,与 `HasPermission` 完全同源的家支集合)——因为 `IsCanAmend` 是锚点级单一布尔,盖不住关系行里的旁支(如锚点儿子视角下,父亲的另一子“test叔”是旁支不可编);管理员不限量不下发,前端按 `IsManager` 短路。
## 四、后端守卫矩阵(防绕过前端的直接 API 调用)
| 操作 | 端点 | 守卫 |
|---|---|---|
| 改族员档案 | `FamilyPerson/Update` | `HasPermission(TreeId, PersonId)`(支系内) |
| 新增族员 | `FamilyPerson/Insert` | **`IsManager \|\| IsMember`**(新人无既有 PersonId 可查支系,最小闸门防普通用户直接调 API 污染族谱;挂接关系仍受 Update 的 HasPermission 约束) |
| 删族员 | `FamilyPerson/Delete` | 同上(曾完全无守卫,已补) |
| 改谱字段(谱名/简介/公开开关/挂始祖) | `FamilyTree/Update` | **仅 `IsManager`**;成员支只准保存配偶/子女关系 map,谱字段不入库;成员改 `AncestorPersonId` 抛「您没有挂始祖权限」 |
| 删谱 | `FamilyTree/Delete` | **仅 `IsManager`** |
| 审批/任免/驳回 | `Approve/Reject...` | 管理员函数级权限 |
| 申请 / 绑定账号 | `Request.../BindSelfPerson` | 函数级 Find + 业务校验(见第二节) |
⚠️ 关键教训:**`HasPermission(始祖)` 不能当谱级闸门**——锚点贴近始祖的成员(如绑始祖、或始祖在其上溯两代内)它也为真。谱级操作必须走独立的 `IsManager(treeId)`。
## 五、前端门控(family-tree-home.vue)
| 入口/元素 | 显示条件 |
|---|---|
| 概览快捷区「编辑资料」、顶部公开开关、权限 tab、挂始祖链 | `IsManager` |
| 成员 tab | `IsManager \|\| IsMember` |
| 当前族员「修改」钮、始祖卡「编辑」链 | `IsCanAmend`(锚点级,随锚点变) |
| 关系行(夫妻/子女)行尾铅笔与垃圾桶 | **逐目标人** `canAmendPerson(id)` = `IsManager \|\| CanAmendPersonIds.includes(id)`;旁支(如兄弟)不显示 |
| 关系行 ↑↓ 调序、卡头「新增配偶/添加子女」 | 锚点级 `IsCanAmend`(属锚点关系列表维护) |
| 「申请管理」 | 常驻(管理员点击只提示不重复发) |
| 「绑定账号 / 解除绑定」(成员tab当前族员卡,同一按钮位互斥切换) | `!IsManager`;当前族员 PersonId == `SelfBindPersonId` 时显「解除绑定」,否则显「绑定账号」 |
| 不可编时弹层前置拦截 | `openQuickCreate` 按锚点级 `IsCanAmend`、`openEditPerson` 按目标人级 `canAmendPerson(id)`:不可编直接 toast「您没有该族谱的修改权限」不弹层(防弹完才报错,也防弹层内非公开族人信息泄漏) |
| 不可见 tab 兜底 | `load()` 内:权限 tab 非管理员踢回概览;成员 tab 非成员/管理员踢回概览 |
## 六、数据与视图要点
- 权限表 `family_tree_permission`,复合主键 **(person_id, tree_id, user_name)**;`person_id=""` 表示已过审/待审但未绑定。
- 列表下发走数据库视图 `family_tree_permission_user_view`:对 `family_person` 必须 **LEFT JOIN**(INNER JOIN 会把未绑定行整联丢弃,导致管理员看不到待审申请——已修);对 `security_user` 保持 INNER。
- 非管理员查询时后端过滤 `FamilyTreePermissions` 只留自己的待审行;`IsMember` 必须在过滤**之前**计算。
- 视图定义不在仓库中,只存在于 MySQL;改动需 `CREATE OR REPLACE VIEW`。
## 七、已验证结论(脚本实测)
- 001 管理员:三标志全 true,任意编辑 ✅
- 002 普通用户:三标志全 false,人员/谱级写操作全部「您没有修改权限」 ✅
- 003 成员(绑 test仲,始祖为其父):改伯系后代/旁支被拒、改自己放行、翻公开开关不入库、删谱/删伯系被拒 ✅
- 两代口径:003 绑四世 test我 时,父/祖可编、曾祖(始祖)出支系 `IsCanAmend=false`、伯叔不可编 ✅
- 同名绑定(中文姓名口径):比较基准是 `security_user.user_local_name`;族员侧匹配 `(姓+谱名) == 中文姓名 || 常用名 == 中文姓名`;不同名拒绑、唯一同名审批时自动绑、自助绑定成功 ✅(003/陈国国 可绑 P0000019 陈+国国)
- 逐目标人集合(003 绑陈国国 19 实测):`CanAmendPersonIds = {19本人, 23/24后代, 16父, 17/20父辈配偶, 14/15祖始祖层}` ✅;test叔(旁支)不在集合、行尾无编辑/删除钮;陈国国自己的行可编 ✅
- 构建链教训:`Master.Service`/`Web.Entry` 走 HintPath dll 引用且用 `-o` 临时目录验证编译会漏刷 `bin\Debug`,导致“新 Master + 旧 Service”组合使新字段恒为空数组;交付前必须按 Master → Master.Service → Web.Entry 顺序正常构建(不带 -o)再重启
@@ -3,6 +3,8 @@ import { request } from "@/utils/request";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
import type { FamilyTreeSearchCriteria } from "@/data/auto/familyTreeSearchCriteria";
import type { FamilyTreeResultData } from "@/data/auto/familyTreeResultData";
import type { FamilyTreeExchangeData } from "@/data/auto/familyTreeExchangeData";
import type { FamilyTreeExchangeFileInfo } from "@/data/auto/familyTreeExchangeFileInfo";
class FamilyTreeService{
@@ -136,6 +138,95 @@ class FamilyTreeService{
)
};
async GetExchangeFactor() {
return request<string>(
{
url: "/FamilyTree/GetExchangeFactor",
method: "post",
}
)
};
async ExportFamilyTree(data: any) {
return request<FamilyTreeExchangeData>(
{
url: "/FamilyTree/ExportFamilyTree",
method: "post",
data,
}
)
};
async SaveExchangeFile(data: any) {
return request<string>(
{
url: "/FamilyTree/SaveExchangeFile",
method: "post",
data,
}
)
};
async ListExchangeFiles(data: string) {
return request<FamilyTreeExchangeFileInfo[]>(
{
url: "/FamilyTree/ListExchangeFiles?treeId="+data,
method: "post",
data,
}
)
};
async ReadExchangeFile(data: string) {
return request<string>(
{
url: "/FamilyTree/ReadExchangeFile?fileName="+data,
method: "post",
data,
}
)
};
async ImportFamilyTree(data: any) {
return request<number>(
{
url: "/FamilyTree/ImportFamilyTree",
method: "post",
data,
}
)
};
async ImportSubTree(data: any) {
return request<number>(
{
url: "/FamilyTree/ImportSubTree",
method: "post",
data,
}
)
};
async ListBackups(data: string) {
return request<FamilyTreeExchangeFileInfo[]>(
{
url: "/FamilyTree/ListBackups?treeId="+data,
method: "post",
data,
}
)
};
async RestoreBackup(data: any) {
return request<number>(
{
url: "/FamilyTree/RestoreBackup",
method: "post",
data,
}
)
};
}
const familyTreeService= new FamilyTreeService();
@@ -0,0 +1,153 @@
/**
* 族谱交换文件(.fm)信封加解密 —— D2 拍板的方案 A:
* 密钥 = PBKDF2(用户密码 ⊕ 系统因子, 随机盐, 10000 轮, SHA256, 512bit),
* 前 256bit 做 AES-256-CBC(Pkcs7 填充),后 256bit 做 HMAC-SHA256(encrypt-then-MAC)。
* 因子由后端 GetExchangeFactor 下发(不进前端包体),密码只当密钥材料、后端不校验,
* 所以解不开统一报「密码错误或文件损坏」,不区分两种失败(避免给攻击者预言机)。
*
* 分工口径:后端只产/吃明文 payload(权限闸裁剪后的 FamilyTreeExchangeData JSON),
* 加解密全在本模块;.fm 文件本体是信封 JSON 文本(纯文本便于 base64 通道中转)。
* crypto-js 纯 JS 实现,H5 / App webview 通吃(D5 范围)。
*/
import CryptoJS from "crypto-js";
/** 字节串类型别名(@types 里真身在 CryptoJS.lib 命名空间下) */
type WordArray = CryptoJS.lib.WordArray;
/** 信封格式版本(改动加密参数时要升版本号,兼容检查用) */
const ENVELOPE_VERSION = "FM1";
/** PBKDF2 轮数:设备端可承受上限附近,导入导出低频操作可以慢 */
const PBKDF2_ITERATIONS = 10000;
/** 派生 512bit:拆 256bit 加密钥 + 256bit MAC 钥(crypto-js keySize 单位是 32bit word) */
const DERIVE_KEY_SIZE_WORDS = 16;
/** .fm 信封 JSON 的字段形态(对外只暴露这一个结构) */
interface ExchangeEnvelope {
V: string;
Iter: number;
Salt: string;
Iv: string;
Ct: string;
Mac: string;
}
/** 口令+因子拼成派生输入:分隔符防止「密码尾+因子头」相同拼接歧义 */
function passphraseOf(password: string, factor: string): string {
return `${password}\u0001${factor}`;
}
/** PBKDF2 派生并拆双钥(前 8 word 加密 / 后 8 word 认证;@types 未声明 slice,按 words 自建) */
function deriveKeys(password: string, factor: string, salt: WordArray) {
const dk = CryptoJS.PBKDF2(passphraseOf(password, factor), salt, {
keySize: DERIVE_KEY_SIZE_WORDS,
iterations: PBKDF2_ITERATIONS,
hasher: CryptoJS.algo.SHA256,
});
const sliceWords = (wa: WordArray, start: number, count: number): WordArray =>
CryptoJS.lib.WordArray.create(
wa.words.slice(start, start + count),
count * 4,
);
return { encKey: sliceWords(dk, 0, 8), macKey: sliceWords(dk, 8, 8) };
}
/** MAC 覆盖 salt+iv+ct(encrypt-then-MAC:先验完整性再解密,padding oracle 面无门) */
function computeMac(
macKey: WordArray,
salt: WordArray,
iv: WordArray,
ct: WordArray,
): WordArray {
return CryptoJS.HmacSHA256(salt.clone().concat(iv).concat(ct), macKey);
}
/**
* 明文 JSON 文本 → .fm 信封文本。
* 盐与 IV 每次随机:同一谱同一密码两次导出密文也不同,不泄露任何相等关系。
*/
export function encryptEnvelope(
plainText: string,
password: string,
factor: string,
): string {
const salt = CryptoJS.lib.WordArray.random(16);
const iv = CryptoJS.lib.WordArray.random(16);
const { encKey, macKey } = deriveKeys(password, factor, salt);
const encrypted = CryptoJS.AES.encrypt(
CryptoJS.enc.Utf8.parse(plainText),
encKey,
{
iv,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7,
},
);
const ct = encrypted.ciphertext;
const envelope: ExchangeEnvelope = {
V: ENVELOPE_VERSION,
Iter: PBKDF2_ITERATIONS,
Salt: CryptoJS.enc.Base64.stringify(salt),
Iv: CryptoJS.enc.Base64.stringify(iv),
Ct: CryptoJS.enc.Base64.stringify(ct),
Mac: CryptoJS.enc.Base64.stringify(computeMac(macKey, salt, iv, ct)),
};
return JSON.stringify(envelope);
}
/**
* .fm 信封文本 → 明文 JSON 文本。
* 任何失败(版本不符/MAC 不对/解不开)都抛同款错误,由调用方转友好提示;
* 不做「密码错」与「文件坏」的区分提示,见文件头注释。
*/
export function decryptEnvelope(
fileText: string,
password: string,
factor: string,
): string {
let envelope: ExchangeEnvelope;
try {
envelope = JSON.parse(fileText) as ExchangeEnvelope;
} catch {
throw new Error("exchange-file-bad");
}
if (
!envelope ||
envelope.V !== ENVELOPE_VERSION ||
!envelope.Salt ||
!envelope.Iv ||
!envelope.Ct ||
!envelope.Mac
) {
throw new Error("exchange-file-bad");
}
const salt = CryptoJS.enc.Base64.parse(envelope.Salt);
const iv = CryptoJS.enc.Base64.parse(envelope.Iv);
const ct = CryptoJS.enc.Base64.parse(envelope.Ct);
const { encKey, macKey } = deriveKeys(password, factor, salt);
const expectMac = CryptoJS.enc.Base64.stringify(
computeMac(macKey, salt, iv, ct),
);
if (expectMac !== envelope.Mac) {
throw new Error("exchange-file-bad");
}
const decrypted = CryptoJS.AES.decrypt(
CryptoJS.lib.CipherParams.create({ ciphertext: ct }),
encKey,
{ iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 },
);
const plainText = decrypted.toString(CryptoJS.enc.Utf8);
if (!plainText) {
throw new Error("exchange-file-bad");
}
return plainText;
}
/** UTF-8 字符串 → ArrayBuffer(.fm 落盘/上传字节;H5 与 App webview 均有 TextEncoder) */
export function stringToBytes(text: string): ArrayBuffer {
return new TextEncoder().encode(text).buffer as ArrayBuffer;
}
/** ArrayBuffer → UTF-8 字符串(读回 .fm 文本) */
export function bytesToString(bytes: ArrayBuffer): string {
return new TextDecoder().decode(bytes);
}