ow
This commit is contained in:
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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "用户查询",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user