5314 lines
187 KiB
Vue
5314 lines
187 KiB
Vue
<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>
|