Files
Docker/uniapp/youjiala/src/components/family-tree-home/family-tree-home.vue
T
2026-10-23 15:15:53 +08:00

5314 lines
187 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 族谱管理两态首页组件(由 pages/dashboard 整页拆入,页面壳只留守卫接线):
* 融合旧 family-tree-enquiry(列表/查询)与 family-tree-operation(详情/编辑/谱系图/关系维护/权限)——
* 交互照 share.vue(群管理)两态模式:列表态显 tabbar、点卡进详情(隐 tabbar,返回回列表),
* 视觉按媒体页同套(themeVars、白底 hero、分区折叠、hero 圆形动作钮)。
* 编辑权限沿用旧页口径:不做功能级权限登记,由数据级 IsManager / IsCanAmend 把关。
* 页面生命周期(onShow 守卫)留在 dashboard.vue:uni-app 页面钩子在子组件中不触发,
* 组件通过 defineExpose 的 reloadIfList 供页面在守卫回调里调用。
*/
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 type { FamilyTreeStatisticsData } from "@/data/auto/familyTreeStatisticsData";
import { StorageUsageData } from "@/data/auto/storageUsageData";
import { useLocale } from "uview-pro";
import { computed, onMounted, ref } from "vue";
import { brandGlyph } from "@/common/brand";
import { buildChunkFileName, requestBuffer, uploadFile } from "@/common/http.interceptor";
import { userTypeGlyph, userTypePillBg } from "@/common/userTypeIcons";
import { useIdCardVerify, useThemeGradient, useThemeTokens } from "@/composables";
import { useArticles } from "@/composables/useArticles";
import { formatFileSize, formatFileTime, useFamilyTreeExchange } from "@/composables/useFamilyTreeExchange";
import { usePersonPickConfig } from "@/composables/usePickerConfigs";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
import { FamilyPersonSearchCriteria } from "@/data/auto/familyPersonSearchCriteria";
import { SearchFilter } from "@/data/auto/searchFilter";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import { FamilyTreeData } from "@/data/auto/familyTreeData";
import { FamilyTreeSearchCriteria } from "@/data/auto/familyTreeSearchCriteria";
import { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
import { FamilyTreeViewEntity } from "@/data/auto/familyTreeViewEntity";
import familyPersonService from "@/service/master/familyPersonService";
import familyTreeService from "@/service/master/familyTreeService";
import dmsFileService from "@/service/master/dmsFileService";
import { useLoginProfileStore } from "@/stores";
import { pageUrl } from "@/router/routes";
import { chooseFiles } from "@/utils/chooseFiles";
import { saveFileBytes } from "@/utils/dmsBytes";
import { absoluteFileUrl, backupFileUrl } from "@/utils/fileUrl";
import { isWeixinBrowser } from "@/utils/weixin";
/** ready:组件挂载完成通知(时序口径见文件底部 onMounted 注释) */
const emit = defineEmits<{ (e: "ready"): void }>();
const { t } = useLocale();
const { themeBackground } = useThemeGradient();
// 主题派生色经 CSS 变量下发(与媒体/共享页同套):卡片渐变/徽章/主按钮/链接全部跟主题
const { primary, accentGradient, accentSoft, accentShadow } = useThemeTokens();
const themeVars = computed(() => ({
"--fg-primary": primary.value,
"--fg-gradient": accentGradient.value,
"--fg-soft": accentSoft.value,
"--fg-shadow": accentShadow.value,
}));
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
// ===== 列表态 =====
/** 族谱卡树形字形:内联 SVG(uview 图标集无树形),白线主干 + 枝头圆点,同 transferGlyph 套路 */
const treeGlyph = `data:image/svg+xml,${encodeURIComponent([
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.6' stroke-linecap='round'>",
"<path d='M24 42V12'/>",
"<path d='M24 33 15 24'/>",
"<path d='M24 26 33 17'/>",
"<path d='M24 19 17 12'/>",
"<path d='M24 19 31 12'/>",
"<circle cx='24' cy='9' r='2.6' fill='#ffffff' stroke='none'/>",
"<circle cx='14' cy='23' r='2.6' fill='#ffffff' stroke='none'/>",
"<circle cx='34' cy='16' r='2.6' fill='#ffffff' stroke='none'/>",
"<circle cx='16' cy='11' r='2.2' fill='#ffffff' stroke='none'/>",
"<circle cx='32' cy='11' r='2.2' fill='#ffffff' stroke='none'/>",
"</svg>",
].join(""))}`;
/** 族谱封面落盘目录:与后端 UploadFolderPolicy.TREE_COVER 同名(上传 FilePath 头 / 展示拼直链共用一个值) */
const TREE_COVER_FOLDER = "TreeCover";
/** 封面展示地址:库里只存物理名,按落盘口径 /FileServer/Backup/TreeCover/{ext}/{name} 拼直链(与文章封面同法) */
function treeCoverUrl(fileName?: string | null): string {
return backupFileUrl(TREE_COVER_FOLDER, fileName);
}
/** 列表卡简介截断:CSS 多行省略在换行处也会补省略号,改按字数在末尾截断,完整简介只在详情看 */
const BRIEF_MAX_LEN = 60;
function briefLine(text?: string | null): string {
const value = String(text ?? "").trim();
if (!value) {
return t("FamilyTreePage.noBrief");
}
return value.length > BRIEF_MAX_LEN ? `${value.slice(0, BRIEF_MAX_LEN)}…` : value;
}
const trees = ref<FamilyTreeViewEntity[]>([]);
const loading = ref(false);
const errorText = ref("");
const keyword = ref("");
const showSearch = ref(false);
const busy = ref(false);
const inDetail = ref(false);
/** 列表整体收起态:卡区缩成一条窄横条(伸缩钮在列表右缘) */
const listCollapsed = ref(false);
// ===== 顶部两张卡片:登录用户身份 + 网盘用量(列表态,静默拉取,失败不阻断族谱列表) =====
const loginProfile = useLoginProfileStore();
const displayName = computed(() => loginProfile.userLocalName || loginProfile.username || "");
const storage = ref<StorageUsageData>(new StorageUsageData());
/** 当前用户类型:优先异步用量快照,未就绪时回退登录档案同步值,保证首帧徽标/文字即为真实等级 */
const currentType = computed(() => storage.value.UserType || loginProfile.userType);
const userTypeText = computed(() => {
const map: Record<string, string> = {
U: "storage.typeUnauth",
N: "storage.typeNormal",
V: "storage.typeVip",
S: "storage.typeSvip",
};
return t(map[currentType.value] ?? "storage.typeNormal");
});
// 后端 file_size/额度均为 KB,formatFileSize 期望字节 → ×1024 对齐
const storageUsedText = computed(() => formatFileSize(storage.value.UsedKB * 1024));
const storageQuotaText = computed(() => storage.value.QuotaKB > 0 ? formatFileSize(storage.value.QuotaKB * 1024) : "-");
const storageOver = computed(() => storage.value.QuotaKB > 0 && storage.value.UsedKB > storage.value.QuotaKB);
/** 占比在前端按小数重算(后端 Percent 为整数会截断成 0),极小用量也能如实显示 */
const storagePercentNum = computed(() => (storage.value.QuotaKB > 0 ? (storage.value.UsedKB / storage.value.QuotaKB) * 100 : 0));
const storagePercentText = computed(() => {
const v = storagePercentNum.value;
if (v <= 0) {
return "0%";
}
if (v < 0.1) {
return "0.1%";
}
if (v < 10) {
return `${v.toFixed(1)}%`;
}
return `${Math.round(v)}%`;
});
/** 进度条宽度:按重算占比取区间;有用量但占比极小时给 2% 最小可见宽度,避免完全空条 */
const storageBarWidth = computed(() => {
const clamped = Math.min(Math.max(storagePercentNum.value, 0), 100);
const min = storage.value.UsedKB > 0 ? 2 : 0;
return `${Math.max(clamped, min)}%`;
});
/** 身份徽标字形与胶囊底色:随 UserType(U/N/V/S)切换,未认证灰、普通跟随主题、VIP 金、SVIP 紫 */
const userTypeIcon = computed(() => userTypeGlyph(currentType.value));
const userTypeBg = computed(() => userTypePillBg(currentType.value));
/** 网盘存储卡图标:内联云形 SVG(同 treeGlyph 套路),绿描边置于柔光圆内(存储卡为浅色渐变描边底) */
const cloudSvg = [
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='#2f9e6b' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'>",
"<path d='M6.8 18.5a4.3 4.3 0 0 1-.5-8.56 5.6 5.6 0 0 1 10.75-1.06A3.9 3.9 0 0 1 17.4 18.5z'/>",
"<path d='M9.5 14.2l1.9 1.9 3.6-3.9'/>",
"</svg>",
].join("");
const cloudGlyph = `data:image/svg+xml,${encodeURIComponent(cloudSvg)}`;
/** 仅 H5 的「下载 App」图标:内联下箭头 SVG(绿描边,放在「网盘存储空间」标题旁),点击从 /FileServer 下载 youjiala.apk */
const downloadSvg = [
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='#2f9e6b' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'>",
"<path d='M12 3.5v11'/>",
"<path d='M7.5 10.5l4.5 4.5 4.5-4.5'/>",
"<path d='M5 20.5h14'/>",
"</svg>",
].join("");
const downloadGlyph = `data:image/svg+xml,${encodeURIComponent(downloadSvg)}`;
// #ifdef H5
/** H5 下载 App 安装包:复用网盘 file 页同一条下载链——requestBuffer 按当前线路取字节
* (后端报错回的是 JSON,按 content-type 转成错误提示,不会跳错误页),saveFileBytes 走 blob <a download> 落盘 */
async function onDownloadApp(): Promise<void> {
// 微信内置浏览器屏蔽 <a download> 且 apk 在微信下载封禁名单里(点击静默无反应),
// 只能引导用户到系统浏览器下载
if (isWeixinBrowser()) {
uni.showModal({
title: t("storage.downloadApp"),
content: t("storage.openInBrowserTip"),
showCancel: false,
});
return;
}
uni.showLoading({ title: t("storage.downloadingApp"), mask: true });
try {
const bytes = await requestBuffer({ url: "/FileServer/youjiala.apk", method: "GET" });
await saveFileBytes({ bytes, name: "youjiala.apk" });
uni.showToast({ title: t("storage.downloadStarted"), icon: "none" });
}
catch {
// requestBuffer 内部已 notifyError 弹示失败原因,这里只收尾
}
finally {
uni.hideLoading();
}
}
// #endif
/** 未认证底部弹层:单击「未认证」徽标从底部弹出身份证认证,复用 useIdCardVerify(与身份验证页同一套流程) */
const idPopup = ref(false);
const {
imageUrl: idvImage,
uploading: idvUploading,
isVerified: idvVerified,
buttonText: idvButtonText,
onUploadIdCard,
} = useIdCardVerify(() => {
idPopup.value = false;
void fetchStorage();
});
/** 仅未认证(U)可单击弹层去认证,其余身份为只读展示 */
function onTapUserType(): void {
if (currentType.value === "U") {
idPopup.value = true;
}
}
async function fetchStorage(): Promise<void> {
try {
const res = await dmsFileService.GetMyStorageUsage();
if (res) storage.value = res;
}
catch { /* 静默:卡片留占位,不打扰族谱列表主流程 */ }
}
/** 点头像上传当前用户头像:单图 -> /DmsFile/UploadUserAvatar(后端落盘 UserAvatar 目录并回写 user_icon_img_path)-> 成功后重拉用量刷新卡片 */
const avatarUploading = ref(false);
function onUploadAvatar(): void {
if (avatarUploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `avatar-${Date.now()}.png`);
if (!filePath) {
return;
}
try {
avatarUploading.value = true;
await uploadFile({
url: "/DmsFile/UploadUserAvatar",
filePath,
name: fileName,
fileName: buildChunkFileName(fileName),
});
await fetchStorage();
uni.showToast({ title: t("storage.avatarUploadedTip"), icon: "none" });
}
catch { /* uploadFile 已统一弹错,这里只补一个失败落点 */ }
finally {
avatarUploading.value = false;
}
},
});
}
/** 关键字过滤:族谱名称/简介 前端包含匹配(列表量级小,不再走后端筛选链) */
const filteredTrees = computed(() => {
const kw = keyword.value.trim().toLowerCase();
if (!kw) {
return trees.value;
}
return trees.value.filter(
row => `${row.TreeName ?? ""}`.toLowerCase().includes(kw)
|| `${row.Brief ?? ""}`.toLowerCase().includes(kw),
);
});
async function loadTrees(): Promise<void> {
loading.value = true;
errorText.value = "";
try {
const criteria = new FamilyTreeSearchCriteria();
criteria.GridColumns = [];
criteria.orderby = FamilyTreeViewEntity.DB_TREE_NAME;
// 分页字段必须显式给正数:criteria 默认 page=0/limit=0,后端按 limit=0 返回空集
criteria.page = 1;
criteria.limit = 999;
const result = await familyTreeService.FindBySearchCriteria(criteria);
trees.value = result?.Datas ?? [];
}
catch (error: any) {
trees.value = [];
errorText.value = error?.message || t("FilePage.loadFailed");
}
finally {
loading.value = false;
}
}
function onToggleSearch(): void {
showSearch.value = !showSearch.value;
}
function onSearch(): void {
// 关键字过滤由 filteredTrees 计算属性即时完成,确认键无需再拉接口
}
async function onRetry(): Promise<void> {
await loadTrees();
}
// ===== 有家文化传承(文章)卡片:与族谱列表并列,数据源见 composables/useArticles =====
const articlesApi = useArticles();
const articleKeyword = ref("");
const showArticleSearch = ref(false);
const articleCollapsed = ref(false);
/** 首页「+」新建弹层可见(编辑层不另开页,直接底部弹出,同相册/群编辑) */
const articleEditorVisible = ref(false);
/** 首页卡只展示最新 5 条(列表已按置顶、创建时间排序) */
const homeArticles = computed(() => articlesApi.list.value.slice(0, 5));
/** 无封面时的书卷字形:内联 SVG(同 treeGlyph 套路),白描卷轴 */
const articleGlyph = `data:image/svg+xml,${encodeURIComponent([
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48' fill='none' stroke='#ffffff' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'>",
"<path d='M10 12c0-2 1.6-3.6 3.6-3.6H30v30H13.6C11.6 38.4 10 36.8 10 34.8Z'/>",
"<path d='M30 8.4h6.4c2 0 3.6 1.6 3.6 3.6v22.8c0 2-1.6 3.6-3.6 3.6H30'/>",
"<path d='M15 16h10M15 22h10'/>",
"</svg>",
].join(""))}`;
async function loadArticles(): Promise<void> {
await articlesApi.loadList();
}
function onToggleArticleSearch(): void {
showArticleSearch.value = !showArticleSearch.value;
}
async function onArticleSearch(): Promise<void> {
await articlesApi.loadList(articleKeyword.value);
}
function onCreateArticle(): void {
// 首页「+」直接弹底部编辑层(不另开页):新建空白整包,保存走 useArticles.save
articlesApi.startCreate();
articleEditorVisible.value = true;
}
function closeArticleEditor(): void {
articleEditorVisible.value = false;
void articlesApi.cancelEdit();
}
async function onArticleSaved(): Promise<void> {
articleEditorVisible.value = false;
await loadArticles();
}
function onOpenArticle(name: string): void {
if (!name) {
return;
}
uni.navigateTo({ url: `${pageUrl("articlePage")}?name=${encodeURIComponent(name)}` });
}
// ===== 详情态数据(原 family-tree-operation 全量照搬) =====
const data = ref<FamilyTreeData>(new FamilyTreeData());
const tree = computed(() => data.value.FamilyTree);
const currentPerson = computed(() => data.value.CurrentPerson?.FamilyPerson);
/** 当前族员显示名(旧系统同款:姓+谱名连写) */
const currentPersonName = computed(() => {
const p = currentPerson.value;
return p ? `${p.Surname ?? ""}${p.GenealogyName ?? ""}` : "";
});
/** 当前族员是否就是自己权限行绑定的族员(切换「绑定账号/解除绑定」按钮) */
const isSelfBound = computed(() => {
const pid = currentPerson.value?.PersonId ?? "";
return !!pid && pid === (data.value.SelfBindPersonId ?? "");
});
/** 逐个目标人的可编判定:管理员全放行;成员按后端下发的家支集合(锚点级 IsCanAmend 盖不住关系行里的旁支,如兄弟) */
function canAmendPerson(personId: string): boolean {
return !!data.value.IsManager || (data.value.CanAmendPersonIds ?? []).includes(personId);
}
const spouseMaps = computed(() => data.value.CurrentPerson?.FamilyTreeSpouseMaps ?? []);
const childMaps = computed(() => data.value.CurrentPerson?.FamilyTreeChildrenMaps ?? []);
/**
* 关系行头像查表:Spouse/Children 视图字段不带头像,回查全族人员档案拿 AvatarUrl(谱系图 getAvatar 同款口径)。
* 后端 AvatarUrl 按请求 Host 拼绝对地址,UAT 反代未传原始 Host 时会拼成内网 IP(如 https://10.1.1.163/...),
* 一律过 absoluteFileUrl 归一(H5 锁回公网 origin,App 跟随当前线路)。
*/
function personAvatar(personId?: string): string {
if (!personId) {
return "";
}
const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[];
return absoluteFileUrl(persons.find(p => p?.PersonId === personId)?.AvatarUrl ?? "");
}
/** 谱系图同款性别判定(family-tree-chart isMale 字面一致):空性别按男兜底 */
function genderIsMale(gender?: string): boolean {
if (!gender) {
return true;
}
const label = getPolicyText("GenderPolicy", gender).toLowerCase();
return label.includes("男") || label.includes("male") || gender === "M" || gender === "1";
}
/** 关系对方性别回查:关系视图的 Gender 是记录拥有者(丈夫/父亲)的字段(谱系图配色踩过的坑),优先拿对方档案真值 */
function personGender(personId?: string, fallback?: string): string {
if (!personId) {
return fallback ?? "";
}
const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[];
return persons.find(p => p?.PersonId === personId)?.Gender || fallback || "";
}
/** 关系对方常用名回查(成员列表行副信息用):无档案或无常用名回空串 */
function personCommonName(personId?: string): string {
if (!personId) {
return "";
}
const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[];
return String(persons.find(p => p?.PersonId === personId)?.CommonName ?? "");
}
/** person-hero 芯片文案:优先常用名,未填回退成员编号(列表简化口径) */
function personChipText(person?: { CommonName?: string; PersonId?: string } | null): string {
if (!person) {
return "";
}
return person.CommonName
? `${t("FamilyPersonPage.commonName")}: ${person.CommonName}`
: `${t("FamilyPersonPage.personId")}: ${person.PersonId ?? ""}`;
}
/** 谱系图同款性别配色:男蓝 / 女粉 */
function genderTheme(gender?: string): string {
return genderIsMale(gender) ? "#1890ff" : "#eb2f96";
}
/** 无头像时的占位圆样式:白底 + 性别同色描边(谱系图同款);有头像返回空不覆盖 */
function avatarFallbackStyle(hasAvatar: boolean, gender?: string): Record<string, string> {
return hasAvatar
? {}
: { backgroundColor: "#ffffff", border: `3rpx solid ${genderTheme(gender)}` };
}
const treeId = ref("");
const loaded = ref(false);
const isEdit = ref(false);
const isCreate = ref(false);
/** 谱系图 sub 模式的懒加载缓存(personId -> 子树记录),对齐旧 loadedNodes */
const loadedNodes = ref<Record<string, LoadedTreeNode>>({});
const subChartVisible = ref(false);
const fullChartVisible = ref(false);
/** 快速建员弹层:三种用途共用一个组件实例(对齐旧 quickCreatePurpose) */
const qcVisible = ref(false);
const qcPurpose = ref<"spouse" | "child" | "ancestor" | "member">("spouse");
const qcTitle = computed(() => {
if (qcPurpose.value === "spouse") {
return t("FamilyTreePage.addSpouse");
}
if (qcPurpose.value === "child") {
return t("FamilyTreePage.addChild");
}
return qcPurpose.value === "ancestor"
? t("FamilyTreePage.addAncestor")
: t("FamilyTreePage.addMember");
});
/** 成员信息修改弹层:与新增共用 person-editor,仅 mode="edit" 并带上要载入的 personId */
const peEditVisible = ref(false);
const peEditPersonId = ref("");
/** 关系行的就地展开(编辑表单可见性),key 用配偶/孩子 PersonId */
const expandedSpouses = ref<Record<string, boolean>>({});
const expandedChildren = ref<Record<string, boolean>>({});
/** 孩子行的两个枚举编辑器:记住当前操作哪一行(同一时刻只有一个 picker 开着) */
const relationTarget = ref<FamilyTreeChildrenMapViewEntity | null>(null);
const relationPickerVisible = ref(false);
const parentTarget = ref<FamilyTreeChildrenMapViewEntity | null>(null);
const parentPickerVisible = ref(false);
const relationItems = computed(() => getPolicyOptions("RelationTypePolicy"));
const spouseOptions = computed(() =>
spouseMaps.value
.filter(item => item.SpouseId)
.map(item => ({
key: item.SpouseId,
label: `${item.SpouseSurname ?? ""}${item.SpouseGenealogyName ?? ""}`,
})),
);
const pendingPermissions = computed(() =>
(data.value.FamilyTreePermissions ?? []).filter(item => item.ValidIndc === "N"),
);
const managerPermissions = computed(() =>
(data.value.FamilyTreePermissions ?? []).filter(item => item.ValidIndc === "Y"),
);
// ===== 详情态重设计:三段式 Tab + 底部操作条 + 弹窗(仅浏览态;编辑态沿用旧内联布局) =====
/** 详情 tab:概览 / 成员 / 权限 / 统计 */
type DetailTab = "overview" | "members" | "perms" | "stats";
const activeTab = ref<DetailTab>("overview");
/** 统计 tab 聚合数据:懒加载(首次切到 stats 才拉),loadedTree 变化时复位重拉 */
const statsData = ref<FamilyTreeStatisticsData | null>(null);
const statsLoading = ref(false);
/** 各世代分布里的最大人数(图表柱按此归一化到 100%) */
const statsMaxGenerationCount = computed(() =>
(statsData.value?.PerGeneration ?? []).reduce(
(m, g) => (g.Count > m ? g.Count : m),
0,
),
);
/** 单世代条宽百分比:以最大世代人数为 100% 归一,零值给极小宽度避免不可见 */
function genBarWidth(count: number): string {
const max = statsMaxGenerationCount.value;
if (!max || count <= 0) {
return "0%";
}
return `${Math.max(2, Math.round((count / max) * 100))}%`;
}
/** 拉取本谱统计:进统计 tab 时懒加载;失败回 null 走空态 */
async function fetchStatistics(): Promise<void> {
const key = String(treeId.value ?? "").trim();
if (!key) {
statsData.value = null;
return;
}
statsLoading.value = true;
try {
statsData.value = await familyTreeService.GetStatistics(key);
}
catch (error) {
console.error(error);
statsData.value = null;
}
finally {
statsLoading.value = false;
}
}
/** 成员搜索关键字 + 性别筛选(支系无后端字段,暂不提供该 chip) */
type MemberFilter = "all" | "male" | "female";
const memberKeyword = ref("");
const memberFilter = ref<MemberFilter>("all");
/** 族人档案弹层已取消(始祖卡单击不弹窗),相关状态/函数/样式/词条已同步清理 */
/** 邀请族人弹层已随底部分享入口取消(无后端接口占位),相关函数/样式/词条已同步清理,待邀请 service 就绪再重建 */
/** 编辑族谱信息弹层:只含 名称/简介/对外公开 三字段(始祖谱名不展示,仍随快照守护),关系与成员在其他入口分开维护 */
const treeEditVisible = ref(false);
/** 打开弹层时的字段快照(含不展示的始祖谱名):取消时本地还原直改,免去重载带来的“处理中” */
const treeEditSnapshot = ref({ TreeName: "", AncestorPersonGenealogyName: "", Brief: "", PublicIndc: "N" });
/** hero 统计「位族人」:全谱总人数(FindByCurrentPerson 只下发直系小集合,切当前族员会跳变,改走搜索链 Total 全量口径) */
const treePersonCount = ref(0);
const memberCount = computed(
() =>
treePersonCount.value ||
(data.value.CurrentPerson?.FamilyTreePersons ?? []).length,
);
/** 静默拉取本谱全量族人数:limit=1 只取 Total,不占 loading;失败回 0 走直系集合兜底 */
async function fetchPersonCount(): Promise<void> {
const key = String(treeId.value ?? "").trim();
if (!key) {
treePersonCount.value = 0;
return;
}
try {
const criteria = new FamilyPersonSearchCriteria();
criteria.GridColumns = [];
criteria.page = 1;
criteria.limit = 1;
const filter = new SearchFilter();
filter.Column = FamilyPersonEntity.DB_TREE_ID;
filter.Type = SearchFilterTypePolicy.TEXT;
filter.Operator = SearchFilterOperatorPolicy.EQUAL_TO;
filter.Value = key;
criteria.Filter = [filter];
const result = await familyPersonService.FindBySearchCriteria(criteria);
treePersonCount.value = result?.Total ?? 0;
}
catch {
treePersonCount.value = 0;
}
}
/** 弹层里添加配偶/子女入口保留在关系卡卡头(行内直达动作),不再设档案弹层 */
/** 始祖档案(概览始祖信息卡):按 AncestorPersonId 回查,无则回 null */
const ancestorPerson = computed(() => {
const id = tree.value.AncestorPersonId;
if (!id) {
return null;
}
const persons = (data.value.CurrentPerson?.FamilyTreePersons ?? []) as any[];
return persons.find(p => p?.PersonId === id) ?? null;
});
/** 成员页搜索/筛选后的配偶、孩子可见行(命中判定走 matchMember) */
const visibleSpouses = computed(() => spouseMaps.value.filter(
item => matchMember(
`${item.SpouseSurname ?? ""}${item.SpouseGenealogyName ?? ""}`,
personGender(item.SpouseId, item.Gender),
item.SpouseId ?? "",
),
));
const visibleChildren = computed(() => childMaps.value.filter(
item => matchMember(
`${item.ChildrenSurname ?? ""}${item.ChildrenGenealogyName ?? ""}`,
personGender(item.ChildrenId, item.Gender),
item.ChildrenId ?? "",
),
));
function switchTab(tab: DetailTab): void {
activeTab.value = tab;
// 统计 tab 首次进入(或换谱后数据为空)懒加载一次,后续复用缓存
if (tab === "stats" && !statsData.value && !statsLoading.value) {
void fetchStatistics();
}
}
/** 成员行是否命中当前搜索 + 性别筛选(name 传姓+谱名连写,personId 供编号检索) */
function matchMember(name: string, gender: string, personId: string): boolean {
if (memberFilter.value === "male" && !genderIsMale(gender)) {
return false;
}
if (memberFilter.value === "female" && genderIsMale(gender)) {
return false;
}
const kw = memberKeyword.value.trim().toLowerCase();
if (!kw) {
return true;
}
return `${name}`.toLowerCase().includes(kw) || `${personId}`.toLowerCase().includes(kw);
}
/** 快捷区「添加族人」入口已取消(建员走关系卡头添加配偶/子女);openAddMember 随之删除 */
/** 复制文本(族谱编号 / 邀请链接) */
function copyText(value: string): void {
if (!value) {
return;
}
uni.setClipboardData({ data: value });
}
/** 浏览态直接切换对外公开:改动即落库(旧内联编辑靠保存钮,此处无保存条故即时 Update) */
async function onPublicToggle(value: boolean): Promise<void> {
if (!data.value.IsCanAmend) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
tree.value.PublicIndc = value ? "Y" : "N";
sanitizeTreePayload();
await withBusy(async () => {
await familyTreeService.Update(data.value);
toast(t("AdminPage.savedTip"));
});
}
/** 点封面上传族谱封面(与首页头像同款交互):单图 -> /DmsFile/UploadFile(FilePath 头 TreeCover,上传即落盘)
* -> 物理名写回 TreeCoverFilename 后马上 Update 落库,不需再点保存 */
const coverUploading = ref(false);
function onUploadTreeCover(): void {
if (!loaded.value) {
return;
}
if (!data.value.IsCanAmend) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
if (coverUploading.value) {
return;
}
uni.chooseImage({
count: 1,
success: async (res: any) => {
const filePath = String(res?.tempFilePaths?.[0] ?? "");
const fileName = String(res?.tempFiles?.[0]?.name || `cover-${Date.now()}.png`);
if (!filePath) {
return;
}
try {
coverUploading.value = true;
const physicalName = buildChunkFileName(fileName);
await uploadFile({
url: "/DmsFile/UploadFile",
filePath,
name: fileName,
fileName: physicalName,
header: { FilePath: TREE_COVER_FOLDER },
});
tree.value.TreeCoverFilename = physicalName;
sanitizeTreePayload();
await familyTreeService.Update(data.value);
toast(t("FamilyTreePage.treeCoverUploaded"));
}
catch { /* uploadFile 已统一弹错,这里只补一个失败落点 */ }
finally {
coverUploading.value = false;
}
},
});
}
/** 打开「编辑族谱信息」弹层:浏览态快改入口(无修改权限挡回),先记快照供取消还原 */
function openTreeEditor(): void {
if (!isCreate.value && !data.value.IsCanAmend) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
treeEditSnapshot.value = {
TreeName: tree.value.TreeName ?? "",
AncestorPersonGenealogyName: tree.value.AncestorPersonGenealogyName ?? "",
Brief: tree.value.Brief ?? "",
PublicIndc: tree.value.PublicIndc ?? "N",
};
treeEditVisible.value = true;
}
/** 弹层保存:先做与 onSave 同款校验(失败保持弹层可继续改),通过则收起并复用 onSave 落库 */
async function saveTreeEdit(): Promise<void> {
if (!String(tree.value.TreeName ?? "").trim()) {
toast(t("FamilyTreePage.treeNameRequired"));
return;
}
if (isCreate.value && !String(tree.value.AncestorPersonId ?? "").trim()) {
toast(t("FamilyTreePage.ancestorRequired"));
return;
}
treeEditVisible.value = false;
await onSave();
}
/** 弹层取消:收起并按快照还原弹层里对 tree 的直改(纯本地,不发请求不转 loading) */
function cancelTreeEdit(): void {
treeEditVisible.value = false;
const snap = treeEditSnapshot.value;
tree.value.TreeName = snap.TreeName;
tree.value.AncestorPersonGenealogyName = snap.AncestorPersonGenealogyName;
tree.value.Brief = snap.Brief;
tree.value.PublicIndc = snap.PublicIndc;
}
/** 发送邀请入口已随邀请弹层取消 */
/** 详情区分区卡折叠态(与媒体/共享页同款交互):默认全展开,点标题行切换 */
type FoldKey = "info" | "person" | "spouses" | "children" | "perms";
const folded = ref<Record<FoldKey, boolean>>({
info: false,
person: false,
spouses: false,
children: false,
perms: false,
});
function toggleFold(key: FoldKey): void {
folded.value[key] = !folded.value[key];
}
const navTitle = computed(() => {
if (isCreate.value) {
return t("FamilyTreePage.addTreeBut");
}
return String(tree.value.TreeName || "") || t("MinePage.familyTree");
});
function toast(title: string): void {
uni.showToast({ title, icon: "none" });
}
function confirmDialog(title: string, content: string): Promise<boolean> {
return new Promise((resolve) => {
uni.showModal({
title,
content,
success: res => resolve(!!res.confirm),
fail: () => resolve(false),
});
});
}
async function withBusy(task: () => Promise<void>, tip?: string): Promise<void> {
if (busy.value) {
return;
}
busy.value = true;
uni.showLoading({ title: tip || t("FilePage.pleaseWait"), mask: true });
try {
await task();
}
catch (error: any) {
toast(error?.message || t("FilePage.actionFailed"));
}
finally {
uni.hideLoading();
busy.value = false;
}
}
function ensureLists(): void {
if (!data.value.CurrentPerson) {
data.value.CurrentPerson = new FamilyTreeData().CurrentPerson;
}
if (!data.value.CurrentPerson.FamilyTreeSpouseMaps) {
data.value.CurrentPerson.FamilyTreeSpouseMaps = [];
}
if (!data.value.CurrentPerson.FamilyTreeChildrenMaps) {
data.value.CurrentPerson.FamilyTreeChildrenMaps = [];
}
}
/** 保存载荷清洗:后端 FindByCurrentPerson 的关系行只按 PersonId 过滤不带 TreeId,
* 跨谱共享同一父/子时会混入他谱关系行,Update 把 TreeId 归一后插库撞主键
* (Duplicate entry ... family_tree_children_map.PRIMARY);此处按当前谱过滤并按主键去重 */
function sanitizeTreePayload(): void {
const cp = data.value.CurrentPerson;
if (!cp) {
return;
}
const keep = <T extends { TreeId?: string }>(rows: T[] | undefined, keyOf: (row: T) => string): T[] => {
const seen = new Set<string>();
return (rows ?? []).filter((row) => {
if (row.TreeId && treeId.value && row.TreeId !== treeId.value) {
return false;
}
const key = keyOf(row);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
};
cp.FamilyTreeSpouseMaps = keep(cp.FamilyTreeSpouseMaps, row => `${row.PersonId}|${row.SpouseId}`);
cp.FamilyTreeChildrenMaps = keep(cp.FamilyTreeChildrenMaps, row => `${row.PersonId}|${row.ChildrenId}`);
}
function resetDetail(): void {
data.value = new FamilyTreeData();
treePersonCount.value = 0;
ensureLists();
treeId.value = "";
loaded.value = false;
isEdit.value = false;
isCreate.value = false;
loadedNodes.value = {};
expandedSpouses.value = {};
expandedChildren.value = {};
// 浏览态重设计相关状态一并复位
activeTab.value = "overview";
memberKeyword.value = "";
memberFilter.value = "all";
treeEditVisible.value = false;
// 换谱时统计缓存一并清空,下次进 stats tab 重新懒加载(避免沿用上一谱数据)
statsData.value = null;
statsLoading.value = false;
}
/** 载入详情:FindByCurrentPerson({ key, personId }),首载 personId 传空串(后端回默认族员) */
async function load(): Promise<void> {
const result = await familyTreeService.FindByCurrentPerson({
key: treeId.value.trim(),
personId: currentPerson.value?.PersonId ?? "",
});
if (!result || result.FamilyTree == null) {
toast(t("AdminPage.notExistTip"));
loaded.value = false;
return;
}
data.value = result;
ensureLists();
loaded.value = true;
// 全量族人数异步补拉(不阻塞详情渲染)
void fetchPersonCount();
// 无修改权限:强制回到查看模式(旧 onSearch 同款)
if (!data.value.IsCanAmend && isEdit.value && !isCreate.value) {
isEdit.value = false;
toast(t("FamilyTreePage.noAmendTip"));
}
// tab 级控权兜底:权限被回收/切换后仍停在不可见 tab 时踢回概览
if (activeTab.value === "perms" && !data.value.IsManager) {
activeTab.value = "overview";
}
else if (activeTab.value === "members" && !data.value.IsManager && !data.value.IsMember) {
activeTab.value = "overview";
}
// 统计 tab 与成员 tab 同一把可见闸(非成员不可见,后端 GetStatistics 也会拒)
else if (activeTab.value === "stats" && !data.value.IsManager && !data.value.IsMember) {
activeTab.value = "overview";
}
}
async function onOpenTree(row: FamilyTreeViewEntity): Promise<void> {
resetDetail();
treeId.value = row.TreeId;
await withBusy(() => load());
if (loaded.value) {
inDetail.value = true;
}
}
function onCreateTree(): void {
resetDetail();
isCreate.value = true;
isEdit.value = true;
inDetail.value = true;
}
async function onBack(): Promise<void> {
if (!inDetail.value) {
// 首页无上级:H5 深链带历史时能退则退,否则停在列表
if (getCurrentPages().length > 1) {
uni.navigateBack();
}
return;
}
await exitDetail();
}
async function exitDetail(): Promise<void> {
resetDetail();
inDetail.value = false;
await loadTrees();
}
async function onCancelEdit(): Promise<void> {
if (isCreate.value || !loaded.value) {
await exitDetail();
return;
}
isEdit.value = false;
await withBusy(() => load());
}
/** 保存按 CreatedBy 有无决定 Update / Insert(旧页口径);成功回查看态 */
async function onSave(): Promise<void> {
if (!String(tree.value.TreeName ?? "").trim()) {
toast(t("FamilyTreePage.treeNameRequired"));
return;
}
// 新建族谱必须先指定始祖(祖先):未新增则挡回(编辑既有族谱不重复校验)
if (isCreate.value && !String(tree.value.AncestorPersonId ?? "").trim()) {
toast(t("FamilyTreePage.ancestorRequired"));
return;
}
ensureLists();
sanitizeTreePayload();
await withBusy(async () => {
const isUpdate = String(tree.value.CreatedBy ?? "").trim() !== "";
const result = await (isUpdate
? familyTreeService.Update(data.value)
: familyTreeService.Insert(data.value));
if (result?.FamilyTree) {
data.value = result;
ensureLists();
treeId.value = result.FamilyTree.TreeId;
}
isEdit.value = false;
isCreate.value = false;
loaded.value = true;
// 新建/保存可能增减族人(如建谱带始祖),重拉全量人数
void fetchPersonCount();
toast(t("AdminPage.savedTip"));
});
}
async function onDeleteTree(): Promise<void> {
const ok = await confirmDialog(
t("AdminPage.confirmDeleteTitle"),
String(tree.value.TreeName || treeId.value),
);
if (!ok) {
return;
}
await withBusy(async () => {
await familyTreeService.Delete(tree.value.TreeId);
toast(t("AdminPage.deletedTip"));
resetDetail();
inDetail.value = false;
await loadTrees();
});
}
function onPublicChange(value: boolean): void {
tree.value.PublicIndc = value ? "Y" : "N";
}
// ===== 当前族员切换(族员搜索 / 图上点人)=====
// 族员搜索统一走 admin-pick-popup 底部弹窗:取消/确定与其他弹层同风格,不再跳整页 enquiry
const personPickConfig = usePersonPickConfig();
const personPickVisible = ref(false);
/** 弹窗用途:current=切换当前族员,spouse=从已有成员里选配偶 */
const personPickMode = ref<"current" | "spouse">("current");
function pickCurrentPerson(): void {
personPickMode.value = "current";
// 只能查本族族员:弹窗打开前把当前 TreeId 下发给 loader 作等值过滤
personPickConfig.fixedFilters.value = { TreeId: treeId.value.trim() };
personPickVisible.value = true;
}
/** 弹窗确定/点行回传:按用途分流——切当前族员或挂配偶(pushSpouse 内含重复挡回) */
function onPersonPicked(rows: AdminRow[]): void {
const row = rows[0];
const personId = String(row?.PersonId ?? "");
if (!personId) {
return;
}
if (personPickMode.value === "current") {
void setCurrentPerson(personId);
return;
}
pushSpouse({
PersonId: personId,
Surname: String(row?.Surname ?? ""),
GenealogyName: String(row?.GenealogyName ?? ""),
CommonName: String(row?.CommonName ?? ""),
Gender: String(row?.Gender ?? ""),
} as FamilyPersonDisplayData);
}
async function setCurrentPerson(personId: string): Promise<void> {
const result = await familyTreeService.FindByCurrentPerson({
key: treeId.value.trim(),
personId,
});
if (result?.CurrentPerson?.FamilyPerson?.PersonId) {
data.value = result;
ensureLists();
loaded.value = true;
// 选定当前族员后自动切到「成员」tab:当前族员卡与夫妻/子女列表都在那里,免去二次点 tab;
// 普通用户无成员 tab 可见(与模板 v-if="data.IsManager || data.IsMember" 同源),保持原 tab
if (data.value.IsManager || data.value.IsMember) {
activeTab.value = "members";
}
}
}
// ===== 谱系图(sub 懒加载 / full 全树)=====
async function onChartSelectChild(personId: string): Promise<void> {
if (!personId || !treeId.value || loadedNodes.value[personId]) {
return;
}
try {
const result = await familyTreeService.FindByCurrentPerson({
key: treeId.value.trim(),
personId,
});
const cp = result?.CurrentPerson;
if (cp?.FamilyPerson) {
loadedNodes.value = {
...loadedNodes.value,
[personId]: {
person: cp.FamilyPerson,
spouses: cp.FamilyTreeSpouseMaps ?? [],
children: cp.FamilyTreeChildrenMaps ?? [],
},
};
}
}
catch (error) {
console.error(error);
}
}
function onChartPickPerson(personId: string): void {
setCurrentPerson(personId);
}
// ===== 夫妻表 =====
function refreshParentOrderby(): void {
spouseMaps.value.forEach((item, idx) => {
item.Orderby = idx + 1;
});
}
function pushSpouse(person: FamilyPersonDisplayData): void {
ensureLists();
// 同一人重复挂配偶会撞 spouse_map 主键,挡回(快速建员/已有成员选入两个入口共用)
if (spouseMaps.value.some(item => item.SpouseId === person.PersonId)) {
toast(t("FamilyTreePage.dupMemberTip"));
return;
}
const spouse = new FamilyTreeSpouseMapViewEntity();
spouse.TreeId = treeId.value;
spouse.PersonId = currentPerson.value?.PersonId ?? "";
spouse.SpouseId = person.PersonId;
spouse.SpouseSurname = person.Surname;
spouse.SpouseCommonName = person.CommonName;
spouse.SpouseGenealogyName = person.GenealogyName;
spouse.Gender = person.Gender;
data.value.CurrentPerson.FamilyTreeSpouseMaps.push(spouse);
refreshParentOrderby();
}
function deleteSpouse(spouseId: string): void {
data.value.CurrentPerson.FamilyTreeSpouseMaps = spouseMaps.value.filter(
item => item.SpouseId !== spouseId,
);
refreshParentOrderby();
}
function moveSpouse(index: number, offset: number): void {
const list = data.value.CurrentPerson.FamilyTreeSpouseMaps;
const target = index + offset;
if (target < 0 || target >= list.length) {
return;
}
const item = list.splice(index, 1)[0];
list.splice(target, 0, item);
refreshParentOrderby();
}
// ===== 孩子表 =====
function refreshSonOrderby(): void {
childMaps.value.forEach((item, idx) => {
item.Orderby = idx + 1;
});
}
function pushChild(person: FamilyPersonDisplayData): void {
ensureLists();
// 同上:同一子女重复挂行会在保存时撞 children_map 主键
if (childMaps.value.some(item => item.ChildrenId === person.PersonId)) {
toast(t("FamilyTreePage.dupMemberTip"));
return;
}
const child = new FamilyTreeChildrenMapViewEntity();
child.TreeId = treeId.value;
child.PersonId = currentPerson.value?.PersonId ?? "";
child.ChildrenId = person.PersonId;
child.ChildrenSurname = person.Surname;
child.ChildrenCommonName = person.CommonName;
child.ChildrenGenealogyName = person.GenealogyName;
child.Gender = person.Gender;
data.value.CurrentPerson.FamilyTreeChildrenMaps.push(child);
refreshSonOrderby();
}
function deleteChild(childrenId: string): void {
data.value.CurrentPerson.FamilyTreeChildrenMaps = childMaps.value.filter(
item => item.ChildrenId !== childrenId,
);
refreshSonOrderby();
}
function moveChild(index: number, offset: number): void {
const list = data.value.CurrentPerson.FamilyTreeChildrenMaps;
const target = index + offset;
if (target < 0 || target >= list.length) {
return;
}
const item = list.splice(index, 1)[0];
list.splice(target, 0, item);
refreshSonOrderby();
}
// ===== 快速建员回填(对齐旧 onPersonQuickCreated)=====
async function onQuickCreated(person: FamilyPersonDisplayData): Promise<void> {
if (!person || !person.PersonId) {
return;
}
if (qcPurpose.value === "spouse") {
pushSpouse(person);
}
else if (qcPurpose.value === "child") {
pushChild(person);
}
else if (qcPurpose.value === "ancestor") {
tree.value.AncestorPersonId = person.PersonId;
tree.value.AncestorPersonGenealogyName = person.GenealogyName;
}
else if (!isCreate.value && String(treeId.value ?? "").trim()) {
// 通用「添加族人」:person-editor 已落库,刷新详情让新族人进入列表
await withBusy(() => load());
}
}
function openQuickCreate(purpose: "spouse" | "child" | "ancestor"): void {
// 前置闸门:不可编(未绑定/当前锚点出支系)直接提示不弹层,避免弹完才报无权限、也防弹层里的非公开族人信息泄漏
if (!data.value.IsCanAmend) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
qcPurpose.value = purpose;
qcVisible.value = true;
}
/** 打开成员修改弹层:夫妻 / 孩子行与当前族员卡的「修改」图标都走这里 */
function openEditPerson(personId: string): void {
if (!personId) {
return;
}
// 前置拦闸:按目标人逐个判(非按锚点),不可编直接提示不弹层,防弹完才报错与非公开信息泄漏
if (!canAmendPerson(personId)) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
peEditPersonId.value = personId;
peEditVisible.value = true;
}
/** 修改保存后刷新:改的是本谱祖先就先回填去冗余的祖先谱名(新建/编辑两态都适用),已落库的族谱再重载详情 */
async function onPersonEdited(person?: FamilyPersonDisplayData): Promise<void> {
if (person && tree.value.AncestorPersonId === person.PersonId) {
tree.value.AncestorPersonGenealogyName = person.GenealogyName;
}
// 新建态族谱尚未落库(treeId 为空),load 取不到,跳过重载
if (!isCreate.value && String(treeId.value ?? "").trim()) {
await withBusy(() => load());
}
}
// 已有成员里选配偶(快速建员之外的入口,对齐旧页):与族员搜索共用同一个底部弹窗
function pickSpouse(): void {
personPickMode.value = "spouse";
// 选配偶同样限本族:与切换当前族员共用同一弹窗与过滤口径
personPickConfig.fixedFilters.value = { TreeId: treeId.value.trim() };
personPickVisible.value = true;
}
// ===== 孩子行的枚举编辑器 =====
function openRelationPicker(record: FamilyTreeChildrenMapViewEntity): void {
relationTarget.value = record;
relationPickerVisible.value = true;
}
async function onRelationConfirm(key: string): Promise<void> {
if (relationTarget.value) {
relationTarget.value.RelationType = key;
}
relationPickerVisible.value = false;
// 浏览态(旧「管理关系」已取消):弹层里改完即落库;编辑态(新建谱)仍走「保存」钮
if (!isEdit.value) {
await onSave();
}
}
function openParentPicker(record: FamilyTreeChildrenMapViewEntity): void {
parentTarget.value = record;
parentPickerVisible.value = true;
}
async function onParentConfirm(key: string): Promise<void> {
if (parentTarget.value) {
parentTarget.value.SpouseId = key;
}
parentPickerVisible.value = false;
if (!isEdit.value) {
await onSave();
}
}
// ===== 关系行快捷动作(配偶/子女行右侧直达,单击即落库,旧编辑态式交互)=====
type RelationKind = "spouse" | "child";
function relationIdOf(kind: RelationKind, record: any): string {
return String(kind === "spouse" ? record?.SpouseId : record?.ChildrenId);
}
function relationNameOf(kind: RelationKind, record: any): string {
return kind === "spouse"
? `${record?.SpouseSurname ?? ""}${record?.SpouseGenealogyName ?? ""}`
: `${record?.ChildrenSurname ?? ""}${record?.ChildrenGenealogyName ?? ""}`;
}
/** 上移/下移关系行(配偶序/长幼序,落库到 Orderby);下标按全量列表回查,搜索筛选态下也不会错位 */
async function onRelationMove(kind: RelationKind, record: any, offset: number): Promise<void> {
if (!data.value.IsCanAmend) {
return;
}
const id = relationIdOf(kind, record);
const list = kind === "spouse" ? spouseMaps.value : childMaps.value;
const index = list.findIndex(item => relationIdOf(kind, item) === id);
if (index < 0 || index + offset < 0 || index + offset >= list.length) {
return;
}
if (kind === "spouse") {
moveSpouse(index, offset);
}
else {
moveChild(index, offset);
}
await onSave();
}
/** 解除关系:二次确认后删关系行并立即保存(只删关系行,不动人员档案) */
async function onRelationRemove(kind: RelationKind, record: any): Promise<void> {
if (!data.value.IsCanAmend) {
toast(t("FamilyTreePage.noAmendTip"));
return;
}
const id = relationIdOf(kind, record);
const ok = await confirmDialog(
t("FamilyTreePage.removeRelation"),
t("FamilyTreePage.removeRelationConfirm", { name: relationNameOf(kind, record) || id }),
);
if (!ok) {
return;
}
if (kind === "spouse") {
deleteSpouse(id);
}
else {
deleteChild(id);
}
await onSave();
}
// ===== 权限维护 =====
async function requestManager(): Promise<void> {
// 入口常驻(快捷区/权限卡):已是管理员就不再发请求,直接提示
if (data.value.IsManager) {
toast(t("FamilyTreePage.alreadyManagerTip"));
return;
}
const personId = currentPerson.value?.PersonId ?? "";
if (!treeId.value || !personId) {
return;
}
await familyTreeService.RequestFamilyTreePermission({
treeId: treeId.value.trim(),
currentPersonId: personId,
});
toast(t("FamilyTreePage.requestSentTip"));
load();
}
/** 绑定账号:把当前族员绑到自己已生效的权限行(后端校验同名,不符会直接报错提示) */
async function bindCurrentPerson(): Promise<void> {
const personId = String(currentPerson.value?.PersonId ?? "").trim();
if (!treeId.value || !personId) {
toast(t("FamilyTreePage.bindNoPersonTip"));
return;
}
const result = await familyTreeService.BindSelfPerson({ treeId: treeId.value.trim(), personId });
if (result?.FamilyTree) {
// 后端已把锚点切到刚绑族员,直接落数据;重拉全量人数与可编口径
data.value = result;
ensureLists();
}
toast(t("FamilyTreePage.bindSuccessTip"));
}
/** 解除绑定:清空自己权限行的族员绑定(后端把锚点退回始祖) */
async function unbindCurrentPerson(): Promise<void> {
if (!treeId.value) {
return;
}
const result = await familyTreeService.UnbindSelfPerson(treeId.value.trim());
if (result?.FamilyTree) {
data.value = result;
ensureLists();
}
toast(t("FamilyTreePage.unbindSuccessTip"));
}
async function approvePermission(
record: { PersonId: string; UserName: string },
managerIndc: string,
): Promise<void> {
await familyTreeService.ApproveFamilyTreePermission({
treeId: treeId.value,
currentPersonId: record.PersonId,
userName: record.UserName,
managerIndc,
});
load();
}
async function rejectPermission(record: { PersonId: string; UserName: string }): Promise<void> {
await familyTreeService.RejectFamilyTreePermission({
treeId: treeId.value,
currentPersonId: record.PersonId,
userName: record.UserName,
});
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 = "";
}
// #ifdef APP-PLUS
// 项目 types 未含 plus 声明,App 运行时真实存在,本地宽松兜底
// eslint-disable-next-line ts/no-explicit-any
declare const plus: any;
/** App 侧读文本:plus.io FileReader(uni.getFileSystemManager 仅小程序可用,App 上调会 undefined) */
function readLocalText(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
plus.io.resolveLocalFileSystemURL(
filePath,
(entry: any) => {
entry.file(
(file: any) => {
const reader = new plus.io.FileReader();
reader.onloadend = (event: any) => resolve(String(event.target.result ?? ""));
reader.onerror = (error: any) => reject(error);
reader.readAsText(file, "utf-8");
},
reject,
);
},
reject,
);
});
}
// #endif
/** 选本地 .fm 并读文本(H5 File.text / 小程序 FileSystemManager / App plus.io FileReader),与服务器列表通道互斥 */
async function pickLocalFile(): Promise<void> {
try {
// 选文件统一走 chooseFiles:uni.chooseFile 在 App 端不存在(官方仅 H5/小程序支持)
const picked = await chooseFiles(1, { extension: [".fm"] });
const file = picked[0];
if (!file) {
return;
}
let text = "";
// #ifdef H5
text = await (file.raw as File).text();
// #endif
// #ifdef MP
const res: any = await new Promise((resolve, reject) => {
uni.getFileSystemManager().readFile({
filePath: file.filePath,
encoding: "utf8",
success: resolve,
fail: reject,
});
});
text = String(res.data ?? "");
// #endif
// #ifdef APP-PLUS
text = await readLocalText(file.filePath);
// #endif
if (!text.trim()) {
toast(t("FamilyTreePage.exchangeLocalBad"));
return;
}
localFileText.value = text;
localFileName.value = file.name;
pickedFileName.value = "";
}
catch (e: any) {
// 用户取消不算错(errMsg 含 cancel),其余给统一读失败提示
if (!String(e?.errMsg ?? e?.message ?? 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 ?? "");
}
/** 孩子行展开态的两个只读文案:母亲(配偶选项)与关系(枚举) */
function spouseLabel(spouseId?: string): string {
if (!spouseId) {
return t("AdminPage.notSetTip");
}
return spouseOptions.value.find(item => item.key === spouseId)?.label ?? spouseId;
}
function relationLabel(relationType?: string): string {
if (!relationType) {
return t("AdminPage.notSetTip");
}
return getPolicyText("RelationTypePolicy", relationType) || relationType;
}
function datePart(value?: Date | string): string {
return String(value ?? "").slice(0, 10);
}
// 首次进入时页面 onShow 早于子组件挂载(页面壳的 ref 还没绑定),
// 挂载完成后 emit ready 让壳页补发首拉;后续 onShow 组件已挂载,直接调 expose
onMounted(() => {
emit("ready");
});
// 供页面壳(dashboard)在 onShow 守卫回调里调用:列表态每次重拉
// (别端增删改后回来能看到最新),详情编辑中回页不打断
defineExpose({
reloadIfList(): void {
if (!inDetail.value) {
loadTrees();
void loadArticles();
void fetchStorage();
}
},
});
</script>
<template>
<!-- 列表态显示 tabbar,进详情(或弹层展开)收起;未认证底部弹层也在列表态弹出,须一并隐 tabbar,否则盖住底部按钮 -->
<!-- 文章底部编辑层同理:不隐 tabbar 会盖住弹层底部的「取消/保存」(tabbar 与弹层不在同一层叠上下文,z-index 压不过) -->
<app-page :hide-nav="true" :show-tabbar="!inDetail && !idPopup && !articleEditorVisible">
<view class="tree-page" :style="[{ background: themeBackground }, themeVars]">
<!-- ===== 列表态:软底分区面板(标题 + 搜索/新建/伸缩圆钮组 + 白底族谱卡) ===== -->
<!-- 隐藏原生滚动条:否则 Chrome 滚动条占右侧 ~17px,面板左右留白不对称 -->
<view v-if="!inDetail" class="tree-page__scroll-wrap tree-page__scroll-wrap--list">
<scroll-view scroll-y :show-scrollbar="false" class="tree-page__scroll tree-page__body--list">
<!-- ===== 顶部两张卡片:用户身份 + 网盘用量 ===== -->
<view class="home-cards">
<view class="home-card home-card--user">
<view class="home-card__sheen" />
<view class="home-card__glow" />
<image class="home-card__mark" :src="brandGlyph" mode="aspectFit" />
<view class="home-card__avatar" @tap="onUploadAvatar">
<image v-if="storage.AvatarUrl" class="home-card__avatar-img" :src="absoluteFileUrl(storage.AvatarUrl)"
mode="aspectFill" />
<text v-else class="home-card__avatar-ph">{{ displayName ? displayName.slice(0, 1) : "?" }}</text>
<view class="home-card__avatar-cam">
<u-icon name="camera-fill" size="24" color="#ffffff" />
</view>
</view>
<view class="home-card__uinfo">
<view class="home-card__uname-row">
<text class="home-card__uname">{{ displayName || "-" }}</text>
<view class="home-card__utype" :class="{ 'home-card__utype--tap': currentType === 'U' }"
:style="{ background: userTypeBg }" @tap="onTapUserType">
<image class="home-card__utype-icon" :src="userTypeIcon" mode="aspectFit" />
<text class="home-card__utype-text">{{ userTypeText }}</text>
</view>
</view>
<text class="home-card__welcome">{{ t("storage.welcome") }}</text>
</view>
</view>
<view class="home-card home-card--storage">
<view class="home-card__sglow" />
<view class="home-card__srow">
<view class="home-card__stitle-wrap">
<view class="home-card__sicon">
<image class="home-card__sicon-img" :src="cloudGlyph" mode="aspectFit" />
</view>
<text class="home-card__stitle">{{ t("storage.title") }}</text>
<!-- #ifdef H5 -->
<view class="home-card__dlapp" @tap="onDownloadApp">
<image class="home-card__dlapp-icon" :src="downloadGlyph" mode="aspectFit" />
</view>
<!-- #endif -->
</view>
<text class="home-card__spct" :class="{ 'home-card__spct--over': storageOver }">{{ storagePercentText
}}</text>
</view>
<view class="home-card__bar">
<view class="home-card__bar-fill" :class="{ 'home-card__bar-fill--over': storageOver }"
:style="{ width: storageBarWidth }" />
</view>
<view class="home-card__snums">
<text class="home-card__snum">{{ t("storage.used") }} {{ storageUsedText }}</text>
<text class="home-card__snum">{{ t("storage.total") }} {{ storageQuotaText }}</text>
</view>
</view>
</view>
<view class="tree-panel">
<view class="tree-panel__head">
<text class="tree-panel__title">{{ t("FamilyTreePage.listTitle") }}</text>
<view class="tree-panel__acts">
<view class="tree-panel__act" @tap="onToggleSearch">
<u-icon name="search" size="34" :color="primary" />
</view>
<view class="tree-panel__act tree-panel__act--add" @tap="onCreateTree">
<u-icon name="plus" size="30" color="#ffffff" />
</view>
<view class="tree-panel__act" @tap="listCollapsed = !listCollapsed">
<u-icon :name="listCollapsed ? 'arrow-up' : 'arrow-down'" size="34" :color="primary" />
</view>
</view>
</view>
<view v-if="showSearch" class="tree-panel__search">
<search-input v-model="keyword" :placeholder="t('FamilyTreePage.searchPlaceholder')" @search="onSearch" />
</view>
<view v-if="!listCollapsed" class="tree-panel__list">
<view v-if="loading" class="tree-page__state">
<text class="tree-page__state-text">{{ t("FilePage.loading") }}</text>
</view>
<view v-else-if="errorText" class="tree-page__state">
<text class="tree-page__state-text tree-page__state-text--error">{{ errorText }}</text>
<text class="tree-page__retry" @click="onRetry">{{ t("FilePage.retryBut") }}</text>
</view>
<template v-else>
<view v-if="!filteredTrees.length" class="tree-page__empty">
<text class="tree-page__state-text">{{ t("FamilyTreePage.chartEmpty") }}</text>
</view>
<view v-for="row in filteredTrees" :key="row.TreeId" class="tree-card" @tap="onOpenTree(row)">
<view class="tree-card__top">
<view class="tree-card__avatar">
<image v-if="row.TreeCoverFilename" class="tree-card__cover"
:src="treeCoverUrl(row.TreeCoverFilename)" mode="aspectFill" />
<image v-else class="tree-card__glyph" :src="treeGlyph" mode="aspectFit" />
</view>
<view class="tree-card__main">
<text class="tree-card__name">{{ row.TreeName || t("FamilyTreePage.unnamed") }}</text>
<view class="tree-card__meta">
<text class="tree-card__chip tree-card__chip--code">{{ row.TreeId }}</text>
<text v-if="row.PublicIndc === 'Y'" class="tree-card__chip tree-card__chip--public">
{{ t("FamilyTreePage.chipPublic") }}
</text>
<text v-else class="tree-card__chip">{{ t("FamilyTreePage.chipPrivate") }}</text>
</view>
</view>
</view>
<!-- 简介整行在下:按字数在末尾截断,完整内容进详情看 -->
<text class="tree-card__desc">{{ briefLine(row.Brief) }}</text>
<u-icon class="tree-card__arrow" name="arrow-right" size="30" color="#c0c4cc" />
</view>
</template>
</view>
</view>
<!-- ===== 有家文化传承:与族谱面板并列的文章卡 ===== -->
<view class="tree-panel article-panel">
<view class="tree-panel__head">
<text class="tree-panel__title">{{ t("article.title") }}</text>
<view class="tree-panel__acts">
<view class="tree-panel__act" @tap="onToggleArticleSearch">
<u-icon name="search" size="34" :color="primary" />
</view>
<view class="tree-panel__act tree-panel__act--add" @tap="onCreateArticle">
<u-icon name="plus" size="30" color="#ffffff" />
</view>
<view class="tree-panel__act" @tap="articleCollapsed = !articleCollapsed">
<u-icon :name="articleCollapsed ? 'arrow-up' : 'arrow-down'" size="34" :color="primary" />
</view>
</view>
</view>
<view v-if="showArticleSearch" class="tree-panel__search">
<search-input v-model="articleKeyword" :placeholder="t('article.searchPlaceholder')"
@search="onArticleSearch" />
</view>
<view v-if="!articleCollapsed" class="tree-panel__list">
<view v-if="articlesApi.loading.value" class="tree-page__state">
<text class="tree-page__state-text">{{ t("FilePage.loading") }}</text>
</view>
<view v-else-if="articlesApi.errorText.value" class="tree-page__state">
<text class="tree-page__state-text tree-page__state-text--error">{{ articlesApi.errorText.value
}}</text>
<text class="tree-page__retry" @click="loadArticles">{{ t("FilePage.retryBut") }}</text>
</view>
<template v-else>
<view v-if="!homeArticles.length" class="tree-page__empty">
<text class="tree-page__state-text">{{ t("article.empty") }}</text>
</view>
<view v-for="row in homeArticles" :key="row.ArticleName" class="article-card"
@tap="onOpenArticle(row.ArticleName)">
<view class="article-card__cover">
<image v-if="row.CoverUrl || row.ArticleCoverFilename" class="article-card__cover-img"
:src="absoluteFileUrl(row.CoverUrl) || backupFileUrl('ArticleCover', row.ArticleCoverFilename)"
mode="aspectFill" />
<image v-else class="article-card__glyph" :src="articleGlyph" mode="aspectFit" />
</view>
<view class="article-card__main">
<view class="article-card__titlerow">
<text class="article-card__name">{{ row.ArticleTitle || t("article.unnamed") }}</text>
<text v-if="row.TopIndc === 'Y'" class="article-card__chip article-card__chip--top">{{
t("article.tagTop") }}</text>
</view>
<text class="article-card__desc">{{ articlesApi.plainText(row.Brief, 100) || t("article.noBrief")
}}</text>
<view class="article-card__meta">
<image v-if="row.AuthorAvatarUrl" class="article-card__avatar"
:src="absoluteFileUrl(row.AuthorAvatarUrl)" mode="aspectFill" />
<view v-else class="article-card__avatar article-card__avatar--empty" />
<text class="article-card__author">{{ row.AuthorLocalName || t("article.unknownAuthor") }}</text>
<!-- 待审核从标题行下移到这里:腾出标题整行,标题可显示更多内容 -->
<text v-if="row.ApprovedIndc !== 'Y'" class="article-card__chip article-card__chip--pending">{{
t("article.tagPending") }}</text>
<text class="article-card__date">{{ articlesApi.formatDate(row.CreatedDatetime) }}</text>
</view>
</view>
<u-icon name="arrow-right" size="30" color="#c0c4cc" />
</view>
</template>
</view>
</view>
<!-- 底部留白:让最后一张卡能滚到底部固定 tabbar 之上 -->
<view class="tree-page__list-space" />
</scroll-view>
</view>
<!-- ===== 详情态:族谱管理 ===== -->
<view v-else class="tree-page__body">
<view class="tree-page__head">
<hero-navbar :title="navTitle" @back="onBack" />
</view>
<!-- ===== 浏览态:Hero + 三段 Tab + 底部操作条(视觉照原型,配色走主题 primary) ===== -->
<template v-if="!isEdit">
<view class="tree-page__scroll-wrap">
<scroll-view scroll-y class="tree-page__scroll">
<!-- Hero 族谱卡 -->
<view class="ft-hero">
<view class="ft-hero__top">
<view class="ft-hero__crest" @tap="onUploadTreeCover">
<image v-if="tree.TreeCoverFilename" class="ft-hero__cover"
:src="treeCoverUrl(tree.TreeCoverFilename)" mode="aspectFill" />
<image v-else class="ft-hero__glyph" :src="treeGlyph" mode="aspectFit" />
<!-- 相机角标:与首页头像同款,可改才显示(整块可点) -->
<view v-if="data.IsCanAmend" class="ft-hero__crest-cam">
<u-icon name="camera-fill" size="24" color="#ffffff" />
</view>
</view>
<view class="ft-hero__info">
<text class="ft-hero__name">{{ tree.TreeName || t("FamilyTreePage.unnamed") }}</text>
<view class="ft-hero__code" @tap="copyText(tree.TreeId)">
<text>{{ tree.TreeId || t("FamilyTreePage.treeIdPlaceholder") }}</text>
<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-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>
</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>
</view>
<!-- 三段 Tab -->
<view class="ft-tabs">
<view class="ft-tab" :class="{ 'ft-tab--on': activeTab === 'overview' }" @tap="switchTab('overview')">
<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')">
<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')">
<text>{{ t("FamilyTreePage.tabPerms") }}</text>
<view v-if="pendingPermissions.length" class="ft-tab__dot" />
</view>
<!-- 统计 tab:总数/男女/世代分布,管理员与已绑定成员可见(与后端 GetStatistics 成员闸同口径) -->
<view v-if="data.IsManager || data.IsMember" class="ft-tab"
:class="{ 'ft-tab--on': activeTab === 'stats' }" @tap="switchTab('stats')">
<text>{{ t("FamilyTreePage.tabStats") }}</text>
</view>
</view>
<!-- ===== 概览 ===== -->
<view v-show="activeTab === 'overview'" class="ft-pane">
<view class="ft-card">
<view class="ft-card__head">
<text class="ft-card__title">{{ t("FamilyTreePage.introTitle") }}</text>
<!-- 卡头不设编辑链:改族谱信息统一走快捷区「编辑资料」 -->
</view>
<text class="ft-intro">{{ tree.Brief || t("FamilyTreePage.noBrief") }}</text>
</view>
<!-- 始祖信息:原「基本信息」卡取消(字段顶部 hero 已有),公开开关移到 hero 徽章旁 -->
<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-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="absoluteFileUrl(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__gender">{{ genderText(ancestorPerson.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">{{ personChipText(ancestorPerson) }}</text>
</view>
</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>
</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-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 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>
<!-- ===== 成员 ===== -->
<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="person-hero">
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
<image v-if="currentPerson?.AvatarUrl" class="person-hero__img"
:src="absoluteFileUrl(currentPerson.AvatarUrl)" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
</view>
<view class="person-hero__main">
<view class="person-hero__line">
<text class="person-hero__name">{{ currentPersonName || "-" }}</text>
<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>
</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="!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-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>
</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 v-for="record in visibleSpouses" :key="record.SpouseId" class="ft-mrow">
<view class="ft-av" hover-class="ft-av--tap" :hover-stay-time="80"
@tap.stop="setCurrentPerson(record.SpouseId)"
: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>
<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>
</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 v-for="record in visibleChildren" :key="record.ChildrenId" class="ft-mrow">
<view class="ft-av" hover-class="ft-av--tap" :hover-stay-time="80"
@tap.stop="setCurrentPerson(record.ChildrenId)"
: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>
<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>
</view>
</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 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-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>
</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 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>
</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>
<!-- 删除族谱:原「...」更多菜单整体取消后,唯一入口放权限 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>
</view>
</view>
<!-- ===== 统计 ===== -->
<view v-show="activeTab === 'stats'" class="ft-pane">
<!-- 顶部四概览卡:总数 / 世代层数 / 男 / 女(与参考图同序) -->
<view class="ft-stat-grid">
<view class="ft-stat-card">
<text class="ft-stat-card__num">{{ statsData?.Total ?? 0 }}</text>
<text class="ft-stat-card__label">{{ t("FamilyTreePage.statsTotal") }}</text>
</view>
<view class="ft-stat-card">
<text class="ft-stat-card__num">{{ statsData?.GenerationCount ?? 0 }}</text>
<text class="ft-stat-card__label">{{ t("FamilyTreePage.statsGeneration") }}</text>
</view>
<view class="ft-stat-card">
<text class="ft-stat-card__num ft-stat-card__num--male">{{ statsData?.Male ?? 0 }}</text>
<text class="ft-stat-card__label">{{ t("FamilyTreePage.statsMale") }}</text>
</view>
<view class="ft-stat-card">
<text class="ft-stat-card__num ft-stat-card__num--female">{{ statsData?.Female ?? 0 }}</text>
<text class="ft-stat-card__label">{{ t("FamilyTreePage.statsFemale") }}</text>
</view>
</view>
<!-- 各世代人数:表头 + 行(世代 / 人数 / 横向条形图,按最大人数归一) -->
<view class="ft-card">
<view class="ft-card__head">
<text class="ft-card__title">{{ t("FamilyTreePage.statsPerGeneration") }}</text>
</view>
<view v-if="statsLoading" class="ft-empty"><text>{{ t("FamilyTreePage.statsLoading") }}</text></view>
<view v-else-if="!statsData?.PerGeneration?.length" class="ft-empty"><text>{{
t("FamilyTreePage.statsEmpty") }}</text></view>
<block v-else>
<view class="ft-gen-head">
<text class="ft-gen-head__col ft-gen-head__col--gen">{{ t("FamilyTreePage.statsColGeneration")
}}</text>
<text class="ft-gen-head__col ft-gen-head__col--cnt">{{ t("FamilyTreePage.statsColCount")
}}</text>
<text class="ft-gen-head__col ft-gen-head__col--bar" />
</view>
<view v-for="gen in statsData?.PerGeneration ?? []" :key="gen.Generation" class="ft-gen-row">
<text class="ft-gen-row__gen">{{ gen.Generation }}{{ t("FamilyTreePage.statsGenerationUnit")
}}</text>
<text class="ft-gen-row__cnt">{{ gen.Count }}</text>
<view class="ft-gen-bar">
<view class="ft-gen-bar__fill" :style="{ width: genBarWidth(gen.Count) }" />
</view>
</view>
</block>
</view>
</view>
<view class="tree-page__detail-space" />
</scroll-view>
</view>
<!-- 底部主操作条 -->
<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>
<!-- 小系图/全系图:定制 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>
</template>
<!-- ===== 编辑态:沿用旧内联编辑布局(原数据与保存函数一字不动) ===== -->
<view v-else class="tree-page__scroll-wrap">
<scroll-view scroll-y class="tree-page__scroll">
<!-- 头卡(与媒体/共享页同套):白底 + 族谱树图标(与列表卡同款渐变底白树形) + 编号灰字独立行 -->
<view class="detail-hero">
<view class="detail-hero__avatar">
<image v-if="tree.TreeCoverFilename" class="detail-hero__cover"
:src="treeCoverUrl(tree.TreeCoverFilename)" mode="aspectFill" />
<image v-else class="detail-hero__glyph" :src="treeGlyph" mode="aspectFit" />
</view>
<view class="detail-hero__main">
<text class="detail-hero__name">{{ tree.TreeName || t("FamilyTreePage.unnamed") }}</text>
<text class="detail-hero__code">
{{ tree.TreeId || t("FamilyTreePage.treeIdPlaceholder") }}
</text>
<view class="detail-hero__chips">
<text v-if="tree.PublicIndc === 'Y'" class="detail-hero__chip">
{{ t("FamilyTreePage.chipPublic") }}
</text>
<text v-else class="detail-hero__chip detail-hero__chip--member">
{{ t("FamilyTreePage.chipPrivate") }}
</text>
<text v-if="data.IsManager" class="detail-hero__chip detail-hero__chip--admin">
{{ t("FamilyTreePage.managerTag") }}
</text>
</view>
</view>
</view>
<!-- 动作栏(仅编辑态:取消 / 保存) -->
<view v-if="isEdit" class="detail-actions">
<view class="detail-actions__btn" @tap="onCancelEdit">
<text>{{ t("AdminPage.cancelBut") }}</text>
</view>
<view class="detail-actions__btn detail-actions__btn--primary" @tap="onSave">
<text>{{ t("AdminPage.saveBut") }}</text>
</view>
</view>
<!-- 族谱信息卡:点标题行折叠/展开 -->
<view class="detail-card">
<view class="detail-card__head" @tap="toggleFold('info')">
<text class="detail-card__title">{{ t("FamilyTreePage.infoTitle") }}</text>
<u-icon :name="folded.info ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
<view v-show="!folded.info">
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.treeId") }}</text>
<text class="detail-field__static">{{ tree.TreeId || "-" }}</text>
</view>
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.treeName") }}</text>
<input v-model="tree.TreeName" class="detail-field__input" :disabled="!isEdit" :maxlength="50"
:placeholder="t('FamilyTreePage.treeNameRequired')">
</view>
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.brief") }}</text>
<textarea v-model="tree.Brief" class="detail-field__textarea" :disabled="!isEdit" :maxlength="1000"
:placeholder="t('FamilyTreePage.noBrief')" />
</view>
<!-- 封面文件暂时不用,先隐藏(保留代码便于日后恢复)
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.treeCover") }}</text>
<input v-model="tree.TreeCoverFilename" class="detail-field__input" :disabled="!isEdit"
:maxlength="255">
</view>
-->
<view class="detail-field detail-field--row">
<text class="detail-field__label">{{ t("FamilyTreePage.publicIndc") }}</text>
<u-switch :model-value="tree.PublicIndc === 'Y'" :disabled="!isEdit" :active-color="primary"
@change="onPublicChange" />
</view>
<view class="detail-field">
<text class="detail-field__label">{{ t("FamilyTreePage.ancestor") }}</text>
<text class="detail-field__static">{{ tree.AncestorPersonGenealogyName || "-" }}</text>
<!-- 一谱一只祖先:未设时才“新增祖先”,已设则只能就地“修改祖先”(避免反复新建孤立成员) -->
<text v-if="isEdit && !tree.AncestorPersonId" class="detail-op-line"
@tap="openQuickCreate('ancestor')">
+ {{ t("FamilyTreePage.addAncestor") }}
</text>
<text v-else-if="isEdit && tree.AncestorPersonId" class="detail-op-line"
@tap="openEditPerson(tree.AncestorPersonId)">
{{ t("FamilyTreePage.editAncestor") }}
</text>
</view>
</view>
</view>
<!-- 当前族员卡:只读信息 + 操作钮组 -->
<view v-if="loaded" class="detail-card">
<view class="detail-card__head" @tap="toggleFold('person')">
<text class="detail-card__title">{{ t("FamilyTreePage.currentPerson") }}</text>
<u-icon :name="folded.person ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
<view v-show="!folded.person">
<!-- 头像行(旧系统同款):圆头像 + 名字带性别 + 编号芯片与常用名,无头像时灰底人形占位 -->
<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="absoluteFileUrl(currentPerson.AvatarUrl)" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="genderTheme(currentPerson?.Gender)" />
</view>
<view class="person-hero__main">
<view class="person-hero__line">
<text class="person-hero__name">{{ currentPersonName || "-" }}</text>
<text class="person-hero__gender">{{ genderText(currentPerson?.Gender) || "-" }}</text>
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">
<text v-if="currentPerson?.CommonName" class="person-hero__chip-name">
{{ currentPerson.CommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${currentPerson.PersonId}` }}
</text>
</view>
</view>
</view>
<view class="op-btns">
<view v-if="currentPerson?.PersonId" 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 class="op-btn" @tap="fullChartVisible = true">
<view class="ft-glyph ft-glyph--full" />
<text class="op-btn-text">{{ t("FamilyTreePage.ancestryChart") }}</text>
</view>
<view v-if="!data.IsManager" class="op-btn" @tap="requestManager">
<u-icon name="server-man" size="26" :color="primary" />
<text class="op-btn-text">{{ t("FamilyTreePage.applyManage") }}</text>
</view>
</view>
</view>
</view>
<!-- 夫妻记录卡:查看态只读可展开(对齐旧系统查看态的关系维护),编辑态带添加/排序/删除 -->
<view class="detail-card">
<view class="detail-card__head" @tap="toggleFold('spouses')">
<view class="detail-card__title-group">
<text class="detail-card__title">{{ t("FamilyTreePage.spouseTitle") }}</text>
<text class="detail-card__count">{{ spouseMaps.length }}</text>
</view>
<view class="detail-card__head-acts">
<template v-if="isEdit">
<text class="detail-card__add" @tap.stop="openQuickCreate('spouse')">
+ {{ t("FamilyTreePage.addSpouse") }}
</text>
<text class="detail-card__add" @tap.stop="pickSpouse">
{{ t("FamilyTreePage.pickSpouse") }}
</text>
</template>
<u-icon :name="folded.spouses ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
</view>
<view v-show="!folded.spouses">
<view v-if="!spouseMaps.length" class="detail-empty">
<text>{{ t("FamilyTreePage.spouseEmpty") }}</text>
</view>
<view v-for="(record, index) in spouseMaps" :key="record.SpouseId" class="rel-item">
<view class="rel-head" @tap="expandedSpouses[record.SpouseId] = !expandedSpouses[record.SpouseId]">
<!-- 当前族员同款:圆头像 + 名字带性别 + 芯片(常用名值+成员编号) -->
<view class="rel-avatar"
:style="avatarFallbackStyle(!!personAvatar(record.SpouseId), personGender(record.SpouseId, record.Gender))">
<image v-if="personAvatar(record.SpouseId)" class="rel-avatar__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="rel-text">
<view class="person-hero__line">
<text class="rel-name">{{ record.SpouseSurname }}{{ record.SpouseGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.SpouseId, record.Gender))
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
<text v-if="record.SpouseCommonName" class="person-hero__chip-name">
{{ record.SpouseCommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${record.SpouseId}` }}
</text>
</view>
</view>
<view class="rel-ops">
<u-icon name="edit-pen" size="28" :color="primary"
@click.stop="openEditPerson(record.SpouseId)" />
<template v-if="isEdit">
<u-icon name="arrow-up" size="28" :color="index === 0 ? '#e0e0e0' : primary"
@click.stop="moveSpouse(index, -1)" />
<u-icon name="arrow-down" size="28"
:color="index === spouseMaps.length - 1 ? '#e0e0e0' : primary"
@click.stop="moveSpouse(index, 1)" />
<u-icon name="trash" size="32" color="#c4c8cf" @click.stop="deleteSpouse(record.SpouseId)" />
</template>
<u-icon :name="expandedSpouses[record.SpouseId] ? 'up' : 'down'" size="26" color="#8f959e" />
</view>
</view>
<view v-if="expandedSpouses[record.SpouseId]" class="rel-form">
<template v-if="isEdit">
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryDateText") }}</text>
<input v-model="record.MarryDateText" class="sub-input" :maxlength="50">
</view>
<picker mode="date" :value="datePart(record.MarryDate)"
@change="record.MarryDate = $event.detail.value">
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ datePart(record.MarryDate) || t("AdminPage.notSetTip")
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
</picker>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.divorceDateText") }}</text>
<input v-model="record.DivorceDateText" class="sub-input" :maxlength="50">
</view>
<picker mode="date" :value="datePart(record.DivorceDate)"
@change="record.DivorceDate = $event.detail.value">
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.divorceDate") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{
datePart(record.DivorceDate) || t("AdminPage.notSetTip")
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
</picker>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryType") }}</text>
<input v-model="record.MarryType" class="sub-input" :maxlength="50">
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.isCurrent") }}</text>
<u-switch :model-value="record.IsCurrent === 'Y'" size="40" :active-color="primary"
@change="record.IsCurrent = $event ? 'Y' : 'N'" />
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.remark") }}</text>
<input v-model="record.Remark" class="sub-input" :maxlength="100">
</view>
</template>
<!-- 查看态只读明细 -->
<template v-else>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
<text class="sub-static">{{ datePart(record.MarryDate) || "-" }}</text>
</view>
<view v-if="record.MarryDateText" class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryDateText") }}</text>
<text class="sub-static">{{ record.MarryDateText }}</text>
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.divorceDate") }}</text>
<text class="sub-static">{{ datePart(record.DivorceDate) || "-" }}</text>
</view>
<view v-if="record.MarryType" class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.marryType") }}</text>
<text class="sub-static">{{ record.MarryType }}</text>
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.isCurrent") }}</text>
<text class="sub-static">
{{ record.IsCurrent === "Y" ? t("FamilyTreePage.yes") : t("FamilyTreePage.no") }}
</text>
</view>
<view v-if="record.Remark" class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.remark") }}</text>
<text class="sub-static">{{ record.Remark }}</text>
</view>
</template>
</view>
</view>
</view>
</view>
<!-- 子女记录卡:查看态只读可展开,编辑态带添加/排序/删除 -->
<view class="detail-card">
<view class="detail-card__head" @tap="toggleFold('children')">
<view class="detail-card__title-group">
<text class="detail-card__title">{{ t("FamilyTreePage.childTitle") }}</text>
<text class="detail-card__count">{{ childMaps.length }}</text>
</view>
<view class="detail-card__head-acts">
<text v-if="isEdit" class="detail-card__add" @tap.stop="openQuickCreate('child')">
+ {{ t("FamilyTreePage.addChild") }}
</text>
<u-icon :name="folded.children ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
</view>
<view v-show="!folded.children">
<view v-if="!childMaps.length" class="detail-empty">
<text>{{ t("FamilyTreePage.childEmpty") }}</text>
</view>
<view v-for="(record, index) in childMaps" :key="record.ChildrenId" class="rel-item">
<view class="rel-head"
@tap="expandedChildren[record.ChildrenId] = !expandedChildren[record.ChildrenId]">
<!-- 当前族员同款:圆头像 + 名字带性别 + 芯片(常用名值+成员编号) -->
<view class="rel-avatar"
:style="avatarFallbackStyle(!!personAvatar(record.ChildrenId), personGender(record.ChildrenId, record.Gender))">
<image v-if="personAvatar(record.ChildrenId)" class="rel-avatar__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="rel-text">
<view class="person-hero__line">
<text class="rel-name">{{ record.ChildrenSurname }}{{ record.ChildrenGenealogyName }}</text>
<text class="person-hero__gender">{{ genderText(personGender(record.ChildrenId, record.Gender))
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
<text v-if="record.ChildrenCommonName" class="person-hero__chip-name">
{{ record.ChildrenCommonName }}
</text>
{{ `${t("FamilyPersonPage.personId")}: ${record.ChildrenId}` }}
</text>
</view>
</view>
<view class="rel-ops">
<u-icon name="edit-pen" size="28" :color="primary"
@click.stop="openEditPerson(record.ChildrenId)" />
<template v-if="isEdit">
<u-icon name="arrow-up" size="28" :color="index === 0 ? '#e0e0e0' : primary"
@click.stop="moveChild(index, -1)" />
<u-icon name="arrow-down" size="28"
:color="index === childMaps.length - 1 ? '#e0e0e0' : primary"
@click.stop="moveChild(index, 1)" />
<u-icon name="trash" size="32" color="#c4c8cf" @click.stop="deleteChild(record.ChildrenId)" />
</template>
<u-icon :name="expandedChildren[record.ChildrenId] ? 'up' : 'down'" size="26" color="#8f959e" />
</view>
</view>
<view v-if="expandedChildren[record.ChildrenId]" class="rel-form">
<template v-if="isEdit">
<view class="sub-row" @tap="openParentPicker(record)">
<text class="sub-label">{{ t("FamilyTreePage.parent") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ spouseLabel(record.SpouseId) }}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
<view class="sub-row" @tap="openRelationPicker(record)">
<text class="sub-label">{{ t("FamilyTreePage.relationType") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ relationLabel(record.RelationType) }}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.remark") }}</text>
<input v-model="record.Remark" class="sub-input" :maxlength="100">
</view>
</template>
<!-- 查看态只读明细 -->
<template v-else>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.parent") }}</text>
<text class="sub-static">{{ spouseLabel(record.SpouseId) }}</text>
</view>
<view class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.relationType") }}</text>
<text class="sub-static">{{ relationLabel(record.RelationType) }}</text>
</view>
<view v-if="record.Remark" class="sub-row">
<text class="sub-label">{{ t("FamilyTreePage.remark") }}</text>
<text class="sub-static">{{ record.Remark }}</text>
</view>
</template>
</view>
</view>
</view>
</view>
<!-- 权限维护卡(仅管理员且查看态) -->
<view v-if="loaded && data.IsManager && !isEdit" class="detail-card">
<view class="detail-card__head" @tap="toggleFold('perms')">
<text class="detail-card__title">{{ t("FamilyTreePage.permTitle") }}</text>
<u-icon :name="folded.perms ? 'arrow-right' : 'arrow-down'" size="26" color="#8f959e" />
</view>
<view v-show="!folded.perms">
<view class="perm-section-head">
<text class="perm-section-label">{{ t("FamilyTreePage.permPending") }}</text>
<text class="perm-section-count">{{ pendingPermissions.length }}</text>
</view>
<view v-if="!pendingPermissions.length" class="detail-empty">
<text>{{ t("FamilyTreePage.permEmpty") }}</text>
</view>
<view v-for="record in pendingPermissions" :key="record.UserName" class="perm-row">
<view class="rel-text">
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
<text v-if="record.PersonLocalName" class="rel-sub">{{ record.PersonLocalName }}</text>
</view>
<view class="perm-ops">
<view class="perm-op" @tap="approvePermission(record, 'N')">
<text class="perm-op-text">{{ t("FamilyTreePage.approve") }}</text>
</view>
<view class="perm-op" @tap="approvePermission(record, 'Y')">
<text class="perm-op-text">{{ t("FamilyTreePage.setManager") }}</text>
</view>
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
<text class="perm-op-text perm-op-text--danger">{{ t("FamilyTreePage.reject") }}</text>
</view>
</view>
</view>
<view class="perm-divider" />
<view class="perm-section-head">
<text class="perm-section-label">{{ t("FamilyTreePage.permManager") }}</text>
<text class="perm-section-count">{{ managerPermissions.length }}</text>
</view>
<view v-if="!managerPermissions.length" class="detail-empty">
<text>{{ t("FamilyTreePage.permEmpty") }}</text>
</view>
<view v-for="record in managerPermissions" :key="record.PersonId" class="perm-row">
<view class="rel-text">
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
<text class="rel-sub">{{
record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag")
}}</text>
</view>
<view class="perm-ops">
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
<text class="perm-op-text perm-op-text--danger">{{ t("FamilyTreePage.remove") }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="tree-page__detail-space" />
</scroll-view>
</view>
</view>
<!-- 族员选择弹窗(族员搜索 / 选配偶共用):与邀请/编辑弹层同套底部 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">
<template #row="{ row }">
<view class="pick-person">
<view class="ft-av" :style="avatarFallbackStyle(!!row.AvatarUrl, row.Gender)">
<image v-if="row.AvatarUrl" class="ft-av__img" :src="absoluteFileUrl(row.AvatarUrl)" mode="aspectFill" />
<u-icon v-else name="account" size="40" :color="genderTheme(row.Gender)" />
</view>
<view class="ft-mrow__main">
<view class="ft-mrow__line">
<text class="ft-mrow__name">{{ `${row.Surname ?? ""}${row.GenealogyName ?? ""}` || "-" }}</text>
<text class="ft-mrow__sex">{{ genderText(row.Gender) }}</text>
</view>
<text class="ft-mrow__code">{{ row.CommonName || row.PersonId }}</text>
</view>
</view>
</template>
</admin-pick-popup>
<!-- 新增 / 修改家族成员(共用 person-editor:新增 create 落谱,修改 edit 载 personId);新增孩子姓默认随当前族员 -->
<person-editor v-model:visible="qcVisible" mode="create" :tree-id="treeId" :title="qcTitle"
:default-surname="qcPurpose === 'child' ? (currentPerson?.Surname ?? '') : ''" @saved="onQuickCreated" />
<person-editor v-model:visible="peEditVisible" mode="edit" :person-id="peEditPersonId"
:title="t('FamilyPersonPage.editTitle')" @saved="onPersonEdited" />
<family-tree-chart v-model:visible="subChartVisible" mode="sub" :root="data" :loaded-nodes="loadedNodes"
:title="t('FamilyTreePage.pedigreeChart')" @select-child="onChartSelectChild"
@pick-person="onChartPickPerson" />
<family-tree-chart v-model:visible="fullChartVisible" mode="full" :tree-id="treeId" :root="data"
:title="t('FamilyTreePage.ancestryChart')" />
<option-picker :visible="relationPickerVisible" :title="t('FamilyTreePage.relationType')" :items="relationItems"
:empty-text="t('FamilyTreePage.relationEmpty')" @close="relationPickerVisible = false"
@confirm="onRelationConfirm" />
<option-picker :visible="parentPickerVisible" :title="t('FamilyTreePage.parent')" :items="spouseOptions"
:empty-text="t('FamilyTreePage.parentEmpty')" @close="parentPickerVisible = false" @confirm="onParentConfirm" />
<!-- 族人档案弹层已取消:始祖卡单击不再弹窗,编辑走卡头「编辑资料」文字链 -->
<!-- ===== 未认证底部弹层:单击「未认证」徽标从底部弹出身份证认证(与身份验证页共用 useIdCardVerify) ===== -->
<u-popup :model-value="idPopup" mode="bottom" round="24" @close="idPopup = false">
<view class="ft-sheet">
<view class="ft-sheet__head">
<text class="ft-sheet__title">{{ t("IdentityVerificationPage.title") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="idPopup = false" />
</view>
<text class="home-verify__intro">{{ t("IdentityVerificationPage.intro") }}</text>
<view v-if="idvVerified" class="home-verify__verified">
<u-icon name="checkmark-circle-fill" size="70" color="#52c41a" />
<text class="home-verify__verified-text">{{ t("IdentityVerificationPage.verifiedDone") }}</text>
</view>
<template v-else>
<view class="home-verify__thumb" @tap="onUploadIdCard">
<image v-if="idvImage" class="home-verify__thumb-img" :src="idvImage" mode="aspectFill" />
<view v-else class="home-verify__empty">
<u-icon name="camera" size="44" color="#c4c8cf" />
<text class="home-verify__empty-text">{{ t("IdentityVerificationPage.uploadBut") }}</text>
</view>
<view v-if="idvUploading" class="home-verify__mask">
<text class="home-verify__mask-text">{{ t("IdentityVerificationPage.uploadingTip") }}</text>
</view>
</view>
<view class="ft-sheet__foot">
<view class="ft-sheet__btn" @tap="idPopup = false"><text>{{ t("AdminPage.cancelBut") }}</text></view>
<view class="ft-sheet__btn ft-sheet__btn--primary" @tap="onUploadIdCard"><text>{{ idvButtonText }}</text>
</view>
</view>
</template>
</view>
</u-popup>
<!-- ===== 编辑族谱信息弹层(仅名称/简介/公开三字段,始祖谱名不展示,关系与成员分开维护) ===== -->
<!-- ===== 交换文件导出弹层(信封加密在前端,密码只当密钥材料、后端不校验)===== -->
<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">
<text class="ft-sheet__title">{{ t("FamilyTreePage.editInfoTitle") }}</text>
<u-icon name="close" size="32" color="#8f959e" @click="cancelTreeEdit" />
</view>
<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')">
</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="-1"
:placeholder="t('FamilyTreePage.noBrief')" />
</view>
</view>
<view class="ft-switch-card">
<view class="ft-switch-card__main">
<text class="ft-switch-card__t">{{ t("FamilyTreePage.publicIndc") }}</text>
<text class="ft-switch-card__d">{{ t("FamilyTreePage.publicHint") }}</text>
</view>
<u-switch :model-value="tree.PublicIndc === 'Y'" :active-color="primary" @change="onPublicChange" />
</view>
</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>
</view>
</u-popup>
<!-- 有家文化传承:首页「+」新建底部编辑层(不另开页) -->
<article-editor :visible="articleEditorVisible" :api="articlesApi" @close="closeArticleEditor"
@saved="onArticleSaved" />
</view>
</app-page>
</template>
<style lang="scss" scoped>
/* ===== 页面骨架(媒体/共享页同套:整屏固定 + 内滚) ===== */
.tree-page {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tree-page__body {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
}
.tree-page__head {
flex-shrink: 0;
}
.tree-page__scroll-wrap {
flex: 1;
min-height: 0;
margin: 20rpx 20rpx 0;
overflow: hidden;
}
/* 列表态:外层 wrap 以 flex:1 + min-height:0 撑出确定视口高,内层 scroll-view 再 height:100%
才能触发内部滚动——与详情态同一套写法(scroll-view 直接挂 flex:1 时其内部滚动层拿不到确定高)。
列表 wrap 不留外边距,四周留白交给 scroll-view 的 padding,避免与状态栏 padding 叠加 */
.tree-page__scroll-wrap--list {
margin: 0;
}
/* 列表态整页滚动:分区面板按内容撑高(为日后首页并列多块面板留位)。
box-sizing 必加:uni 给 scroll-view 根设了 width:100%,默认 content-box 下
padding 会外溢成 100%+两侧留白,导致面板整体右移、两边不对称 */
.tree-page__body--list {
display: block;
box-sizing: border-box;
/* 顶部让出系统状态栏(App 端 navigationStyle:custom 会顶到状态栏;H5 下变量为 0) */
padding: calc(20rpx + var(--status-bar-height, 0px)) 20rpx 20rpx;
}
/* 列表底部留白:首页带固定 tabbar,末张卡需能滚到 tabbar 之上才看得见 */
.tree-page__list-space {
height: calc(140rpx + env(safe-area-inset-bottom, 0px));
}
/* ===== 顶部两张卡片:用户身份 + 网盘用量(白底圆角,与下方族谱面板同宽同调) ===== */
.home-cards {
display: flex;
flex-direction: column;
gap: 20rpx;
margin: 0 0 22rpx;
}
.home-card {
box-sizing: border-box;
flex: 1;
min-width: 0;
padding: 24rpx;
border-radius: 32rpx;
background: #ffffff;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.04);
}
.home-card--user {
position: relative;
overflow: hidden;
display: flex;
align-items: center;
gap: 30rpx;
min-height: 236rpx;
padding: 48rpx 44rpx;
border-radius: 40rpx;
background: var(--fg-gradient, linear-gradient(135deg, #2f9e6b 0%, #1f7d55 55%, #14664a 100%));
box-shadow: 0 20rpx 48rpx rgba(0, 0, 0, 0.26), inset 0 2rpx 0 rgba(255, 255, 255, 0.30);
}
/* 装饰层:斜向高光 + 右上柔光圆 + 品牌水印,纯装饰不拦截点击 */
.home-card__sheen {
position: absolute;
top: 0;
left: -30%;
width: 55%;
height: 100%;
background: linear-gradient(115deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0));
transform: skewX(-18deg);
pointer-events: none;
}
.home-card__glow {
position: absolute;
top: -70rpx;
right: -50rpx;
width: 240rpx;
height: 240rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0) 70%);
pointer-events: none;
}
.home-card__mark {
position: absolute;
right: 24rpx;
bottom: 18rpx;
width: 120rpx;
height: 120rpx;
opacity: 0.14;
pointer-events: none;
}
/* 仅 H5 下载 App 图标:放在「网盘存储空间」标题旁,绿描边 + 柔光圆底(与 sicon 同色系,白卡可见) */
.home-card__dlapp {
display: flex;
align-items: center;
justify-content: center;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: var(--fg-soft, #e6f6ec);
}
.home-card__dlapp-icon {
width: 28rpx;
height: 28rpx;
}
.home-card__avatar {
position: relative;
z-index: 1;
width: 128rpx;
height: 128rpx;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.22);
border: 4rpx solid rgba(255, 255, 255, 0.75);
box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.20);
}
.home-card__avatar-img {
width: 128rpx;
height: 128rpx;
border-radius: 50%;
}
.home-card__avatar-cam {
position: absolute;
right: 2rpx;
bottom: 2rpx;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
border: 2rpx solid #ffffff;
pointer-events: none;
}
.home-card__avatar-ph {
font-size: 60rpx;
font-weight: 600;
color: #ffffff;
}
.home-card__uinfo {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
}
.home-card__uname-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.home-card__uname {
max-width: 340rpx;
font-size: 42rpx;
font-weight: 700;
color: #ffffff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card__welcome {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-card__utype {
display: flex;
align-self: center;
align-items: center;
gap: 10rpx;
padding: 7rpx 22rpx 7rpx 16rpx;
border: 1rpx solid rgba(255, 255, 255, 0.5);
border-radius: 999rpx;
font-size: 26rpx;
color: #ffffff;
background: var(--fg-primary, #d98a3d);
}
.home-card__utype--tap {
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.12);
}
.home-card__utype-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.home-card__utype-text {
color: #ffffff;
font-size: 26rpx;
line-height: 1.4;
}
/* 未认证底部弹层:身份证缩略图与说明(复用 ft-sheet 头部/底部按钮) */
.home-verify__intro {
margin-bottom: 20rpx;
font-size: 24rpx;
line-height: 1.6;
color: #9aa0a6;
}
.home-verify__thumb {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 300rpx;
overflow: hidden;
border: 2rpx dashed #d6d9de;
border-radius: 16rpx;
background-color: #f5f6f8;
}
.home-verify__thumb-img {
width: 100%;
height: 100%;
}
.home-verify__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
}
.home-verify__empty-text {
font-size: 26rpx;
color: #9aa0a6;
}
.home-verify__mask {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.35);
}
.home-verify__mask-text {
font-size: 24rpx;
color: #ffffff;
}
.home-verify__verified {
display: flex;
flex-direction: column;
align-items: center;
gap: 20rpx;
padding: 48rpx 0;
}
.home-verify__verified-text {
font-size: 28rpx;
font-weight: 600;
color: #52c41a;
}
.home-card--storage {
position: relative;
overflow: hidden;
margin: 0 16rpx;
padding: 40rpx 44rpx;
border: 2rpx solid transparent;
border-radius: 40rpx;
background:
linear-gradient(135deg, #ffffff 0%, #f1f7f3 100%) padding-box,
linear-gradient(135deg, rgba(47, 158, 107, 0.55), rgba(47, 158, 107, 0.05)) border-box;
box-shadow: 0 16rpx 40rpx rgba(31, 45, 38, 0.10), inset 0 2rpx 0 rgba(255, 255, 255, 0.9);
}
.home-card__sglow {
position: absolute;
top: -60rpx;
right: -40rpx;
z-index: 0;
width: 220rpx;
height: 220rpx;
border-radius: 50%;
background: radial-gradient(circle, rgba(47, 158, 107, 0.16), rgba(47, 158, 107, 0) 70%);
pointer-events: none;
}
.home-card__stitle-wrap {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 14rpx;
}
.home-card__sicon {
display: flex;
align-items: center;
justify-content: center;
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
background: var(--fg-soft, #e6f6ec);
}
.home-card__sicon-img {
width: 34rpx;
height: 34rpx;
}
.home-card__srow {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.home-card__stitle {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.home-card__spct {
font-size: 28rpx;
font-weight: 700;
color: var(--fg-primary, #d98a3d);
}
.home-card__spct--over {
color: #e5484d;
}
.home-card__bar {
position: relative;
z-index: 1;
height: 16rpx;
border-radius: 999rpx;
background: #e9edf0;
overflow: hidden;
}
.home-card__bar-fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--fg-primary, #d98a3d), #f0b877);
transition: width 0.3s ease;
}
.home-card__bar-fill--over {
background: linear-gradient(90deg, #e5484d, #ff8a80);
}
.home-card__snums {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
}
.home-card__snum {
font-size: 26rpx;
color: #7a827f;
}
/* ===== 列表态分区面板:主题浅底圆角,标题与圆钮组同行,白底族谱卡嵌在其中 ===== */
.tree-panel {
padding: 28rpx;
border-radius: 32rpx;
background: var(--fg-soft, #f6efe8);
}
.tree-panel__head {
display: flex;
align-items: center;
gap: 16rpx;
}
.tree-panel__title {
flex: 1;
font-size: 38rpx;
font-weight: 700;
color: #1f2329;
}
.tree-panel__acts {
display: flex;
align-items: center;
gap: 14rpx;
}
/* 头部圆钮:搜索/伸缩白底深图,新建主题渐变底白图(与旧悬浮球同面色);
白钮投影与橙钮光晕同量级,三颗视觉分量一致 */
.tree-panel__act {
display: flex;
align-items: center;
justify-content: center;
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background-color: #ffffff;
box-shadow: 0 6rpx 16rpx rgba(31, 35, 41, 0.12);
}
.tree-panel__act--add {
background: var(--fg-gradient, #ff9d45);
box-shadow: 0 6rpx 16rpx var(--fg-shadow, rgba(255, 157, 69, 0.35));
}
.tree-panel__search {
margin-top: 20rpx;
}
/* search-input 自带页级 24rpx 左右留白(通栏页面用),嵌进面板要剥掉才能与卡片对齐 */
.tree-panel__search :deep(.search-input) {
padding-right: 0;
padding-left: 0;
}
.tree-panel__list {
margin-top: 24rpx;
}
.tree-page__scroll {
height: 100%;
}
.tree-page__detail-space {
height: 48rpx;
}
.tree-page__state,
.tree-page__empty {
display: flex;
align-items: center;
justify-content: center;
gap: 24rpx;
padding: 64rpx 32rpx;
}
.tree-page__state-text {
font-size: 26rpx;
color: #9aa0a6;
}
.tree-page__state-text--error {
color: #f0413e;
}
.tree-page__retry {
padding: 8rpx 28rpx;
border-radius: 999rpx;
font-size: 26rpx;
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
/* ===== 列表卡片(媒体页 album-card 同套,改版:上行 头像+名称+徽章+始祖,下行整行简介) ===== */
.tree-card {
position: relative;
display: flex;
flex-direction: column;
gap: 16rpx;
margin: 0 0 20rpx;
padding: 28rpx 56rpx 28rpx 28rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
}
.tree-card:last-child {
margin-bottom: 0;
}
/* 首行:头像 + 名称/徽章 + 始祖,垂直居中 */
.tree-card__top {
display: flex;
align-items: center;
gap: 20rpx;
}
.tree-card__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
overflow: hidden;
border-radius: 20rpx;
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
flex-shrink: 0;
}
/* 封面图铺满头像位(与文章卡封面同口径,无封面才回退树形 glyph) */
.tree-card__cover {
width: 100%;
height: 100%;
}
/* 树形字形(内联 SVG data-URI) */
.tree-card__glyph {
width: 56rpx;
height: 56rpx;
}
.tree-card__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 8rpx;
}
.tree-card__name {
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 简介整行多行显示:省略号由 briefLine 按字数在末尾补(CSS 多行省略会在换行处也补) */
.tree-card__desc {
font-size: 24rpx;
line-height: 1.6;
color: #8f959e;
word-break: break-word;
}
.tree-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10rpx;
}
.tree-card__chip {
padding: 2rpx 14rpx;
border-radius: 8rpx;
font-size: 20rpx;
color: #5c626b;
background-color: #f2f3f5;
&--code,
&--public {
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
}
/* 箭头改挂右上角,不再占首行一列(简介要整行铺开) */
.tree-card__arrow {
position: absolute;
top: 50%;
right: 16rpx;
transform: translateY(-50%);
}
/* ===== 有家文化传承文章卡(复用 tree-panel 面板几何,卡体换横向图文) ===== */
.article-card {
display: flex;
align-items: center;
gap: 20rpx;
margin: 0 0 20rpx;
padding: 24rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
}
.article-card:last-child {
margin-bottom: 0;
}
.article-card__cover {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 128rpx;
height: 128rpx;
overflow: hidden;
border-radius: 20rpx;
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
}
.article-card__cover-img {
width: 100%;
height: 100%;
}
.article-card__glyph {
width: 60rpx;
height: 60rpx;
}
.article-card__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 8rpx;
}
.article-card__titlerow {
display: flex;
align-items: center;
gap: 10rpx;
}
.article-card__name {
flex: 1;
min-width: 0;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.article-card__chip {
flex: none;
padding: 2rpx 12rpx;
border-radius: 8rpx;
font-size: 20rpx;
color: #5c626b;
background-color: #f2f3f5;
}
.article-card__chip--top {
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
.article-card__chip--pending {
color: #d48806;
background-color: #fff7e6;
}
.article-card__desc {
font-size: 24rpx;
line-height: 34rpx;
color: #8f959e;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.article-card__meta {
display: flex;
align-items: center;
gap: 10rpx;
}
.article-card__avatar {
flex: none;
width: 36rpx;
height: 36rpx;
border-radius: 50%;
background-color: #f2f3f5;
}
.article-card__avatar--empty {
background: var(--fg-soft, #eaf4ff);
}
.article-card__author {
font-size: 22rpx;
color: #5c626b;
}
.article-card__date {
margin-left: auto;
font-size: 20rpx;
color: #bfc3c9;
}
/* ===== 详情头卡 + 圆形动作钮(与共享页同套) ===== */
.detail-hero {
position: relative;
display: flex;
align-items: center;
gap: 28rpx;
margin: 0 4rpx 20rpx;
padding: 40rpx 32rpx;
border-radius: 32rpx;
background-color: #ffffff;
}
.detail-hero__acts {
position: absolute;
top: 28rpx;
right: 28rpx;
display: flex;
align-items: center;
gap: 20rpx;
}
/* 圆形动作钮:浅灰底 + 主题色图标,删除用浅红底红图标 */
.detail-hero__act {
display: flex;
align-items: center;
justify-content: center;
width: 76rpx;
height: 76rpx;
border-radius: 50%;
background-color: #f2f3f5;
&--danger {
background-color: #ffeceb;
}
}
.detail-hero__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 140rpx;
height: 140rpx;
overflow: hidden;
border-radius: 32rpx;
background: var(--fg-gradient, linear-gradient(135deg, #4a9dff, #2b8fff));
flex-shrink: 0;
}
/* 编辑态头卡同步显示封面(上传入口在浏览态族徽上) */
.detail-hero__cover {
width: 100%;
height: 100%;
}
/* 树形字形(与列表卡同款内联 SVG data-URI) */
.detail-hero__glyph {
width: 84rpx;
height: 84rpx;
}
/* 当前族员头像行(旧系统 hero 同款):圆头像 + 名字 + 编号芯片 */
.person-hero {
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
/* 上距标题 24rpx;下距分隔线由 .op-btns 的 margin-top(24rpx) 提供,两侧视觉等距 */
padding: 24rpx 0 0;
}
.person-hero__avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
overflow: hidden;
background-color: var(--fg-soft, #eaf4ff);
border: 4rpx solid #ffffff;
border-radius: 50%;
box-shadow: 0 4rpx 14rpx rgba(31, 35, 41, 0.12);
}
.person-hero__img {
width: 100%;
height: 100%;
}
.person-hero__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 10rpx;
}
.person-hero__line {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 16rpx;
}
.person-hero__name {
font-size: 32rpx;
font-weight: 700;
color: #1f2329;
}
.person-hero__gender {
font-size: 26rpx;
color: #8f959e;
}
.person-hero__chips {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 12rpx;
}
.person-hero__chip {
padding: 4rpx 18rpx;
font-size: 22rpx;
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
border-radius: 10rpx;
}
/* 芯片内的常用名值:排在编号前,同芯片浅底、深色字(旧系统同款合并位) */
.person-hero__chip-name {
margin-right: 24rpx;
font-size: 24rpx;
color: #1f2329;
}
.detail-hero__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 10rpx;
}
.detail-hero__name {
font-size: 40rpx;
font-weight: 700;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 族谱编号独立成行:灰字说明(同媒体页专辑编号) */
.detail-hero__code {
font-size: 26rpx;
color: #8f959e;
}
.detail-hero__chips {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 6rpx;
}
/* 头卡胶囊:公开=主题浅底主题字,管理员=黄底橙字,私密=浅灰 */
.detail-hero__chip {
padding: 6rpx 22rpx;
border-radius: 12rpx;
font-size: 24rpx;
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
&--admin {
color: #ff9500;
background-color: #fff4e0;
}
&--member {
color: #5c626b;
background-color: #f2f3f5;
}
}
/* ===== 动作栏(仅编辑态):96/24 描边规格,主钮跟主题 ===== */
.detail-actions {
display: flex;
gap: 20rpx;
margin: 0 4rpx 20rpx;
}
.detail-actions__btn {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
height: 96rpx;
border: 2rpx solid #e5e6eb;
border-radius: 24rpx;
font-size: 32rpx;
color: #5c626b;
background-color: #ffffff;
&--primary {
border-color: transparent;
font-weight: 600;
color: #ffffff;
background-color: var(--fg-primary, #2b8fff);
}
}
/* ===== 分区卡片(折叠) ===== */
.detail-card {
margin: 0 4rpx 20rpx;
padding: 28rpx;
border-radius: 24rpx;
background-color: #ffffff;
}
.detail-card__head {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 标题行右侧一组:添加钮 + 折叠箭头 */
.detail-card__head-acts {
display: flex;
align-items: center;
gap: 28rpx;
}
.detail-card__title-group {
display: flex;
align-items: center;
gap: 14rpx;
}
.detail-card__title {
font-size: 34rpx;
font-weight: 700;
color: #1f2329;
}
/* 标题后的条数徽标(对齐旧系统「夫妻关系 1」的计数) */
.detail-card__count {
padding: 2rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
color: var(--fg-primary, #ff7d1f);
background: var(--fg-soft, rgba(255, 157, 69, 0.14));
}
.detail-card__add {
font-size: 26rpx;
color: var(--fg-primary, #2b8fff);
}
.detail-empty {
padding: 40rpx 0;
text-align: center;
font-size: 24rpx;
color: #bfc3c9;
}
/* ===== 字段行:白底描边输入件(同媒体页编辑态) ===== */
.detail-field {
display: flex;
flex-direction: column;
gap: 12rpx;
margin-top: 24rpx;
}
.detail-field--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.detail-field__label {
font-size: 26rpx;
color: #5c626b;
}
/* 编号/祖先等不可编辑说明:纯灰字 */
.detail-field__static {
font-size: 28rpx;
line-height: 1.6;
color: #8f959e;
}
.detail-field__value {
font-size: 28rpx;
color: #1f2329;
}
.detail-field__input {
box-sizing: border-box;
width: 100%;
padding: 0 24rpx;
height: 88rpx;
border: 2rpx solid #e6e7ea;
border-radius: 16rpx;
font-size: 28rpx;
color: #1f2329;
background-color: #ffffff;
}
.detail-field__textarea {
box-sizing: border-box;
width: 100%;
min-height: 200rpx;
padding: 20rpx 24rpx;
border: 2rpx solid #e6e7ea;
border-radius: 16rpx;
font-size: 28rpx;
color: #1f2329;
background-color: #ffffff;
}
/* 字段下的操作行(新增祖先等) */
.detail-op-line {
font-size: 26rpx;
color: var(--fg-primary, #2b8fff);
}
/* ===== 当前族员操作钮组 ===== */
.op-btns {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-around;
gap: 16rpx;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 2rpx solid #f2f3f5;
}
.op-btn {
display: flex;
flex-direction: row;
align-items: center;
padding: 8rpx 0;
}
.op-btn-text {
margin-left: 6rpx;
font-size: 26rpx;
color: var(--fg-primary, #2b8fff);
}
/* ===== 关系行(夫妻/孩子):展开编辑 + 排序/删除 ===== */
.rel-item {
padding: 16rpx 0;
border-bottom: 2rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
.rel-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
/* 关系行圆头像(当前族员同款缩小一号):档案无头像时灰底人形占位 */
.rel-avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 80rpx;
height: 80rpx;
overflow: hidden;
background-color: var(--fg-soft, #eaf4ff);
border-radius: 50%;
}
.rel-avatar__img {
width: 100%;
height: 100%;
}
.rel-text {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
gap: 8rpx;
}
.rel-name {
font-size: 28rpx;
color: #1f2329;
}
.rel-sub {
margin-top: 4rpx;
font-size: 22rpx;
color: #8f959e;
}
.rel-ops {
display: flex;
flex-direction: row;
align-items: center;
gap: 20rpx;
flex-shrink: 0;
}
.rel-form {
margin-top: 12rpx;
padding: 8rpx 20rpx 8rpx 24rpx;
background-color: #f7f8fa;
border-radius: 16rpx;
}
.sub-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 80rpx;
}
.sub-label {
font-size: 24rpx;
color: #5c626b;
flex-shrink: 0;
}
/* 查看态只读值:与编辑控件同列右对齐 */
.sub-static {
flex: 1;
min-width: 0;
margin-left: 24rpx;
font-size: 26rpx;
color: #1f2329;
text-align: right;
}
.sub-input {
box-sizing: border-box;
flex: 1;
min-width: 0;
height: 64rpx;
margin-left: 24rpx;
padding: 0 20rpx;
border: 2rpx solid #e6e7ea;
border-radius: 12rpx;
font-size: 26rpx;
color: #1f2329;
background-color: #ffffff;
text-align: right;
}
.sub-picker {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.sub-picker-text {
font-size: 24rpx;
color: #5c626b;
}
/* ===== 权限维护行 ===== */
/* 子标题分组头:加深字色 + 加粗 + 计数徽章,让分区标题清晰可辨(对齐参照图) */
.perm-section-head {
display: flex;
flex-direction: row;
align-items: center;
gap: 12rpx;
padding: 28rpx 0 8rpx;
}
/* 第二分区(管理名单)上方的通栏分隔带:负外边距 bleed 到卡片左右边,色带用主题软底把白卡横向"切开";
加 border-radius 让色带两端收圆角,圆角处透出白卡底,两分区过渡更柔和 */
.perm-divider {
height: 16rpx;
margin: 28rpx -28rpx 0;
background-color: var(--fg-soft, #f6efe8);
border-radius: 16rpx;
}
.perm-section-label {
font-size: 28rpx;
font-weight: 600;
color: #1f2329;
}
.perm-section-count {
min-width: 34rpx;
padding: 0 10rpx;
font-size: 22rpx;
line-height: 34rpx;
color: var(--fg-primary, #2b8fff);
text-align: center;
background-color: var(--fg-soft, #eaf4ff);
border-radius: 17rpx;
}
.perm-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
padding: 12rpx 0;
border-bottom: 2rpx solid #f2f3f5;
&:last-child {
border-bottom: none;
}
}
.perm-ops {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
flex-shrink: 0;
}
.perm-op {
padding: 8rpx 20rpx;
background-color: #f2f3f5;
border-radius: 999rpx;
&--danger {
background-color: #ffeceb;
}
}
.perm-op-text {
font-size: 24rpx;
color: var(--fg-primary, #2b8fff);
&--danger {
color: #f0413e;
}
}
/* ============================================================
详情态重设计(浏览态):Hero + 三段 Tab + 底部操作条 + 弹窗
配色全部走主题变量(--fg-primary/--fg-gradient/--fg-soft/--fg-shadow)
============================================================ */
/* ===== Hero 族谱卡:主题浅底渐变 + 族徽 + 名称/编号/徽章 + 三栏统计 ===== */
.ft-hero {
position: relative;
overflow: hidden;
margin: 0 4rpx 20rpx;
padding: 32rpx;
border-radius: 32rpx;
background: linear-gradient(135deg, #ffffff 0%, var(--fg-soft, #f6efe8) 100%);
}
.ft-hero__top {
display: flex;
align-items: flex-start;
gap: 24rpx;
}
.ft-hero__crest {
position: relative;
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 168rpx;
height: 168rpx;
overflow: hidden;
border-radius: 36rpx;
background: var(--fg-gradient, linear-gradient(135deg, #ff9d45, #ff7d1f));
box-shadow: 0 12rpx 24rpx -12rpx var(--fg-shadow, rgba(255, 125, 31, 0.5));
}
/* 封面图铺满族徽位(族徽已放大,无封面才回退树形 glyph) */
.ft-hero__cover {
width: 100%;
height: 100%;
}
/* 相机角标:与首页头像角标同款(黑半透圆 + 白描边,不吃点击) */
.ft-hero__crest-cam {
position: absolute;
right: 8rpx;
bottom: 8rpx;
display: flex;
align-items: center;
justify-content: center;
width: 44rpx;
height: 44rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
border: 2rpx solid #ffffff;
pointer-events: none;
}
.ft-hero__glyph {
width: 96rpx;
height: 96rpx;
}
.ft-hero__info {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 10rpx;
}
.ft-hero__name {
font-size: 40rpx;
font-weight: 700;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.ft-hero__code {
display: inline-flex;
align-items: center;
gap: 8rpx;
align-self: flex-start;
padding: 4rpx 16rpx;
border-radius: 10rpx;
font-size: 24rpx;
color: #8f959e;
background-color: rgba(255, 255, 255, 0.7);
}
.ft-hero__badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 4rpx;
}
.ft-badge {
padding: 4rpx 18rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 600;
&--pub {
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
&--priv {
color: #5c626b;
background-color: #f2f3f5;
}
&--admin {
color: #ff9500;
background-color: #fff4e0;
}
}
.ft-hero__stats {
display: flex;
margin-top: 28rpx;
padding-top: 24rpx;
border-top: 2rpx solid rgba(31, 35, 41, 0.06);
}
.ft-stat {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
gap: 4rpx;
&+&::before {
content: "";
position: absolute;
top: 6rpx;
bottom: 6rpx;
left: 0;
width: 2rpx;
background-color: rgba(31, 35, 41, 0.08);
}
}
.ft-stat__n {
font-size: 36rpx;
font-weight: 700;
color: var(--fg-primary, #2b8fff);
}
.ft-stat__l {
font-size: 22rpx;
color: #8f959e;
}
/* ===== 三段 Tab 吸顶胶囊 ===== */
.ft-tabs {
display: flex;
gap: 6rpx;
margin: 0 4rpx 20rpx;
padding: 6rpx;
border-radius: 999rpx;
background-color: rgba(240, 229, 217, 0.7);
}
.ft-tab {
position: relative;
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 8rpx;
height: 68rpx;
border-radius: 999rpx;
font-size: 27rpx;
color: #6e6459;
&--on {
font-weight: 600;
color: #1f2329;
background-color: #ffffff;
box-shadow: 0 4rpx 14rpx -6rpx rgba(60, 40, 20, 0.28);
}
}
.ft-tab__cnt {
padding: 0 10rpx;
border-radius: 999rpx;
font-size: 20rpx;
color: #6e6459;
background-color: rgba(31, 35, 41, 0.07);
}
.ft-tab--on .ft-tab__cnt {
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
}
.ft-tab__dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #e2583f;
}
/* ===== 分区容器 ===== */
.ft-pane {
padding-bottom: 8rpx;
}
.ft-card {
margin: 0 4rpx 20rpx;
padding: 28rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
}
.ft-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.ft-card__title-group {
display: flex;
align-items: center;
gap: 14rpx;
}
.ft-card__title {
font-size: 30rpx;
font-weight: 700;
color: #1f2329;
}
.ft-card__count {
padding: 2rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
color: var(--fg-primary, #ff7d1f);
background: var(--fg-soft, rgba(255, 157, 69, 0.14));
}
.ft-card__link {
font-size: 26rpx;
color: var(--fg-primary, #2b8fff);
}
.ft-intro {
display: block;
margin-top: 8rpx;
font-size: 27rpx;
line-height: 1.7;
color: #5c626b;
}
/* ===== 信息行 ===== */
.ft-rows {
margin-top: 8rpx;
}
.ft-row {
display: flex;
align-items: center;
gap: 16rpx;
min-height: 76rpx;
padding: 12rpx 0;
border-top: 2rpx solid #f5f6f8;
&:first-child {
border-top: none;
}
}
.ft-rows--inset {
margin: 20rpx 0;
padding: 8rpx 24rpx;
border-radius: 20rpx;
background-color: #f7f8fa;
}
.ft-row__k {
flex: none;
font-size: 26rpx;
color: #8f959e;
}
.ft-row__v {
flex: 1;
min-width: 0;
text-align: right;
font-size: 28rpx;
color: #1f2329;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
&--mono {
font-weight: 600;
}
}
.ft-empty {
padding: 40rpx 0;
text-align: center;
font-size: 24rpx;
color: #bfc3c9;
}
/* ===== 统计 tab:顶部四概览卡 + 各世代人数分布表 ===== */
.ft-stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16rpx;
margin-bottom: 20rpx;
}
.ft-stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 28rpx 8rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(0, 0, 0, 0.06);
}
.ft-stat-card__num {
font-size: 44rpx;
font-weight: 700;
line-height: 1.1;
color: var(--fg-primary, #d98a3d);
}
.ft-stat-card__num--male {
color: #2b8fff;
}
.ft-stat-card__num--female {
color: #eb2f96;
}
.ft-stat-card__label {
font-size: 22rpx;
color: #8a8f99;
}
/* 各世代表头:与行同列宽对齐(世代列固定 / 人数列固定 / 条占余下) */
.ft-gen-head {
display: flex;
align-items: center;
padding: 12rpx 8rpx;
font-size: 22rpx;
color: #8a8f99;
}
.ft-gen-head__col--gen,
.ft-gen-row__gen {
flex: none;
width: 96rpx;
}
.ft-gen-head__col--cnt,
.ft-gen-row__cnt {
flex: none;
width: 96rpx;
text-align: right;
}
.ft-gen-head__col--bar {
flex: 1;
margin-left: 16rpx;
}
.ft-gen-row {
display: flex;
align-items: center;
padding: 14rpx 8rpx;
border-top: 1rpx solid rgba(0, 0, 0, 0.05);
}
.ft-gen-row__gen {
font-size: 26rpx;
color: #303133;
}
.ft-gen-row__cnt {
font-size: 26rpx;
font-weight: 600;
color: #303133;
}
.ft-gen-bar {
flex: 1;
height: 20rpx;
margin-left: 16rpx;
overflow: hidden;
border-radius: 999rpx;
background-color: var(--fg-soft, #f0e6da);
}
.ft-gen-bar__fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, var(--fg-primary, #d98a3d), #f0b877);
transition: width 0.3s ease;
}
/* ===== 快捷操作 2 列 ===== */
.ft-quick {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18rpx;
margin-top: 16rpx;
}
.ft-qa {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx;
border-radius: 20rpx;
background-color: #fbf7f2;
font-size: 26rpx;
color: #1f2329;
}
/* ===== 成员搜索 + 筛选 chips ===== */
.ft-search {
margin: 0 4rpx 16rpx;
}
.ft-search :deep(.search-input) {
padding-right: 0;
padding-left: 0;
}
.ft-chips {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
margin: 0 4rpx 20rpx;
}
.ft-chip {
padding: 8rpx 26rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: #6e6459;
background-color: #ffffff;
&--on {
font-weight: 600;
color: #ffffff;
background: var(--fg-gradient, #ff7d1f);
}
}
/* ===== 成员行 ===== */
.ft-mrow {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 0;
border-top: 2rpx solid #f5f6f8;
&:first-of-type {
border-top: none;
}
}
.ft-av {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 84rpx;
height: 84rpx;
overflow: hidden;
border-radius: 50%;
background-color: var(--fg-soft, #eaf4ff);
}
.ft-av__img {
width: 100%;
height: 100%;
}
/* 成员行头像作为「切换当前族员」可点区,按下反馈:微隔加背景,与行尾操作区拉开灰度,避免误判为无交互 */
.ft-av--tap {
opacity: 0.72;
transform: scale(0.96);
}
.ft-mrow__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 8rpx;
}
.ft-mrow__line {
display: flex;
align-items: baseline;
gap: 14rpx;
}
.ft-mrow__name {
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
.ft-mrow__sex {
padding: 0 12rpx;
border-radius: 8rpx;
font-size: 20rpx;
color: #8f959e;
background-color: #f2f3f5;
}
.ft-mrow__code {
font-size: 22rpx;
color: #b0b5bd;
}
/* ===== 虚线 ghost 钮(查看完整谱系图 / 邀请) ===== */
.ft-ghost {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin: 4rpx 4rpx 20rpx;
padding: 26rpx;
border: 2rpx dashed var(--fg-primary, #2b8fff);
border-radius: 20rpx;
font-size: 27rpx;
color: var(--fg-primary, #2b8fff);
background-color: rgba(255, 255, 255, 0.5);
}
/* ===== 权限行 ===== */
.ft-perm-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 20rpx 0;
border-top: 2rpx solid #f5f6f8;
&:first-of-type {
border-top: none;
}
}
.ft-perm-row__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 6rpx;
}
.ft-perm-row__name {
font-size: 28rpx;
color: #1f2329;
}
.ft-perm-row__sub {
font-size: 22rpx;
color: #8f959e;
}
.ft-perm-ops {
display: flex;
flex: none;
align-items: center;
gap: 12rpx;
}
.ft-perm-op {
padding: 10rpx 22rpx;
border-radius: 999rpx;
font-size: 24rpx;
color: var(--fg-primary, #2b8fff);
background-color: var(--fg-soft, #eaf4ff);
&--danger {
color: #f0413e;
background-color: #ffeceb;
}
}
.ft-perm-op__t--danger {
color: #f0413e;
}
.ft-apply {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
margin-top: 12rpx;
padding: 24rpx;
border-radius: 20rpx;
font-size: 28rpx;
color: #ffffff;
background: var(--fg-gradient, #ff7d1f);
}
/* ===== 底部主操作条 ===== */
.ft-bottombar {
display: flex;
flex: none;
align-items: center;
gap: 16rpx;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background-color: rgba(255, 255, 255, 0.92);
border-top: 2rpx solid #f0f1f3;
}
.ft-bottombar__main {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
gap: 10rpx;
height: 88rpx;
border-radius: 999rpx;
font-size: 30rpx;
font-weight: 600;
color: #ffffff;
background: var(--fg-gradient, linear-gradient(135deg, #ff9d45, #ff7d1f));
box-shadow: 0 10rpx 22rpx -10rpx var(--fg-shadow, rgba(255, 125, 31, 0.6));
}
.ft-bottombar__icon {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background-color: var(--fg-soft, #eaf4ff);
}
/* 定制谱系图 glyph:static SVG 走 CSS mask 上色,跟随主题色 var(--fg-primary);--bar 为底部条小一号
用 longhand 写法(mask-image/repeat/size/position)而非 mask 简写,uni-app H5 下更稳 */
.ft-glyph {
display: inline-block;
flex: none;
width: 48rpx;
height: 48rpx;
background-color: var(--fg-primary, #2b8fff);
mask-repeat: no-repeat;
mask-position: center;
mask-size: 100% 100%;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: 100% 100%;
}
.ft-glyph--sub {
mask-image: url("/static/icons/sub-chart.svg");
-webkit-mask-image: url("/static/icons/sub-chart.svg");
}
.ft-glyph--full {
mask-image: url("/static/icons/full-chart.svg");
-webkit-mask-image: url("/static/icons/full-chart.svg");
}
.ft-glyph--bar {
width: 44rpx;
height: 44rpx;
}
/* ===== 底部弹窗通用壳 ===== */
.ft-sheet {
display: flex;
flex-direction: column;
padding: 32rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
}
.ft-sheet__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.ft-sheet__title {
font-size: 34rpx;
font-weight: 700;
color: #1f2329;
}
.ft-sheet__foot {
display: flex;
gap: 20rpx;
margin-top: 24rpx;
}
.ft-sheet__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 92rpx;
border-radius: 999rpx;
font-size: 30rpx;
color: #5c626b;
background-color: #f2f3f5;
&--primary {
font-weight: 600;
color: #ffffff;
background: var(--fg-gradient, linear-gradient(135deg, #ff9d45, #ff7d1f));
}
}
/* 族员选择弹窗行:头像+姓名带性别+常用名副行,与成员列表 ft-mrow 同观感(ft-av/ft-mrow 系本组件作用域样式,插槽内容随父作用域生效) */
.pick-person {
display: flex;
align-items: center;
gap: 16rpx;
width: 100%;
}
/* 关系卡头右侧快捷添加(添加配偶/添加子女):旧编辑态卡头链接同款文字链 */
.ft-card__add {
display: flex;
flex: none;
align-items: center;
gap: 6rpx;
font-size: 27rpx;
color: var(--fg-primary, #2b8fff);
}
/* 关系行右侧直达动作组(旧编辑态式 ↑↓/编辑/垃圾桶):紧凑横排,与行尾留距 */
.ft-mrow__ops {
display: flex;
flex: none;
align-items: center;
gap: 28rpx;
margin-left: 8rpx;
}
/* ===== 危险动作菜单项(权限 tab 底部删除族谱;原更多弹层已取消,样式保留复用) ===== */
.ft-menu-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 24rpx 0;
border-bottom: 2rpx solid #f5f6f8;
&:last-child {
border-bottom: none;
}
}
.ft-menu-item__ic {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 68rpx;
height: 68rpx;
border-radius: 18rpx;
background-color: var(--fg-soft, #eaf4ff);
}
.ft-menu-item--danger .ft-menu-item__ic {
background-color: #ffeceb;
}
.ft-menu-item__main {
display: flex;
flex: 1;
min-width: 0;
flex-direction: column;
gap: 4rpx;
}
.ft-menu-item__t {
font-size: 30rpx;
color: #1f2329;
&--danger {
color: #f0413e;
}
}
.ft-menu-item__d {
font-size: 22rpx;
color: #b0b5bd;
}
/* ===== 编辑族谱信息弹层(四字段表单) ===== */
.ft-sheet__form {
display: flex;
flex-direction: column;
gap: 28rpx;
padding: 8rpx 0;
}
.ft-field {
display: flex;
flex-direction: column;
gap: 12rpx;
&__label {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
&__input {
box-sizing: border-box;
width: 100%;
height: 88rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1d2129;
border: 2rpx solid #e5e6eb;
border-radius: 20rpx;
background-color: #f7f8fa;
}
&__area {
position: relative;
}
&__textarea {
box-sizing: border-box;
width: 100%;
height: 180rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
line-height: 1.5;
color: #1d2129;
border: 2rpx solid #e5e6eb;
border-radius: 20rpx;
background-color: #f7f8fa;
}
}
/* ===== 交换文件弹层专属:说明小字/警示/文件行(选中态跟主题 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;
justify-content: space-between;
gap: 24rpx;
padding: 24rpx;
border-radius: 20rpx;
background-color: #f7f8fa;
&__main {
display: flex;
flex-direction: column;
gap: 6rpx;
}
&__t {
font-size: 28rpx;
font-weight: 600;
color: #1d2129;
}
&__d {
font-size: 24rpx;
color: #8f959e;
}
}
</style>