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

No files matched your search

@@ -11,9 +11,12 @@
import type { LoadedTreeNode } from "@/components/family-tree-chart/types";
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
import type { AdminRow } from "@/admin/admin-types";
import type { ExchangeScope } from "@/composables/useFamilyTreeExchange";
import type { FamilyTreeExchangeFileInfo } from "@/data/auto/familyTreeExchangeFileInfo";
import { useLocale } from "uview-pro";
import { computed, onMounted, ref } from "vue";
import { useThemeGradient, useThemeTokens } from "@/composables";
import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange";
import { usePersonPickConfig } from "@/composables/usePickerConfigs";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
@@ -1035,6 +1038,298 @@ async function rejectPermission(record: { PersonId: string; UserName: string }):
load();
}
// ===== 交换文件(.fm 导入导出,加密信封见 utils/familyTreeExchange)=====
const { exchangeBusy, exportTree, listFiles, decryptPayload, readAndDecrypt, importTree, importSubTree, listBackups, restoreBackup } = useFamilyTreeExchange();
const exportVisible = ref(false);
const importVisible = ref(false);
const exScope = ref<ExchangeScope>("ALL");
const exPassword = ref("");
const exSaveLocal = ref(true);
const exUploadServer = ref(true);
const importFiles = ref<FamilyTreeExchangeFileInfo[]>([]);
const importFilesLoading = ref(false);
const pickedFileName = ref("");
const imPassword = ref("");
// 二期:导入模式(ALL=整谱替换 / SUB=支系替换,与所选文件范围必须同构)
const imScope = ref<ExchangeScope>("ALL");
/** 小系导出闸:管理员任意锚点(D8),成员只限可编支系内的当前族员 */
const canSubExport = computed(() => data.value.IsManager || (data.value.IsCanAmend && !!currentPerson.value?.PersonId));
const canExport = computed(() => data.value.IsManager || data.value.IsMember);
function openExchangeExport(): void {
exScope.value = "ALL";
exPassword.value = "";
exSaveLocal.value = true;
exUploadServer.value = true;
exportVisible.value = true;
}
function onExSaveChange(value: boolean): void {
exSaveLocal.value = value;
}
function onExUploadChange(value: boolean): void {
exUploadServer.value = value;
}
/** 导出全链:拉明文→加密→落盘/上传(composable 内部完成),成功报文件名 */
async function runExchangeExport(): Promise<void> {
if (exPassword.value.length < 4) {
toast(t("FamilyTreePage.exchangePwdShort"));
return;
}
if (!exSaveLocal.value && !exUploadServer.value) {
toast(t("FamilyTreePage.exchangeTargetTip"));
return;
}
if (exScope.value === "SUB" && !canSubExport.value) {
toast(t("FamilyTreePage.exchangeSubDenied"));
return;
}
try {
const fileName = await exportTree({
treeId: treeId.value,
scope: exScope.value,
anchorPersonId: currentPerson.value?.PersonId,
password: exPassword.value,
saveLocal: exSaveLocal.value,
uploadServer: exUploadServer.value,
});
exportVisible.value = false;
toast(t("FamilyTreePage.exchangeExportDone").replace("{name}", fileName));
}
catch (error: any) {
toast(error?.message === "exchange-export-empty"
? t("FamilyTreePage.exchangeExportEmpty")
: error?.message || t("FamilyTreePage.exchangeFailed"));
}
}
async function openExchangeImport(): Promise<void> {
// 非管理员看不到整谱替换 chip(D4 管理员限),默认落在小系替换
imScope.value = data.value.IsManager ? "ALL" : "SUB";
pickedFileName.value = "";
imPassword.value = "";
bkMode.value = false;
pickedBackup.value = "";
clearLocalFile();
importVisible.value = true;
await loadImportFiles();
}
/** 模式切换:换范围重拉列表,已选文件清空防串模式 */
async function onImScopeChange(scope: ExchangeScope): Promise<void> {
if (imScope.value === scope) {
return;
}
imScope.value = scope;
pickedFileName.value = "";
await loadImportFiles();
}
/** 交换目录里本谱对应模式的文件(后端已降序,composable 再按 Scope 过滤) */
async function loadImportFiles(): Promise<void> {
importFilesLoading.value = true;
try {
importFiles.value = await listFiles(treeId.value, imScope.value);
}
catch {
importFiles.value = [];
}
finally {
importFilesLoading.value = false;
}
}
// 三期备份还原(R4):导入弹窗第三个 chips;BK 明文列表不需密码,选中即待还原回滚点
const bkMode = ref(false);
const backupFiles = ref<FamilyTreeExchangeFileInfo[]>([]);
const backupLoading = ref(false);
const pickedBackup = ref("");
/** 回文件模式(ALL/SUB):退 bkMode,选中文件保留(同模式切回不丢进度) */
function pickImScope(scope: ExchangeScope): void {
bkMode.value = false;
void onImScopeChange(scope);
}
async function enterBkMode(): Promise<void> {
if (bkMode.value) {
return;
}
bkMode.value = true;
pickedFileName.value = "";
clearLocalFile();
if (!backupFiles.value.length) {
await fetchBackups();
}
}
async function fetchBackups(): Promise<void> {
backupLoading.value = true;
try {
backupFiles.value = await listBackups(treeId.value);
}
catch {
backupFiles.value = [];
}
finally {
backupLoading.value = false;
}
}
/** 还原执行:二次确认(整谱回滚+重编号+清绑定说清)→ 后端回放 BK(还原前自动再备份现谱) */
async function runRestoreBackup(): Promise<void> {
if (!pickedBackup.value) {
toast(t("FamilyTreePage.exchangePickBackupTip"));
return;
}
const bk = backupFiles.value.find(x => x.FileName === pickedBackup.value);
const confirmResult = await new Promise<boolean>((resolve) => {
uni.showModal({
title: t("FamilyTreePage.exchangeRestoreConfirmTitle"),
content: t("FamilyTreePage.exchangeRestoreConfirm").replace("{time}", formatFileTime(bk?.LastModified ?? "")),
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
if (!confirmResult) {
return;
}
try {
const count = await restoreBackup(treeId.value, pickedBackup.value);
importVisible.value = false;
toast(t("FamilyTreePage.exchangeRestoreDone").replace("{count}", String(count)));
data.value = new FamilyTreeData(); // 还原后全员重编号,清旧上下文再重拉
await load();
}
catch (error: any) {
toast(error?.message || t("FamilyTreePage.exchangeFailed"));
}
}
// 本地上传通道:A 谱导出的 .fm 在任意谱界面直接选中导入(跨谱嫁接,不经服务器交换目录)
const localFileText = ref("");
const localFileName = ref("");
function clearLocalFile(): void {
localFileText.value = "";
localFileName.value = "";
}
/** uni.chooseFile 选本地 .fm 并读文本(H5 原生 File.text / App FileSystemManager),与服务器列表通道互斥 */
async function pickLocalFile(): Promise<void> {
try {
const picked: any = await new Promise((resolve, reject) => {
uni.chooseFile({ count: 1, extension: [".fm"], success: resolve, fail: reject });
});
const file = picked.tempFiles?.[0];
const name = String(file?.name || picked.tempFilePaths?.[0]?.split("/").pop() || "exchange.fm");
let text = "";
// #ifdef H5
text = await file.text();
// #endif
// #ifndef H5
const res: any = await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath: picked.tempFilePaths[0],
encoding: "utf8",
success: resolve,
fail: reject,
});
});
text = String(res.data ?? "");
// #endif
if (!text.trim()) {
toast(t("FamilyTreePage.exchangeLocalBad"));
return;
}
localFileText.value = text;
localFileName.value = name;
pickedFileName.value = "";
}
catch (e: any) {
// 用户取消不算错(errMsg 含 cancel),其余给统一读失败提示
if (!String(e?.errMsg ?? e ?? "").includes("cancel")) {
toast(t("FamilyTreePage.exchangeLocalBad"));
console.warn("[familyTreeExchange] choose local file failed:", e);
}
}
}
/** 导入三步合一:验文件(服务器/本地两路)+验密码 → 破坏性行为二次确认 → 按模式执行(整谱替换/支系替换) */
async function runExchangeImport(): Promise<void> {
if (!pickedFileName.value && !localFileName.value) {
toast(t("FamilyTreePage.exchangePickFileTip"));
return;
}
if (imPassword.value.length < 4) {
toast(t("FamilyTreePage.exchangePwdShort"));
return;
}
let payload;
let preview;
let scope;
try {
if (localFileName.value) {
// 本地文件:模式自动识别(payload.Scope 说了算,支持跨谱嫁接),chips 跟随文件
({ payload, preview, scope } = await decryptPayload(localFileText.value, imPassword.value, null));
imScope.value = scope;
}
else {
({ payload, preview } = await readAndDecrypt(pickedFileName.value, imPassword.value, imScope.value));
scope = imScope.value;
}
}
catch (error: any) {
toast(error?.message === "exchange-scope-mismatch"
? t("FamilyTreePage.exchangeScopeMismatch")
: error?.message === "exchange-legacy-sub"
? t("FamilyTreePage.exchangeLegacySub")
: t("FamilyTreePage.exchangeDecryptFailed"));
return;
}
// 支系替换的锚点口径(P2):与当前族员对比,姓+谱名同一人才同意;本地 SUB 文件解密后才知道要锚点
if (scope === "SUB" && !currentPerson.value?.PersonId) {
toast(t("FamilyTreePage.exchangeSubAnchorMissing"));
return;
}
// 二次确认:ALL 按同谱/跨谱区分——同谱(含本谱服务器文件)编号与绑定原样保留,跨谱嫁接才清绑定重编号;SUB 说清只动哪根枝(备份都在后端自动落盘可回滚)
const sameTree = String(payload.FamilyTree?.TreeId ?? "").toUpperCase() === String(treeId.value).toUpperCase();
const confirmResult = await new Promise<boolean>((resolve) => {
uni.showModal({
title: t(scope === "SUB" ? "FamilyTreePage.exchangeImportConfirmSubTitle" : "FamilyTreePage.exchangeImportConfirmTitle"),
content: scope === "SUB"
? t("FamilyTreePage.exchangeImportConfirmSub")
.replace("{tree}", preview.TreeName)
.replace("{count}", String(preview.PersonCount))
.replace("{anchor}", currentPersonName.value || "-")
: t(sameTree ? "FamilyTreePage.exchangeImportConfirmSame" : "FamilyTreePage.exchangeImportConfirm")
.replace("{tree}", preview.TreeName)
.replace("{count}", String(preview.PersonCount))
.replace("{time}", preview.ExportedAt),
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
if (!confirmResult) {
return;
}
try {
const count = scope === "SUB"
? await importSubTree(treeId.value, currentPerson.value!.PersonId, payload)
: await importTree(treeId.value, payload);
importVisible.value = false;
toast(t("FamilyTreePage.exchangeImportDone").replace("{count}", String(count)));
// 导入/还原后全员重编号,旧 currentPerson 已失效:先清上下文再重拉,避免带旧 Id 查询
data.value = new FamilyTreeData();
await load();
}
catch (error: any) {
toast(error?.message || t("FamilyTreePage.exchangeFailed"));
}
}
// ===== 只读文案小工具 =====
function genderText(gender?: string): string {
return getPolicyText("GenderPolicy", gender ?? "");
@@ -1160,18 +1455,24 @@ defineExpose({
<u-icon name="file-text" size="22" color="#9a8f82" />
</view>
<view class="ft-hero__badges">
<text v-if="tree.PublicIndc === 'Y'" class="ft-badge ft-badge--pub">{{ t("FamilyTreePage.chipPublic") }}</text>
<text v-if="tree.PublicIndc === 'Y'" class="ft-badge ft-badge--pub">{{
t("FamilyTreePage.chipPublic") }}</text>
<text v-else class="ft-badge ft-badge--priv">{{ t("FamilyTreePage.chipPrivate") }}</text>
<!-- 公开开关属谱级设置:仅管理员可见可操作,成员/普通用户只读徽章 -->
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40" :active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag") }}</text>
<u-switch v-if="data.IsManager" :model-value="tree.PublicIndc === 'Y'" size="40"
:active-color="primary" @change="onPublicToggle" />
<text v-if="data.IsManager" class="ft-badge ft-badge--admin">{{ t("FamilyTreePage.managerTag")
}}</text>
</view>
</view>
</view>
<view class="ft-hero__stats">
<view class="ft-stat"><text class="ft-stat__n">{{ memberCount }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statMembers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ managerPermissions.length }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statManagers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ pendingPermissions.length }}</text><text class="ft-stat__l">{{ t("FamilyTreePage.statPending") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ memberCount }}</text><text class="ft-stat__l">{{
t("FamilyTreePage.statMembers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ managerPermissions.length }}</text><text
class="ft-stat__l">{{ t("FamilyTreePage.statManagers") }}</text></view>
<view class="ft-stat"><text class="ft-stat__n">{{ pendingPermissions.length }}</text><text
class="ft-stat__l">{{ t("FamilyTreePage.statPending") }}</text></view>
</view>
</view>
@@ -1181,12 +1482,14 @@ defineExpose({
<text>{{ t("FamilyTreePage.tabOverview") }}</text>
</view>
<!-- 成员 tab:管理员与已绑定成员可见;普通用户仅概览(查看公开信息 + 两谱图) -->
<view v-if="data.IsManager || data.IsMember" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'members' }" @tap="switchTab('members')">
<view v-if="data.IsManager || data.IsMember" class="ft-tab"
:class="{ 'ft-tab--on': activeTab === 'members' }" @tap="switchTab('members')">
<text>{{ t("FamilyTreePage.tabMembers") }}</text>
<text class="ft-tab__cnt">{{ memberCount }}</text>
</view>
<!-- 权限 tab:审批/管理名单属全谱人事权,仅管理员可见 -->
<view v-if="data.IsManager" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'perms' }" @tap="switchTab('perms')">
<view v-if="data.IsManager" class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'perms' }"
@tap="switchTab('perms')">
<text>{{ t("FamilyTreePage.tabPerms") }}</text>
<view v-if="pendingPermissions.length" class="ft-tab__dot" />
</view>
@@ -1206,19 +1509,24 @@ defineExpose({
<view v-if="loaded" class="ft-card">
<view class="ft-card__head">
<text class="ft-card__title">{{ t("FamilyTreePage.ancestorInfo") }}</text>
<text v-if="ancestorPerson && data.IsCanAmend" class="ft-card__link" @tap="openEditPerson(ancestorPerson.PersonId)">{{ t("FamilyTreePage.edit") }}</text>
<text v-if="ancestorPerson && data.IsCanAmend" class="ft-card__link"
@tap="openEditPerson(ancestorPerson.PersonId)">{{ t("FamilyTreePage.edit") }}</text>
<!-- 没始祖时补设属谱级动作:挂始祖只跟管理员,成员支系内可编辑已有始祖档案但不可改挂 -->
<text v-else-if="!ancestorPerson && data.IsManager" class="ft-card__link" @tap="openQuickCreate('ancestor')">{{ t("FamilyTreePage.addAncestor") }}</text>
<text v-else-if="!ancestorPerson && data.IsManager" class="ft-card__link"
@tap="openQuickCreate('ancestor')">{{ t("FamilyTreePage.addAncestor") }}</text>
</view>
<template v-if="ancestorPerson">
<view class="person-hero">
<view class="person-hero__avatar" :style="avatarFallbackStyle(!!ancestorPerson.AvatarUrl, ancestorPerson.Gender)">
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img" :src="ancestorPerson.AvatarUrl" mode="aspectFill" />
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!ancestorPerson.AvatarUrl, ancestorPerson.Gender)">
<image v-if="ancestorPerson.AvatarUrl" class="person-hero__img" :src="ancestorPerson.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(ancestorPerson.Gender)" />
</view>
<view class="person-hero__main">
<view class="person-hero__line">
<text class="person-hero__name">{{ `${ancestorPerson.Surname ?? ""}${ancestorPerson.GenealogyName ?? ""}` || "-" }}</text>
<text class="person-hero__name">{{ `${ancestorPerson.Surname ??
""}${ancestorPerson.GenealogyName ?? ""}` || "-" }}</text>
<text class="person-hero__gender">{{ genderText(ancestorPerson.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
@@ -1227,20 +1535,31 @@ defineExpose({
</view>
</view>
<view class="ft-rows">
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.genealogyName") }}</text><text class="ft-row__v">{{ ancestorPerson.GenealogyName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.commonName") }}</text><text class="ft-row__v">{{ ancestorPerson.CommonName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.genealogyName") }}</text><text
class="ft-row__v">{{ ancestorPerson.GenealogyName || "-" }}</text></view>
<view class="ft-row"><text class="ft-row__k">{{ t("FamilyPersonPage.commonName") }}</text><text
class="ft-row__v">{{ ancestorPerson.CommonName || "-" }}</text></view>
</view>
</template>
<view v-else class="ft-empty"><text>{{ t("FamilyTreePage.noAncestor") }}</text></view>
</view>
<view class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.quickActions") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.quickActions") }}</text>
</view>
<view class="ft-quick">
<!-- 编辑资料改的是族谱本体(谱级):仅管理员;普通用户/成员不可见 -->
<view v-if="data.IsManager" class="ft-qa" @tap="openTreeEditor"><u-icon name="edit-pen" size="30" :color="primary" /><text>{{ t("FamilyTreePage.editProfile") }}</text></view>
<view v-if="data.IsManager" class="ft-qa" @tap="openTreeEditor"><u-icon name="edit-pen" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.editProfile") }}</text></view>
<!-- 申请管理三档都可用(已是管理员时点击只提示不重复发) -->
<view class="ft-qa" @tap="requestManager"><u-icon name="server-man" size="30" :color="primary" /><text>{{ t("FamilyTreePage.applyManage") }}</text></view>
<view class="ft-qa" @tap="requestManager"><u-icon name="server-man" size="30"
:color="primary" /><text>{{
t("FamilyTreePage.applyManage") }}</text></view>
<!-- 交换文件:导出需成员及以上(D8 管理员不限);导入入口同样成员可见(小系替换 P6 / 备份还原 R1 均已开成员闸),仅整谱替换留管理员 -->
<view v-if="canExport" class="ft-qa" @tap="openExchangeExport"><u-icon name="download" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.exchangeExportEntry") }}</text></view>
<view v-if="canExport" class="ft-qa" @tap="openExchangeImport"><u-icon name="upload" size="30"
:color="primary" /><text>{{ t("FamilyTreePage.exchangeImportEntry") }}</text></view>
</view>
</view>
</view>
@@ -1249,10 +1568,13 @@ defineExpose({
<view v-show="activeTab === 'members'" class="ft-pane">
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
<view v-if="loaded && currentPerson" class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
}}</text></view>
<view class="person-hero">
<view class="person-hero__avatar" :style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl" mode="aspectFill" />
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img" :src="currentPerson.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
</view>
<view class="person-hero__main">
@@ -1261,108 +1583,197 @@ defineExpose({
<text class="person-hero__gender">{{ genderText(currentPerson?.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson) }}</text>
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
}}</text>
</view>
</view>
</view>
<view class="op-btns">
<view v-if="currentPerson?.PersonId && data.IsCanAmend" class="op-btn" @tap="openEditPerson(currentPerson.PersonId)"><u-icon name="edit-pen" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyPersonPage.editBut") }}</text></view>
<view class="op-btn" @tap="pickCurrentPerson"><u-icon name="search" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.personSearch") }}</text></view>
<view class="op-btn" @tap="subChartVisible = true"><view class="ft-glyph ft-glyph--sub" /><text class="op-btn-text">{{ t("FamilyTreePage.pedigreeChart") }}</text></view>
<view v-if="currentPerson?.PersonId && data.IsCanAmend" class="op-btn"
@tap="openEditPerson(currentPerson.PersonId)"><u-icon name="edit-pen" size="26"
:color="primary" /><text class="op-btn-text">{{ t("FamilyPersonPage.editBut") }}</text></view>
<view class="op-btn" @tap="pickCurrentPerson"><u-icon name="search" size="26"
:color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.personSearch") }}</text>
</view>
<view class="op-btn" @tap="subChartVisible = true">
<view class="ft-glyph ft-glyph--sub" /><text class="op-btn-text">{{
t("FamilyTreePage.pedigreeChart") }}</text>
</view>
<!-- 绑定/解绑开关:当前族员已绑到自己权限行时显「解除绑定」,否则显「绑定账号」(需已过审且同名;申请入口仍在概览快捷区/底部条) -->
<view v-if="!data.IsManager && isSelfBound" class="op-btn" @tap="unbindCurrentPerson"><u-icon name="close" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.unbindAccount") }}</text></view>
<view v-else-if="!data.IsManager" class="op-btn" @tap="bindCurrentPerson"><u-icon name="scan" size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.bindAccount") }}</text></view>
<view v-if="!data.IsManager && isSelfBound" class="op-btn" @tap="unbindCurrentPerson"><u-icon
name="close" size="26" :color="primary" /><text class="op-btn-text">{{
t("FamilyTreePage.unbindAccount") }}</text></view>
<view v-else-if="!data.IsManager" class="op-btn" @tap="bindCurrentPerson"><u-icon name="scan"
size="26" :color="primary" /><text class="op-btn-text">{{ t("FamilyTreePage.bindAccount")
}}</text></view>
</view>
</view>
<view class="ft-search"><search-input v-model="memberKeyword" :placeholder="t('FamilyTreePage.searchMember')" /></view>
<view class="ft-search"><search-input v-model="memberKeyword"
:placeholder="t('FamilyTreePage.searchMember')" />
</view>
<view class="ft-chips">
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'all' }" @tap="memberFilter = 'all'">{{ t("FamilyTreePage.filterAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'male' }" @tap="memberFilter = 'male'">{{ t("FamilyTreePage.filterMale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'female' }" @tap="memberFilter = 'female'">{{ t("FamilyTreePage.filterFemale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'all' }"
@tap="memberFilter = 'all'">{{
t("FamilyTreePage.filterAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'male' }"
@tap="memberFilter = 'male'">{{
t("FamilyTreePage.filterMale") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': memberFilter === 'female' }"
@tap="memberFilter = 'female'">{{
t("FamilyTreePage.filterFemale") }}</text>
</view>
<view class="ft-card">
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle") }}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view><view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view></view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.spouseTitle")
}}</text><text class="ft-card__count">{{ spouseMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('spouse')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addSpouse") }}</text></view>
</view>
<view v-if="!visibleSpouses.length" class="ft-empty"><text>{{ t("FamilyTreePage.spouseEmpty")
}}</text></view>
<view v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
<view class="ft-av" :style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="ft-av__img" :src="personAvatar(record.SpouseId)" mode="aspectFill" />
<u-icon v-else name="account" size="40" :color="genderTheme(personGender(record.SpouseId, record.Gender))" />
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="ft-av__img"
:src="personAvatar(record.SpouseId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.SpouseId, record.Gender))" />
</view>
<view class="ft-mrow__main">
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.SpouseSurname ?? ""}${record.SpouseGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{ genderText(personGender(record.SpouseId, record.Gender)) }}</text></view>
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.SpouseSurname ??
""}${record.SpouseGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{
genderText(personGender(record.SpouseId, record.Gender)) }}</text></view>
<text class="ft-mrow__code">{{ personCommonName(record.SpouseId) || record.SpouseId }}</text>
</view>
<!-- 行尾直达动作(旧编辑态式):↑↓ 调序属锚点关系列表维护留锚点级;编辑/解除按目标人逐个判,旁支(如兄弟)不显示 -->
<view v-if="data.IsCanAmend" class="ft-mrow__ops" @tap.stop>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, -1)"><u-icon name="arrow-up" size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, 1)"><u-icon name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op" @tap.stop="openEditPerson(record.SpouseId)"><u-icon name="edit-pen" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op" @tap.stop="onRelationRemove('spouse', record)"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, -1)"><u-icon name="arrow-up"
size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('spouse', record, 1)"><u-icon
name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op"
@tap.stop="openEditPerson(record.SpouseId)">
<u-icon name="edit-pen" size="28" :color="primary" />
</view>
<view v-if="canAmendPerson(record.SpouseId)" class="ft-mrow__op"
@tap.stop="onRelationRemove('spouse', record)">
<u-icon name="trash" size="28" color="#f0413e" />
</view>
</view>
</view>
</view>
<view class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle") }}</text><text class="ft-card__count">{{ childMaps.length }}</text></view><view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text></view></view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.childTitle")
}}</text><text class="ft-card__count">{{ childMaps.length }}</text></view>
<view v-if="data.IsCanAmend" class="ft-card__add" @tap="openQuickCreate('child')"><u-icon
name="plus" size="26" :color="primary" /><text>{{ t("FamilyTreePage.addChildBtn") }}</text>
</view>
</view>
<view v-if="!visibleChildren.length" class="ft-empty"><text>{{ t("FamilyTreePage.childEmpty")
}}</text></view>
<view v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
<view class="ft-av" :style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="ft-av__img" :src="personAvatar(record.ChildrenId)" mode="aspectFill" />
<u-icon v-else name="account" size="40" :color="genderTheme(personGender(record.ChildrenId, record.Gender))" />
<view class="ft-av"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="ft-av__img"
:src="personAvatar(record.ChildrenId)" mode="aspectFill" />
<u-icon v-else name="account" size="40"
:color="genderTheme(personGender(record.ChildrenId, record.Gender))" />
</view>
<view class="ft-mrow__main">
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.ChildrenSurname ?? ""}${record.ChildrenGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{ genderText(personGender(record.ChildrenId, record.Gender)) }}</text></view>
<view class="ft-mrow__line"><text class="ft-mrow__name">{{ `${record.ChildrenSurname ??
""}${record.ChildrenGenealogyName ?? ""}` }}</text><text class="ft-mrow__sex">{{
genderText(personGender(record.ChildrenId, record.Gender)) }}</text></view>
<text class="ft-mrow__code">{{ personCommonName(record.ChildrenId) || record.ChildrenId }}</text>
</view>
<view v-if="data.IsCanAmend" class="ft-mrow__ops" @tap.stop>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, -1)"><u-icon name="arrow-up" size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, 1)"><u-icon name="arrow-down" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op" @tap.stop="openEditPerson(record.ChildrenId)"><u-icon name="edit-pen" size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op" @tap.stop="onRelationRemove('child', record)"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, -1)"><u-icon name="arrow-up"
size="28" :color="primary" /></view>
<view class="ft-mrow__op" @tap.stop="onRelationMove('child', record, 1)"><u-icon name="arrow-down"
size="28" :color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op"
@tap.stop="openEditPerson(record.ChildrenId)"><u-icon name="edit-pen" size="28"
:color="primary" /></view>
<view v-if="canAmendPerson(record.ChildrenId)" class="ft-mrow__op"
@tap.stop="onRelationRemove('child', record)"><u-icon name="trash" size="28" color="#f0413e" />
</view>
</view>
</view>
</view>
<view class="ft-ghost" @tap="fullChartVisible = true"><view class="ft-glyph ft-glyph--full" /><text>{{ t("FamilyTreePage.viewFullChart") }}</text></view>
<view class="ft-ghost" @tap="fullChartVisible = true">
<view class="ft-glyph ft-glyph--full" /><text>{{ t("FamilyTreePage.viewFullChart") }}</text>
</view>
</view>
<!-- ===== 权限 ===== -->
<view v-show="activeTab === 'perms'" class="ft-pane">
<view v-if="data.IsManager && pendingPermissions.length" class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending") }}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permPending")
}}</text><text class="ft-card__count">{{ pendingPermissions.length }}</text></view>
</view>
<view v-for="record in pendingPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName || record.UserName }}</text><text v-if="record.PersonLocalName" class="ft-perm-row__sub">{{ record.PersonLocalName }}</text></view>
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
}}</text><text v-if="record.PersonLocalName" class="ft-perm-row__sub">{{ record.PersonLocalName
}}</text>
</view>
<view class="ft-perm-ops">
<view class="ft-perm-op" @tap="approvePermission(record, 'N')"><text>{{ t("FamilyTreePage.approve") }}</text></view>
<view class="ft-perm-op" @tap="approvePermission(record, 'Y')"><text>{{ t("FamilyTreePage.setManager") }}</text></view>
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text class="ft-perm-op__t--danger">{{ t("FamilyTreePage.reject") }}</text></view>
<view class="ft-perm-op" @tap="approvePermission(record, 'N')"><text>{{
t("FamilyTreePage.approve") }}</text>
</view>
<view class="ft-perm-op" @tap="approvePermission(record, 'Y')"><text>{{
t("FamilyTreePage.setManager") }}</text>
</view>
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text
class="ft-perm-op__t--danger">{{ t("FamilyTreePage.reject") }}</text></view>
</view>
</view>
</view>
<view v-if="data.IsManager" class="ft-card">
<view class="ft-card__head"><view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager") }}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view></view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty") }}</text></view>
<view class="ft-card__head">
<view class="ft-card__title-group"><text class="ft-card__title">{{ t("FamilyTreePage.permManager")
}}</text><text class="ft-card__count">{{ managerPermissions.length }}</text></view>
</view>
<view v-if="!managerPermissions.length" class="ft-empty"><text>{{ t("FamilyTreePage.permEmpty")
}}</text></view>
<view v-for="record in managerPermissions" :key="record.UserName" class="ft-perm-row">
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName || record.UserName }}</text><text class="ft-perm-row__sub">{{ record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag") }}</text></view>
<view class="ft-perm-ops"><view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text class="ft-perm-op__t--danger">{{ t("FamilyTreePage.remove") }}</text></view></view>
<view class="ft-perm-row__main"><text class="ft-perm-row__name">{{ record.UserLocalName ||
record.UserName
}}</text><text class="ft-perm-row__sub">{{ record.ManagerIndc === "Y" ?
t("FamilyTreePage.managerTag") :
t("FamilyTreePage.memberTag") }}</text></view>
<view class="ft-perm-ops">
<view class="ft-perm-op ft-perm-op--danger" @tap="rejectPermission(record)"><text
class="ft-perm-op__t--danger">{{ t("FamilyTreePage.remove") }}</text></view>
</view>
</view>
</view>
<!-- 申请管理(旧系统同款):常驻卡——非管理员点按钮发申请,管理员显示当前状态;原邀请占位卡无后端直接取消 -->
<view class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.applyManage") }}</text></view>
<view v-if="data.IsManager" class="ft-empty"><text>{{ t("FamilyTreePage.alreadyManagerTip") }}</text></view>
<view v-else class="ft-apply" @tap="requestManager"><u-icon name="server-man" size="28" color="#ffffff" /><text>{{ t("FamilyTreePage.applyManage") }}</text></view>
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.applyManage") }}</text>
</view>
<view v-if="data.IsManager" class="ft-empty"><text>{{ t("FamilyTreePage.alreadyManagerTip") }}</text>
</view>
<view v-else class="ft-apply" @tap="requestManager"><u-icon name="server-man" size="28"
color="#ffffff" /><text>{{
t("FamilyTreePage.applyManage") }}</text></view>
</view>
<!-- 删除族谱:原「...」更多菜单整体取消后,唯一入口放权限 tab 底部(仅有修改权) -->
<view v-if="data.IsCanAmend" class="ft-card">
<view class="ft-menu-item ft-menu-item--danger" @tap="onDeleteTree">
<view class="ft-menu-item__ic"><u-icon name="trash" size="28" color="#f0413e" /></view>
<view class="ft-menu-item__main"><text class="ft-menu-item__t ft-menu-item__t--danger">{{ t("FamilyTreePage.deleteTree") }}</text><text class="ft-menu-item__d">{{ t("FamilyTreePage.deleteTreeHint") }}</text></view>
<view class="ft-menu-item__main"><text class="ft-menu-item__t ft-menu-item__t--danger">{{
t("FamilyTreePage.deleteTree") }}</text><text class="ft-menu-item__d">{{
t("FamilyTreePage.deleteTreeHint")
}}</text></view>
</view>
</view>
</view>
@@ -1374,10 +1785,16 @@ defineExpose({
<!-- 底部主操作条 -->
<view class="ft-bottombar">
<!-- 主按钮:选定当前族员(添加配偶/子女都以它为基准;建员入口在概览快捷区与关系卡头) -->
<view class="ft-bottombar__main" @tap="pickCurrentPerson"><u-icon name="account" size="30" color="#ffffff" /><text>{{ t("FamilyTreePage.selectMember") }}</text></view>
<view class="ft-bottombar__main" @tap="pickCurrentPerson"><u-icon name="account" size="30"
color="#ffffff" /><text>{{
t("FamilyTreePage.selectMember") }}</text></view>
<!-- 小系图/全系图:定制 SVG glyph(mask 随主题着色);原分享入口随邀请占位弹层一并取消 -->
<view class="ft-bottombar__icon" @tap="subChartVisible = true"><view class="ft-glyph ft-glyph--bar ft-glyph--sub" /></view>
<view class="ft-bottombar__icon" @tap="fullChartVisible = true"><view class="ft-glyph ft-glyph--bar ft-glyph--full" /></view>
<view class="ft-bottombar__icon" @tap="subChartVisible = true">
<view class="ft-glyph ft-glyph--bar ft-glyph--sub" />
</view>
<view class="ft-bottombar__icon" @tap="fullChartVisible = true">
<view class="ft-glyph ft-glyph--bar ft-glyph--full" />
</view>
</view>
</template>
@@ -1828,10 +2245,9 @@ defineExpose({
<!-- 族员选择弹窗(族员搜索 / 选配偶共用):与邀请/编辑弹层同套底部 sheet,取消/确定齐全;行样式复用成员列表同款 -->
<admin-pick-popup v-model:visible="personPickVisible"
:title="personPickMode === 'current' ? t('FamilyTreePage.personSearch') : t('FamilyTreePage.pickSpouse')"
:columns="personPickConfig.columns.value" :loader="personPickConfig.loader"
:row-key="personPickConfig.rowKey" :title-key="personPickConfig.titleKey"
:fixed-filters="personPickConfig.fixedFilters.value" @confirm="onPersonPicked"
@close="personPickVisible = false">
:columns="personPickConfig.columns.value" :loader="personPickConfig.loader" :row-key="personPickConfig.rowKey"
:title-key="personPickConfig.titleKey" :fixed-filters="personPickConfig.fixedFilters.value"
@confirm="onPersonPicked" @close="personPickVisible = false">
<template #row="{ row }">
<view class="pick-person">
<view class="ft-av" :style="avatarFallbackStyle(!!row.AvatarUrl, row.Gender)">
@@ -1868,6 +2284,144 @@ defineExpose({
<!-- 族人档案弹层已取消:始祖卡单击不再弹窗,编辑走卡头「编辑资料」文字链 -->
<!-- ===== 编辑族谱信息弹层(仅名称/始祖谱名/简介/公开四字段,关系与成员分开维护) ===== -->
<!-- ===== 交换文件导出弹层(信封加密在前端,密码只当密钥材料、后端不校验)===== -->
<u-popup :model-value="exportVisible" mode="bottom" round="24" @close="exportVisible = false">
<view class="ft-sheet">
<view class="ft-sheet__head">
<text class="ft-sheet__title">{{ t("FamilyTreePage.exchangeExportTitle") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="exportVisible = false" />
</view>
<view class="ft-sheet__form">
<view class="ft-chips">
<text class="ft-chip" :class="{ 'ft-chip--on': exScope === 'ALL' }" @tap="exScope = 'ALL'">{{
t("FamilyTreePage.exchangeScopeAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': exScope === 'SUB' }" @tap="exScope = 'SUB'">{{
t("FamilyTreePage.exchangeScopeSub") }}</text>
</view>
<text v-if="exScope === 'SUB'" class="ft-exchange__sub">{{ t("FamilyTreePage.exchangeAnchorTip") }}:{{
currentPersonName || "-" }}</text>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.exchangePwd") }}</text>
<input v-model="exPassword" class="ft-field__input" :password="true" :maxlength="50"
:placeholder="t('FamilyTreePage.exchangePwdHint')">
</view>
<text class="ft-exchange__warn">{{ t("FamilyTreePage.exchangePwdLostTip") }}</text>
<view class="ft-switch-card">
<view class="ft-switch-card__main">
<text class="ft-switch-card__t">{{ t("FamilyTreePage.exchangeSaveLocal") }}</text>
<text class="ft-switch-card__d">{{ t("FamilyTreePage.exchangeSaveLocalTip") }}</text>
</view>
<u-switch :model-value="exSaveLocal" :active-color="primary" @change="onExSaveChange" />
</view>
<view class="ft-switch-card">
<view class="ft-switch-card__main">
<text class="ft-switch-card__t">{{ t("FamilyTreePage.exchangeUploadServer") }}</text>
<text class="ft-switch-card__d">{{ t("FamilyTreePage.exchangeUploadServerTip") }}</text>
</view>
<u-switch :model-value="exUploadServer" :active-color="primary" @change="onExUploadChange" />
</view>
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="exportVisible = false"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="runExchangeExport"><text>{{ exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeExportBut") }}</text></view>
</view>
</view>
</u-popup>
<!-- ===== 交换文件导入弹层(一期仅全系整谱替换,二次确认里说清破坏性行为)===== -->
<u-popup :model-value="importVisible" mode="bottom" round="24" @close="importVisible = false">
<view class="ft-sheet">
<view class="ft-sheet__head">
<text class="ft-sheet__title">{{ t("FamilyTreePage.exchangeImportTitle") }}</text>
<view class="ft-exchange__head-ops">
<u-icon name="reload" size="32" color="#8f959e" @click="bkMode ? fetchBackups() : loadImportFiles()" />
<u-icon name="close" size="32" color="#8f959e" @click="importVisible = false" />
</view>
</view>
<view class="ft-sheet__form">
<!-- 导入模式:整谱替换(ALL)/ 支系替换(SUB)/ 备份还原(BK 明文列表,不需密码) -->
<view class="ft-chips">
<text v-if="data.IsManager" class="ft-chip" :class="{ 'ft-chip--on': imScope === 'ALL' && !bkMode }"
@tap="pickImScope('ALL')">{{ t("FamilyTreePage.exchangeScopeAll") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': imScope === 'SUB' && !bkMode }"
@tap="pickImScope('SUB')">{{
t("FamilyTreePage.exchangeScopeSub") }}</text>
<text class="ft-chip" :class="{ 'ft-chip--on': bkMode }" @tap="enterBkMode">{{
t("FamilyTreePage.exchangeScopeBk") }}</text>
</view>
<template v-if="!bkMode">
<text v-if="imScope === 'SUB' && !localFileName" class="ft-exchange__sub">{{
t("FamilyTreePage.exchangeSubAnchorRow") }}:{{ currentPersonName || "-" }}</text>
<!-- 本地上传通道:选中后暂时遮住服务器列表(两路互斥),点本地行可移除 -->
<view v-if="!localFileName" class="ft-exchange__row" @tap="pickLocalFile">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ t("FamilyTreePage.exchangeLocalPick") }}</text>
<text class="ft-exchange__row-sub">{{ t("FamilyTreePage.exchangeLocalPickSub") }}</text>
</view>
<u-icon name="plus" size="28" :color="primary" />
</view>
<view v-else class="ft-exchange__row ft-exchange__row--on" @tap="clearLocalFile">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ localFileName }}</text>
<text class="ft-exchange__row-sub">{{ t("FamilyTreePage.exchangeLocalTag") }}</text>
</view>
<u-icon name="close" size="28" color="#8f959e" />
</view>
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
}}</text></view>
<view v-else-if="!importFiles.length" class="ft-empty"><text>{{ t(imScope === "SUB" ?
"FamilyTreePage.exchangeFileEmptySub" : "FamilyTreePage.exchangeFileEmpty") }}</text></view>
<view v-for="file in importFiles" :key="file.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedFileName === file.FileName }"
@tap="pickedFileName = file.FileName">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ file.FileName }}</text>
<text class="ft-exchange__row-sub">{{ formatFileSize(file.Size) }} · {{
formatFileTime(file.LastModified) }}</text>
</view>
<u-icon v-if="pickedFileName === file.FileName" name="checkbox-mark" size="28" :color="primary" />
</view>
</scroll-view>
</template>
<!-- 备份还原模式:本谱 BK 列表(一年内、时刻降序),选中即待还原回滚点;限高滚动防备份多了撑爆弹层 -->
<template v-else>
<scroll-view scroll-y class="ft-exchange__list">
<view v-if="backupLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading") }}</text>
</view>
<view v-else-if="!backupFiles.length" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeBackupEmpty")
}}</text></view>
<view v-for="bk in backupFiles" :key="bk.FileName" class="ft-exchange__row"
:class="{ 'ft-exchange__row--on': pickedBackup === bk.FileName }" @tap="pickedBackup = bk.FileName">
<view class="ft-exchange__row-main">
<text class="ft-exchange__row-name">{{ formatFileTime(bk.LastModified) }}</text>
<text class="ft-exchange__row-sub">{{ bk.FileName }} · {{ formatFileSize(bk.Size) }}</text>
</view>
<u-icon v-if="pickedBackup === bk.FileName" name="checkbox-mark" size="28" :color="primary" />
</view>
</scroll-view>
</template>
<view v-if="!bkMode" class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.exchangePwd") }}</text>
<input v-model="imPassword" class="ft-field__input" :password="true" :maxlength="50"
:placeholder="t('FamilyTreePage.exchangePwdHint')">
</view>
<text class="ft-exchange__warn">{{ t(bkMode ? "FamilyTreePage.exchangeRestoreWarn" :
"FamilyTreePage.exchangeImportWarn") }}</text>
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="importVisible = false"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view v-if="!bkMode" class="ft-sheet__btn ft-sheet__btn--primary" @tap="runExchangeImport"><text>{{
exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeImportBut") }}</text></view>
<view v-else class="ft-sheet__btn ft-sheet__btn--primary" @tap="runRestoreBackup"><text>{{ exchangeBusy ?
t("FamilyTreePage.exchangeRunning") : t("FamilyTreePage.exchangeRestoreBut") }}</text></view>
</view>
</view>
</u-popup>
<u-popup :model-value="treeEditVisible" mode="bottom" round="24" @close="cancelTreeEdit">
<view class="ft-sheet">
<view class="ft-sheet__head">
@@ -1877,16 +2431,19 @@ defineExpose({
<view class="ft-sheet__form">
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.treeName") }}</text>
<input v-model="tree.TreeName" class="ft-field__input" :maxlength="50" :placeholder="t('FamilyTreePage.treeNameRequired')">
<input v-model="tree.TreeName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.treeNameRequired')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.ancestorGenealogyName") }}</text>
<input v-model="tree.AncestorPersonGenealogyName" class="ft-field__input" :maxlength="50" :placeholder="t('FamilyTreePage.ancestorGenealogyName')">
<input v-model="tree.AncestorPersonGenealogyName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.ancestorGenealogyName')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.brief") }}</text>
<view class="ft-field__area">
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="60" :placeholder="t('FamilyTreePage.noBrief')" />
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="60"
:placeholder="t('FamilyTreePage.noBrief')" />
<text class="ft-field__counter">{{ (tree.Brief || "").length }}/60</text>
</view>
</view>
@@ -1900,7 +2457,9 @@ defineExpose({
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="cancelTreeEdit"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="saveTreeEdit"><text>{{ t("AdminPage.saveBut")
}}</text>
</view>
</view>
</view>
</u-popup>
@@ -2783,7 +3342,7 @@ defineExpose({
align-items: center;
gap: 4rpx;
& + &::before {
&+&::before {
content: "";
position: absolute;
top: 6rpx;
@@ -3406,6 +3965,87 @@ defineExpose({
}
}
/* ===== 交换文件弹层专属:说明小字/警示/文件行(选中态跟主题 soft 底) ===== */
.ft-exchange__sub {
display: block;
margin-bottom: 16rpx;
font-size: 24rpx;
color: #8f959e;
}
.ft-exchange__warn {
display: block;
margin: 8rpx 0 16rpx;
font-size: 22rpx;
line-height: 1.6;
color: #f0413e;
}
.ft-exchange__head-ops {
display: flex;
align-items: center;
gap: 24rpx;
}
/* 导入/还原弹层内的文件与备份列表:限高滚动,条目再多也不撑爆弹层(密码框/警告/按钮始终可见) */
.ft-exchange__list {
max-height: 42vh;
}
/* H5 端给滚动条一个可见的细条样式(默认 overlay 条在桌面浏览器不可见,用户会以为列表被截断) */
/* #ifdef H5 */
.ft-exchange__list ::v-deep ::-webkit-scrollbar {
width: 6px;
appearance: none;
-webkit-appearance: none;
}
.ft-exchange__list ::v-deep ::-webkit-scrollbar-thumb {
border-radius: 3px;
background: rgba(128, 128, 128, 0.35);
}
.ft-exchange__list ::v-deep ::-webkit-scrollbar-track {
background: transparent;
}
/* #endif */
.ft-exchange__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 24rpx;
margin-bottom: 12rpx;
border-radius: 20rpx;
background: var(--fg-soft, rgba(0, 0, 0, 0.03));
}
.ft-exchange__row--on {
border: 2rpx solid var(--fg-primary, #12b35d);
}
.ft-exchange__row-main {
display: flex;
flex-direction: column;
gap: 6rpx;
overflow: hidden;
}
.ft-exchange__row-name {
font-size: 26rpx;
font-weight: 600;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ft-exchange__row-sub {
font-size: 22rpx;
color: #8f959e;
}
.ft-switch-card {
display: flex;
align-items: center;