This commit is contained in:
owenchen committed 2026-10-15 23:20:36 +08:00
1 parent dcc112c076
commit b858386e86
15 files changed
+3634 -69

No files matched your search

@@ -0,0 +1,578 @@
<script setup lang="ts">
/**
* 族谱图(原生递归树容器):全平台不依赖 @antv/g6,用 scroll-view + 递归 family-tree-node 绘制。
*
* 一个组件承载两种数据源(对齐旧 WebVue 的两张 G6 图):
* - mode="sub"(谱系图):根 = 当前族员(props.root.CurrentPerson),子树按需懒加载——
* 点未加载的子女 emit("select-child"),页面拉完写进 props.loadedNodes 再回来展开;
* - mode="full"(祖谱系图):组件内部 FindAllByTreeId 拉全族三张表(人员/夫妻/亲子)拍平索引,
* 从始祖向下渲染;同一孩子挂多个父时只在「承嗣/主承担」父下展开,其余父仅显示引用卡(对齐旧图)。
*
* 展开态、性别/关系文案、头像查表都集中在这里,node 只经 inject 的 api 取用(见 types.ts)。
*/
import type { LoadedTreeNode } from "./types";
import type { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import type { FamilyTreeData } from "@/data/auto/familyTreeData";
import type { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
import { useLocale } from "uview-pro";
import { computed, provide, ref, watch } from "vue";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import familyTreeService from "@/service/master/familyTreeService";
import { FAMILY_TREE_CHART_API } from "./types";
const props = defineProps({
visible: { type: Boolean, default: false },
/** sub=当前族员子树(懒加载);full=祖谱全树 */
mode: { type: String as () => "sub" | "full", default: "sub" },
treeId: { type: String, default: "" },
/** sub 模式数据源:当前族员所在族谱 */
root: { type: Object as () => FamilyTreeData | null, default: null },
/** sub 模式已展开加载的族员(personId -> 子树) */
loadedNodes: { type: Object as () => Record<string, LoadedTreeNode>, default: () => ({}) },
title: { type: String, default: "" },
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "select-child", personId: string): void;
(e: "pick-person", personId: string): void;
}>();
const { t } = useLocale();
const { getPolicyText } = usePolicyOptions();
/** 展开状态:personId -> 是否展开(子树/全树共用) */
const expanded = ref<Record<string, boolean>>({});
const loading = ref(false);
// ===== 全树模式:FindAllByTreeId 拍平出的索引 =====
interface FullIndex {
rootId: string;
personMap: Map<string, any>;
spousesOf: Map<string, any[]>;
childrenOf: Map<string, any[]>;
parentsOf: Map<string, { parentId: string; relation?: string }[]>;
/** 当前族人 PersonId(FindAllByTreeId 的 CurrentPerson) */
currentId: string;
/** 主系链:父 PersonId -> 该父子女中位于「祖→当前族人」链上的那一个,排序时置顶(旧图主系居首同款) */
lineageChildOf: Map<string, string>;
}
const fullIndex = ref<FullIndex | null>(null);
/** 承祧 / 入嗣 / 收养类关系:孩子过继到该父名下,由其承担后代世系(对齐旧图) */
const ADOPTIVE_RELATIONS = ["C", "E", "F", "G", "H", "I", "J", "K", "L"];
function isMale(gender?: string): boolean {
if (!gender) {
return true;
}
const label = getPolicyText("GenderPolicy", gender).toLowerCase();
return label.includes("男") || label.includes("male") || gender === "M" || gender === "1";
}
function childTag(relationType?: string): string {
return relationType ? getPolicyText("RelationTypePolicy", relationType) || "子女" : "子女";
}
function byOrder(a: { Orderby?: number }, b: { Orderby?: number }): number {
return (a.Orderby ?? 0) - (b.Orderby ?? 0);
}
/** 全族头像查表:sub 用 root.CurrentPerson.FamilyTreePersons + loadedNodes;full 用 personMap */
function getAvatar(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.AvatarUrl) {
return hit.AvatarUrl;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.AvatarUrl) {
return loaded.person.AvatarUrl;
}
const full = fullIndex.value?.personMap.get(personId);
return full?.AvatarUrl ?? "";
}
/** 性别查表:关系视图里的 Gender 是拥有者(丈夫/父亲)本人的字段,配偶/子女必须回查族人档案拿真实性别 */
function getGender(personId: string): string {
const subPersons = (props.root?.CurrentPerson?.FamilyTreePersons ?? []) as any[];
const hit = subPersons.find(p => p?.PersonId === personId);
if (hit?.Gender) {
return hit.Gender;
}
const loaded = props.loadedNodes[personId];
if (loaded?.person?.Gender) {
return loaded.person.Gender;
}
return fullIndex.value?.personMap.get(personId)?.Gender ?? "";
}
const rootPersonId = computed(() => {
if (props.mode === "full") {
return fullIndex.value?.rootId ?? "";
}
return props.root?.CurrentPerson?.FamilyPerson?.PersonId ?? "";
});
const hasRoot = computed(() => Boolean(rootPersonId.value) && describe(rootPersonId.value) !== null);
/** 把一份「人 + 配偶 + 子女」原始记录归一化成 ResolvedNode(sub:来自 CurrentPerson/loadedNodes) */
function resolveSubNode(personId: string, record: { person: any; spouses: FamilyTreeSpouseMapViewEntity[]; children: FamilyTreeChildrenMapViewEntity[] }, isRoot: boolean) {
const p = record.person;
const self = {
personId,
name: `${p.Surname ?? ""}${p.GenealogyName ?? ""}`,
commonName: p.CommonName ?? "",
gender: p.Gender ?? "",
avatarUrl: p.AvatarUrl ?? getAvatar(personId),
tag: isRoot ? "本人" : "族员",
};
const spouses = [...record.spouses].sort(byOrder).map((s: any) => ({
personId: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName ?? "",
gender: getGender(s.SpouseId),
avatarUrl: getAvatar(s.SpouseId),
tag: "配偶",
}));
const children = [...record.children].sort(byOrder).map((c: any) => {
const childId = c.ChildrenId;
const loaded = childId === rootPersonId.value || Boolean(props.loadedNodes[childId]);
return {
personId: childId,
card: {
personId: childId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName ?? "",
gender: getGender(childId),
avatarUrl: getAvatar(childId),
tag: childTag(c.RelationType),
},
loaded,
expandable: loaded,
};
});
return { self, spouses, children, loaded: true };
}
/** 全树节点归一化:索引数据恒已加载,expandable 由「主承担父」判定 */
function resolveFullNode(personId: string): any | null {
const index = fullIndex.value;
if (!index) {
return null;
}
const info = index.personMap.get(personId);
if (!info) {
return null;
}
const isRoot = personId === index.rootId;
const self = {
personId,
name: `${info.Surname ?? ""}${info.GenealogyName ?? ""}`,
commonName: info.CommonName ?? "",
gender: info.Gender ?? "",
avatarUrl: getAvatar(personId),
tag: isRoot ? "始祖" : "族员",
};
const spouses = [...(index.spousesOf.get(personId) ?? [])].sort(byOrder).map((s: any) => ({
personId: s.id,
name: s.name,
commonName: s.commonName ?? "",
gender: s.gender || index.personMap.get(s.id)?.Gender || "",
avatarUrl: getAvatar(s.id),
tag: "配偶",
}));
// 主系置顶:链向当前族人的那个子女排第 1,其余兄弟依原始顺序跟在后
const spineId = index.lineageChildOf.get(personId);
const children = [...(index.childrenOf.get(personId) ?? [])].sort((a: any, b: any) => {
if (spineId && a.id === spineId && b.id !== spineId) {
return -1;
}
if (spineId && b.id === spineId && a.id !== spineId) {
return 1;
}
return byOrder(a, b);
}).map((c: any) => {
const primary = primaryParentOf(c.id);
return {
personId: c.id,
card: {
personId: c.id,
name: c.name,
commonName: c.commonName ?? "",
gender: c.gender || index.personMap.get(c.id)?.Gender || "",
avatarUrl: getAvatar(c.id),
tag: childTag(c.relation),
},
loaded: true,
expandable: primary === personId,
};
});
return { self, spouses, children, loaded: true };
}
function primaryParentOf(personId: string): string {
const parents = fullIndex.value?.parentsOf.get(personId) ?? [];
if (parents.length === 0) {
return "";
}
const adopted = parents.find(p => ADOPTIVE_RELATIONS.includes(p.relation ?? ""));
return (adopted ?? parents[0]).parentId;
}
/** 供 node 调用:按模式解析某人;无法解析返回 null */
function describe(personId: string) {
if (!personId) {
return null;
}
if (props.mode === "full") {
return resolveFullNode(personId);
}
if (personId === rootPersonId.value) {
const cp = props.root?.CurrentPerson;
if (!cp?.FamilyPerson) {
return null;
}
return resolveSubNode(personId, {
person: cp.FamilyPerson,
spouses: cp.FamilyTreeSpouseMaps ?? [],
children: cp.FamilyTreeChildrenMaps ?? [],
}, true);
}
const loaded = props.loadedNodes[personId];
if (loaded) {
return resolveSubNode(personId, loaded, false);
}
return null;
}
function toggle(personId: string): void {
expanded.value = { ...expanded.value, [personId]: !expanded.value[personId] };
}
// ===== 缩放与布局方向(弹窗右上控制条)=====
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 2;
const ZOOM_STEP = 0.1;
const zoom = ref(1);
// 默认纵向:世代向下推进、主系居首列(⇅ 可切回横向阶梯)
const vertical = ref(true);
const zoomLabel = computed(() => `${Math.round(zoom.value * 100)}%`);
function clampZoom(value: number): number {
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Math.round(value * 10) / 10));
}
function zoomIn(): void {
zoom.value = clampZoom(zoom.value + ZOOM_STEP);
}
function zoomOut(): void {
zoom.value = clampZoom(zoom.value - ZOOM_STEP);
}
function toggleDirection(): void {
vertical.value = !vertical.value;
}
function requestLoad(personId: string): void {
emit("select-child", personId);
}
function requestPick(personId: string): void {
emit("pick-person", personId);
}
provide(FAMILY_TREE_CHART_API, {
describe,
expanded,
toggle,
requestLoad,
requestPick,
isMale,
vertical,
});
/** 全树:从 FamilyTreeData 拍平三张索引表(照抄旧 buildIndex 的字段映射) */
function buildFullIndex(resp: FamilyTreeData): FullIndex {
const personMap = new Map<string, any>();
const spousesOf = new Map<string, any[]>();
const childrenOf = new Map<string, any[]>();
const parentsOf = new Map<string, { parentId: string; relation?: string }[]>();
const cp = resp?.CurrentPerson;
(cp?.FamilyTreePersons ?? []).forEach((p: any) => {
if (p?.PersonId) {
personMap.set(p.PersonId, { ...p });
}
});
const ancestor = cp?.FamilyPerson;
if (ancestor?.PersonId && !personMap.has(ancestor.PersonId)) {
personMap.set(ancestor.PersonId, { ...ancestor });
}
const pushSpouse = (ownerId: string, entry: any) => {
if (!ownerId) {
return;
}
const list = spousesOf.get(ownerId) ?? [];
if (!list.some(x => x.id === entry.id)) {
list.push(entry);
spousesOf.set(ownerId, list);
}
};
(cp?.FamilyTreeSpouseMaps ?? []).forEach((s: any) => {
pushSpouse(s.PersonId, {
id: s.SpouseId,
name: `${s.SpouseSurname ?? ""}${s.SpouseGenealogyName ?? ""}`,
commonName: s.SpouseCommonName,
// 不取 s.Gender:那是拥有者本人的性别,配偶真实性别由 personMap 回查(见 resolveFullNode)
gender: undefined,
orderby: s.Orderby,
});
pushSpouse(s.SpouseId, {
id: s.PersonId,
name: `${s.Surname ?? ""}${s.GenealogyName ?? ""}`,
commonName: s.CommonName,
gender: undefined,
orderby: s.Orderby,
});
});
(cp?.FamilyTreeChildrenMaps ?? []).forEach((c: any) => {
const list = childrenOf.get(c.PersonId) ?? [];
if (!list.some(x => x.id === c.ChildrenId)) {
list.push({
id: c.ChildrenId,
name: `${c.ChildrenSurname ?? ""}${c.ChildrenGenealogyName ?? ""}`,
commonName: c.ChildrenCommonName,
// 同上:c.Gender 是父亲的性别,子女真实性别由 personMap 回查
gender: undefined,
relation: c.RelationType,
orderby: c.Orderby,
});
childrenOf.set(c.PersonId, list);
const parentList = parentsOf.get(c.ChildrenId) ?? [];
parentList.push({ parentId: c.PersonId, relation: c.RelationType });
parentsOf.set(c.ChildrenId, parentList);
}
});
const rootId = ancestor?.PersonId || resp?.FamilyTree?.AncestorPersonId || "";
// 主系链:从当前族人逐级向上到始祖,记下每一环「父 -> 链上子」,渲染时把该子排到子女首位。
// 锚点优先用页面当前选中族人(props.root,如切到表妹);FindAllByTreeId 的 CurrentPerson 是登录者本人,仅作兜底
const currentId = props.root?.CurrentPerson?.FamilyPerson?.PersonId || cp?.FamilyPerson?.PersonId || "";
const lineageChildOf = new Map<string, string>();
const visited = new Set<string>();
let cursor = currentId;
while (cursor && !visited.has(cursor)) {
visited.add(cursor);
const parentId = (parentsOf.get(cursor) ?? [])[0]?.parentId ?? "";
if (!parentId) {
break;
}
lineageChildOf.set(parentId, cursor);
cursor = parentId;
}
return { rootId, personMap, spousesOf, childrenOf, parentsOf, currentId, lineageChildOf };
}
async function loadFull(): Promise<void> {
const treeId = props.treeId.trim();
if (!treeId) {
fullIndex.value = null;
return;
}
try {
loading.value = true;
const resp = await familyTreeService.FindAllByTreeId(treeId);
if (resp?.CurrentPerson) {
fullIndex.value = buildFullIndex(resp);
}
else {
fullIndex.value = null;
}
}
catch (error) {
console.error(error);
fullIndex.value = null;
}
finally {
loading.value = false;
}
}
/** 弹窗打开:重置展开态到根节点;full 默认全树展开(旧祖谱系图同款),sub 仅展开根待懒加载 */
watch(
() => props.visible,
(on) => {
if (!on) {
return;
}
expanded.value = {};
if (props.mode === "full") {
loadFull().then(() => {
const next: Record<string, boolean> = {};
if (rootPersonId.value) {
next[rootPersonId.value] = true;
}
// 所有有子女的族员一律展开;非主承担父由 node 的 expandable 门控,不会重复渲染子树
fullIndex.value?.childrenOf.forEach((_list: any[], parentId: string) => {
next[parentId] = true;
});
expanded.value = next;
});
}
else if (rootPersonId.value) {
expanded.value = { [rootPersonId.value]: true };
}
},
);
/** sub 模式:新加载的族员自动展开,方便直接看其子树(对齐旧图 watch loadedNodes) */
watch(
() => Object.keys(props.loadedNodes),
(ids) => {
if (!props.visible || props.mode !== "sub") {
return;
}
const next = { ...expanded.value };
for (const id of ids) {
next[id] = true;
}
expanded.value = next;
},
);
function close(): void {
emit("update:visible", false);
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="0" :safe-area-inset-bottom="true" @close="close">
<view class="ftc">
<view class="ftc__bar">
<text class="ftc__title">
{{ props.title || t('FamilyTreePage.pedigreeChart') }}
</text>
<u-icon name="close" size="40" color="#8f959e" @click="close" />
</view>
<!-- 悬浮控制条:放大/缩小/百分比复位/横纵布局切换(对齐旧图 G6 工具钮) -->
<view class="ftc__ctrl">
<view class="ftc__ctrl-btn" @tap="zoomIn">
<text class="ftc__ctrl-glyph">+</text>
</view>
<view class="ftc__ctrl-btn ftc__ctrl-btn--label" @tap="zoom = 1">
<text class="ftc__ctrl-label">{{ zoomLabel }}</text>
</view>
<view class="ftc__ctrl-btn" @tap="zoomOut">
<text class="ftc__ctrl-glyph">-</text>
</view>
<view class="ftc__ctrl-btn" @tap="toggleDirection">
<text class="ftc__ctrl-glyph">⇅</text>
</view>
</view>
<scroll-view class="ftc__scroll" scroll-x scroll-y :show-scrollbar="true">
<view v-if="hasRoot" class="ftc__tree" :style="{ zoom }">
<family-tree-node :person-id="rootPersonId" :preview="null" :expandable="true" :depth="0" />
</view>
<view v-else class="ftc__empty">
<text>{{ loading ? t('AdminPage.loadingTip') : t('FamilyTreePage.chartEmpty') }}</text>
</view>
</scroll-view>
</view>
</u-popup>
</template>
<style lang="scss" scoped>
.ftc {
position: relative;
display: flex;
flex-direction: column;
/* 默认满屏:bottom 弹窗内容撞满视口,顶部预留安全区 */
height: 100vh;
background-color: #f5f6f8;
}
.ftc__bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: calc(28rpx + env(safe-area-inset-top)) 32rpx 28rpx;
background-color: #ffffff;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__title {
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
}
/* 悬浮控制条:右侧竖排,白底圆角轻阴影 */
.ftc__ctrl {
position: absolute;
top: calc(120rpx + env(safe-area-inset-top));
right: 24rpx;
z-index: 10;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 16rpx;
background: #ffffff;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.12);
}
.ftc__ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 88rpx;
height: 76rpx;
border-bottom: 2rpx solid #f2f3f5;
}
.ftc__ctrl-btn--label {
border-bottom-color: #f2f3f5;
}
.ftc__ctrl-glyph {
font-size: 34rpx;
color: #1f2329;
}
.ftc__ctrl-label {
font-size: 24rpx;
color: #646a73;
}
.ftc__scroll {
flex: 1;
min-height: 0;
white-space: nowrap;
}
.ftc__tree {
display: inline-block;
box-sizing: border-box;
/* 按内容实际宽度撑开:inline-block 默认被可用宽度封顶,超出部分只溢出不扩滚动区,
scroll-x 必须靠 max-content 拿到真实宽度才能横向滚起来;窄于容器时由 min-width 撑满 */
width: max-content;
min-width: 100%;
padding: 24rpx;
}
.ftc__empty {
display: flex;
align-items: center;
justify-content: center;
height: 320rpx;
font-size: 26rpx;
color: #8f959e;
}
</style>
@@ -0,0 +1,362 @@
<script setup lang="ts">
/**
* 族谱图递归节点:渲染「一个人 + 其配偶(同行、紫虚线相连)+ 其子女(实折线下钻)」,自引用形成整棵树。
*
* 数据来源全部经 chart 注入的 api(见 types.ts):
* - describe(personId) 命中即已加载,能拿到自身/配偶/子女并继续递归;
* - 命中不到(懒加载子树里还没拉取的子女)就用父节点传下来的 preview 卡片渲染占位,点击 requestLoad。
* 展开态集中在 chart.expanded(personId->bool),本组件只读不算自己的树,避免每层各存一份。
*
* 视觉对齐旧 G6 图:卡片顶粗色条 + 大圆头像同色描边(本人/始祖=橙,男=蓝,女=粉),
* 身份标签为同色浅底 chip,「点击展开/折叠」青底白字、「点击查看」紫底白字 chip。
*/
import type { FamilyCard, FamilyTreeChartApi } from "./types";
import { computed, inject } from "vue";
import { FAMILY_TREE_CHART_API } from "./types";
const props = defineProps({
personId: { type: String, required: true },
/** 未加载时的占位卡片;已加载可留空,由 describe 解析 */
preview: { type: Object as () => FamilyCard | null, default: null },
/** 是否允许就地展开(chart 依据加载态 + 承嗣去重决定) */
expandable: { type: Boolean, default: false },
/** 世代深度,用于逐级左缩进 */
depth: { type: Number, default: 0 },
});
const api = inject<FamilyTreeChartApi>(FAMILY_TREE_CHART_API)!;
const resolved = computed(() => api.describe(props.personId));
const loaded = computed(() => resolved.value !== null);
const card = computed<FamilyCard>(() => resolved.value?.self ?? (props.preview as FamilyCard));
const spouses = computed(() => resolved.value?.spouses ?? []);
const children = computed(() => resolved.value?.children ?? []);
const expanded = computed(() => Boolean(api.expanded.value[props.personId]));
const canToggle = computed(() => loaded.value && props.expandable && children.value.length > 0);
const isSelf = computed(() => card.value.tag === "本人" || card.value.tag === "始祖");
const theme = computed(() => {
if (isSelf.value) {
return "#fa8c16";
}
return api.isMale(card.value.gender) ? "#1890ff" : "#eb2f96";
});
/** 标签 chip 的同色浅底(hex 尾缀透明度,与旧图浅底彩字一致) */
const themeSoft = computed(() => `${theme.value}1a`);
function spouseTheme(sp: FamilyCard): string {
return api.isMale(sp.gender) ? "#1890ff" : "#eb2f96";
}
const actionText = computed(() => {
if (!loaded.value) {
return "点击查看";
}
if (canToggle.value) {
return expanded.value ? "点击折叠" : "点击展开";
}
return "";
});
/** 未加载=紫底「点击查看」,可展开/折叠=青底,与旧图两种按钮同款 */
const actionClass = computed(() => (loaded.value ? "ftn__action--toggle" : "ftn__action--load"));
function onCardTap(): void {
if (!loaded.value) {
api.requestLoad(props.personId);
return;
}
if (canToggle.value) {
api.toggle(props.personId);
return;
}
api.requestPick(props.personId);
}
</script>
<template>
<view class="ftn" :class="{ 'ftn--v': api.vertical.value }">
<!-- 本人 + 配偶:同一行,夫妻间紫色虚线相连 -->
<view class="ftn__row">
<view class="ftn__card" :style="{ borderTopColor: theme }" @tap="onCardTap">
<view class="ftn__head">
<view class="ftn__avatar" :style="{ borderColor: theme }">
<image v-if="card.avatarUrl" class="ftn__avatar-img" :src="card.avatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="theme" />
</view>
<view class="ftn__info">
<text class="ftn__name">
{{ card.name || "-" }}
</text>
<text v-if="card.commonName" class="ftn__sub">
常用名:{{ card.commonName }}
</text>
</view>
</view>
<view class="ftn__foot">
<text class="ftn__tag" :style="{ color: theme, backgroundColor: themeSoft }">
{{ card.tag }}
</text>
<text v-if="actionText" class="ftn__action" :class="actionClass">
{{ actionText }}
</text>
</view>
</view>
<template v-for="sp in spouses" :key="sp.personId">
<view class="ftn__couple-line" />
<view class="ftn__card" :style="{ borderTopColor: spouseTheme(sp) }">
<view class="ftn__head">
<view class="ftn__avatar" :style="{ borderColor: spouseTheme(sp) }">
<image v-if="sp.avatarUrl" class="ftn__avatar-img" :src="sp.avatarUrl" mode="aspectFill" />
<u-icon v-else name="account" size="56" :color="spouseTheme(sp)" />
</view>
<view class="ftn__info">
<text class="ftn__name">
{{ sp.name || "-" }}
</text>
<text v-if="sp.commonName" class="ftn__sub">
常用名:{{ sp.commonName }}
</text>
</view>
</view>
<view class="ftn__foot">
<text class="ftn__tag"
:style="{ color: spouseTheme(sp), backgroundColor: `${spouseTheme(sp)}1a` }">
配偶
</text>
</view>
</view>
</template>
</view>
<!-- 子女:展开时逐级下钻,竖干线 + 横支线实折线连接 -->
<view v-if="expanded && children.length" class="ftn__children">
<view v-for="child in children" :key="child.personId" class="ftn__child">
<family-tree-node :person-id="child.personId" :preview="child.card" :expandable="child.expandable"
:depth="depth + 1" />
</view>
</view>
</view>
</template>
<style lang="scss" scoped>
.ftn {
display: flex;
flex-direction: column;
}
.ftn__row {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
align-items: stretch;
}
/* 夫妻虚线:横排连接段,垂直居中于两卡之间(超出弹窗宽度由 chart 的 scroll-x 承载) */
.ftn__couple-line {
flex: none;
align-self: center;
width: 40rpx;
border-top: 3rpx dashed #722ed1;
}
.ftn__card {
box-sizing: border-box;
flex: none;
/* 长宽比约 1.75:1(340 宽,内容自然高 ~190),对齐用户给的卡片样例 */
width: 340rpx;
padding: 20rpx 24rpx;
background: #ffffff;
border: 1px solid #e8e8e8;
border-top-width: 8rpx;
border-radius: 16rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
}
.ftn__head {
display: flex;
flex-direction: row;
align-items: center;
gap: 16rpx;
}
/* 大圆头像:同色细描边 + 线框人像图标(对齐旧图) */
.ftn__avatar {
display: flex;
flex: none;
align-items: center;
justify-content: center;
width: 96rpx;
height: 96rpx;
overflow: hidden;
background: #ffffff;
border: 3rpx solid #e8e8e8;
border-radius: 50%;
}
.ftn__avatar-img {
width: 100%;
height: 100%;
}
.ftn__info {
display: flex;
flex-direction: column;
min-width: 0;
}
.ftn__name {
display: block;
overflow: hidden;
font-size: 26rpx;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
text-overflow: ellipsis;
}
.ftn__sub {
display: block;
overflow: hidden;
margin-top: 6rpx;
font-size: 22rpx;
color: #8f959e;
white-space: nowrap;
text-overflow: ellipsis;
}
.ftn__foot {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 16rpx;
}
/* 身份标签:同色浅底彩字 chip */
.ftn__tag {
padding: 6rpx 16rpx;
font-size: 22rpx;
border-radius: 8rpx;
}
/* 动作按钮 chip:展开/折叠=青,点击查看=紫(旧图同款双色) */
.ftn__action {
padding: 6rpx 16rpx;
font-size: 22rpx;
color: #ffffff;
border-radius: 8rpx;
&--toggle {
background: #13c2c2;
}
&--load {
background: #722ed1;
}
}
/* 子女区:竖干线从父卡中心(170)下方落下;每代统一右移一个卡宽(340)+ 间隙(40),
各世代列左缘严格对齐(旧图同款阶梯),配偶卡正好叠在下代列上方 */
.ftn__children {
display: flex;
flex-direction: column;
gap: 24rpx;
padding-bottom: 8rpx;
padding-left: 207rpx;
margin-top: 24rpx;
margin-left: 170rpx;
border-left: 3rpx solid #c9ced6;
}
.ftn__child {
position: relative;
}
.ftn__child::before {
position: absolute;
top: 70rpx;
left: -207rpx;
width: 203rpx;
content: "";
border-top: 3rpx solid #c9ced6;
}
/* 容器 border-left 会随最后一个子女的子树一路延伸到底;
用画布底色盖掉其 elbow 以下的线段,竖干线停在最后一个子女卡片处(旧图同款) */
.ftn__child:last-child::after {
position: absolute;
top: 74rpx;
bottom: 0;
left: -210rpx;
width: 8rpx;
content: "";
background: #f5f6f8;
}
/* ===== 纵向布局(⇅ 切换):世代向下推进,子女横排,横线干在顶部 ===== */
.ftn--v .ftn__children {
position: relative;
flex-direction: row;
align-items: flex-start;
margin-left: 0;
padding-bottom: 8rpx;
padding-left: 0;
border-top: 3rpx solid #c9ced6;
border-left: none;
}
/* 父卡底部到横线的短连接段(跨容器 margin-top,落在父卡中心 170) */
.ftn--v .ftn__children::before {
position: absolute;
top: -27rpx;
left: 168rpx;
width: 0;
height: 27rpx;
content: "";
border-left: 3rpx solid #c9ced6;
}
/* 子女卡与横线拉开间距,竖支线落在卡中心 */
.ftn--v .ftn__child {
margin-top: 40rpx;
}
.ftn--v .ftn__child::before {
top: -40rpx;
left: 168rpx;
width: 0;
height: 37rpx;
border-top: none;
border-left: 3rpx solid #c9ced6;
}
/* 横线只存在于首尾支线之间:两端用画布底色盖掉多余线段 */
.ftn--v .ftn__child:first-child::after {
position: absolute;
top: -43rpx;
bottom: auto;
left: -24rpx;
width: 195rpx;
height: 8rpx;
content: "";
background: #f5f6f8;
}
.ftn--v .ftn__child:last-child::after {
top: -43rpx;
bottom: auto;
left: 171rpx;
width: auto;
right: -24rpx;
height: 8rpx;
}
/* 独子:横线整段盖掉,只剩父卡连接段接子女竖支线 */
.ftn--v .ftn__child:only-child::after {
left: -24rpx;
width: calc(100% + 48rpx);
right: auto;
}
</style>
@@ -0,0 +1,70 @@
/**
* 族谱图(原生递归树)的共享形状与注入 key
*
* 单列出来只为一个原因:family-tree-chart(持有数据源与展开状态)与
* family-tree-node(递归渲染单个节点)要共享「节点长什么样、页面能调哪些动作」这两个契约,
* 放进任何一个 .vue 里都会变成互相 import 的循环引用。
*/
import type { Ref } from "vue";
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
import type { FamilyTreeChildrenMapViewEntity } from "@/data/auto/familyTreeChildrenMapViewEntity";
import type { FamilyTreeSpouseMapViewEntity } from "@/data/auto/familyTreeSpouseMapViewEntity";
/** 卡片显示所需的最小人员信息(本人 / 配偶 / 子女预览共用) */
export interface FamilyCard {
personId: string;
/** 姓 + 谱名拼出的展示名 */
name: string;
commonName?: string;
gender?: string;
avatarUrl?: string;
/** 左上角身份标签:本人 / 始祖 / 族员 / 配偶 / 子女(或具体关系) */
tag: string;
}
/** 一条子女引用:父节点列出孩子时携带,子节点据此渲染占位或递归 */
export interface ChildRef {
personId: string;
card: FamilyCard;
/** 该子女的数据是否已加载(子树模式懒加载;全树模式恒为 true) */
loaded: boolean;
/** 是否可就地展开(已加载且确有后代、且本父是其「承嗣/主承担」父,避免重复子树) */
expandable: boolean;
}
/** describe(personId) 的产物:一个节点渲染自身所需的全部信息 */
export interface ResolvedNode {
self: FamilyCard;
spouses: FamilyCard[];
children: ChildRef[];
/** self 是否已加载(未加载时只有卡片预览,点击触发 select-child 去拉取) */
loaded: boolean;
}
/** chart 通过 provide 注入给(递归)node 的数据源与动作集 */
export interface FamilyTreeChartApi {
/** 解析某人的节点数据;无法解析(未加载且无预览)返回 null */
describe: (personId: string) => ResolvedNode | null;
/** 展开状态:personId -> 是否展开 */
expanded: Ref<Record<string, boolean>>;
/** 切换展开 */
toggle: (personId: string) => void;
/** 点击「未加载」的子女:通知页面去懒加载其子树 */
requestLoad: (personId: string) => void;
/** 点击「无后代」的人:通知页面把它设为当前族员 */
requestPick: (personId: string) => void;
/** 性别是否男性(空值按男处理,对齐旧图) */
isMale: (gender?: string) => boolean;
/** 布局方向:false=世代向右(横向阶梯),true=世代向下(纵向排列),由容器 ⇅ 按钮切换 */
vertical: Ref<boolean>;
}
/** sub 模式懒加载缓存的一项:某人的原始「人 + 配偶 + 子女」记录 */
export interface LoadedTreeNode {
person: FamilyPersonDisplayData;
spouses: FamilyTreeSpouseMapViewEntity[];
children: FamilyTreeChildrenMapViewEntity[];
}
/** provide / inject 的键 */
export const FAMILY_TREE_CHART_API = "familyTreeChartApi";
@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* 快速新增家族成员(底部抽屉):旧 WebVue components/master/FamilyPersonQuickCreateModal.vue 的移动端等价物
*
* 族谱关系维护里「新增配偶 / 新增孩子 / 新增祖先」都调它:只填 姓* / 名(谱名)* / 性别*,
* 落库走 FamilyPersonService.Insert(TreeId 由调用方带入),成功后 emit("created", person),
* created 取 result?.FamilyPerson || payload.FamilyPerson,由页面按用途回填到对应关系列表。
* 性别用 option-picker 单选(数据来自 GenderPolicy 语言包),与成员详情页同口径。
*/
import type { FamilyPersonDisplayData } from "@/data/auto/familyPersonDisplayData";
import { useLocale } from "uview-pro";
import { computed, ref, watch } from "vue";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonData } from "@/data/auto/familyPersonData";
import { FamilyPersonDisplayData as FamilyPersonDisplayDataCls } from "@/data/auto/familyPersonDisplayData";
import familyPersonService from "@/service/master/familyPersonService";
const props = defineProps({
visible: { type: Boolean, default: false },
treeId: { type: String, default: "" },
title: { type: String, default: "" },
});
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "created", person: FamilyPersonDisplayData): void;
}>();
const { t } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
const surname = ref("");
const genealogyName = ref("");
const gender = ref("");
const saving = ref(false);
const genderPickerVisible = ref(false);
const genderLabel = computed(() => getPolicyText("GenderPolicy", gender.value));
watch(
() => props.visible,
(on) => {
if (on) {
surname.value = "";
genealogyName.value = "";
gender.value = "";
}
},
);
function close(): void {
emit("update:visible", false);
}
function onGenderConfirm(key: string): void {
gender.value = key;
genderPickerVisible.value = false;
}
async function onConfirm(): Promise<void> {
if (!surname.value.trim()) {
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
return;
}
if (!genealogyName.value.trim()) {
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
return;
}
if (!gender.value) {
uni.showToast({ title: t("FamilyPersonPage.genderRequired"), icon: "none" });
return;
}
const payload = new FamilyPersonData();
payload.FamilyPerson = new FamilyPersonDisplayDataCls();
payload.FamilyPerson.Surname = surname.value.trim();
payload.FamilyPerson.GenealogyName = genealogyName.value.trim();
payload.FamilyPerson.Gender = gender.value;
payload.FamilyPerson.TreeId = props.treeId || "";
try {
saving.value = true;
const result = await familyPersonService.Insert(payload);
const created = result?.FamilyPerson || payload.FamilyPerson;
emit("created", created);
close();
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.quickCreateFailed"), icon: "none" });
}
finally {
saving.value = false;
}
}
</script>
<template>
<u-popup :model-value="props.visible" mode="bottom" round="24" :close-on-click-overlay="!saving" @close="close">
<view class="pqc">
<text class="pqc__title">
{{ props.title || t('FamilyPersonPage.quickCreateTitle') }}
</text>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.surname") }}<text class="pqc__required">*</text>
</text>
<u-input v-model="surname" :border="true" :maxlength="100"
:placeholder="t('FamilyPersonPage.surnamePlaceholder')" />
</view>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.genealogyName") }}<text class="pqc__required">*</text>
</text>
<u-input v-model="genealogyName" :border="true" :maxlength="100"
:placeholder="t('FamilyPersonPage.genealogyNamePlaceholder')" />
</view>
<view class="pqc__field">
<text class="pqc__label">
{{ t("FamilyPersonPage.gender") }}<text class="pqc__required">*</text>
</text>
<view class="pqc__picker" @tap="genderPickerVisible = true">
<text :class="{ 'pqc__picker-empty': !genderLabel }">
{{ genderLabel || t('FamilyPersonPage.genderPlaceholder') }}
</text>
<u-icon name="arrow-right" size="26" color="#c4c8cf" />
</view>
</view>
<view class="pqc__footer">
<view class="pqc__btn" @tap="close">
<text class="pqc__btn-text">
{{ t("AdminPage.cancelBut") }}
</text>
</view>
<view class="pqc__btn pqc__btn--primary" :class="{ 'pqc__btn--muted': saving }" @tap="onConfirm">
<text class="pqc__btn-text pqc__btn-text--primary">
{{ saving ? t("AdminPage.savingTip") : t("AdminPage.confirmBut") }}
</text>
</view>
</view>
<view class="pqc__safe" />
</view>
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
:empty-text="t('FamilyPersonPage.genderEmpty')" @close="genderPickerVisible = false"
@confirm="onGenderConfirm" />
</u-popup>
</template>
<style lang="scss" scoped>
.pqc {
padding: 40rpx 32rpx 0;
background-color: #ffffff;
&__title {
display: block;
padding-bottom: 24rpx;
font-size: 32rpx;
font-weight: 600;
color: #1f2329;
text-align: center;
}
&__field {
margin-bottom: 24rpx;
}
&__label {
display: block;
margin-bottom: 12rpx;
font-size: 26rpx;
color: #646a73;
}
&__required {
margin-left: 2rpx;
color: #ff4d4f;
}
&__picker {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 76rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: #1f2329;
border: 1px solid #e5e6eb;
border-radius: 12rpx;
}
&__picker-empty {
color: #8f959e;
}
&__footer {
display: flex;
gap: 20rpx;
margin-top: 8rpx;
}
&__btn {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
height: 76rpx;
border-radius: 999rpx;
background-color: #f5f6f8;
&--primary {
background-color: #2b8fff;
}
&--muted {
background-color: #b9d6ff;
}
}
&__btn-text {
font-size: 28rpx;
color: #5c626b;
&--primary {
color: #ffffff;
}
}
&__safe {
height: calc(32rpx + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,64 @@
/**
* 枚举策略选项(性别、关系类型等)——移动端对旧 WebVue hooks/useLocalePolicy 的等价实现
*
* 数据来源不额外发请求:应用启动时 locale/db.ts 的 mergeDbLocaleMessages 已把
* /Locale/FindAll 的结果按 `{GroupName: {Name: Value}}` 就地合并进 uView 的当前语言包,
* 因此这里直接读 useLocale().currentLocale(响应式 ref,指向那个语言包对象)即可,
* 切换语言时 computed 自动失效重算。
*
* 映射规则与后端 LocaleService.FindSelectOption、旧 useLocalePolicy 一致:
* BindValue = Name(对象 key),DisplayName = Value(对象 value)。
*/
import type { ComputedRef } from "vue";
import { useLocale } from "uview-pro";
import { computed } from "vue";
/** 供 option-picker 直接消费的单选项:key=后端存储值,label=显示文案 */
export interface PolicyOption {
key: string;
label: string;
}
export function usePolicyOptions() {
const { currentLocale } = useLocale();
/** 取当前语言包下某策略组的 { 值: 文案 };缺失/非对象一律回空表 */
function readGroup(groupName: string): Record<string, unknown> {
const locale = currentLocale.value as Record<string, any> | null;
const group = locale?.[groupName];
return group && typeof group === "object"
? (group as Record<string, unknown>)
: {};
}
/** 策略组的选项列表;blank=true 时在最前补一条空值项(供"全部/未选"场景) */
function getPolicyOptions(groupName: string, blank = false): PolicyOption[] {
const options: PolicyOption[] = [];
if (blank) {
options.push({ key: "", label: "" });
}
for (const [key, value] of Object.entries(readGroup(groupName))) {
options.push({ key, label: String(value ?? "") });
}
return options;
}
/** 把存储值翻译成显示文案;查不到原样返回(对齐旧 getPolicyText) */
function getPolicyText(groupName: string, value?: string | null): string {
if (value === null || value === undefined || value === "") {
return "";
}
const group = readGroup(groupName);
return group[value] != null ? String(group[value]) : String(value);
}
/** 响应式选项:语言切换后自动重算 */
function policyOptions(
groupName: string,
blank = false,
): ComputedRef<PolicyOption[]> {
return computed(() => getPolicyOptions(groupName, blank));
}
return { getPolicyOptions, getPolicyText, policyOptions };
}
+121 -1
View File
@@ -18,6 +18,8 @@
"title": "Me",
"myTheme": "My Theme",
"mySecurity": "Security",
"familyTree": "Family Trees",
"familyPerson": "Family Members",
"currentTheme": "Current Theme",
"aboutTitle": "About",
"appName": "App",
@@ -57,6 +59,122 @@
"helpFeature": "Help Features",
"helpFeatureDesc": "Help topics and grouping maintenance"
},
"FamilyTreePage": {
"title": "Family Tree Query",
"detailTitle": "Family Tree Detail",
"infoTitle": "Tree Information",
"treeId": "Tree ID",
"treeIdPlaceholder": "Enter tree ID",
"treeName": "Tree Name",
"brief": "Brief",
"publicIndc": "Public",
"treeCover": "Cover File",
"ancestor": "Ancestor Genealogy Name",
"ancestorSurname": "Ancestor Surname",
"ancestorGenealogyName": "Ancestor Genealogy Name",
"yes": "Yes",
"no": "No",
"searchBut": "Search",
"resetBut": "Reset",
"addTreeBut": "New Tree",
"currentPerson": "Current Member",
"gender": "Gender",
"genealogyName": "Genealogy Name",
"commonName": "Common Name",
"personSearch": "Member Search",
"pedigreeChart": "Pedigree Chart",
"ancestryChart": "Ancestry Chart",
"chartEmpty": "No family tree data",
"applyManage": "Apply for Manager",
"requestSentTip": "Request submitted, waiting for approval",
"addAncestor": "Add Ancestor",
"spouseTitle": "Spouses",
"addSpouse": "Add Spouse",
"pickSpouse": "Pick Member",
"spouseEmpty": "No spouse record",
"marryDateText": "Marriage Date Text",
"marryDate": "Marriage Date",
"divorceDateText": "Divorce Date Text",
"divorceDate": "Divorce Date",
"marryType": "Marriage Type",
"isCurrent": "Current Marriage",
"childTitle": "Children",
"addChild": "Add Child",
"childEmpty": "No child record",
"parent": "Parent",
"parentEmpty": "Add a spouse first",
"relationType": "Relation Type",
"relationEmpty": "No relation available",
"remark": "Remark",
"permTitle": "Permission Maintenance",
"permPending": "Pending Requests",
"permManager": "Managers",
"permEmpty": "No record",
"approve": "Approve",
"setManager": "Set Manager",
"reject": "Reject",
"remove": "Remove",
"managerTag": "Manager",
"memberTag": "Member",
"inputTreeIdTip": "Please enter tree ID",
"noAmendTip": "You have no permission to amend this family tree",
"treeNameRequired": "Please enter tree name"
},
"FamilyPersonPage": {
"title": "Family Member Query",
"detailTitle": "Member Detail",
"personId": "Member ID",
"treeId": "Tree ID",
"surname": "Surname",
"genealogyName": "Genealogy Name",
"commonName": "Common Name",
"gender": "Gender",
"createdDatetime": "Created Date",
"basicTitle": "Basic Information",
"familyTitle": "Family & Education",
"workTitle": "Career & Status",
"introTitle": "Biography",
"alias": "Alias",
"zi": "Courtesy Name (Zi)",
"hao": "Art Name (Hao)",
"birthOrder": "Birth Order",
"maritalStatus": "Marital Status",
"fatherId": "Father ID",
"motherName": "Mother Name",
"school": "School",
"major": "Major",
"education": "Education",
"graduationStatus": "Graduation Status",
"duty": "Duty",
"title": "Title",
"privacy": "Public Profile",
"liveStatus": "Living Status",
"bindUserId": "Bound User ID",
"bindUser": "Bound User",
"birthText": "Birth Text",
"birthDate": "Birth Date",
"deathText": "Death Text",
"deathDate": "Death Date",
"introduction": "Biography",
"yes": "Yes",
"no": "No",
"avatarTitle": "Avatar",
"uploadAvatar": "Upload Avatar",
"clearAvatar": "Clear Avatar",
"uploadingTip": "Uploading…",
"avatarUploadedTip": "Avatar uploaded",
"avatarUploadFailed": "Avatar upload failed",
"readonlyTip": "Detailed profile is private, only basic information is visible",
"quickCreateTitle": "Quick Add Member",
"surnamePlaceholder": "Enter surname",
"genealogyNamePlaceholder": "Enter genealogy name",
"genderPlaceholder": "Select gender",
"genderEmpty": "No gender available",
"surnameRequired": "Please enter surname",
"genealogyNameRequired": "Please enter genealogy name",
"genderRequired": "Please select gender",
"quickCreateFailed": "Failed to create member"
},
"GeneratorPage": {
"title": "Number Generators",
"detailTitle": "Number Rule Detail",
@@ -491,7 +609,9 @@
"noPermissionText": "No accessible admin function",
"menuLoading": "Loading menu…",
"menuRetryBut": "Reload",
"menuEmptyText": "The server returned no authorized function"
"menuEmptyText": "The server returned no authorized function",
"loadingTip": "Loading…",
"savingTip": "Saving…"
},
"UserPage": {
"title": "User Query",
+121 -1
View File
@@ -18,6 +18,8 @@
"title": "我的",
"myTheme": "我的主题",
"mySecurity": "安全管理",
"familyTree": "族谱管理",
"familyPerson": "家族成员",
"currentTheme": "当前主题",
"aboutTitle": "关于",
"appName": "应用名称",
@@ -57,6 +59,122 @@
"helpFeature": "帮助专题",
"helpFeatureDesc": "帮助专题与分组维护"
},
"FamilyTreePage": {
"title": "族谱查询",
"detailTitle": "族谱详情",
"infoTitle": "族谱信息",
"treeId": "族谱编号",
"treeIdPlaceholder": "请输入族谱编号",
"treeName": "族谱名称",
"brief": "简介",
"publicIndc": "是否公开",
"treeCover": "封面文件",
"ancestor": "祖先谱名",
"ancestorSurname": "祖先姓氏",
"ancestorGenealogyName": "祖先谱名",
"yes": "是",
"no": "否",
"searchBut": "查询",
"resetBut": "重置",
"addTreeBut": "新增族谱",
"currentPerson": "当前族员",
"gender": "性别",
"genealogyName": "谱名",
"commonName": "常用名",
"personSearch": "族员搜索",
"pedigreeChart": "谱系图",
"ancestryChart": "祖谱系图",
"chartEmpty": "暂无族谱数据",
"applyManage": "申请管理",
"requestSentTip": "申请已提交,等待管理员审批",
"addAncestor": "新增祖先",
"spouseTitle": "夫妻记录",
"addSpouse": "新增配偶",
"pickSpouse": "选择成员",
"spouseEmpty": "暂无配偶记录",
"marryDateText": "结婚日期文本",
"marryDate": "结婚日期",
"divorceDateText": "离婚日期文本",
"divorceDate": "离婚日期",
"marryType": "婚姻类型",
"isCurrent": "当前婚姻",
"childTitle": "子女记录",
"addChild": "新增孩子",
"childEmpty": "暂无孩子记录",
"parent": "父母",
"parentEmpty": "请先添加配偶",
"relationType": "关系类型",
"relationEmpty": "暂无关系可选",
"remark": "备注",
"permTitle": "权限维护",
"permPending": "待审核申请",
"permManager": "管理名单",
"permEmpty": "暂无记录",
"approve": "通过",
"setManager": "设为管理员",
"reject": "拒绝",
"remove": "移出",
"managerTag": "管理员",
"memberTag": "成员",
"inputTreeIdTip": "请输入族谱编号",
"noAmendTip": "您没有该族谱的修改权限",
"treeNameRequired": "请输入族谱名称"
},
"FamilyPersonPage": {
"title": "家族成员查询",
"detailTitle": "成员详情",
"personId": "成员编号",
"treeId": "族谱编号",
"surname": "姓",
"genealogyName": "谱名",
"commonName": "常用名",
"gender": "性别",
"createdDatetime": "创建日期",
"basicTitle": "基本资料",
"familyTitle": "家庭与学业",
"workTitle": "任职与状态",
"introTitle": "生平简介",
"alias": "别名",
"zi": "字",
"hao": "号",
"birthOrder": "排行",
"maritalStatus": "婚姻状况",
"fatherId": "父亲编号",
"motherName": "母亲姓名",
"school": "学校",
"major": "专业",
"education": "学历",
"graduationStatus": "毕业情况",
"duty": "职务",
"title": "头衔",
"privacy": "公开资料",
"liveStatus": "在世状态",
"bindUserId": "绑定用户编号",
"bindUser": "绑定用户",
"birthText": "出生文本",
"birthDate": "出生日期",
"deathText": "逝世文本",
"deathDate": "逝世日期",
"introduction": "生平简介",
"yes": "是",
"no": "否",
"avatarTitle": "头像",
"uploadAvatar": "上传头像",
"clearAvatar": "清除头像",
"uploadingTip": "上传中…",
"avatarUploadedTip": "头像上传成功",
"avatarUploadFailed": "头像上传失败",
"readonlyTip": "该成员未公开详细资料,仅可查看基本资料",
"quickCreateTitle": "快速新增成员",
"surnamePlaceholder": "请输入姓氏",
"genealogyNamePlaceholder": "请输入谱名",
"genderPlaceholder": "请选择性别",
"genderEmpty": "暂无性别可选",
"surnameRequired": "请输入姓氏",
"genealogyNameRequired": "请输入谱名",
"genderRequired": "请选择性别",
"quickCreateFailed": "成员创建失败"
},
"GeneratorPage": {
"title": "编号生成器",
"detailTitle": "编号规则详情",
@@ -491,7 +609,9 @@
"noPermissionText": "没有可访问的管理功能",
"menuLoading": "菜单加载中…",
"menuRetryBut": "重新加载",
"menuEmptyText": "后端没有返回你有权限的功能"
"menuEmptyText": "后端没有返回你有权限的功能",
"loadingTip": "加载中…",
"savingTip": "保存中…"
},
"UserPage": {
"title": "用户查询",
+14
View File
@@ -91,6 +91,20 @@
{
"path": "pages/security/help-feature-operation"
},
// 族谱管理(/family-tree-enquiry、/family-tree-operation):旧 master/FamilyTree 两页迁移,挂安全管理枢纽下
{
"path": "pages/security/family-tree-enquiry"
},
{
"path": "pages/security/family-tree-operation"
},
// 家族成员(/family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器
{
"path": "pages/security/family-person-enquiry"
},
{
"path": "pages/security/family-person-operation"
},
// 底部导航测试页(首页复用上面的 dashboard):文件/多媒体/共享/我的
{
"path": "pages/tab/file"
@@ -0,0 +1,192 @@
<script setup lang="ts">
/**
* 家族成员查询(列表页):旧 WebVue views/master/FamilyPersonEnquiry.vue 的移动端等价物
*
* 保留:四条筛选(谱名/常用名/族谱编号 文本包含、性别 单选枚举)、每页 20 条、行点击进详情。
* 旧页的新增/删除按钮本就被注释禁用,故这里不出新增入口、也不做批量删除,行为对齐。
*
* 兼作「族员选择器」:family-tree-operation 的「族员搜索」带 ?channel=person 跳入,
* 单选回传后调用方按 PersonId 切换当前族员(useAdminPick / useAdminPickTarget 协议)。
*/
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { fillCriteria, makeFilterOption, toSelectOption } from "@/admin/admin-filter";
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonEntity } from "@/data/auto/familyPersonEntity";
import { FamilyPersonSearchCriteria } from "@/data/auto/familyPersonSearchCriteria";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { pageUrl } from "@/router/routes";
import familyPersonService from "@/service/master/familyPersonService";
const { t, currentLocale } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const pickTarget = useAdminPickTarget();
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
const isPick = ref(false);
const LABEL_KEYS: Record<string, string> = {
[FamilyPersonEntity.DB_GENEALOGY_NAME]: "FamilyPersonPage.genealogyName",
[FamilyPersonEntity.DB_COMMON_NAME]: "FamilyPersonPage.commonName",
[FamilyPersonEntity.DB_GENDER]: "FamilyPersonPage.gender",
[FamilyPersonEntity.DB_TREE_ID]: "FamilyPersonPage.treeId",
};
const genderChoices = computed(() =>
getPolicyOptions("GenderPolicy").map(o => ({ label: o.label, value: o.key })),
);
const bindOptions = reactive([
makeFilterOption(
FamilyPersonEntity.DB_GENEALOGY_NAME,
t(LABEL_KEYS[FamilyPersonEntity.DB_GENEALOGY_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyPersonEntity.DB_COMMON_NAME,
t(LABEL_KEYS[FamilyPersonEntity.DB_COMMON_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyPersonEntity.DB_GENDER,
t(LABEL_KEYS[FamilyPersonEntity.DB_GENDER]),
SearchFilterTypePolicy.SINGLE_SELECTION,
SearchFilterOperatorPolicy.SINGLE,
genderChoices.value,
),
makeFilterOption(
FamilyPersonEntity.DB_TREE_ID,
t(LABEL_KEYS[FamilyPersonEntity.DB_TREE_ID]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
]);
async function fetchData(query: AdminQuery) {
const criteria = new FamilyPersonSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await familyPersonService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({
bindOptions,
loader: fetchData,
orderby: FamilyPersonEntity.DB_GENEALOGY_NAME,
});
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("FamilyPersonPage.surname"),
prop: FamilyPersonEntity.SURNAME,
},
{
label: t("FamilyPersonPage.genealogyName"),
prop: FamilyPersonEntity.GENEALOGY_NAME,
},
{
label: t("FamilyPersonPage.gender"),
prop: FamilyPersonEntity.GENDER,
format: (row: AdminRow) => getPolicyText("GenderPolicy", row.Gender),
},
{
label: t("FamilyPersonPage.commonName"),
prop: FamilyPersonEntity.COMMON_NAME,
},
{
label: t("FamilyPersonPage.createdDatetime"),
prop: FamilyPersonEntity.CREATED_DATETIME,
date: "datetime",
},
]);
const keyword = ref("");
const listRef = ref<any>(null);
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === FamilyPersonEntity.DB_GENEALOGY_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
if (isPick.value) {
if (pickTarget.multiple.value) {
return;
}
pickTarget.deliverOne(row);
return;
}
uni.navigateTo({
url: `${pageUrl("familyPersonOperation")}?id=${row.PersonId}&mode=view`,
});
}
function onConfirm(rows: AdminRow[]): void {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
pickTarget.deliver(rows);
}
watch(currentLocale, () => {
for (const option of bindOptions) {
const key = LABEL_KEYS[option.BindValue];
if (key) {
option.DisplayName = t(key);
}
if (option.BindValue === FamilyPersonEntity.DB_GENDER) {
option.SelectOptions = genderChoices.value.map(toSelectOption);
}
}
});
onLoad((options) => {
pickTarget.readOptions(options);
isPick.value = String(options?.channel ?? "").trim().length > 0;
});
useAuthGuard(() => {
// 每次显示都重拉第一页:详情修改后返回能看到最新数据(navigateBack 不重新挂页面)
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyPersonPage.title')"
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyPersonEntity.PERSON_ID"
title-key="GenealogyName" :selectable="isPick && pickTarget.multiple.value" :filter-count="filterCount"
:filter-visible="filterVisible" :filter-items="draft" :search-token="searchToken"
:keep-selection-on-reload="isPick" @search="onSearch" @open-filter="state.openFilter()"
@close-filter="state.closeFilter()" @apply-filter="state.applyFilter($event)"
@clear-filter="state.clearFiltersAndSearch()" @row-tap="onRowTap">
<template v-if="isPick" #actions="{ rows }">
<u-button type="primary" shape="circle" size="mini" :text="t('AdminPage.pickDoneBut')"
@click="onConfirm(rows)" />
</template>
</admin-list>
</view>
</template>
<style lang="scss" scoped>
.page {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
</style>
@@ -0,0 +1,466 @@
<script setup lang="ts">
/**
* 家族成员详情(查看 / 修改):旧 WebVue views/master/FamilyPersonOperation.vue 的移动端等价物
*
* 对齐旧逻辑:
* - FindByKey(PersonId) 取 FamilyPersonData,主对象 FamilyPerson;
* - 头像上传 file-path=FamilyPersonAvatar(旧 FileUpload 的移动端等价:chooseImage 单图 +
* uploadFile 直传 DmsFile/UploadDmsFile,FilePath 走请求头、form 字段名=原始文件名);
* - 性别是 GenderPolicy 枚举,admin-form 无 select,用 #extra 行点开 option-picker 回写;
* - 只读门控:!IsCanAmend 且 Privacy!=='Y' 时只显「基本资料」卡、隐藏其余卡与修改/删除;
* - 保存按 CreatedBy 有无决定 Update / Insert。
* 旧页的新增入口在查询页即被注释,本页只承担 view/amend 两态(create 走快速建员弹层)。
*/
import type { AdminFormAction, AdminFormField, AdminFormSection } from "@/admin/admin-types";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, ref } from "vue";
import { uploadFile } from "@/common/http.interceptor";
import { useAuthGuard } from "@/composables";
import { usePolicyOptions } from "@/composables/usePolicyOptions";
import { FamilyPersonData } from "@/data/auto/familyPersonData";
import { smartBack } from "@/router/routes";
import familyPersonService from "@/service/master/familyPersonService";
const { t } = useLocale();
const { getPolicyOptions, getPolicyText } = usePolicyOptions();
const data = ref<FamilyPersonData>(new FamilyPersonData());
const person = computed(() => data.value.FamilyPerson);
const readonly = ref(true);
const routeId = ref("");
const genderPickerVisible = ref(false);
const uploading = ref(false);
const genderItems = computed(() => getPolicyOptions("GenderPolicy"));
const genderLabel = computed(() => getPolicyText("GenderPolicy", person.value.Gender));
/** 只读门控(旧页同款):无权修改且本人未公开详细资料时,只保留基本资料卡 */
const readonlyBasicOnly = computed(
() => routeId.value !== "" && !person.value.IsCanAmend && person.value.Privacy !== "Y",
);
const heroTitle = computed(
() => `${person.value.Surname ?? ""}${person.value.GenealogyName ?? ""}`,
);
const heroChips = computed(() => {
const chips: string[] = [];
if (person.value.PersonId) {
chips.push(`${t("FamilyPersonPage.personId")}: ${person.value.PersonId}`);
}
if (person.value.TreeId) {
chips.push(`${t("FamilyPersonPage.treeId")}: ${person.value.TreeId}`);
}
return chips;
});
const sections = computed<AdminFormSection[]>(() => {
const basic: AdminFormSection = {
title: t("FamilyPersonPage.basicTitle"),
fields: [
{ label: t("FamilyPersonPage.surname"), prop: "Surname", required: true },
{ label: t("FamilyPersonPage.genealogyName"), prop: "GenealogyName", required: true },
{ label: t("FamilyPersonPage.commonName"), prop: "CommonName" },
{ label: t("FamilyPersonPage.alias"), prop: "Alias" },
{ label: t("FamilyPersonPage.zi"), prop: "Zi" },
{ label: t("FamilyPersonPage.hao"), prop: "Hao" },
{ label: t("FamilyPersonPage.birthOrder"), prop: "BirthOrder" },
],
};
if (readonlyBasicOnly.value) {
return [basic];
}
return [
basic,
{
title: t("FamilyPersonPage.familyTitle"),
fields: [
{ label: t("FamilyPersonPage.maritalStatus"), prop: "MaritalStatus" },
{ label: t("FamilyPersonPage.fatherId"), prop: "FatherId" },
{ label: t("FamilyPersonPage.motherName"), prop: "MotherName" },
{ label: t("FamilyPersonPage.school"), prop: "School" },
{ label: t("FamilyPersonPage.major"), prop: "Major" },
{ label: t("FamilyPersonPage.education"), prop: "Education" },
{ label: t("FamilyPersonPage.graduationStatus"), prop: "GraduationStatus" },
{ label: t("FamilyPersonPage.duty"), prop: "Duty" },
],
},
{
title: t("FamilyPersonPage.workTitle"),
fields: [
{ label: t("FamilyPersonPage.title"), prop: "Title" },
{
label: t("FamilyPersonPage.privacy"),
prop: "Privacy",
type: "switch",
display: model => (model.Privacy === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
},
{ label: t("FamilyPersonPage.liveStatus"), prop: "LiveStatus" },
{ label: t("FamilyPersonPage.bindUserId"), prop: "BindUserId" },
{
label: t("FamilyPersonPage.bindUser"),
prop: "IsBindUser",
type: "switch",
display: model => (model.IsBindUser === "Y" ? t("FamilyPersonPage.yes") : t("FamilyPersonPage.no")),
},
{ label: t("FamilyPersonPage.birthText"), prop: "BirthText" },
{ label: t("FamilyPersonPage.birthDate"), prop: "BirthDate", type: "date", date: true },
{ label: t("FamilyPersonPage.deathText"), prop: "DeathText" },
{ label: t("FamilyPersonPage.deathDate"), prop: "DeathDate", type: "date", date: true },
],
},
{
title: t("FamilyPersonPage.introTitle"),
fields: [
{
label: t("FamilyPersonPage.introduction"),
prop: "Introduction",
type: "textarea",
maxlength: 2000,
},
],
},
];
});
const actions = computed<AdminFormAction[]>(() => {
const list: AdminFormAction[] = [];
if (readonlyBasicOnly.value) {
return list;
}
list.push({ key: "amend", label: t("AdminPage.amendBut"), when: "view" });
// 旧页口径:仅未挂族谱(TreeId 为空)的成员允许删除
if (person.value.TreeId === "") {
list.push({
key: "delete",
label: t("AdminPage.deleteBut"),
tone: "error",
confirm: true,
when: "view",
});
}
list.push({ key: "save", label: t("AdminPage.saveBut"), when: "edit" });
list.push({ key: "cancel", label: t("AdminPage.cancelBut"), when: "edit" });
return list;
});
async function load(): Promise<void> {
const result = await familyPersonService.FindByKey(routeId.value);
if (!result?.FamilyPerson?.PersonId) {
uni.showToast({ title: t("AdminPage.notExistTip"), icon: "none" });
return;
}
data.value = result;
}
function onFieldChange(prop: string, value: unknown): void {
if (prop === "Privacy" || prop === "IsBindUser") {
person.value[prop] = value ? "Y" : "N";
return;
}
(person.value as Record<string, any>)[prop] = value;
}
function onGenderConfirm(key: string): void {
person.value.Gender = key;
genderPickerVisible.value = false;
}
/** 头像上传:单图 -> DmsFile/UploadDmsFile(FilePath=FamilyPersonAvatar),成功后本地预览 */
async function onUploadAvatar(): Promise<void> {
if (uploading.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 {
uploading.value = true;
await uploadFile({
url: "/DmsFile/UploadDmsFile",
filePath,
name: fileName,
header: { FilePath: "FamilyPersonAvatar" },
});
person.value.AvatarFilename = fileName;
person.value.AvatarUrl = filePath;
uni.showToast({ title: t("FamilyPersonPage.avatarUploadedTip"), icon: "none" });
}
catch (error) {
console.error(error);
uni.showToast({ title: t("FamilyPersonPage.avatarUploadFailed"), icon: "none" });
}
finally {
uploading.value = false;
}
},
});
}
function onClearAvatar(): void {
// H5 本地预览是 blob: URL,清掉引用防泄漏;其余端是本地临时路径,无需回收
if (person.value.AvatarUrl.startsWith("blob:") && typeof URL !== "undefined") {
URL.revokeObjectURL(person.value.AvatarUrl);
}
person.value.AvatarFilename = "";
person.value.AvatarUrl = "";
}
async function onSave(): Promise<void> {
if (!String(person.value.Surname ?? "").trim()) {
uni.showToast({ title: t("FamilyPersonPage.surnameRequired"), icon: "none" });
return;
}
if (!String(person.value.GenealogyName ?? "").trim()) {
uni.showToast({ title: t("FamilyPersonPage.genealogyNameRequired"), icon: "none" });
return;
}
const isUpdate = String(person.value.CreatedBy ?? "").trim() !== "";
const result = await (isUpdate
? familyPersonService.Update(data.value)
: familyPersonService.Insert(data.value));
if (result?.FamilyPerson?.PersonId) {
data.value = result;
routeId.value = result.FamilyPerson.PersonId;
}
readonly.value = true;
uni.showToast({ title: t("AdminPage.savedTip"), icon: "none" });
}
async function onDelete(): Promise<void> {
await familyPersonService.Delete(routeId.value);
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
setTimeout(() => smartBack("mineTab"), 600);
}
function onAction(action: AdminFormAction): void {
switch (action.key) {
case "amend":
readonly.value = false;
break;
case "save":
onSave();
break;
case "delete":
onDelete();
break;
case "cancel":
if (!routeId.value) {
smartBack("mineTab");
return;
}
readonly.value = true;
load();
break;
default:
break;
}
}
function onBack(): void {
smartBack("mineTab");
}
onLoad((options) => {
routeId.value = String(options?.id ?? "");
const mode = String(options?.mode ?? "view");
readonly.value = mode !== "create" && mode !== "amend";
});
let booted = false;
useAuthGuard(() => {
if (booted) {
return;
}
booted = true;
if (routeId.value) {
load();
}
});
</script>
<template>
<admin-form :nav-title="t('FamilyPersonPage.detailTitle')" :show-nav-back="true" :hero-title="heroTitle"
:hero-chips="heroChips" :sections="sections" :model="person" :readonly="readonly" :actions="actions"
@back="onBack" @action="onAction" @field-change="onFieldChange">
<template #extra="{ readonly: ro }">
<!-- 只读门控提示:无修改权限且对方未公开详细资料 -->
<view v-if="readonlyBasicOnly" class="ro-tip">
<text>
{{ t("FamilyPersonPage.readonlyTip") }}
</text>
</view>
<template v-else>
<!-- 头像 + 性别(admin-form 无 select,枚举行在这里点开的 option-picker 里回写) -->
<view class="group">
<view class="group-head">
<text class="group-title">
{{ t("FamilyPersonPage.avatarTitle") }}
</text>
</view>
<view class="group-card avatar-card">
<view class="avatar-box" :class="{ 'avatar-box--tap': !ro }" @tap="!ro && onUploadAvatar()">
<image v-if="person.AvatarUrl" class="avatar-img" :src="person.AvatarUrl"
mode="aspectFill" />
<u-icon v-else name="account-fill" size="72" color="#c4c8cf" />
</view>
<view v-if="!ro" class="avatar-ops">
<view class="avatar-op" @tap="onUploadAvatar">
<u-icon name="camera" size="30" color="#059669" />
<text class="avatar-op-text">
{{ uploading ? t("FamilyPersonPage.uploadingTip") :
t("FamilyPersonPage.uploadAvatar") }}
</text>
</view>
<view v-if="person.AvatarUrl || person.AvatarFilename" class="avatar-op"
@tap="onClearAvatar">
<u-icon name="close-circle" size="30" color="#c4c8cf" />
<text class="avatar-op-text">
{{ t("FamilyPersonPage.clearAvatar") }}
</text>
</view>
</view>
</view>
</view>
<view class="group">
<view class="group-card">
<view class="enum-row" :class="{ 'enum-row--tap': !ro }"
@tap="!ro && (genderPickerVisible = true)">
<text class="enum-label">
{{ t("FamilyPersonPage.gender") }}
</text>
<view class="enum-value-box">
<text class="enum-value" :class="{ 'enum-value-empty': !genderLabel }">
{{ genderLabel || t("FamilyPersonPage.genderPlaceholder") }}
</text>
<u-icon v-if="!ro" name="arrow-right" size="24" color="#c4c8cf" />
</view>
</view>
</view>
</view>
</template>
<option-picker :visible="genderPickerVisible" :title="t('FamilyPersonPage.gender')" :items="genderItems"
:empty-text="t('FamilyPersonPage.genderEmpty')" @close="genderPickerVisible = false"
@confirm="onGenderConfirm" />
</template>
</admin-form>
</template>
<style lang="scss" scoped>
.ro-tip {
padding: 28rpx;
margin-bottom: 28rpx;
font-size: 24rpx;
color: $u-tips-color;
background-color: #ffffff;
border-radius: 20rpx;
}
.group {
margin-bottom: 28rpx;
}
.group-head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 0 12rpx 12rpx;
}
.group-title {
font-size: 24rpx;
color: $u-tips-color;
}
.group-card {
padding: 0 28rpx;
overflow: hidden;
background-color: #ffffff;
border-radius: 20rpx;
}
.avatar-card {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 28rpx;
}
.avatar-box {
display: flex;
align-items: center;
justify-content: center;
width: 160rpx;
height: 160rpx;
overflow: hidden;
background-color: $u-bg-color;
border: 2rpx solid $u-border-color;
border-radius: 50%;
}
.avatar-box--tap {
border-style: dashed;
}
.avatar-img {
width: 100%;
height: 100%;
}
.avatar-ops {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.avatar-op {
display: flex;
flex-direction: row;
align-items: center;
}
.avatar-op-text {
margin-left: 8rpx;
font-size: 26rpx;
color: $u-content-color;
}
.enum-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
min-height: 92rpx;
}
.enum-label {
font-size: 28rpx;
color: $u-main-color;
}
.enum-value-box {
display: flex;
flex-direction: row;
align-items: center;
gap: 8rpx;
}
.enum-value {
font-size: 26rpx;
color: $u-content-color;
}
.enum-value-empty {
color: $u-tips-color;
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
/**
* 族谱查询(列表页):旧 WebVue views/master/FamilyTreeEnquiry.vue 的移动端等价物
*
* 保留:三条筛选(族谱名称/简介 文本包含、公开? 单选)、每页 20 条、
* 行点击进详情、多选批量删除(旧页面同样是循环 Delete(TreeId))。
* 丢掉:多列网格、列配置、打印。
*
* 挂在「安全管理」枢纽下(族谱属 master 域但按产品要求与权限体系同入口),
* 未登记 PAGE_PERMISSIONS:路由级 fail-open,编辑能力由数据级 IsManager/IsCanAmend 把关。
*/
import type { AdminColumn, AdminQuery, AdminRow } from "@/admin/admin-types";
import { onLoad } from "@dcloudio/uni-app";
import { useLocale } from "uview-pro";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { fillCriteria, makeFilterOption } from "@/admin/admin-filter";
import { useAdminList, useAdminPickTarget, useAuthGuard } from "@/composables";
import { FamilyTreeSearchCriteria } from "@/data/auto/familyTreeSearchCriteria";
import { FamilyTreeViewEntity } from "@/data/auto/familyTreeViewEntity";
import { SearchFilterOperatorPolicy } from "@/data/custom/searchFilterOperatorPolicy";
import { SearchFilterTypePolicy } from "@/data/custom/searchFilterTypePolicy";
import { usePagePermission } from "@/permission/permission-context";
import { pageUrl } from "@/router/routes";
import familyTreeService from "@/service/master/familyTreeService";
const { t, currentLocale } = useLocale();
const perm = usePagePermission("familyTreeEnquiry");
const pickTarget = useAdminPickTarget();
/** 选择器模式:带 channel 进来时点行回传而非跳详情 */
const isPick = ref(false);
const LABEL_KEYS: Record<string, string> = {
[FamilyTreeViewEntity.DB_TREE_NAME]: "FamilyTreePage.treeName",
[FamilyTreeViewEntity.DB_BRIEF]: "FamilyTreePage.brief",
[FamilyTreeViewEntity.DB_PUBLIC_INDC]: "FamilyTreePage.publicIndc",
};
function yesNo() {
return [
{ label: t("FamilyTreePage.yes"), value: "Y" },
{ label: t("FamilyTreePage.no"), value: "N" },
];
}
const bindOptions = reactive([
makeFilterOption(
FamilyTreeViewEntity.DB_TREE_NAME,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_TREE_NAME]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CONTAIN,
),
makeFilterOption(
FamilyTreeViewEntity.DB_BRIEF,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_BRIEF]),
SearchFilterTypePolicy.TEXT,
SearchFilterOperatorPolicy.CN_CONTAIN,
),
makeFilterOption(
FamilyTreeViewEntity.DB_PUBLIC_INDC,
t(LABEL_KEYS[FamilyTreeViewEntity.DB_PUBLIC_INDC]),
SearchFilterTypePolicy.SINGLE_SELECTION,
SearchFilterOperatorPolicy.SINGLE,
yesNo(),
),
]);
async function fetchData(query: AdminQuery) {
const criteria = new FamilyTreeSearchCriteria();
criteria.GridColumns = [];
fillCriteria(criteria, query.filters, query);
const data = await familyTreeService.FindBySearchCriteria(criteria);
return { rows: data?.Datas ?? [], total: data?.Total ?? 0 };
}
const state = useAdminList({
bindOptions,
loader: fetchData,
orderby: FamilyTreeViewEntity.DB_TREE_NAME,
});
const { filterCount, filterVisible, draft, searchToken } = state;
const columns = computed<AdminColumn[]>(() => [
{
label: t("FamilyTreePage.treeName"),
prop: FamilyTreeViewEntity.TREE_NAME,
},
{
label: t("FamilyTreePage.brief"),
prop: FamilyTreeViewEntity.BRIEF,
},
{
label: t("FamilyTreePage.publicIndc"),
prop: FamilyTreeViewEntity.PUBLIC_INDC,
format: (row: AdminRow) => {
if (row.PublicIndc === "Y") {
return t("FamilyTreePage.yes");
}
return row.PublicIndc === "N" ? t("FamilyTreePage.no") : "";
},
},
{
label: t("FamilyTreePage.ancestorSurname"),
prop: FamilyTreeViewEntity.SURNAME,
},
{
label: t("FamilyTreePage.ancestorGenealogyName"),
prop: FamilyTreeViewEntity.GENEALOGY_NAME,
},
]);
const keyword = ref("");
const listRef = ref<any>(null);
function onSearch(): void {
const option = bindOptions.find(
item => item.BindValue === FamilyTreeViewEntity.DB_TREE_NAME,
);
if (option) {
option.Value = keyword.value;
}
state.reload();
}
function onRowTap(row: AdminRow): void {
if (isPick.value) {
if (pickTarget.multiple.value) {
return;
}
pickTarget.deliverOne(row);
return;
}
uni.navigateTo({
url: `${pageUrl("familyTreeOperation")}?id=${row.TreeId}&mode=view`,
});
}
function onCreate(): void {
uni.navigateTo({ url: `${pageUrl("familyTreeOperation")}?mode=create` });
}
function onConfirm(rows: AdminRow[]): void {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
pickTarget.deliver(rows);
}
async function onDelete(rows: AdminRow[]): Promise<void> {
if (!rows.length) {
uni.showToast({ title: t("AdminPage.pickNoneTip"), icon: "none" });
return;
}
uni.showModal({
title: t("AdminPage.confirmDeleteTitle"),
success: async (res: any) => {
if (!res.confirm) {
return;
}
for (const row of rows) {
await familyTreeService.Delete(String(row.TreeId));
}
listRef.value?.clearSelection();
state.reload();
uni.showToast({ title: t("AdminPage.deletedTip"), icon: "none" });
},
});
}
watch(currentLocale, () => {
for (const option of bindOptions) {
const key = LABEL_KEYS[option.BindValue];
if (key) {
option.DisplayName = t(key);
}
}
});
onLoad((options) => {
pickTarget.readOptions(options);
isPick.value = String(options?.channel ?? "").trim().length > 0;
});
useAuthGuard(() => {
// 每次显示都重拉第一页:增/改/删后从详情返回能看到最新数据(navigateBack 不重新挂页面)
nextTick(() => listRef.value?.refresh());
});
</script>
<template>
<view class="page">
<admin-list ref="listRef" v-model:keyword="keyword" :nav-title="t('FamilyTreePage.title')"
:show-nav-back="!isPick" :columns="columns" :loader="state.query" :row-key="FamilyTreeViewEntity.TREE_ID"
title-key="TreeName" :selectable="isPick ? pickTarget.multiple.value : Boolean(perm.canDelete.value)"
:filter-count="filterCount" :filter-visible="filterVisible" :filter-items="draft"
:search-token="searchToken" :keep-selection-on-reload="isPick" @search="onSearch"
@open-filter="state.openFilter()" @close-filter="state.closeFilter()"
@apply-filter="state.applyFilter($event)" @clear-filter="state.clearFiltersAndSearch()" @row-tap="onRowTap">
<template v-if="!isPick && perm.canCreate.value" #toolbar-actions>
<view class="create-entry" @click="onCreate">
<u-icon name="plus" size="40" />
</view>
</template>
<template #actions="{ rows }">
<u-button v-if="isPick" type="primary" shape="circle" size="mini" :text="t('AdminPage.pickDoneBut')"
@click="onConfirm(rows)" />
<u-button v-else-if="perm.canDelete.value" type="error" shape="circle" size="mini"
:text="t('AdminPage.deleteBut')" @click="onDelete(rows)" />
</template>
</admin-list>
</view>
</template>
<style lang="scss" scoped>
.page {
width: 100%;
height: 100vh;
background-color: $u-bg-color;
}
.create-entry {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
margin-left: 12rpx;
color: $u-type-primary;
background-color: $u-bg-color;
border-radius: 16rpx;
}
</style>
File diff suppressed because it is too large. Load diff
+67 -67
View File
@@ -14,8 +14,8 @@ import { pageUrl } from "@/router/routes";
/** 一条功能入口:i18n 标题/副标题键 + 目标列表页名(null=旧系统尚未迁移) */
interface SecurityEntry {
key: string;
target: PageName | null;
key: string;
target: PageName | null;
}
const { t } = useLocale();
@@ -23,109 +23,109 @@ const { t } = useLocale();
useAuthGuard();
const entries: SecurityEntry[] = [
{ key: "user", target: "userEnquiry" },
{ key: "actor", target: "actorEnquiry" },
{ key: "group", target: "groupEnquiry" },
{ key: "function", target: "functionEnquiry" },
{ key: "permission", target: "permissionEnquiry" },
{ key: "generator", target: "generatorEnquiry" },
{ key: "master", target: "masterEnquiry" },
{ key: "helpFunction", target: "helpFunctionEnquiry" },
{ key: "helpFeature", target: "helpFeatureEnquiry" },
{ key: "user", target: "userEnquiry" },
{ key: "actor", target: "actorEnquiry" },
{ key: "group", target: "groupEnquiry" },
{ key: "function", target: "functionEnquiry" },
{ key: "permission", target: "permissionEnquiry" },
{ key: "generator", target: "generatorEnquiry" },
{ key: "master", target: "masterEnquiry" },
{ key: "helpFunction", target: "helpFunctionEnquiry" },
{ key: "helpFeature", target: "helpFeatureEnquiry" },
];
function onPick(entry: SecurityEntry): void {
if (!entry.target) {
uni.showToast({ title: t("SecurityPage.pendingTip"), icon: "none" });
return;
}
uni.navigateTo({ url: pageUrl(entry.target) });
if (!entry.target) {
uni.showToast({ title: t("SecurityPage.pendingTip"), icon: "none" });
return;
}
uni.navigateTo({ url: pageUrl(entry.target) });
}
</script>
<template>
<app-page :nav-title="t('SecurityPage.title')">
<view class="security-page">
<text class="security-page__hint">
{{ t("SecurityPage.pageHint") }}
<app-page :nav-title="t('SecurityPage.title')">
<view class="security-page">
<text class="security-page__hint">
{{ t("SecurityPage.pageHint") }}
</text>
<view class="security-list">
<view v-for="entry in entries" :key="entry.key" class="security-cell" @tap="onPick(entry)">
<view class="security-cell__body">
<text class="security-cell__title">
{{ t(`SecurityPage.${entry.key}`) }}
</text>
<view class="security-list">
<view v-for="entry in entries" :key="entry.key" class="security-cell" @tap="onPick(entry)">
<view class="security-cell__body">
<text class="security-cell__title">
{{ t(`SecurityPage.${entry.key}`) }}
</text>
<text class="security-cell__desc">
{{ t(`SecurityPage.${entry.key}Desc`) }}
</text>
</view>
<text v-if="!entry.target" class="security-cell__tag">
{{ t("SecurityPage.pendingTag") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
</view>
<text class="security-cell__desc">
{{ t(`SecurityPage.${entry.key}Desc`) }}
</text>
</view>
<text v-if="!entry.target" class="security-cell__tag">
{{ t("SecurityPage.pendingTag") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
</app-page>
</view>
</view>
</app-page>
</template>
<style lang="scss" scoped>
.security-page {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 24rpx;
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 24rpx;
}
.security-page__hint {
padding: 0 8rpx;
font-size: 24rpx;
color: #9aa0a6;
padding: 0 8rpx;
font-size: 24rpx;
color: #9aa0a6;
}
/* 白底圆角卡片承载列表,行间用细分隔线,与「我的」页卡片语言一致 */
.security-list {
overflow: hidden;
border-radius: 24rpx;
background-color: #ffffff;
overflow: hidden;
border-radius: 24rpx;
background-color: #ffffff;
}
.security-cell {
display: flex;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #f2f3f5;
display: flex;
align-items: center;
padding: 32rpx;
border-bottom: 2rpx solid #f2f3f5;
}
.security-cell:last-child {
border-bottom: none;
border-bottom: none;
}
.security-cell__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
display: flex;
flex: 1;
flex-direction: column;
gap: 8rpx;
}
.security-cell__title {
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
font-size: 30rpx;
font-weight: 600;
color: #1f2329;
}
.security-cell__desc {
font-size: 24rpx;
color: #9aa0a6;
font-size: 24rpx;
color: #9aa0a6;
}
/* 未迁移条目的灰色小标签 */
.security-cell__tag {
margin-right: 12rpx;
padding: 4rpx 12rpx;
font-size: 20rpx;
color: #9aa0a6;
background-color: #f2f3f5;
border-radius: 8rpx;
margin-right: 12rpx;
padding: 4rpx 12rpx;
font-size: 20rpx;
color: #9aa0a6;
background-color: #f2f3f5;
border-radius: 8rpx;
}
</style>
+26
View File
@@ -23,6 +23,16 @@ function goTheme(): void {
function goSecurity(): void {
uni.navigateTo({ url: pageUrl("securityPage") });
}
/** 进入「族谱管理」列表页(/family-tree-enquiry) */
function goFamilyTree(): void {
uni.navigateTo({ url: pageUrl("familyTreeEnquiry") });
}
/** 进入「家族成员」列表页(/family-person-enquiry) */
function goFamilyPerson(): void {
uni.navigateTo({ url: pageUrl("familyPersonEnquiry") });
}
</script>
<template>
@@ -44,6 +54,22 @@ function goSecurity(): void {
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 族谱管理:族谱列表与谱系图维护(/family-tree-enquiry) -->
<view class="mine-cell" @tap="goFamilyTree">
<text class="mine-cell__title">
{{ t("MinePage.familyTree") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 家族成员:族人档案检索(/family-person-enquiry) -->
<view class="mine-cell" @tap="goFamilyPerson">
<text class="mine-cell__title">
{{ t("MinePage.familyPerson") }}
</text>
<u-icon name="arrow-right" size="32" color="#9aa0a6" />
</view>
<!-- 关于:应用名 + 当前版本 + 运行平台 -->
<view class="mine-card">
<text class="mine-card__title">
+6
View File
@@ -45,6 +45,12 @@ export const PAGE_PATHS = {
// 帮助特性(短地址 /help-feature-enquiry、/help-feature-operation):旧 SecurityHelpFeature 两页迁移
helpFeatureEnquiry: "pages/security/help-feature-enquiry",
helpFeatureOperation: "pages/security/help-feature-operation",
// 族谱管理(短地址 /family-tree-enquiry、/family-tree-operation):旧 master/FamilyTree 两页迁移,挂安全管理枢纽下
familyTreeEnquiry: "pages/security/family-tree-enquiry",
familyTreeOperation: "pages/security/family-tree-operation",
// 家族成员(短地址 /family-person-enquiry、/family-person-operation):旧 master/FamilyPerson 两页迁移,含族员选择器
familyPersonEnquiry: "pages/security/family-person-enquiry",
familyPersonOperation: "pages/security/family-person-operation",
// 底部导航 tab 页(首页复用 dashboard):先各建一个简单测试页占位
fileTab: "pages/tab/file",
mediaTab: "pages/tab/media",