This commit is contained in:
owenchen committed 2026-10-23 15:15:53 +08:00
1 parent 6c86c59bdc
commit 31de6c4cd6
15 files changed
+1001 -666

No files matched your search

@@ -77,6 +77,24 @@ const treeGlyph = `data:image/svg+xml,${encodeURIComponent([
"</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("");
@@ -531,9 +549,9 @@ const memberFilter = ref<MemberFilter>("all");
/** 邀请族人弹层已随底部分享入口取消(无后端接口占位),相关函数/样式/词条已同步清理,待邀请 service 就绪再重建 */
/** 编辑族谱信息弹层:只含 名称/始祖谱名/简介/对外公开 四字段,关系与成员在其他入口分开维护 */
/** 编辑族谱信息弹层:只含 名称/简介/对外公开 三字段(始祖谱名不展示,仍随快照守护),关系与成员在其他入口分开维护 */
const treeEditVisible = ref(false);
/** 打开弹层时的四字段快照:取消时本地还原直改,免去重载带来的“处理中” */
/** 打开弹层时的字段快照(含不展示的始祖谱名):取消时本地还原直改,免去重载带来的“处理中” */
const treeEditSnapshot = ref({ TreeName: "", AncestorPersonGenealogyName: "", Brief: "", PublicIndc: "N" });
/** hero 统计「位族人」:全谱总人数(FindByCurrentPerson 只下发直系小集合,切当前族员会跳变,改走搜索链 Total 全量口径) */
@@ -645,7 +663,52 @@ async function onPublicToggle(value: boolean): Promise<void> {
});
}
/** 打开「编辑族谱信息」弹层:浏览态四字段快改入口(无修改权限挡回),先记快照供取消还原 */
/** 点封面上传族谱封面(与首页头像同款交互):单图 -> /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"));
@@ -1693,177 +1756,185 @@ defineExpose({
<view class="tree-page" :style="[{ background: themeBackground }, themeVars]">
<!-- ===== 列表态:软底分区面板(标题 + 搜索/新建/伸缩圆钮组 + 白底族谱卡) ===== -->
<!-- 隐藏原生滚动条:否则 Chrome 滚动条占右侧 ~17px,面板左右留白不对称 -->
<scroll-view v-if="!inDetail" scroll-y :show-scrollbar="false" class="tree-page__body 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 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>
<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__avatar">
<image 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>
<text class="tree-card__desc">{{ row.Brief || t("FamilyTreePage.noBrief") }}</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>
<text v-if="row.Surname || row.GenealogyName" class="tree-card__ancestor">
{{ `${row.Surname}${row.GenealogyName}` }}
</text>
<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>
<u-icon 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" />
<text class="home-card__welcome">{{ t("storage.welcome") }}</text>
</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>
<text v-if="row.ApprovedIndc !== 'Y'" class="article-card__chip article-card__chip--pending">{{
t("article.tagPending") }}</text>
<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="article-card__desc">{{ articlesApi.plainText(row.Brief, 100) || t("article.noBrief")
<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 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 class="article-card__date">{{ articlesApi.formatDate(row.CreatedDatetime) }}</text>
</view>
</view>
<u-icon name="arrow-right" size="30" color="#c0c4cc" />
</view>
</template>
<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>
</scroll-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">
@@ -1877,8 +1948,14 @@ defineExpose({
<!-- Hero 族谱卡 -->
<view class="ft-hero">
<view class="ft-hero__top">
<view class="ft-hero__crest">
<image class="ft-hero__glyph" :src="treeGlyph" mode="aspectFit" />
<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>
@@ -1894,7 +1971,7 @@ defineExpose({
<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>
}}</text>
</view>
</view>
</view>
@@ -2006,7 +2083,7 @@ defineExpose({
<!-- 当前族员:从概览移入,夫妻/子女列表都是相对此人 -->
<view v-if="loaded && currentPerson" class="ft-card">
<view class="ft-card__head"><text class="ft-card__title">{{ t("FamilyTreePage.currentPerson")
}}</text></view>
}}</text></view>
<view class="person-hero">
<view class="person-hero__avatar"
:style="avatarFallbackStyle(!!currentPerson?.AvatarUrl, currentPerson?.Gender)">
@@ -2021,7 +2098,7 @@ defineExpose({
</view>
<view class="person-hero__chips">
<text v-if="currentPerson?.PersonId" class="person-hero__chip">{{ personChipText(currentPerson)
}}</text>
}}</text>
</view>
</view>
</view>
@@ -2064,12 +2141,12 @@ defineExpose({
<!-- 新增入口与行尾操作同一把闸:当前锚点不可编(未绑定/出支系)时隐藏 -->
<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>
}}</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>
}}</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)"
@@ -2106,13 +2183,13 @@ defineExpose({
<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>
}}</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>
}}</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)"
@@ -2153,7 +2230,7 @@ defineExpose({
<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>
}}</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 ||
@@ -2177,10 +2254,10 @@ defineExpose({
<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>
}}</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>
}}</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
@@ -2250,17 +2327,17 @@ defineExpose({
<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--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>
<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 class="ft-gen-bar__fill" :style="{ width: genBarWidth(gen.Count) }" />
</view>
</view>
</block>
@@ -2293,7 +2370,9 @@ defineExpose({
<!-- 头卡(与媒体/共享页同套):白底 + 族谱树图标(与列表卡同款渐变底白树形) + 编号灰字独立行 -->
<view class="detail-hero">
<view class="detail-hero__avatar">
<image class="detail-hero__glyph" :src="treeGlyph" mode="aspectFit" />
<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>
@@ -2465,7 +2544,7 @@ defineExpose({
<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>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2502,7 +2581,7 @@ defineExpose({
<text class="sub-label">{{ t("FamilyTreePage.marryDate") }}</text>
<view class="sub-picker">
<text class="sub-picker-text">{{ datePart(record.MarryDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2518,7 +2597,7 @@ defineExpose({
<view class="sub-picker">
<text class="sub-picker-text">{{
datePart(record.DivorceDate) || t("AdminPage.notSetTip")
}}</text>
}}</text>
<u-icon name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
@@ -2604,7 +2683,7 @@ defineExpose({
<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>
}}</text>
</view>
<view class="person-hero__chips">
<text class="person-hero__chip">
@@ -2715,7 +2794,7 @@ defineExpose({
<text class="rel-name">{{ record.UserLocalName || record.UserName }}</text>
<text class="rel-sub">{{
record.ManagerIndc === "Y" ? t("FamilyTreePage.managerTag") : t("FamilyTreePage.memberTag")
}}</text>
}}</text>
</view>
<view class="perm-ops">
<view class="perm-op perm-op--danger" @tap="rejectPermission(record)">
@@ -2804,7 +2883,7 @@ defineExpose({
</view>
</u-popup>
<!-- ===== 编辑族谱信息弹层(仅名称/始祖谱名/简介/公开四字段,关系与成员分开维护) ===== -->
<!-- ===== 编辑族谱信息弹层(仅名称/简介/公开三字段,始祖谱名不展示,关系与成员分开维护) ===== -->
<!-- ===== 交换文件导出弹层(信封加密在前端,密码只当密钥材料、后端不校验)===== -->
<u-popup :model-value="exportVisible" mode="bottom" round="24" @close="exportVisible = false">
<view class="ft-sheet">
@@ -2892,7 +2971,7 @@ defineExpose({
<!-- 服务器文件列表限高滚动:文件多了不挤掉密码框/警告/按钮 -->
<scroll-view v-if="!localFileName" scroll-y class="ft-exchange__list">
<view v-if="importFilesLoading" class="ft-empty"><text>{{ t("FamilyTreePage.exchangeFileLoading")
}}</text></view>
}}</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"
@@ -2913,7 +2992,7 @@ defineExpose({
<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>
}}</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">
@@ -2955,17 +3034,11 @@ defineExpose({
<input v-model="tree.TreeName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.treeNameRequired')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.ancestorGenealogyName") }}</text>
<input v-model="tree.AncestorPersonGenealogyName" class="ft-field__input" :maxlength="50"
:placeholder="t('FamilyTreePage.ancestorGenealogyName')">
</view>
<view class="ft-field">
<text class="ft-field__label">{{ t("FamilyTreePage.brief") }}</text>
<view class="ft-field__area">
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="60"
<textarea v-model="tree.Brief" class="ft-field__textarea" :maxlength="-1"
:placeholder="t('FamilyTreePage.noBrief')" />
<text class="ft-field__counter">{{ (tree.Brief || "").length }}/60</text>
</view>
</view>
<view class="ft-switch-card">
@@ -2979,7 +3052,7 @@ defineExpose({
<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>
}}</text>
</view>
</view>
</view>
@@ -3023,6 +3096,13 @@ defineExpose({
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%+两侧留白,导致面板整体右移、两边不对称 */
@@ -3033,6 +3113,11 @@ defineExpose({
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;
@@ -3485,13 +3570,14 @@ defineExpose({
background-color: var(--fg-soft, #eaf4ff);
}
/* ===== 列表卡片(媒体页 album-card 同套) ===== */
/* ===== 列表卡片(媒体页 album-card 同套,改版:上行 头像+名称+徽章+始祖,下行整行简介) ===== */
.tree-card {
position: relative;
display: flex;
align-items: center;
gap: 20rpx;
flex-direction: column;
gap: 16rpx;
margin: 0 0 20rpx;
padding: 28rpx;
padding: 28rpx 56rpx 28rpx 28rpx;
border-radius: 24rpx;
background-color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(31, 35, 41, 0.05);
@@ -3501,17 +3587,31 @@ defineExpose({
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;
@@ -3535,12 +3635,12 @@ defineExpose({
text-overflow: ellipsis;
}
/* 简介整行多行显示:省略号由 briefLine 按字数在末尾补(CSS 多行省略会在换行处也补) */
.tree-card__desc {
font-size: 24rpx;
line-height: 1.6;
color: #8f959e;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
word-break: break-word;
}
.tree-card__meta {
@@ -3564,10 +3664,12 @@ defineExpose({
}
}
.tree-card__ancestor {
margin-left: auto;
font-size: 20rpx;
color: #bfc3c9;
/* 箭头改挂右上角,不再占首行一列(简介要整行铺开) */
.tree-card__arrow {
position: absolute;
top: 50%;
right: 16rpx;
transform: translateY(-50%);
}
/* ===== 有家文化传承文章卡(复用 tree-panel 面板几何,卡体换横向图文) ===== */
@@ -3733,11 +3835,18 @@ defineExpose({
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;
@@ -4259,20 +4368,44 @@ defineExpose({
}
.ft-hero__crest {
position: relative;
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
border-radius: 28rpx;
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: 64rpx;
height: 64rpx;
width: 96rpx;
height: 96rpx;
}
.ft-hero__info {
@@ -5060,7 +5193,7 @@ defineExpose({
box-sizing: border-box;
width: 100%;
height: 180rpx;
padding: 20rpx 24rpx 44rpx;
padding: 20rpx 24rpx;
font-size: 28rpx;
line-height: 1.5;
color: #1d2129;
@@ -5068,14 +5201,6 @@ defineExpose({
border-radius: 20rpx;
background-color: #f7f8fa;
}
&__counter {
position: absolute;
right: 24rpx;
bottom: 16rpx;
font-size: 24rpx;
color: #8f959e;
}
}
/* ===== 交换文件弹层专属:说明小字/警示/文件行(选中态跟主题 soft 底) ===== */