This commit is contained in:
owenchen committed 2026-10-23 09:46:48 +08:00
1 parent 94d5fb2ec9
commit 6c86c59bdc
15 files changed
+429 -14

No files matched your search

@@ -13,6 +13,7 @@ import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayDat
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";
@@ -477,10 +478,50 @@ const managerPermissions = computed(() =>
);
// ===== 详情态重设计:三段式 Tab + 底部操作条 + 弹窗(仅浏览态;编辑态沿用旧内联布局) =====
/** 详情 tab:概览 / 成员 / 权限 */
type DetailTab = "overview" | "members" | "perms";
/** 详情 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("");
@@ -559,6 +600,10 @@ const visibleChildren = computed(() => childMaps.value.filter(
function switchTab(tab: DetailTab): void {
activeTab.value = tab;
// 统计 tab 首次进入(或换谱后数据为空)懒加载一次,后续复用缓存
if (tab === "stats" && !statsData.value && !statsLoading.value) {
void fetchStatistics();
}
}
/** 成员行是否命中当前搜索 + 性别筛选(name 传姓+谱名连写,personId 供编号检索) */
@@ -749,6 +794,9 @@ function resetDetail(): void {
memberKeyword.value = "";
memberFilter.value = "all";
treeEditVisible.value = false;
// 换谱时统计缓存一并清空,下次进 stats tab 重新懒加载(避免沿用上一谱数据)
statsData.value = null;
statsLoading.value = false;
}
/** 载入详情:FindByCurrentPerson({ key, personId }),首载 personId 传空串(后端回默认族员) */
@@ -779,6 +827,10 @@ async function load(): Promise<void> {
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> {
@@ -885,6 +937,8 @@ const personPickMode = ref<"current" | "spouse">("current");
function pickCurrentPerson(): void {
personPickMode.value = "current";
// 只能查本族族员:弹窗打开前把当前 TreeId 下发给 loader 作等值过滤
personPickConfig.fixedFilters.value = { TreeId: treeId.value.trim() };
personPickVisible.value = true;
}
@@ -917,6 +971,11 @@ async function setCurrentPerson(personId: string): Promise<void> {
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";
}
}
}
@@ -1098,6 +1157,8 @@ async function onPersonEdited(person?: FamilyPersonDisplayData): Promise<void> {
// 已有成员里选配偶(快速建员之外的入口,对齐旧页):与族员搜索共用同一个底部弹窗
function pickSpouse(): void {
personPickMode.value = "spouse";
// 选配偶同样限本族:与切换当前族员共用同一弹窗与过滤口径
personPickConfig.fixedFilters.value = { TreeId: treeId.value.trim() };
personPickVisible.value = true;
}
@@ -1864,6 +1925,11 @@ defineExpose({
<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>
<!-- ===== 概览 ===== -->
@@ -2005,7 +2071,8 @@ defineExpose({
<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"
<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" />
@@ -2047,7 +2114,8 @@ defineExpose({
<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"
<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" />
@@ -2149,6 +2217,56 @@ defineExpose({
</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>
@@ -4409,6 +4527,106 @@ defineExpose({
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;
@@ -4489,6 +4707,12 @@ defineExpose({
height: 100%;
}
/* 成员行头像作为「切换当前族员」可点区,按下反馈:微隔加背景,与行尾操作区拉开灰度,避免误判为无交互 */
.ft-av--tap {
opacity: 0.72;
transform: scale(0.96);
}
.ft-mrow__main {
display: flex;
flex: 1;
@@ -400,19 +400,30 @@ export function usePersonPickConfig() {
// 成员编号列不显示:弹窗列表口径与族谱页统一用常用名辨识(rowKey 仍是 PersonId,仅不占行)
]);
async function loader(page: number, pageSize: number, keyword: string) {
async function loader(
page: number,
pageSize: number,
keyword: string,
filters: Record<string, string>,
) {
const criteria = new FamilyPersonSearchCriteria();
criteria.GridColumns = [];
criteria.page = page;
criteria.limit = pageSize;
criteria.orderby = FamilyPersonEntity.DB_GENEALOGY_NAME;
const filters: SearchFilter[] = [];
const filterList: SearchFilter[] = [];
// 固定过滤:按 TreeId 等值限定本族(由调用方设定,缺失则不限制)
if (filters.TreeId) {
filterList.push(
equalFilter(FamilyPersonEntity.DB_TREE_ID, filters.TreeId),
);
}
if (keyword) {
filters.push(
filterList.push(
containFilter(FamilyPersonEntity.DB_GENEALOGY_NAME, keyword),
);
}
criteria.Filter = filters;
criteria.Filter = filterList;
const data = await familyPersonService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
@@ -1,9 +1,12 @@
import { ArticleEntity } from "./articleEntity";
export class ArticleDisplayData extends ArticleEntity {
export class ArticleDisplayData extends ArticleEntity
{
public AuthorLocalName: string = "";
public AuthorAvatarUrl: string = "";
public CoverUrl: string = "";
public IsOwner: boolean = false;
public IsMember: boolean = false;
public IsManager: boolean = false;
public IsOwner: boolean =false;
public IsMember: boolean =false;
public IsManager: boolean =false;
}
@@ -1,4 +1,7 @@
import { ArticleFilenameEntity } from "./articleFilenameEntity";
export class ArticleFilenameDisplayData extends ArticleFilenameEntity {
export class ArticleFilenameDisplayData extends ArticleFilenameEntity
{
public ImageUrl: string = "";
}
@@ -0,0 +1,7 @@
export class FamilyTreeGenerationCountData
{
public Generation: number = 0;
public Count: number = 0;
}
@@ -0,0 +1,11 @@
import { FamilyTreeGenerationCountData } from "./familyTreeGenerationCountData";
export class FamilyTreeStatisticsData
{
public Total: number = 0;
public Male: number = 0;
public Female: number = 0;
public GenerationCount: number = 0;
public PerGeneration: FamilyTreeGenerationCountData[] = [];
}
@@ -337,9 +337,20 @@
"tabOverview": "Overview",
"tabMembers": "Members",
"tabPerms": "Access",
"tabStats": "Stats",
"statMembers": "members",
"statManagers": "managers",
"statPending": "pending",
"statsTotal": "Members",
"statsGeneration": "Generations",
"statsMale": "Male",
"statsFemale": "Female",
"statsPerGeneration": "Members per Generation",
"statsColGeneration": "Gen.",
"statsColCount": "Count",
"statsGenerationUnit": "G",
"statsLoading": "Loading statistics…",
"statsEmpty": "No statistics yet",
"introTitle": "Brief",
"quickActions": "Quick Actions",
"editProfile": "Edit Info",
@@ -337,9 +337,20 @@
"tabOverview": "概览",
"tabMembers": "成员",
"tabPerms": "权限",
"tabStats": "统计",
"statMembers": "位族人",
"statManagers": "管理员",
"statPending": "待审核",
"statsTotal": "入树成员",
"statsGeneration": "世代层数",
"statsMale": "男",
"statsFemale": "女",
"statsPerGeneration": "各世代人数",
"statsColGeneration": "世代",
"statsColCount": "人数",
"statsGenerationUnit": "世",
"statsLoading": "统计加载中…",
"statsEmpty": "暂无统计数据",
"introTitle": "族谱简介",
"quickActions": "快捷操作",
"editProfile": "编辑资料",
@@ -5,6 +5,7 @@ import { request } from "@/utils/request";
import type { FamilyTreeResultData } from "@/data/auto/familyTreeResultData";
import type { FamilyTreeExchangeData } from "@/data/auto/familyTreeExchangeData";
import type { FamilyTreeExchangeFileInfo } from "@/data/auto/familyTreeExchangeFileInfo";
import type { FamilyTreeStatisticsData } from "@/data/auto/familyTreeStatisticsData";
class FamilyTreeService{
@@ -227,6 +228,16 @@ class FamilyTreeService{
)
};
async GetStatistics(data: string) {
return request<FamilyTreeStatisticsData>(
{
url: "/FamilyTree/GetStatistics?treeId="+data,
method: "post",
data,
}
)
};
}
const familyTreeService= new FamilyTreeService();