ow
This commit is contained in:
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);
|
||||
}
|
||||
Reference in new issue
Block a user