完成70%
This commit is contained in:
@@ -30,7 +30,7 @@
|
||||
>回到当前</text
|
||||
>
|
||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||
<text @click="toRelationship">关系维护</text>
|
||||
<text @click="toRank">调整排行</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
class="member-node"
|
||||
:class="{ 'member-node--selected': selected?.id === member.id }"
|
||||
:style="nodeGridStyle(member)"
|
||||
@click="selected = member"
|
||||
@click="openMemberPanel(member)"
|
||||
>
|
||||
<image
|
||||
class="member-node__skin"
|
||||
@@ -110,6 +110,11 @@
|
||||
"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<image
|
||||
class="member-node__avatar"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation"
|
||||
@@ -145,33 +150,54 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view v-if="treeState === 'tree' && selected" class="member-sheet">
|
||||
<image
|
||||
class="member-sheet__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-member-drawer.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="member-sheet__copy">
|
||||
<text class="sheet-name">{{ selected.name }}</text>
|
||||
<text class="sheet-meta"
|
||||
>第 {{ selected.generation }} 世 · {{ selected.relation }} ·
|
||||
{{ selected.branch }}</text
|
||||
>
|
||||
<AppDialog
|
||||
:visible="memberActionPanelVisible"
|
||||
eyebrow="人物操作"
|
||||
:title="selected ? `管理${selected.name}` : '管理人物'"
|
||||
message="仅展示当前人物可达的操作。未具备可靠后端合同的写入会明确说明,且不会创建本地假数据。"
|
||||
confirm-text="关闭"
|
||||
@confirm="memberActionPanelVisible = false"
|
||||
@cancel="memberActionPanelVisible = false"
|
||||
>
|
||||
<view
|
||||
v-if="selected"
|
||||
class="member-action-profile"
|
||||
role="button"
|
||||
:aria-label="`查看${selected.name}的资料`"
|
||||
@click="toMember"
|
||||
>
|
||||
<image
|
||||
class="member-action-profile__avatar"
|
||||
src="/static/assets/foundation/transparent/meta-member.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-action-profile__copy">
|
||||
<text>{{ selected.name }}</text>
|
||||
<text>第 {{ selected.generation }} 世 · {{ selected.relation }} · {{ selected.branch }}</text>
|
||||
<text>点击头像查看资料</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="sheet-actions">
|
||||
<view class="member-action-grid">
|
||||
<AppButton
|
||||
class="sheet-action"
|
||||
v-for="action in memberActions"
|
||||
:key="action.key"
|
||||
type="secondary"
|
||||
label="查看资料"
|
||||
@click="toMember"
|
||||
/>
|
||||
<AppButton
|
||||
class="sheet-action"
|
||||
label="添加亲属"
|
||||
@click="toAddRelative"
|
||||
compact
|
||||
:label="action.label"
|
||||
@click="openMemberAction(action)"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog
|
||||
:visible="unavailableActionVisible"
|
||||
eyebrow="服务状态"
|
||||
:title="unavailableAction?.label || '当前操作'"
|
||||
:message="unavailableAction?.unavailableCopy || '该操作暂未接入可靠服务合同。'"
|
||||
confirm-text="我知道了"
|
||||
@confirm="unavailableActionVisible = false"
|
||||
@cancel="unavailableActionVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -179,6 +205,7 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
@@ -193,6 +220,9 @@ import { openPage } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const selected = ref(null);
|
||||
const memberActionPanelVisible = ref(false);
|
||||
const unavailableActionVisible = ref(false);
|
||||
const unavailableAction = ref(null);
|
||||
const treeScrollLeft = ref(90);
|
||||
const currentTreeScrollLeft = ref(90);
|
||||
const centeredTreeScrollLeft = ref(90);
|
||||
@@ -209,6 +239,23 @@ const MEMBER_GAP = 250;
|
||||
const GENERATION_GAP = 220;
|
||||
const members = ref([]);
|
||||
|
||||
const memberActions = Object.freeze([
|
||||
{ key: "VIEW_PROFILE", label: "查看资料", routeKey: "T03" },
|
||||
{ key: "ADD_FATHER", label: "添加父亲", routeKey: "T04", relationType: "FATHER" },
|
||||
{ key: "ADD_MOTHER", label: "添加母亲", routeKey: "T04", relationType: "MOTHER" },
|
||||
{ key: "ADD_SPOUSE", label: "添加配偶", routeKey: "T04", relationType: "SPOUSE" },
|
||||
{ key: "ADD_SIBLING", label: "添加兄弟姐妹", routeKey: "T04", relationType: "SIBLING" },
|
||||
{ key: "ADJUST_RANK", label: "调整排行", routeKey: "T06", mode: "rank" },
|
||||
{ key: "ADD_SON", label: "添加儿子", routeKey: "T04", relationType: "SON" },
|
||||
{ key: "ADD_DAUGHTER", label: "添加女儿", routeKey: "T04", relationType: "DAUGHTER" },
|
||||
{
|
||||
key: "BIND_INVITE",
|
||||
label: "邀请绑定",
|
||||
unavailableCopy: "邀请签发、目标身份查找、绑定 mutation 和结果查询尚无可靠合同;当前不会借邀请码或普通入谱申请替代。",
|
||||
},
|
||||
{ key: "EDIT_PROFILE", label: "编辑信息", routeKey: "T05" },
|
||||
]);
|
||||
|
||||
const snapToGrid = (value) => Math.ceil(value / GRID_UNIT) * GRID_UNIT;
|
||||
const layoutMembers = computed(() => {
|
||||
const generations = Array.from(
|
||||
@@ -482,20 +529,40 @@ const handleStateAction = () => {
|
||||
selected.value = layoutMembers.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
const toMember = () =>
|
||||
selected.value
|
||||
? openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
|
||||
: Promise.resolve(false);
|
||||
const toMember = () => {
|
||||
if (!selected.value) return Promise.resolve(false);
|
||||
memberActionPanelVisible.value = false;
|
||||
return openPage("T03", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01");
|
||||
};
|
||||
const toDirectory = () =>
|
||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||
const toRelationship = () =>
|
||||
const toRank = () =>
|
||||
selected.value
|
||||
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
|
||||
: Promise.resolve(false);
|
||||
const toAddRelative = () =>
|
||||
selected.value
|
||||
? openPage("T04", { genealogyId: genealogyId.value, personId: String(selected.value.id) }, "T01")
|
||||
? openPage("T06", { genealogyId: genealogyId.value, personId: String(selected.value.id), mode: "rank" }, "T01")
|
||||
: Promise.resolve(false);
|
||||
const openMemberPanel = (member) => {
|
||||
selected.value = member;
|
||||
memberActionPanelVisible.value = true;
|
||||
};
|
||||
const openMemberAction = (action) => {
|
||||
if (!selected.value) return Promise.resolve(false);
|
||||
memberActionPanelVisible.value = false;
|
||||
if (!action.routeKey) {
|
||||
unavailableAction.value = action;
|
||||
unavailableActionVisible.value = true;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const params = {
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selected.value.id),
|
||||
};
|
||||
if (action.routeKey === "T04") {
|
||||
params.relationType = action.relationType;
|
||||
} else if (action.routeKey === "T06") {
|
||||
params.mode = action.mode;
|
||||
}
|
||||
return openPage(action.routeKey, params, "T01");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -638,8 +705,18 @@ const toAddRelative = () =>
|
||||
.member-node__copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.member-node__avatar {
|
||||
grid-area: 1 / 1;
|
||||
z-index: 3;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
width: 42rpx;
|
||||
height: 42rpx;
|
||||
margin: 13rpx 0 0 18rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
z-index: 1;
|
||||
padding-left: 44rpx;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
@@ -736,50 +813,53 @@ const toAddRelative = () =>
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-sheet {
|
||||
position: fixed;
|
||||
right: 18rpx;
|
||||
bottom: calc(14rpx + env(safe-area-inset-bottom));
|
||||
left: 18rpx;
|
||||
.member-action-profile {
|
||||
display: flex;
|
||||
min-height: 240rpx;
|
||||
flex-direction: column;
|
||||
padding: 38rpx 44rpx 24rpx;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 18rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.member-sheet__skin {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
.member-action-profile__avatar {
|
||||
width: 82rpx;
|
||||
aspect-ratio: 1;
|
||||
flex: 0 0 82rpx;
|
||||
}
|
||||
.member-sheet__copy {
|
||||
z-index: 1;
|
||||
.member-action-profile__copy {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
.sheet-name {
|
||||
.member-action-profile__copy text {
|
||||
display: block;
|
||||
}
|
||||
.member-action-profile__copy text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: 31rpx;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sheet-meta {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
.member-action-profile__copy text:nth-child(2) {
|
||||
margin-top: 4rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.sheet-actions {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
margin-top: auto;
|
||||
.member-action-profile__copy text:last-child {
|
||||
margin-top: 4rpx;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.sheet-action {
|
||||
width: 250rpx;
|
||||
min-height: 88rpx;
|
||||
.member-action-grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.member-action-grid .app-button {
|
||||
width: 100%;
|
||||
min-height: 70rpx;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
@@ -790,12 +870,10 @@ const toAddRelative = () =>
|
||||
.tree-toolbar__actions {
|
||||
gap: 14rpx;
|
||||
}
|
||||
.member-sheet {
|
||||
min-height: 240rpx;
|
||||
padding-top: 32rpx;
|
||||
padding-right: 36rpx;
|
||||
padding-left: 36rpx;
|
||||
padding-bottom: 20rpx;
|
||||
.member-action-profile {
|
||||
gap: 14rpx;
|
||||
padding-right: 14rpx;
|
||||
padding-left: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -93,11 +93,11 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import {
|
||||
consumeNavigationResult,
|
||||
handleBackPress,
|
||||
@@ -113,13 +113,25 @@ const errorMessage = ref("");
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const memberTrail = reactive([]);
|
||||
const trailIndex = ref(-1);
|
||||
const memberRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const details = computed(() => member.value ? [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{ label: "性别(字典值)", value: member.value.sex },
|
||||
{ label: "人物状态(字典值)", value: member.value.personStatus },
|
||||
{ label: "出生日期", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthDate },
|
||||
{ label: "出生农历", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthLunar },
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{ label: "祖居地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
|
||||
{ label: "出生地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.birthplace },
|
||||
{ label: "逝世农历", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.deathLunar },
|
||||
{ label: "逝世地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.deathPlace },
|
||||
{ label: "安葬地", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.burialPlace },
|
||||
{ label: "配偶", value: member.value.spouseNames },
|
||||
{ label: "所属支系", value: member.value.branch },
|
||||
{ label: "生平", value: member.value.status === "forbidden" ? "按权限隐藏" : member.value.biography },
|
||||
{ label: "备注", value: member.value.remark },
|
||||
] : []);
|
||||
const canPreviewEdit = computed(() => memberState.value === "detail");
|
||||
const restrictedCopy = computed(() =>
|
||||
@@ -146,40 +158,59 @@ const memberContextDescription = computed(() => ({
|
||||
error: "请重新选择成员",
|
||||
}[memberState.value] || "请重新选择成员"));
|
||||
|
||||
const loadMember = async (nextPersonId) => {
|
||||
const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
const normalizedPersonId = String(nextPersonId || "");
|
||||
const fixture = findTreeMemberPresentationFixture(genealogyId.value, normalizedPersonId);
|
||||
if (!fixture) {
|
||||
errorMessage.value = "这位成员不存在或已不属于当前家谱。";
|
||||
const activeLoad = ++loadSequence;
|
||||
const previous = {
|
||||
personId: personId.value,
|
||||
member: member.value,
|
||||
state: memberState.value,
|
||||
errorMessage: errorMessage.value,
|
||||
genealogyName: genealogyName.value,
|
||||
};
|
||||
const restorePrevious = () => {
|
||||
personId.value = previous.personId;
|
||||
member.value = previous.member;
|
||||
memberState.value = previous.state;
|
||||
errorMessage.value = previous.errorMessage;
|
||||
genealogyName.value = previous.genealogyName;
|
||||
};
|
||||
|
||||
memberState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const nextMember = await appApi.getPerson(
|
||||
genealogyId.value,
|
||||
normalizedPersonId,
|
||||
{ requestController: memberRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return false;
|
||||
personId.value = nextMember.id;
|
||||
member.value = nextMember;
|
||||
genealogyName.value = nextMember.genealogyName;
|
||||
memberState.value = "detail";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) {
|
||||
if (preserveCurrent && activeLoad === loadSequence) restorePrevious();
|
||||
return false;
|
||||
}
|
||||
if (preserveCurrent) {
|
||||
restorePrevious();
|
||||
return false;
|
||||
}
|
||||
member.value = null;
|
||||
memberState.value = "error";
|
||||
errorMessage.value = error?.message || "这位成员不存在或已不属于当前家谱。";
|
||||
return false;
|
||||
}
|
||||
const restricted = ["privacy", "forbidden"].includes(fixture.status);
|
||||
const relatives = restricted ? [] : fixture.relatives
|
||||
.map((relative) => {
|
||||
const relativeMember = findTreeMemberPresentationFixture(
|
||||
genealogyId.value,
|
||||
relative.personId,
|
||||
);
|
||||
return relativeMember
|
||||
? { id: relativeMember.id, name: relativeMember.name, relation: relative.relation }
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
personId.value = normalizedPersonId;
|
||||
member.value = {
|
||||
...fixture,
|
||||
relatives,
|
||||
};
|
||||
errorMessage.value = "";
|
||||
memberState.value = restricted ? "restricted" : "detail";
|
||||
return true;
|
||||
};
|
||||
|
||||
const initializeMemberTrail = async (initialPersonId) => {
|
||||
memberTrail.splice(0, memberTrail.length);
|
||||
trailIndex.value = -1;
|
||||
const normalizedPersonId = String(initialPersonId || "");
|
||||
const loaded = await loadMember(normalizedPersonId);
|
||||
const loaded = await loadMember(normalizedPersonId, { preserveCurrent: true });
|
||||
if (!loaded) return false;
|
||||
memberTrail.splice(0, memberTrail.length, normalizedPersonId);
|
||||
trailIndex.value = 0;
|
||||
@@ -204,7 +235,7 @@ const popMemberTrail = async () => {
|
||||
while (trailIndex.value > 0) {
|
||||
const targetIndex = trailIndex.value - 1;
|
||||
const historicalPersonId = memberTrail[targetIndex];
|
||||
const loaded = await loadMember(historicalPersonId);
|
||||
const loaded = await loadMember(historicalPersonId, { preserveCurrent: true });
|
||||
if (loaded) {
|
||||
trailIndex.value = targetIndex;
|
||||
return true;
|
||||
@@ -235,11 +266,7 @@ onLoad((query) => {
|
||||
: "当前家谱上下文无效,请重新进入。";
|
||||
return;
|
||||
}
|
||||
void initializeMemberTrail(initialPersonId).then((loaded) => {
|
||||
if (loaded) return;
|
||||
member.value = null;
|
||||
memberState.value = "error";
|
||||
});
|
||||
void initializeMemberTrail(initialPersonId);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
@@ -251,6 +278,11 @@ onShow(() => {
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
memberRequestController.abort();
|
||||
});
|
||||
|
||||
const toEdit = () => {
|
||||
if (!canPreviewEdit.value) return false;
|
||||
return openPage("T05", { genealogyId: genealogyId.value, personId: personId.value }, "T03");
|
||||
|
||||
+121
-68
@@ -4,7 +4,7 @@
|
||||
class="add-relative-page"
|
||||
:class="{
|
||||
'add-state--form': addState === 'form',
|
||||
'add-state--preview': addState === 'preview',
|
||||
'add-state--loading': addState === 'loading',
|
||||
'add-state--error': addState === 'error',
|
||||
}"
|
||||
>
|
||||
@@ -14,7 +14,9 @@
|
||||
</view>
|
||||
|
||||
<view class="add-relative-panel">
|
||||
<view v-if="addState === 'form'" class="add-relative-form">
|
||||
<AppLoading v-if="addState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认当前人物。" />
|
||||
|
||||
<view v-else-if="addState === 'form'" class="add-relative-form">
|
||||
<text class="form-eyebrow">{{ isFirstMember ? "建立世系起点" : "补全家族关系" }}</text>
|
||||
<text class="form-title">{{ formTitle }}</text>
|
||||
<text class="form-copy">{{ formCopy }}</text>
|
||||
@@ -48,13 +50,6 @@
|
||||
</picker>
|
||||
<text v-if="fieldErrors.relation" class="field-error">{{ fieldErrors.relation }}</text>
|
||||
|
||||
<picker :range="genderOptions" :value="genderIndex" @change="selectGender">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>性别</text><text>{{ addForm.gender || "请选择" }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text v-if="fieldErrors.gender" class="field-error">{{ fieldErrors.gender }}</text>
|
||||
|
||||
<picker mode="date" :value="addForm.birthDate" @change="selectBirthDate">
|
||||
<view class="form-field form-field--picker">
|
||||
<text>出生日期</text><text>{{ addForm.birthDate || "选填" }}</text>
|
||||
@@ -75,17 +70,17 @@
|
||||
<text class="form-note">{{ formNote }}</text>
|
||||
<view class="form-action" @click="submitAdd">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="add-result">
|
||||
<text class="form-eyebrow">{{ addState === "preview" ? "本地流程预览" : hasValidContext ? "成员未保存" : "成员入口无效" }}</text>
|
||||
<text class="form-title">{{ addState === "preview" ? previewTitle : hasValidContext ? "暂时无法校验成员" : "没有找到要关联的成员" }}</text>
|
||||
<text class="form-copy">{{ addState === "preview" ? previewCopy : hasValidContext ? "当前填写内容仍保留,可返回修改后重试。" : "请从世系树重新进入,页面不会创建临时成员身份。" }}</text>
|
||||
<view class="form-action" @click="addState === 'preview' ? returnToTree() : retryForm()">
|
||||
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||
<text class="form-title">{{ resultCopy.title }}</text>
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view class="form-action" @click="handleResultAction">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ addState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "返回修改" : "返回上一页" }}</text>
|
||||
<text>{{ resultCopy.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -108,46 +103,57 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findTreeMemberFixture } from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const addState = ref("form");
|
||||
const addState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const mode = ref("relative");
|
||||
const relationType = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const discardDialogVisible = ref(false);
|
||||
let submitTimer = null;
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const relationOptions = ["长子", "次子", "女儿", "配偶", "兄弟", "姐妹"];
|
||||
const genderOptions = ["男", "女", "未说明"];
|
||||
const addForm = reactive({ name: "", relation: "", gender: "", birthDate: "", summary: "" });
|
||||
const fieldErrors = reactive({ name: "", relation: "", gender: "" });
|
||||
const relationIntents = Object.freeze({
|
||||
FATHER: { label: "父亲" },
|
||||
MOTHER: { label: "母亲" },
|
||||
SPOUSE: { label: "配偶" },
|
||||
SIBLING: { label: "兄弟姐妹" },
|
||||
SON: { label: "儿子" },
|
||||
DAUGHTER: { label: "女儿" },
|
||||
});
|
||||
const genericRelationTypes = Object.freeze(["FATHER", "MOTHER", "SPOUSE", "SIBLING", "SON", "DAUGHTER"]);
|
||||
const addForm = reactive({ name: "", relation: "", birthDate: "", summary: "" });
|
||||
const fieldErrors = reactive({ name: "", relation: "" });
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const currentMember = computed(() =>
|
||||
isFirstMember.value
|
||||
? null
|
||||
: findTreeMemberFixture(genealogyId.value, personId.value),
|
||||
);
|
||||
const activeRelationIntent = computed(() => relationIntents[relationType.value] || null);
|
||||
const relationOptions = computed(() => {
|
||||
const types = activeRelationIntent.value ? [relationType.value] : genericRelationTypes;
|
||||
return types.map((type) => relationIntents[type].label);
|
||||
});
|
||||
const relationLabel = computed(() => activeRelationIntent.value?.label || addForm.relation || "亲属关系");
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
(isFirstMember.value ? !personId.value : Boolean(currentMember.value)),
|
||||
);
|
||||
const relationIndex = computed(() => Math.max(0, relationOptions.indexOf(addForm.relation)));
|
||||
const genderIndex = computed(() => Math.max(0, genderOptions.indexOf(addForm.gender)));
|
||||
const relationIndex = computed(() => Math.max(0, relationOptions.value.indexOf(addForm.relation)));
|
||||
const formTitle = computed(() =>
|
||||
isFirstMember.value ? "录入家谱中的第一位成员" : `为${currentMember.value.name}添加一位亲属`,
|
||||
isFirstMember.value ? "录入家谱中的第一位成员" : `为${currentMember.value?.name || "当前成员"}添加${relationLabel.value}`,
|
||||
);
|
||||
const formCopy = computed(() =>
|
||||
isFirstMember.value
|
||||
@@ -155,16 +161,24 @@ const formCopy = computed(() =>
|
||||
: "先确认新成员与当前成员的关系,再填写可核实的身份信息。",
|
||||
);
|
||||
const formNote = computed(() =>
|
||||
isFirstMember.value
|
||||
? "当前阶段只校验首位成员资料,不会修改世系树。"
|
||||
: "当前阶段只校验亲属资料,不会新增成员或改变世系关系。",
|
||||
);
|
||||
const previewTitle = computed(() =>
|
||||
isFirstMember.value ? `${addForm.name}可作为世系起点` : `${addForm.name}的亲属资料已通过本地校验`,
|
||||
);
|
||||
const previewCopy = computed(() =>
|
||||
`本地预览尚未提交服务器,返回后不会保存${isFirstMember.value ? "首位成员" : `${addForm.relation}关系`},世系树也不会变化。`,
|
||||
"本次只提交姓名、出生日期和人物简介;性别编码、头像和同辈排行不在当前写入范围。",
|
||||
);
|
||||
const resultCopy = computed(() => {
|
||||
if (hasValidContext.value) {
|
||||
return {
|
||||
eyebrow: "保存失败",
|
||||
title: `${relationLabel.value}尚未保存`,
|
||||
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
};
|
||||
}
|
||||
return {
|
||||
eyebrow: "成员入口无效",
|
||||
title: "没有找到要关联的成员",
|
||||
copy: errorMessage.value || "请从世系树重新进入。",
|
||||
action: "返回世系树",
|
||||
};
|
||||
});
|
||||
const hasDraft = computed(() =>
|
||||
Object.values(addForm).some((value) => String(value).trim()),
|
||||
);
|
||||
@@ -175,27 +189,58 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const loadCurrentMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
addState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: addRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
currentMember.value = member;
|
||||
addState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
currentMember.value = null;
|
||||
addState.value = "error";
|
||||
errorMessage.value = error?.message || "当前成员暂不可用。";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
mode.value = query.mode === "first" ? "first" : "relative";
|
||||
addState.value = !hasValidContext.value || query.state === "error"
|
||||
? "error"
|
||||
: query.state === "preview"
|
||||
? "preview"
|
||||
: "form";
|
||||
relationType.value = String(query.relationType || "");
|
||||
if (!genealogyId.value || query.state === "error" || (!isFirstMember.value && !personId.value)) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
|
||||
return;
|
||||
}
|
||||
if (relationType.value && !activeRelationIntent.value) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
|
||||
return;
|
||||
}
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
}
|
||||
void loadCurrentMember();
|
||||
});
|
||||
onUnload(() => {
|
||||
const timer = submitTimer;
|
||||
submitTimer = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardDialogVisible.value,
|
||||
dirty: (addState.value === "form" || addState.value === "preview") && hasDraft.value,
|
||||
dirty: addState.value === "form" && hasDraft.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
@@ -206,36 +251,44 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
const clearError = (field) => { fieldErrors[field] = ""; };
|
||||
const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions[Number(event.detail.value)] || "";
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
clearError("relation");
|
||||
};
|
||||
const selectGender = (event) => {
|
||||
addForm.gender = genderOptions[Number(event.detail.value)] || "";
|
||||
clearError("gender");
|
||||
};
|
||||
const selectBirthDate = (event) => { addForm.birthDate = event.detail.value || ""; };
|
||||
const validateAddForm = () => {
|
||||
fieldErrors.name = addForm.name.trim() ? "" : "请填写成员姓名";
|
||||
fieldErrors.relation = isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
|
||||
fieldErrors.gender = addForm.gender ? "" : "请选择性别或未说明";
|
||||
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.gender;
|
||||
return !fieldErrors.name && !fieldErrors.relation;
|
||||
};
|
||||
const submitAdd = () => {
|
||||
const submitAdd = async () => {
|
||||
if (isSubmitting.value || !validateAddForm()) return;
|
||||
const submitSnapshot = Object.freeze({ ...addForm });
|
||||
isSubmitting.value = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
addState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
|
||||
try {
|
||||
const payload = {
|
||||
name: addForm.name,
|
||||
birthDate: addForm.birthDate,
|
||||
biography: addForm.summary,
|
||||
...(isFirstMember.value ? {} : { relationName: relationLabel.value }),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
await appApi.createPerson(genealogyId.value, payload, { requestController: addRequestController });
|
||||
} else {
|
||||
const type = activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
await appApi.createRelatedPerson(genealogyId.value, personId.value, type, payload, { requestController: addRequestController });
|
||||
}
|
||||
Object.assign(addForm, { name: "", relation: "", birthDate: "", summary: "" });
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
errorMessage.value = error?.message || "服务未确认本次写入。";
|
||||
addState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
submitTimer = null;
|
||||
}, 280);
|
||||
submitTimer = timer;
|
||||
};
|
||||
const retryForm = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
addState.value = "form";
|
||||
}
|
||||
};
|
||||
const handleResultAction = () => hasValidContext.value ? (addState.value = "form") : returnToTree();
|
||||
const returnToTree = async () => {
|
||||
const confirmed = hasDraft.value
|
||||
? await requestDiscardConfirmation()
|
||||
|
||||
+125
-52
@@ -4,15 +4,16 @@
|
||||
class="edit-member-page"
|
||||
:class="{
|
||||
'edit-state--form': editState === 'form',
|
||||
'edit-state--preview': editState === 'preview',
|
||||
'edit-state--loading': editState === 'loading',
|
||||
'edit-state--error': editState === 'error',
|
||||
'edit-state--no-permission': editState === 'no-permission',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="edit-member-page__header"><PageHeader title="编辑成员" custom-back @back="requestBack" /></view>
|
||||
<view class="edit-member-panel">
|
||||
<view v-if="editState === 'form'" class="edit-member-form">
|
||||
<AppLoading v-if="editState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认可编辑字段。" />
|
||||
|
||||
<view v-else-if="editState === 'form'" class="edit-member-form">
|
||||
<text class="form-eyebrow">成员档案维护</text>
|
||||
<text class="form-title">完善{{ originalMember.name }}的生命记录</text>
|
||||
<text class="form-copy">基础身份用于世系展示,生平说明会显示在有权限查看的成员档案中。</text>
|
||||
@@ -26,6 +27,10 @@
|
||||
</view>
|
||||
<text v-if="fieldErrors.name" class="field-error">{{ fieldErrors.name }}</text>
|
||||
|
||||
<view class="form-field">
|
||||
<text>别名</text>
|
||||
<input v-model="editForm.aliasName" placeholder="别名或曾用名" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>字辈</text>
|
||||
<input v-model="editForm.generationName" maxlength="12" placeholder="例如:文字辈" placeholder-class="form-placeholder" />
|
||||
@@ -33,19 +38,43 @@
|
||||
<picker mode="date" :value="editForm.birthDate" @change="selectDate('birthDate', $event)">
|
||||
<view class="form-field form-field--picker"><text>出生日期</text><text>{{ editForm.birthDate || "未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>出生农历</text>
|
||||
<input v-model="editForm.birthLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>出生地</text>
|
||||
<input v-model="editForm.birthPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<picker mode="date" :value="editForm.deathDate" @change="selectDate('deathDate', $event)">
|
||||
<view class="form-field form-field--picker"><text>离世日期</text><text>{{ editForm.deathDate || "在世或未填写" }}</text></view>
|
||||
</picker>
|
||||
<view class="form-field">
|
||||
<text>逝世农历</text>
|
||||
<input v-model="editForm.deathLunar" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>逝世地</text>
|
||||
<input v-model="editForm.deathPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field">
|
||||
<text>安葬地</text>
|
||||
<input v-model="editForm.burialPlace" placeholder="按家谱记载填写" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>人物简介</text>
|
||||
<textarea v-model="editForm.summary" auto-height maxlength="500" placeholder="记录生平、迁徙或重要经历" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<view class="form-field form-field--summary">
|
||||
<text>备注</text>
|
||||
<textarea v-model="editForm.remark" auto-height placeholder="记录其他需要保留的信息" placeholder-class="form-placeholder" />
|
||||
</view>
|
||||
<text v-if="fieldErrors.dates" class="field-error">{{ fieldErrors.dates }}</text>
|
||||
|
||||
<text class="form-note">隐私字段只向本人和具备维护权限的家谱管理员展示。</text>
|
||||
<view class="form-action" @click="saveMember">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -78,10 +107,11 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
@@ -89,16 +119,32 @@ import {
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const editState = ref("form");
|
||||
const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const discardDialogVisible = ref(false);
|
||||
let submitTimer = null;
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const editRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
const editForm = reactive({ name: "", generationName: "", birthDate: "", deathDate: "", summary: "" });
|
||||
const editForm = reactive({
|
||||
name: "",
|
||||
aliasName: "",
|
||||
generationName: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
summary: "",
|
||||
remark: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "" });
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
@@ -107,16 +153,16 @@ const hasValidContext = computed(() =>
|
||||
);
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
(editState.value === "form" || editState.value === "preview") &&
|
||||
editState.value === "form" &&
|
||||
Boolean(baseline.value) &&
|
||||
formSnapshot.value !== baseline.value,
|
||||
);
|
||||
const resultCopy = computed(() => ({
|
||||
preview: { eyebrow: "本地流程预览", title: `${editForm.name}的资料已通过本地校验`, copy: "尚未提交服务器,返回成员档案后不会显示本次修改。", action: "返回成员档案(不保存)" },
|
||||
error: hasValidContext.value
|
||||
? { eyebrow: "资料未保存", title: "暂时无法保存成员档案", copy: "当前修改仍保留,可返回表单后重试。", action: "返回修改" }
|
||||
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: "请从成员档案重新进入,页面不会创建临时成员身份。", action: "返回上一页" },
|
||||
"no-permission": { eyebrow: "权限不足", title: "当前账号不能编辑这位成员", copy: "本人或具备成员维护权限的家谱管理员才能修改档案。", action: "返回成员档案" },
|
||||
error: failedAction.value === "save"
|
||||
? { eyebrow: "保存失败", title: "成员档案尚未保存", copy: errorMessage.value || "服务未确认本次修改,请检查填写后重试。", action: "返回修改" }
|
||||
: hasValidContext.value
|
||||
? { eyebrow: "资料读取失败", title: "暂时无法读取成员档案", copy: errorMessage.value || "请返回后重试。", action: "重新读取" }
|
||||
: { eyebrow: "成员入口无效", title: "没有找到要编辑的成员", copy: errorMessage.value || "请从成员档案重新进入。", action: "返回上一页" },
|
||||
}[editState.value] || {}));
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardDialogVisible.value = visible;
|
||||
@@ -125,49 +171,55 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const loadMember = (id) => {
|
||||
const member = findTreeMemberPresentationFixture(genealogyId.value, id);
|
||||
if (!member) return false;
|
||||
originalMember.value = { ...member };
|
||||
if (["privacy", "forbidden"].includes(member.status)) {
|
||||
Object.assign(editForm, {
|
||||
name: member.name,
|
||||
generationName: "",
|
||||
birthDate: "",
|
||||
deathDate: "",
|
||||
summary: "",
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
editState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: editRequestController,
|
||||
});
|
||||
} else {
|
||||
if (activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
Object.assign(editForm, {
|
||||
name: member.name,
|
||||
aliasName: member.aliasName || "",
|
||||
generationName: member.generationName || "",
|
||||
birthDate: member.birthDate || "",
|
||||
birthLunar: member.birthLunar || "",
|
||||
birthPlace: member.birthplace || "",
|
||||
deathDate: member.deathDate || "",
|
||||
summary: member.summary || "",
|
||||
deathLunar: member.deathLunar || "",
|
||||
deathPlace: member.deathPlace || "",
|
||||
burialPlace: member.burialPlace || "",
|
||||
summary: member.biography || "",
|
||||
remark: member.remark || "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
errorMessage.value = "";
|
||||
failedAction.value = "load";
|
||||
editState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
originalMember.value = null;
|
||||
failedAction.value = "load";
|
||||
errorMessage.value = error?.message || "成员资料暂不可用。";
|
||||
editState.value = "error";
|
||||
}
|
||||
baseline.value = formSnapshot.value;
|
||||
return true;
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
const loaded = Boolean(genealogyId.value && personId.value) && loadMember(personId.value);
|
||||
editState.value = !loaded
|
||||
? "error"
|
||||
: ["privacy", "forbidden"].includes(originalMember.value.status)
|
||||
? "no-permission"
|
||||
: query.state === "preview"
|
||||
? "preview"
|
||||
: query.state === "error"
|
||||
? "error"
|
||||
: "form";
|
||||
if (!genealogyId.value || !personId.value || query.state === "error") {
|
||||
editState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
onUnload(() => {
|
||||
const timer = submitTimer;
|
||||
submitTimer = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
loadSequence += 1;
|
||||
editRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -193,17 +245,34 @@ const validateEditForm = () => {
|
||||
fieldErrors.dates = editForm.birthDate && editForm.deathDate && editForm.deathDate < editForm.birthDate ? "离世日期不能早于出生日期" : "";
|
||||
return !fieldErrors.name && !fieldErrors.dates;
|
||||
};
|
||||
const saveMember = () => {
|
||||
const saveMember = async () => {
|
||||
if (isSubmitting.value || !validateEditForm()) return;
|
||||
const submitSnapshot = Object.freeze({ ...editForm });
|
||||
isSubmitting.value = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
editState.value = submitSnapshot.name.trim() === "失败" ? "error" : "preview";
|
||||
try {
|
||||
await appApi.updatePerson(genealogyId.value, personId.value, {
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
generationName: editForm.generationName,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
}, { requestController: editRequestController });
|
||||
baseline.value = formSnapshot.value;
|
||||
await goBack();
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
failedAction.value = "save";
|
||||
errorMessage.value = error?.message || "服务未确认本次修改。";
|
||||
editState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
submitTimer = null;
|
||||
}, 280);
|
||||
submitTimer = timer;
|
||||
}
|
||||
};
|
||||
const returnToMember = async () => {
|
||||
const confirmed = isDirty.value
|
||||
@@ -213,11 +282,15 @@ const returnToMember = async () => {
|
||||
return goBack();
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (editState.value === "error" && hasValidContext.value) {
|
||||
if (failedAction.value === "save") {
|
||||
editState.value = "form";
|
||||
return;
|
||||
}
|
||||
return returnToMember();
|
||||
if (editState.value === "error") {
|
||||
if (!genealogyId.value || !personId.value) return returnToMember();
|
||||
return loadMember();
|
||||
}
|
||||
editState.value = "form";
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,291 +1,126 @@
|
||||
<!-- 页面编号:T-06;用途:选择两位现有成员并校正其家族关系。 -->
|
||||
<!-- 页面编号:T-06;用途:展示指定成员的同辈排行调整入口。 -->
|
||||
<template>
|
||||
<view
|
||||
class="relationship-page"
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'relationship-state--form': relationshipState === 'form',
|
||||
'relationship-state--preview': relationshipState === 'preview',
|
||||
'relationship-state--conflict': relationshipState === 'conflict',
|
||||
'relationship-state--error': relationshipState === 'error',
|
||||
'rank-state--loading': rankState === 'loading',
|
||||
'rank-state--form': rankState === 'form',
|
||||
'rank-state--unavailable': rankState === 'unavailable',
|
||||
'rank-state--error': rankState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="relationship-page__header"><PageHeader title="关系维护" custom-back @back="requestBack" /></view>
|
||||
<view class="relationship-panel">
|
||||
<view v-if="relationshipState === 'form'" class="relationship-form">
|
||||
<text class="form-eyebrow">亲属关系校正</text>
|
||||
<text class="form-title">确认两位成员的家族关系</text>
|
||||
<text class="form-copy">选择成员和关系类型后,先查看对世系的影响,再决定是否保存。</text>
|
||||
<view class="rank-page__header"><PageHeader title="调整排行" custom-back @back="goBack" /></view>
|
||||
<view class="rank-panel">
|
||||
<AppLoading v-if="rankState === 'loading'" text="正在读取成员资料" description="请稍候,正在确认待调整人物。" />
|
||||
|
||||
<picker :range="memberLabels" :value="sourceIndex" @change="selectMember('sourceId', $event)">
|
||||
<view class="form-field form-field--picker"><text>当前成员</text><text>{{ memberName(relationshipForm.sourceId) || "请选择成员" }}</text></view>
|
||||
</picker>
|
||||
<text v-if="fieldErrors.sourceId" class="field-error">{{ fieldErrors.sourceId }}</text>
|
||||
<view v-else-if="rankState === 'form' && member" class="rank-form">
|
||||
<text class="form-eyebrow">同辈排行</text>
|
||||
<text class="form-title">调整{{ member.name }}的排行</text>
|
||||
<text class="form-copy">排行调整必须由服务端以同辈原子操作完成,避免逐人保存造成部分成功。</text>
|
||||
|
||||
<picker :range="memberLabels" :value="targetIndex" @change="selectMember('targetId', $event)">
|
||||
<view class="form-field form-field--picker"><text>关联成员</text><text>{{ memberName(relationshipForm.targetId) || "请选择另一位成员" }}</text></view>
|
||||
</picker>
|
||||
<text v-if="fieldErrors.targetId" class="field-error">{{ fieldErrors.targetId }}</text>
|
||||
|
||||
<picker :range="relationshipOptions" :value="relationshipIndex" @change="selectRelationship">
|
||||
<view class="form-field form-field--picker"><text>关系类型</text><text>{{ relationshipForm.relationship || "请选择关系" }}</text></view>
|
||||
</picker>
|
||||
<text v-if="fieldErrors.relationship" class="field-error">{{ fieldErrors.relationship }}</text>
|
||||
|
||||
<view class="relationship-preview">
|
||||
<text>关系影响预览</text>
|
||||
<text>{{ relationshipPreview }}</text>
|
||||
<view class="member-context">
|
||||
<text>当前成员</text><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
|
||||
<text class="form-note">父母子女关系会改变世系位置;配偶和兄弟姐妹关系不会自动改写现有父母。</text>
|
||||
<view class="form-action" @click="saveRelationship">
|
||||
<view class="rank-notice">
|
||||
<text>当前服务状态</text>
|
||||
<text>尚未提供带版本、冲突处理和完整结果的同辈原子重排合同。</text>
|
||||
</view>
|
||||
<view class="form-action" @click="showRankUnavailable">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ isSubmitting ? "正在校验…" : "生成本地预览" }}</text>
|
||||
<text>查看服务状态</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="relationship-result">
|
||||
<text class="form-eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||
<text class="form-title">{{ resultCopy.title }}</text>
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view v-if="relationshipState === 'conflict'" class="result-actions">
|
||||
<view class="form-action form-action--secondary" @click="relationshipState = 'form'">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-secondary-v3.png" mode="scaleToFill" /><text>返回核对</text>
|
||||
</view>
|
||||
<view class="form-action" @click="conflictDialogVisible = true">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" /><text>查看规则</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="form-action" @click="handleResultAction">
|
||||
<view v-else class="rank-result">
|
||||
<text class="form-eyebrow">{{ rankState === "unavailable" ? "服务暂未开放" : "成员入口无效" }}</text>
|
||||
<text class="form-title">{{ rankState === "unavailable" ? "暂不能提交排行调整" : "暂时无法读取成员资料" }}</text>
|
||||
<text class="form-copy">{{ rankState === "unavailable" ? "当前人物更新接口只有单人物 sortOrder 候选,不能保证同辈排行原子一致。本页不会逐人写入,也不会显示本地预览成功。" : errorMessage }}</text>
|
||||
<view class="form-action" @click="rankState === 'unavailable' ? (rankState = 'form') : goBack()">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ relationshipState === "preview" ? "返回世系树(不保存)" : hasValidContext ? "重新选择" : "返回上一页" }}</text>
|
||||
<text>{{ rankState === "unavailable" ? "返回查看" : "返回世系树" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog
|
||||
:visible="conflictDialogVisible"
|
||||
eyebrow="关系校验规则"
|
||||
title="为什么不能保存这段关系"
|
||||
:message="conflictReason || '同一成员不能成为自己的亲属,也不能形成上下代循环或重复父母关系。'"
|
||||
:close-on-mask="false"
|
||||
@confirm="conflictDialogVisible = false"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="discardDialogVisible"
|
||||
eyebrow="关系尚未保存"
|
||||
title="要放弃本次关系调整吗"
|
||||
message="当前关系只在本页预览,确认返回后不会修改世系。"
|
||||
cancel-text="继续核对"
|
||||
confirm-text="放弃并返回"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@cancel="cancelDiscard"
|
||||
@confirm="confirmDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { listTreeMemberFixtures } from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation.js";
|
||||
|
||||
const relationshipState = ref("form");
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const conflictDialogVisible = ref(false);
|
||||
const discardDialogVisible = ref(false);
|
||||
const conflictReason = ref("");
|
||||
let submitTimer = null;
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const rankRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const memberOptions = computed(() => listTreeMemberFixtures(genealogyId.value));
|
||||
const relationshipOptions = ["父子(当前成员为父)", "父女(当前成员为父)", "母子(当前成员为母)", "母女(当前成员为母)", "配偶", "兄弟姐妹"];
|
||||
const relationshipForm = reactive({ sourceId: "", targetId: "", relationship: "" });
|
||||
const fieldErrors = reactive({ sourceId: "", targetId: "", relationship: "" });
|
||||
const baseline = ref("");
|
||||
const memberLabels = computed(() => memberOptions.value.map((item) => `${item.name} · 第 ${item.generation} 世`));
|
||||
const memberById = computed(() => new Map(memberOptions.value.map((item) => [item.id, item])));
|
||||
const hasValidContext = computed(() =>
|
||||
Boolean(genealogyId.value && memberById.value.has(personId.value)),
|
||||
);
|
||||
const sourceIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.sourceId)));
|
||||
const targetIndex = computed(() => Math.max(0, memberOptions.value.findIndex((item) => item.id === relationshipForm.targetId)));
|
||||
const relationshipIndex = computed(() => Math.max(0, relationshipOptions.indexOf(relationshipForm.relationship)));
|
||||
const memberName = (id) => memberById.value.get(String(id))?.name || "";
|
||||
const relationshipPreview = computed(
|
||||
() => relationshipForm.sourceId && relationshipForm.targetId && relationshipForm.relationship
|
||||
? `${memberName(relationshipForm.sourceId)}将以“${relationshipForm.relationship}”关联${memberName(relationshipForm.targetId)}。`
|
||||
: "完成三项选择后,这里会说明世系位置将如何变化。",
|
||||
);
|
||||
const formSnapshot = computed(() => JSON.stringify(relationshipForm));
|
||||
const isDirty = computed(() =>
|
||||
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
|
||||
);
|
||||
const resultCopy = computed(() => ({
|
||||
preview: { eyebrow: "本地流程预览", title: "关系校验已经通过", copy: `${relationshipPreview.value} 尚未提交服务器,返回后世系不会变化。` },
|
||||
conflict: { eyebrow: "发现关系冲突", title: "这段关系会造成世系矛盾", copy: conflictReason.value },
|
||||
error: hasValidContext.value
|
||||
? { eyebrow: "关系未保存", title: "暂时无法完成关系调整", copy: "当前选择仍然保留,可返回后重新校验。" }
|
||||
: { eyebrow: "成员入口无效", title: "没有找到要调整的成员", copy: "请从世系树重新进入,页面不会回退到其他成员。" },
|
||||
}[relationshipState.value] || {}));
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardDialogVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
rankState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: rankRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
member.value = result;
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
errorMessage.value = error?.message || "当前成员资料暂不可用,请返回世系树后重试。";
|
||||
rankState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const showRankUnavailable = () => {
|
||||
rankState.value = "unavailable";
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
relationshipForm.sourceId = hasValidContext.value ? personId.value : "";
|
||||
baseline.value = formSnapshot.value;
|
||||
relationshipState.value = !hasValidContext.value || query.state === "error"
|
||||
? "error"
|
||||
: query.state === "preview"
|
||||
? "preview"
|
||||
: query.state === "conflict"
|
||||
? "conflict"
|
||||
: "form";
|
||||
if (relationshipState.value === "conflict") conflictReason.value = "目标成员已经存在父级关系,请先核对原关系。";
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "当前排行入口无效,请从人物操作面板重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
const timer = submitTimer;
|
||||
submitTimer = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
loadSequence += 1;
|
||||
rankRequestController.abort();
|
||||
});
|
||||
|
||||
const closeTransient = () => {
|
||||
if (conflictDialogVisible.value) {
|
||||
conflictDialogVisible.value = false;
|
||||
return;
|
||||
}
|
||||
cancelDiscard();
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: conflictDialogVisible.value || discardDialogVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": closeTransient,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
const clearFieldError = (field) => { fieldErrors[field] = ""; };
|
||||
const selectMember = (field, event) => {
|
||||
relationshipForm[field] = memberOptions.value[Number(event.detail.value)]?.id || "";
|
||||
clearFieldError(field);
|
||||
};
|
||||
const selectRelationship = (event) => {
|
||||
relationshipForm.relationship = relationshipOptions[Number(event.detail.value)] || "";
|
||||
clearFieldError("relationship");
|
||||
};
|
||||
const isAncestor = (possibleAncestorId, memberId) => {
|
||||
let current = memberById.value.get(String(memberId));
|
||||
const visited = new Set();
|
||||
while (current?.parentId && !visited.has(current.id)) {
|
||||
if (current.parentId === String(possibleAncestorId)) return true;
|
||||
visited.add(current.id);
|
||||
current = memberById.value.get(current.parentId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const validateRelationship = () => {
|
||||
fieldErrors.sourceId = relationshipForm.sourceId ? "" : "请选择当前成员";
|
||||
fieldErrors.targetId = relationshipForm.targetId ? "" : "请选择关联成员";
|
||||
fieldErrors.relationship = relationshipForm.relationship ? "" : "请选择关系类型";
|
||||
if (fieldErrors.sourceId || fieldErrors.targetId || fieldErrors.relationship) return false;
|
||||
if (relationshipForm.sourceId === relationshipForm.targetId) {
|
||||
conflictReason.value = "同一成员不能与自己建立亲属关系。";
|
||||
return false;
|
||||
}
|
||||
const isParentRelationship = relationshipForm.relationship.includes("当前成员为");
|
||||
const target = memberById.value.get(relationshipForm.targetId);
|
||||
if (isParentRelationship && isAncestor(relationshipForm.targetId, relationshipForm.sourceId)) {
|
||||
conflictReason.value = "保存后会形成上下代循环,请重新选择成员方向。";
|
||||
return false;
|
||||
}
|
||||
if (isParentRelationship && target?.parentId && target.parentId !== relationshipForm.sourceId) {
|
||||
conflictReason.value = `${target.name}已经存在父级成员,不能直接重复建立父母关系。`;
|
||||
return false;
|
||||
}
|
||||
conflictReason.value = "";
|
||||
return true;
|
||||
};
|
||||
const saveRelationship = () => {
|
||||
if (isSubmitting.value) return;
|
||||
if (!validateRelationship()) {
|
||||
if (conflictReason.value) relationshipState.value = "conflict";
|
||||
return;
|
||||
}
|
||||
const submitSnapshot = Object.freeze({ ...relationshipForm });
|
||||
isSubmitting.value = true;
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
relationshipState.value = submitSnapshot.relationship === "失败" ? "error" : "preview";
|
||||
isSubmitting.value = false;
|
||||
submitTimer = null;
|
||||
}, 280);
|
||||
submitTimer = timer;
|
||||
};
|
||||
const returnToTree = async () => {
|
||||
const confirmed = isDirty.value
|
||||
? await requestDiscardConfirmation()
|
||||
: true;
|
||||
if (!confirmed) return false;
|
||||
return returnTo("T01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (relationshipState.value === "error") {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
relationshipState.value = "form";
|
||||
return;
|
||||
}
|
||||
return returnToTree();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, goBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.relationship-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.relationship-page__header { z-index: 3; }
|
||||
.relationship-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
|
||||
.rank-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.rank-page__header { z-index: 3; }
|
||||
.rank-panel { @include adaptive.adaptive-tree-panel; z-index: 2; width: calc(100% - 32rpx); margin: 18rpx auto 28rpx; padding: 7.5% 8%; }
|
||||
.form-eyebrow { display: block; color: $brand-red; font-size: 22rpx; letter-spacing: 3rpx; }
|
||||
.form-title { display: block; margin-top: 10rpx; color: $ink; font-family: "STKaiti", "KaiTi", serif; font-size: 34rpx; font-weight: 700; line-height: 1.35; }
|
||||
.form-copy, .form-note { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
|
||||
.form-field { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 14rpx; padding: 12rpx 22rpx; }
|
||||
.form-field text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.form-field text:last-child { min-width: 0; color: $ink; font-size: 24rpx; line-height: 1.45; text-align: right; }
|
||||
.field-error { display: block; margin: 5rpx 18rpx 0; color: $brand-red; font-size: 22rpx; line-height: 32rpx; }
|
||||
.relationship-preview { @include adaptive.adaptive-genealogy-list-card; margin-top: 18rpx; padding: 20rpx 22rpx; }
|
||||
.relationship-preview text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.5; }
|
||||
.relationship-preview text:first-child { color: $brand-red; font-weight: 700; }
|
||||
.form-note { text-align: center; }
|
||||
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 18rpx; }
|
||||
.form-copy { display: block; margin-top: 10rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
|
||||
.member-context, .rank-notice { @include adaptive.adaptive-tree-field; display: grid; grid-template-columns: auto minmax(0, 1fr); min-height: 78rpx; align-items: center; gap: 20rpx; margin-top: 16rpx; padding: 12rpx 22rpx; }
|
||||
.member-context text:first-child, .rank-notice text:first-child { color: $ink; font-size: 24rpx; font-weight: 700; }
|
||||
.member-context text:last-child, .rank-notice text:last-child { min-width: 0; color: $ink-muted; font-size: 23rpx; line-height: 1.45; text-align: right; }
|
||||
.form-action { display: grid; width: 100%; min-height: 76rpx; margin-top: 22rpx; }
|
||||
.form-action image, .form-action text { grid-area: 1 / 1; width: 100%; height: 100%; }
|
||||
.form-action text { z-index: 1; display: flex; align-items: center; justify-content: center; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
|
||||
.form-action--secondary text { color: $ink; }
|
||||
.relationship-result { margin-top: 28%; text-align: center; }
|
||||
.relationship-result .form-eyebrow, .relationship-result .form-copy { text-align: center; }
|
||||
.relationship-result > .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
|
||||
.result-actions { display: flex; gap: 14rpx; margin-top: 24rpx; }
|
||||
.result-actions .form-action { width: calc(50% - 7rpx); margin-top: 0; }
|
||||
@media (min-width: 400px) { .relationship-panel { width: calc(100% - 48rpx); } }
|
||||
.rank-result { margin-top: 30%; text-align: center; }
|
||||
.rank-result .form-eyebrow, .rank-result .form-copy { text-align: center; }
|
||||
.rank-result .form-action { width: 420rpx; max-width: 100%; margin-right: auto; margin-left: auto; }
|
||||
@media (min-width: 400px) { .rank-panel { width: calc(100% - 48rpx); } }
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<view v-if="hasValidContext" class="directory-context">
|
||||
<text class="directory-context__name">汤氏家谱</text>
|
||||
<text class="directory-context__meta"
|
||||
>主支 · {{ members.length }} 位成员</text
|
||||
>已加载 {{ members.length }} / {{ total }} 位成员</text
|
||||
>
|
||||
</view>
|
||||
<view
|
||||
@@ -46,10 +46,10 @@
|
||||
<template v-else-if="directoryState === 'list'">
|
||||
<view class="directory-summary"
|
||||
><text>家谱成员</text
|
||||
><text>{{ filteredMembers.length }} 人 · 按世代排列</text></view
|
||||
><text>{{ members.length }} / {{ total }} 人 · 按世代排列</text></view
|
||||
>
|
||||
<view
|
||||
v-for="item in filteredMembers"
|
||||
v-for="item in members"
|
||||
:key="item.id"
|
||||
class="directory-card"
|
||||
@click="openMember(item)"
|
||||
@@ -60,6 +60,13 @@
|
||||
<text class="directory-card__status">{{ memberStatus(item) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton
|
||||
v-if="hasMore"
|
||||
block
|
||||
type="secondary"
|
||||
:label="loadingMore ? '正在加载…' : '加载更多成员'"
|
||||
@click="loadMore"
|
||||
/>
|
||||
</template>
|
||||
<view v-else class="directory-state-card">
|
||||
<view
|
||||
@@ -85,61 +92,82 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { listTreeMemberPresentationFixtures } from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
const genealogyId = ref("");
|
||||
const keyword = ref("");
|
||||
const directoryState = ref("loading");
|
||||
const members = ref([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const directoryRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const members = computed(() => listTreeMemberPresentationFixtures(genealogyId.value));
|
||||
const isRestrictedMember = (member) =>
|
||||
["privacy", "forbidden"].includes(member.status);
|
||||
const memberMeta = (member) =>
|
||||
isRestrictedMember(member)
|
||||
? `第 ${member.generation} 世 · ${member.relation}`
|
||||
: `第 ${member.generation} 世 · ${member.generationName} · ${member.branch}`;
|
||||
`第 ${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
|
||||
const memberStatus = (member) =>
|
||||
isRestrictedMember(member)
|
||||
? member.status === "forbidden"
|
||||
? "访问受限"
|
||||
: "隐私资料"
|
||||
: member.note;
|
||||
const filteredMembers = computed(() => {
|
||||
const value = keyword.value.trim();
|
||||
if (!value) return members.value;
|
||||
return members.value.filter((item) =>
|
||||
`${item.name}${item.relation}${item.generation}${item.generationName || ""}${item.branch || ""}`.includes(value),
|
||||
);
|
||||
});
|
||||
member.personStatus ? `人物状态:${member.personStatus}` : "人物状态待补";
|
||||
const hasMore = computed(() => members.value.length < total.value);
|
||||
|
||||
const loadMembers = async ({ append = false } = {}) => {
|
||||
if (!hasValidContext.value) return;
|
||||
const activeLoad = ++loadSequence;
|
||||
if (append) loadingMore.value = true;
|
||||
else directoryState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: directoryRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
members.value = append ? [...members.value, ...result.rows] : result.rows;
|
||||
total.value = result.total;
|
||||
directoryState.value = members.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!append) members.value = [];
|
||||
directoryState.value = "error";
|
||||
} finally {
|
||||
if (activeLoad === loadSequence) loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
directoryState.value =
|
||||
!hasValidContext.value ? "error" : query.state === "loading"
|
||||
? "loading"
|
||||
: query.state === "empty"
|
||||
? "empty"
|
||||
: query.state === "error"
|
||||
? "error"
|
||||
: members.value.length
|
||||
? "list"
|
||||
: "empty";
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
directoryState.value = "error";
|
||||
return;
|
||||
}
|
||||
void loadMembers();
|
||||
});
|
||||
const searchMembers = () => {
|
||||
directoryState.value = filteredMembers.value.length ? "list" : "empty";
|
||||
pageNum.value = 1;
|
||||
void loadMembers();
|
||||
};
|
||||
const loadMore = () => {
|
||||
if (loadingMore.value || !hasMore.value) return;
|
||||
pageNum.value += 1;
|
||||
void loadMembers({ append: true });
|
||||
};
|
||||
const retryDirectory = () => {
|
||||
if (!hasValidContext.value) return goBack();
|
||||
directoryState.value = "list";
|
||||
pageNum.value = 1;
|
||||
return loadMembers();
|
||||
};
|
||||
const openMember = (item) =>
|
||||
hasValidContext.value
|
||||
? openPage("T03", { genealogyId: genealogyId.value, personId: String(item.id) }, "T07")
|
||||
: Promise.resolve(false);
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
directoryRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
<view
|
||||
class="member-status-page"
|
||||
:class="{
|
||||
'member-status--privacy': statusState === 'privacy',
|
||||
'member-status--deceased': statusState === 'deceased',
|
||||
'member-status--forbidden': statusState === 'forbidden',
|
||||
'member-status--available': statusState === 'available',
|
||||
'member-status--error': statusState === 'error',
|
||||
}"
|
||||
>
|
||||
@@ -19,21 +17,26 @@
|
||||
</view>
|
||||
|
||||
<view class="status-card">
|
||||
<view class="status-card__copy">
|
||||
<AppLoading
|
||||
v-if="statusState === 'loading'"
|
||||
text="正在读取成员状态"
|
||||
description="请稍候,正在读取人物详情中的状态字段。"
|
||||
/>
|
||||
<view v-else class="status-card__copy">
|
||||
<text>{{ activeStatus.eyebrow }}</text>
|
||||
<text>{{ activeStatus.title }}</text>
|
||||
<text>{{ activeStatus.copy }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="status-guidance">
|
||||
<view v-if="statusState !== 'loading'" class="status-guidance">
|
||||
<view>
|
||||
<text>{{ activeStatus.guideTitle }}</text>
|
||||
<text v-for="line in activeStatus.guides" :key="line">{{ line }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="status-action" @click="handleAction">
|
||||
<view v-if="statusState !== 'loading'" class="status-action" @click="handleAction">
|
||||
<image src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png" mode="scaleToFill" />
|
||||
<text>{{ activeStatus.action }}</text>
|
||||
</view>
|
||||
@@ -42,43 +45,38 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findTreeMemberPresentationFixture } from "@/data/mock.js";
|
||||
import { goBack, goRoot } from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const statusState = ref("loading");
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const member = ref(null);
|
||||
const statusRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const states = {
|
||||
privacy: {
|
||||
eyebrow: "隐私成员",
|
||||
title: "敏感资料只向授权成员展示",
|
||||
copy: "该成员姓名和世系位置仍然保留,出生信息、联系方式和生平内容按权限隐藏。",
|
||||
guideTitle: "当前可见范围",
|
||||
guides: ["姓名、世代与家族关系可见", "联系方式和详细生平已隐藏", "本人或谱主可维护授权范围"],
|
||||
action: "返回成员档案",
|
||||
loading: {
|
||||
eyebrow: "正在读取",
|
||||
title: "正在读取成员状态",
|
||||
copy: "",
|
||||
guideTitle: "",
|
||||
guides: [],
|
||||
action: "",
|
||||
},
|
||||
deceased: {
|
||||
eyebrow: "离世纪念",
|
||||
title: "这位家人的生命记录被温和保留",
|
||||
copy: "离世状态不会删除成员关系;有权限的家人仍可共同维护生卒年月、生平和追思资料。",
|
||||
guideTitle: "纪念资料范围",
|
||||
guides: ["生卒年月与世系关系继续保留", "生平内容由有权限家人维护", "敏感资料继续遵守原有隐私设置"],
|
||||
available: {
|
||||
eyebrow: "人物状态",
|
||||
title: "当前状态已由服务端返回",
|
||||
copy: "页面只展示人物详情中的原始状态值,不把未声明的字典值解释成隐私、纪念或访问限制。",
|
||||
guideTitle: "当前合同范围",
|
||||
guides: ["人物状态值来自 personStatus 字段", "状态展示文案需要后端提供字典 owner", "当前页不执行停用或任何状态写入"],
|
||||
action: "返回成员档案",
|
||||
},
|
||||
forbidden: {
|
||||
eyebrow: "访问受限",
|
||||
title: "当前账号没有查看该档案的权限",
|
||||
copy: "页面不会展示被隐藏字段,也不会提供绕过家谱权限的入口。",
|
||||
guideTitle: "如何申请查看",
|
||||
guides: ["先确认已经加入对应家谱", "联系谱主说明亲属关系和用途", "权限变更后重新进入成员档案"],
|
||||
action: "返回我的家谱",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "成员状态不可用",
|
||||
title: "没有找到要查看的成员",
|
||||
@@ -89,35 +87,48 @@ const states = {
|
||||
},
|
||||
};
|
||||
const activeStatus = computed(() => states[statusState.value] || states.error);
|
||||
const pageTitle = computed(() => statusState.value === "deceased" ? "成员纪念" : statusState.value === "privacy" ? "隐私资料" : "成员状态");
|
||||
const pageTitle = computed(() => "成员状态");
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value && personId.value));
|
||||
const memberIdentityCopy = computed(() => {
|
||||
if (!member.value) return "";
|
||||
const identity = `${member.value.name} · 第 ${member.value.generation} 世`;
|
||||
return ["privacy", "forbidden"].includes(member.value.status)
|
||||
? `${identity} · ${member.value.relation}`
|
||||
: `${identity} · ${member.value.branch}`;
|
||||
const status = member.value.personStatus || "未填写";
|
||||
return `${member.value.name} · 第 ${member.value.generation} 世 · 状态值 ${status}`;
|
||||
});
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
statusState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: statusRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
member.value = result;
|
||||
genealogyName.value = result.genealogyName;
|
||||
statusState.value = "available";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
statusState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
member.value = hasValidContext.value
|
||||
? findTreeMemberPresentationFixture(genealogyId.value, personId.value)
|
||||
: null;
|
||||
const requestedState = ["privacy", "deceased", "forbidden"].includes(query.state) ? query.state : "";
|
||||
const derivedState = member.value?.status || "";
|
||||
statusState.value = member.value && (!requestedState || requestedState === derivedState)
|
||||
? requestedState || derivedState
|
||||
: "error";
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
statusState.value = "error";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
const handleAction = () => {
|
||||
if (statusState.value === "forbidden") {
|
||||
return goRoot("G01");
|
||||
}
|
||||
return goBack();
|
||||
};
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
statusRequestController.abort();
|
||||
});
|
||||
|
||||
const handleAction = () => goBack();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
Reference in New Issue
Block a user