feat: migrate app routes and business modules
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-04;用途:录入首位成员或为指定成员新增亲属。 -->
|
||||
<template>
|
||||
<view
|
||||
class="add-relative-page"
|
||||
@@ -78,6 +77,28 @@
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{
|
||||
fieldErrors.bindingMode
|
||||
}}</text>
|
||||
<picker
|
||||
v-if="addForm.bindingMode === 'SPECIFIED'"
|
||||
:range="memberOptionLabels"
|
||||
:value="memberOptionIndex(addForm.appUserId)"
|
||||
:disabled="memberOptionsState !== 'ready' || !memberOptions.length"
|
||||
@change="selectBoundMember"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text><text class="required-mark">*</text>指定成员</text>
|
||||
<text>{{ memberOptionLabel(addForm.appUserId) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text
|
||||
v-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
|
||||
class="form-note"
|
||||
>正在读取可绑定成员</text
|
||||
>
|
||||
<text
|
||||
v-else-if="addForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
|
||||
class="field-error"
|
||||
>可绑定成员暂不可用,请稍后重试。</text
|
||||
>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
@@ -260,7 +281,7 @@
|
||||
<text class="form-note">{{ formNote }}</text>
|
||||
<view class="form-action" @click="submitAdd">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存成员" }}</text>
|
||||
@@ -273,7 +294,7 @@
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view class="form-action" @click="handleResultAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ resultCopy.action }}</text>
|
||||
@@ -304,16 +325,28 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
findMemberOptionIndex,
|
||||
findMemberOptionLabel,
|
||||
memberFormOptions,
|
||||
memberRelationOptions,
|
||||
memberRelationTypes,
|
||||
updateMemberBindingMode,
|
||||
updateMemberFormOption,
|
||||
} from "@/utils/tree/member-form.js";
|
||||
|
||||
const addState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -321,51 +354,37 @@ const personId = ref("");
|
||||
const mode = ref("relative");
|
||||
const relationType = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const memberCreationCommitted = ref(false);
|
||||
const memberCreationOutcomeUnknown = ref(false);
|
||||
const isAvatarUploading = ref(false);
|
||||
const avatarFileName = ref("");
|
||||
const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const currentMember = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const addRequestController = createRequestController();
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberCreationRequestController = createRequestController();
|
||||
const memberCreationGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
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 sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
]);
|
||||
const relationIntents = Object.freeze(
|
||||
Object.fromEntries(
|
||||
memberRelationOptions.map(({ value, label }) => [value, Object.freeze({ label })]),
|
||||
),
|
||||
);
|
||||
const genericRelationTypes = Object.freeze(
|
||||
memberRelationOptions.map(({ value }) => value),
|
||||
);
|
||||
const sexOptions = memberFormOptions.sex;
|
||||
const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||
const addForm = reactive({
|
||||
bindingMode: "NONE",
|
||||
appUserId: "",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
@@ -386,6 +405,8 @@ const addForm = reactive({
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", relation: "", bindingMode: "" });
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
|
||||
const isFirstMember = computed(() => mode.value === "first");
|
||||
const isDeceased = computed(() => addForm.personStatus === "1");
|
||||
@@ -401,6 +422,9 @@ const relationOptions = computed(() => {
|
||||
const relationLabel = computed(
|
||||
() => activeRelationIntent.value?.label || addForm.relation || "亲属关系",
|
||||
);
|
||||
const memberOptionLabels = computed(() =>
|
||||
memberOptions.value.map((item) => item.label),
|
||||
);
|
||||
const hasValidContext = computed(
|
||||
() =>
|
||||
Boolean(genealogyId.value) &&
|
||||
@@ -421,16 +445,36 @@ const formCopy = computed(() =>
|
||||
);
|
||||
const formNote = computed(() => "红色 * 为必填项,其余内容可按家谱记载补充。");
|
||||
const resultCopy = computed(() => {
|
||||
if (memberCreationOutcomeUnknown.value) {
|
||||
return {
|
||||
eyebrow: "结果待确认",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}可能已经保存`,
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"暂时无法确认成员是否已经写入。请先返回世系树检查,不要重复新增。",
|
||||
action: "返回世系树",
|
||||
};
|
||||
}
|
||||
if (memberCreationCommitted.value) {
|
||||
return {
|
||||
eyebrow: "保存完成",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}已经保存`,
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"成员已经写入家谱,但页面暂时没有返回世系树。请重试返回,不要重复新增。",
|
||||
action: "返回世系树",
|
||||
};
|
||||
}
|
||||
if (hasValidContext.value) {
|
||||
return {
|
||||
eyebrow: "保存失败",
|
||||
title: `${isFirstMember.value ? "首位成员" : relationLabel.value}尚未保存`,
|
||||
copy: errorMessage.value || "服务未确认本次写入,请检查填写后重试。",
|
||||
copy: errorMessage.value || "成员资料尚未保存,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
};
|
||||
}
|
||||
return {
|
||||
eyebrow: "成员入口无效",
|
||||
eyebrow: "暂时无法打开成员页面",
|
||||
title: "没有找到要关联的成员",
|
||||
copy: errorMessage.value || "请从世系树重新进入。",
|
||||
action: "返回世系树",
|
||||
@@ -438,7 +482,8 @@ const resultCopy = computed(() => {
|
||||
});
|
||||
const hasDraft = computed(() =>
|
||||
Object.entries(addForm).some(
|
||||
([field, value]) => field !== "bindingMode" && String(value).trim(),
|
||||
([field, value]) =>
|
||||
field !== "bindingMode" && field !== "appUserId" && String(value).trim(),
|
||||
),
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -452,17 +497,39 @@ const loadCurrentMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
addState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: addRequestController,
|
||||
const member = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
currentMember.value = member;
|
||||
addState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
currentMember.value = null;
|
||||
addState.value = "error";
|
||||
errorMessage.value = error?.message || "当前成员暂不可用。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "当前成员暂不可用。");
|
||||
}
|
||||
};
|
||||
const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
|
||||
requestController: memberOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memberOptions.value = rows
|
||||
.filter((item) => item.eligible)
|
||||
.map((item) => ({
|
||||
value: item.appUserId,
|
||||
label: item.relationName
|
||||
? `${item.memberName} · ${item.relationName}`
|
||||
: item.memberName,
|
||||
}));
|
||||
memberOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
memberOptions.value = [];
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
onLoad((query) => {
|
||||
@@ -472,21 +539,21 @@ onLoad((query) => {
|
||||
relationType.value = String(query.relationType || "");
|
||||
if (
|
||||
!genealogyId.value ||
|
||||
query.state === "error" ||
|
||||
(!isFirstMember.value && !personId.value)
|
||||
) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效,请从世系树重新进入。";
|
||||
errorMessage.value = "这个页面已经过期,请从世系树重新进入。";
|
||||
return;
|
||||
}
|
||||
if (relationType.value && !activeRelationIntent.value) {
|
||||
addState.value = "error";
|
||||
errorMessage.value = "未知的亲属关系意图,本页不会推断或替换。";
|
||||
errorMessage.value = "暂时无法确定要添加的亲属关系,请从世系树重新选择。";
|
||||
return;
|
||||
}
|
||||
if (activeRelationIntent.value) {
|
||||
addForm.relation = activeRelationIntent.value.label;
|
||||
}
|
||||
void loadMemberOptions();
|
||||
if (isFirstMember.value) {
|
||||
addState.value = "form";
|
||||
return;
|
||||
@@ -494,8 +561,12 @@ onLoad((query) => {
|
||||
void loadCurrentMember();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
addRequestController.abort();
|
||||
memberDetailRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberCreationRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -518,27 +589,30 @@ const selectRelation = (event) => {
|
||||
addForm.relation = relationOptions.value[Number(event.detail.value)] || "";
|
||||
clearError("relation");
|
||||
};
|
||||
const optionIndex = (options, value) =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((item) => item.value === value),
|
||||
);
|
||||
const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
const optionLabel = findMemberOptionLabel;
|
||||
const selectOption = (field, options, event) => {
|
||||
addForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
if (field === "personStatus" && addForm.personStatus !== "1") {
|
||||
Object.assign(addForm, {
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
});
|
||||
}
|
||||
updateMemberFormOption(addForm, field, options, event);
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
addForm.bindingMode =
|
||||
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
updateMemberBindingMode(addForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const memberOptionIndex = (value) =>
|
||||
Math.max(
|
||||
0,
|
||||
memberOptions.value.findIndex((item) => item.value === value),
|
||||
);
|
||||
const memberOptionLabel = (value) =>
|
||||
memberOptions.value.find((item) => item.value === value)?.label ||
|
||||
(memberOptionsState.value === "loading"
|
||||
? "正在读取"
|
||||
: memberOptions.value.length
|
||||
? "请选择可绑定成员"
|
||||
: "暂无可绑定成员");
|
||||
const selectBoundMember = (event) => {
|
||||
addForm.appUserId =
|
||||
memberOptions.value[Number(event.detail.value)]?.value || "";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const uploadAvatar = async () => {
|
||||
@@ -547,15 +621,17 @@ const uploadAvatar = async () => {
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: addRequestController,
|
||||
requestController: avatarUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
addForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectBirthDate = (event) => {
|
||||
@@ -569,94 +645,112 @@ const validateAddForm = () => {
|
||||
fieldErrors.relation =
|
||||
isFirstMember.value || addForm.relation ? "" : "请选择与当前成员的关系";
|
||||
fieldErrors.bindingMode =
|
||||
addForm.bindingMode === "SPECIFIED"
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
addForm.bindingMode === "SPECIFIED" && !addForm.appUserId
|
||||
? memberOptionsState.value === "error"
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.relation && !fieldErrors.bindingMode;
|
||||
};
|
||||
const submitAdd = async () => {
|
||||
if (isSubmitting.value || isAvatarUploading.value || !validateAddForm())
|
||||
return;
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
...(addForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: addForm.appUserId }
|
||||
: {}),
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1 }
|
||||
: {
|
||||
...(addForm.generation
|
||||
? { generation: Number(addForm.generation) }
|
||||
: {}),
|
||||
...(relationType.value === memberRelationTypes.SPOUSE
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
const submittedRelationType = isFirstMember.value
|
||||
? ""
|
||||
: activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
const createAttempt = memberCreationGuard.begin({
|
||||
genealogyId: genealogyId.value,
|
||||
personId: isFirstMember.value ? "" : personId.value,
|
||||
relationType: submittedRelationType,
|
||||
payload,
|
||||
});
|
||||
if (createAttempt === null) {
|
||||
memberCreationOutcomeUnknown.value = true;
|
||||
errorMessage.value =
|
||||
"上次保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
|
||||
addState.value = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
bindingMode: addForm.bindingMode,
|
||||
name: addForm.name,
|
||||
aliasName: addForm.aliasName,
|
||||
sex: addForm.sex,
|
||||
generationName: addForm.generationName,
|
||||
avatarOssId: addForm.avatarOssId,
|
||||
birthDate: addForm.birthDate,
|
||||
birthLunar: addForm.birthLunar,
|
||||
birthPlace: addForm.birthPlace,
|
||||
deathDate: addForm.deathDate,
|
||||
deathLunar: addForm.deathLunar,
|
||||
deathPlace: addForm.deathPlace,
|
||||
burialPlace: addForm.burialPlace,
|
||||
personStatus: addForm.personStatus,
|
||||
biography: addForm.biography,
|
||||
remark: addForm.remark,
|
||||
sortOrder: addForm.sortOrder,
|
||||
...(isFirstMember.value
|
||||
? { generation: 1 }
|
||||
: {
|
||||
...(addForm.generation
|
||||
? { generation: Number(addForm.generation) }
|
||||
: {}),
|
||||
...(relationType.value === "SPOUSE"
|
||||
? { relationName: relationLabel.value }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
if (isFirstMember.value) {
|
||||
await appApi.createPerson(genealogyId.value, payload, {
|
||||
requestController: addRequestController,
|
||||
await lineageApi.createPerson(genealogyId.value, payload, {
|
||||
requestController: memberCreationRequestController,
|
||||
});
|
||||
} else {
|
||||
const type = activeRelationIntent.value
|
||||
? relationType.value
|
||||
: genericRelationTypes[relationOptions.value.indexOf(addForm.relation)];
|
||||
await appApi.createRelatedPerson(
|
||||
await lineageApi.createRelatedPerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
type,
|
||||
submittedRelationType,
|
||||
payload,
|
||||
{ requestController: addRequestController },
|
||||
{ requestController: memberCreationRequestController },
|
||||
);
|
||||
}
|
||||
Object.assign(addForm, {
|
||||
bindingMode: "NONE",
|
||||
name: "",
|
||||
relation: "",
|
||||
aliasName: "",
|
||||
sex: "",
|
||||
generation: "",
|
||||
generationName: "",
|
||||
avatarOssId: "",
|
||||
birthDate: "",
|
||||
birthLunar: "",
|
||||
birthPlace: "",
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
personStatus: "",
|
||||
biography: "",
|
||||
remark: "",
|
||||
sortOrder: "",
|
||||
});
|
||||
avatarFileName.value = "";
|
||||
if (!pageActive) return;
|
||||
memberCreationCommitted.value = true;
|
||||
await returnTo("T01", { genealogyId: genealogyId.value });
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (
|
||||
!memberCreationCommitted.value &&
|
||||
memberCreationGuard.recordFailure(createAttempt, error)
|
||||
) {
|
||||
memberCreationOutcomeUnknown.value = true;
|
||||
errorMessage.value =
|
||||
"保存结果暂时无法确认,请先返回世系树检查,避免重复新增。";
|
||||
addState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (isRequestCancelled(error)) return;
|
||||
errorMessage.value = error?.message || "服务未确认本次写入。";
|
||||
errorMessage.value = memberCreationCommitted.value
|
||||
? "成员已经保存,但页面返回失败。请重试返回世系树,不要重复新增。"
|
||||
: getRequestErrorMessage(error, "成员资料尚未保存,请稍后重试。");
|
||||
addState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const handleResultAction = () =>
|
||||
hasValidContext.value ? (addState.value = "form") : returnToTree();
|
||||
memberCreationCommitted.value || memberCreationOutcomeUnknown.value
|
||||
? returnTo("T01", { genealogyId: genealogyId.value })
|
||||
: hasValidContext.value
|
||||
? (addState.value = "form")
|
||||
: returnToTree();
|
||||
const returnToTree = async () => {
|
||||
const confirmed = hasDraft.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-05;用途:维护指定成员的身份与生平资料。 -->
|
||||
<template>
|
||||
<view
|
||||
class="edit-member-page"
|
||||
@@ -16,7 +15,7 @@
|
||||
<AppLoading
|
||||
v-if="editState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认可编辑字段。"
|
||||
description="请稍候,正在打开编辑资料。"
|
||||
/>
|
||||
|
||||
<view v-else-if="editState === 'form'" class="edit-member-form">
|
||||
@@ -59,14 +58,7 @@
|
||||
}}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<view
|
||||
v-if="editForm.bindingMode === 'SPECIFIED'"
|
||||
class="form-field form-field--picker"
|
||||
>
|
||||
<text>身份认领</text><text>已绑定指定用户</text>
|
||||
</view>
|
||||
<picker
|
||||
v-else
|
||||
:range="bindingModeOptions.map((item) => item.label)"
|
||||
:value="optionIndex(bindingModeOptions, editForm.bindingMode)"
|
||||
@change="selectBindingMode"
|
||||
@@ -81,6 +73,28 @@
|
||||
<text v-if="fieldErrors.bindingMode" class="field-error">{{
|
||||
fieldErrors.bindingMode
|
||||
}}</text>
|
||||
<picker
|
||||
v-if="editForm.bindingMode === 'SPECIFIED'"
|
||||
:range="memberOptionLabels"
|
||||
:value="memberOptionIndex(editForm.appUserId)"
|
||||
:disabled="memberOptionsState !== 'ready' || !memberBindingOptions.length"
|
||||
@change="selectBoundMember"
|
||||
>
|
||||
<view class="form-field form-field--picker">
|
||||
<text><text class="required-mark">*</text>指定成员</text>
|
||||
<text>{{ memberOptionLabel(editForm.appUserId) }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<text
|
||||
v-if="editForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'loading'"
|
||||
class="form-note"
|
||||
>正在读取可绑定成员</text
|
||||
>
|
||||
<text
|
||||
v-else-if="editForm.bindingMode === 'SPECIFIED' && memberOptionsState === 'error'"
|
||||
class="field-error"
|
||||
>可绑定成员暂不可用,请稍后重试。</text
|
||||
>
|
||||
<view class="form-field form-field--upload">
|
||||
<text>头像</text>
|
||||
<view>
|
||||
@@ -262,11 +276,11 @@
|
||||
}}</text>
|
||||
|
||||
<text class="form-note"
|
||||
>隐私字段只向本人和具备维护权限的家谱管理员展示。</text
|
||||
>私密信息仅你本人和可维护家谱的管理员可见。</text
|
||||
>
|
||||
<view class="form-action" @click="saveMember">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ isSubmitting ? "正在保存…" : "保存资料" }}</text>
|
||||
@@ -279,7 +293,7 @@
|
||||
<text class="form-copy">{{ resultCopy.copy }}</text>
|
||||
<view class="form-action" @click="handleResultAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ resultCopy.action }}</text>
|
||||
@@ -310,21 +324,30 @@ import AppDialog from "@/components/AppDialog.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { genealogyMemberApi } from "@/services/api/genealogy-member-service.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
isImagePickCancelled,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/resumable-image-upload.js";
|
||||
} from "@/utils/media-upload.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
finishPage,
|
||||
goBack,
|
||||
handleBackPress,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
findMemberOptionIndex,
|
||||
findMemberOptionLabel,
|
||||
memberFormOptions,
|
||||
updateMemberBindingMode,
|
||||
updateMemberFormOption,
|
||||
} from "@/utils/tree/member-form.js";
|
||||
|
||||
const editState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
@@ -336,12 +359,17 @@ const avatarUploadError = ref("");
|
||||
const discardDialogVisible = ref(false);
|
||||
const errorMessage = ref("");
|
||||
const failedAction = ref("load");
|
||||
const editRequestController = createRequestController();
|
||||
const memberDetailRequestController = createRequestController();
|
||||
const personOptionsRequestController = createRequestController();
|
||||
const memberOptionsRequestController = createRequestController();
|
||||
const avatarUploadRequestController = createRequestController();
|
||||
const memberUpdateRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
const originalMember = ref(null);
|
||||
const baseline = ref("");
|
||||
const committedMemberSnapshot = ref("");
|
||||
const editForm = reactive({
|
||||
appUserId: "",
|
||||
bindingMode: "NONE",
|
||||
@@ -366,28 +394,30 @@ const editForm = reactive({
|
||||
sortOrder: "",
|
||||
});
|
||||
const fieldErrors = reactive({ name: "", dates: "", bindingMode: "" });
|
||||
const sexOptions = Object.freeze([
|
||||
{ label: "男", value: "0" },
|
||||
{ label: "女", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const lunarOptions = Object.freeze([
|
||||
{ label: "否", value: "0" },
|
||||
{ label: "是", value: "1" },
|
||||
]);
|
||||
const personStatusOptions = Object.freeze([
|
||||
{ label: "健在", value: "0" },
|
||||
{ label: "已故", value: "1" },
|
||||
{ label: "未知", value: "2" },
|
||||
]);
|
||||
const bindingModeOptions = Object.freeze([
|
||||
{ label: "不绑定账号", value: "NONE" },
|
||||
{ label: "绑定当前账号", value: "SELF" },
|
||||
]);
|
||||
const sexOptions = memberFormOptions.sex;
|
||||
const lunarOptions = memberFormOptions.lunar;
|
||||
const personStatusOptions = memberFormOptions.personStatus;
|
||||
const bindingModeOptions = memberFormOptions.bindingMode;
|
||||
const personOptions = ref([{ label: "不选择", value: "" }]);
|
||||
const personOptionLabels = computed(() =>
|
||||
personOptions.value.map((item) => item.label),
|
||||
);
|
||||
const memberOptionsState = ref("loading");
|
||||
const memberOptions = ref([]);
|
||||
const memberBindingOptions = computed(() => {
|
||||
const options = memberOptions.value.slice();
|
||||
if (
|
||||
editForm.bindingMode === "SPECIFIED" &&
|
||||
editForm.appUserId &&
|
||||
!options.some((item) => item.value === editForm.appUserId)
|
||||
) {
|
||||
options.unshift({ value: editForm.appUserId, label: "当前已绑定成员" });
|
||||
}
|
||||
return options;
|
||||
});
|
||||
const memberOptionLabels = computed(() =>
|
||||
memberBindingOptions.value.map((item) => item.label),
|
||||
);
|
||||
const isDeceased = computed(() => editForm.personStatus === "1");
|
||||
|
||||
const formSnapshot = computed(() => JSON.stringify(editForm));
|
||||
@@ -404,12 +434,21 @@ const resultCopy = computed(
|
||||
() =>
|
||||
({
|
||||
error:
|
||||
failedAction.value === "save"
|
||||
failedAction.value === "navigate"
|
||||
? {
|
||||
eyebrow: "保存完成",
|
||||
title: "成员档案已经保存",
|
||||
copy:
|
||||
errorMessage.value ||
|
||||
"资料已保存,但页面暂时没有返回成员档案。",
|
||||
action: "返回成员档案",
|
||||
}
|
||||
: failedAction.value === "save"
|
||||
? {
|
||||
eyebrow: "保存失败",
|
||||
title: "成员档案尚未保存",
|
||||
copy:
|
||||
errorMessage.value || "服务未确认本次修改,请检查填写后重试。",
|
||||
errorMessage.value || "资料尚未保存,请检查填写后重试。",
|
||||
action: "返回修改",
|
||||
}
|
||||
: hasValidContext.value
|
||||
@@ -420,7 +459,7 @@ const resultCopy = computed(
|
||||
action: "重新读取",
|
||||
}
|
||||
: {
|
||||
eyebrow: "成员入口无效",
|
||||
eyebrow: "暂时无法打开成员页面",
|
||||
title: "没有找到要编辑的成员",
|
||||
copy: errorMessage.value || "请从成员档案重新进入。",
|
||||
action: "返回上一页",
|
||||
@@ -439,10 +478,10 @@ const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
editState.value = "loading";
|
||||
try {
|
||||
const member = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: editRequestController,
|
||||
const member = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDetailRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
originalMember.value = member;
|
||||
const deceased = member.personStatus === "1";
|
||||
Object.assign(editForm, {
|
||||
@@ -472,54 +511,86 @@ const loadMember = async () => {
|
||||
sortOrder: member.sortOrder ?? "",
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
committedMemberSnapshot.value = "";
|
||||
errorMessage.value = "";
|
||||
failedAction.value = "load";
|
||||
editState.value = "form";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
originalMember.value = null;
|
||||
failedAction.value = "load";
|
||||
errorMessage.value = error?.message || "成员资料暂不可用。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "成员资料暂不可用。");
|
||||
editState.value = "error";
|
||||
}
|
||||
};
|
||||
const loadPersonOptions = async () => {
|
||||
try {
|
||||
const rows = await appApi.getLineagePersonOptions(genealogyId.value, {
|
||||
const rows = await lineageApi.getLineagePersonOptions(genealogyId.value, {
|
||||
requestController: personOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
const options = rows
|
||||
.map((item) => {
|
||||
const value = String(item?.personId ?? item?.id ?? "");
|
||||
if (!/^[1-9]\d*$/.test(value) || value === personId.value) return null;
|
||||
const name = String(
|
||||
item?.name || item?.personName || item?.personNo || "未命名人物",
|
||||
);
|
||||
return { value, label: `${name}(${value})` };
|
||||
const personOptionId = item.id;
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(personOptionId) ||
|
||||
personOptionId === personId.value
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const name = item.name;
|
||||
return { value: personOptionId, label: `${name}(${personOptionId})` };
|
||||
})
|
||||
.filter(Boolean);
|
||||
personOptions.value = [{ label: "不选择", value: "" }, ...options];
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error))
|
||||
if (pageActive && !isRequestCancelled(error))
|
||||
personOptions.value = [{ label: "暂无可选人物", value: "" }];
|
||||
}
|
||||
};
|
||||
const loadMemberOptions = async () => {
|
||||
memberOptionsState.value = "loading";
|
||||
try {
|
||||
const rows = await genealogyMemberApi.getMemberOptions(genealogyId.value, {
|
||||
requestController: memberOptionsRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
memberOptions.value = rows
|
||||
.filter((item) => item.eligible)
|
||||
.map((item) => ({
|
||||
value: item.appUserId,
|
||||
label: item.relationName
|
||||
? `${item.memberName} · ${item.relationName}`
|
||||
: item.memberName,
|
||||
}));
|
||||
memberOptionsState.value = "ready";
|
||||
} catch (error) {
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
memberOptions.value = [];
|
||||
memberOptionsState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.state === "error") {
|
||||
if (!genealogyId.value || !personId.value) {
|
||||
editState.value = "error";
|
||||
errorMessage.value = "当前家谱或成员上下文无效。";
|
||||
errorMessage.value = "这个页面已经过期,请从成员档案重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
void loadPersonOptions();
|
||||
void loadMemberOptions();
|
||||
});
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
editRequestController.abort();
|
||||
memberDetailRequestController.abort();
|
||||
personOptionsRequestController.abort();
|
||||
memberOptionsRequestController.abort();
|
||||
avatarUploadRequestController.abort();
|
||||
memberUpdateRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
|
||||
@@ -538,27 +609,30 @@ onBackPress((event) => handleBackPress(event, requestBack));
|
||||
const clearError = (field) => {
|
||||
fieldErrors[field] = "";
|
||||
};
|
||||
const optionIndex = (options, value) =>
|
||||
Math.max(
|
||||
0,
|
||||
options.findIndex((item) => item.value === value),
|
||||
);
|
||||
const optionLabel = (options, value) =>
|
||||
options.find((item) => item.value === value)?.label || "";
|
||||
const optionIndex = findMemberOptionIndex;
|
||||
const optionLabel = findMemberOptionLabel;
|
||||
const selectOption = (field, options, event) => {
|
||||
editForm[field] = options[Number(event.detail.value)]?.value || "";
|
||||
if (field === "personStatus" && editForm.personStatus !== "1") {
|
||||
Object.assign(editForm, {
|
||||
deathDate: "",
|
||||
deathLunar: "",
|
||||
deathPlace: "",
|
||||
burialPlace: "",
|
||||
});
|
||||
}
|
||||
updateMemberFormOption(editForm, field, options, event);
|
||||
};
|
||||
const selectBindingMode = (event) => {
|
||||
editForm.bindingMode =
|
||||
bindingModeOptions[Number(event.detail.value)]?.value || "NONE";
|
||||
updateMemberBindingMode(editForm, event);
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const memberOptionIndex = (value) =>
|
||||
Math.max(
|
||||
0,
|
||||
memberBindingOptions.value.findIndex((item) => item.value === value),
|
||||
);
|
||||
const memberOptionLabel = (value) =>
|
||||
memberBindingOptions.value.find((item) => item.value === value)?.label ||
|
||||
(memberOptionsState.value === "loading"
|
||||
? "正在读取"
|
||||
: memberBindingOptions.value.length
|
||||
? "请选择可绑定成员"
|
||||
: "暂无可绑定成员");
|
||||
const selectBoundMember = (event) => {
|
||||
editForm.appUserId =
|
||||
memberBindingOptions.value[Number(event.detail.value)]?.value || "";
|
||||
fieldErrors.bindingMode = "";
|
||||
};
|
||||
const personOptionIndex = (value) =>
|
||||
@@ -578,15 +652,17 @@ const uploadAvatar = async () => {
|
||||
avatarUploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: editRequestController,
|
||||
requestController: avatarUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
editForm.avatarOssId = receipt.ossId;
|
||||
avatarFileName.value = receipt.fileName || "头像图片";
|
||||
} catch (error) {
|
||||
if (!pageActive) return;
|
||||
if (!isImagePickCancelled(error) && !isRequestCancelled(error))
|
||||
avatarUploadError.value = error?.message || "头像上传失败,请稍后重试";
|
||||
avatarUploadError.value = getRequestErrorMessage(error, "头像上传失败,请稍后重试");
|
||||
} finally {
|
||||
isAvatarUploading.value = false;
|
||||
if (pageActive) isAvatarUploading.value = false;
|
||||
}
|
||||
};
|
||||
const selectDate = (field, event) => {
|
||||
@@ -603,7 +679,9 @@ const validateEditForm = () => {
|
||||
: "";
|
||||
fieldErrors.bindingMode =
|
||||
editForm.bindingMode === "SPECIFIED" && !editForm.appUserId
|
||||
? "当前未提供可选业务用户,不能指定认领"
|
||||
? memberOptionsState.value === "error"
|
||||
? "可绑定成员暂不可用,请稍后重试"
|
||||
: "请选择可绑定成员"
|
||||
: "";
|
||||
return !fieldErrors.name && !fieldErrors.dates && !fieldErrors.bindingMode;
|
||||
};
|
||||
@@ -612,43 +690,48 @@ const saveMember = async () => {
|
||||
return;
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
await appApi.updatePerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation
|
||||
? Number(editForm.generation)
|
||||
: undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
...(isDeceased.value
|
||||
? {
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
}
|
||||
: {}),
|
||||
personStatus: editForm.personStatus,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
sortOrder: editForm.sortOrder,
|
||||
},
|
||||
{ requestController: editRequestController },
|
||||
);
|
||||
baseline.value = formSnapshot.value;
|
||||
const currentSnapshot = formSnapshot.value;
|
||||
if (committedMemberSnapshot.value !== currentSnapshot) {
|
||||
await lineageApi.updatePerson(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
{
|
||||
bindingMode: editForm.bindingMode,
|
||||
...(editForm.bindingMode === "SPECIFIED"
|
||||
? { appUserId: editForm.appUserId }
|
||||
: {}),
|
||||
name: editForm.name,
|
||||
aliasName: editForm.aliasName,
|
||||
sex: editForm.sex,
|
||||
generation: editForm.generation
|
||||
? Number(editForm.generation)
|
||||
: undefined,
|
||||
generationName: editForm.generationName,
|
||||
fatherId: editForm.fatherId,
|
||||
motherId: editForm.motherId,
|
||||
avatarOssId: editForm.avatarOssId,
|
||||
birthDate: editForm.birthDate,
|
||||
birthLunar: editForm.birthLunar,
|
||||
birthPlace: editForm.birthPlace,
|
||||
...(isDeceased.value
|
||||
? {
|
||||
deathDate: editForm.deathDate,
|
||||
deathLunar: editForm.deathLunar,
|
||||
deathPlace: editForm.deathPlace,
|
||||
burialPlace: editForm.burialPlace,
|
||||
}
|
||||
: {}),
|
||||
personStatus: editForm.personStatus,
|
||||
biography: editForm.summary,
|
||||
remark: editForm.remark,
|
||||
sortOrder: editForm.sortOrder,
|
||||
},
|
||||
{ requestController: memberUpdateRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedMemberSnapshot.value = currentSnapshot;
|
||||
baseline.value = currentSnapshot;
|
||||
}
|
||||
await finishPage(
|
||||
"T03",
|
||||
{
|
||||
@@ -662,12 +745,16 @@ const saveMember = async () => {
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
failedAction.value = "save";
|
||||
errorMessage.value = error?.message || "服务未确认本次修改。";
|
||||
if (!pageActive || isRequestCancelled(error)) return;
|
||||
const updateCommitted =
|
||||
committedMemberSnapshot.value === formSnapshot.value;
|
||||
failedAction.value = updateCommitted ? "navigate" : "save";
|
||||
errorMessage.value = updateCommitted
|
||||
? "资料已经保存,但页面返回失败。请重试返回成员档案。"
|
||||
: getRequestErrorMessage(error, "资料尚未保存,请稍后重试。");
|
||||
editState.value = "error";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
if (pageActive) isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const returnToMember = async () => {
|
||||
@@ -676,6 +763,7 @@ const returnToMember = async () => {
|
||||
return goBack();
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (failedAction.value === "navigate") return saveMember();
|
||||
if (failedAction.value === "save") {
|
||||
editState.value = "form";
|
||||
return;
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-07;用途:成员目录、搜索、空态与失败状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="directory-page"
|
||||
@@ -98,11 +97,11 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack, openPage } from "@/utils/navigation/gateway.js";
|
||||
const genealogyId = ref("");
|
||||
const keyword = ref("");
|
||||
const directoryState = ref("loading");
|
||||
@@ -110,19 +109,15 @@ const members = ref([]);
|
||||
const total = ref(0);
|
||||
const pageNum = ref(1);
|
||||
const loadingMore = ref(false);
|
||||
const directoryRequestController = createRequestController();
|
||||
const memberDirectoryRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const hasValidContext = computed(() => Boolean(genealogyId.value));
|
||||
const memberMeta = (member) =>
|
||||
`第 ${member.generation} 世 · ${member.generationName || "字辈待补"} · ${member.branch}`;
|
||||
const memberStatus = (member) => {
|
||||
const labels = {
|
||||
0: "健在",
|
||||
1: "已故",
|
||||
2: "未知",
|
||||
};
|
||||
const label = labels[String(member.personStatus ?? "").trim()];
|
||||
return label ? `人物状态:${label}` : "人物状态待确认";
|
||||
return member.personStatusLabel
|
||||
? `人物状态:${member.personStatusLabel}`
|
||||
: "人物状态待确认";
|
||||
};
|
||||
const hasMore = computed(() => members.value.length < total.value);
|
||||
|
||||
@@ -132,14 +127,14 @@ const loadMembers = async ({ append = false } = {}) => {
|
||||
if (append) loadingMore.value = true;
|
||||
else directoryState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPersonPage(
|
||||
const personPage = await lineageApi.getPersonPage(
|
||||
genealogyId.value,
|
||||
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
|
||||
{ requestController: directoryRequestController },
|
||||
{ requestController: memberDirectoryRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return;
|
||||
members.value = append ? [...members.value, ...result.rows] : result.rows;
|
||||
total.value = result.total;
|
||||
members.value = append ? [...members.value, ...personPage.rows] : personPage.rows;
|
||||
total.value = personPage.total;
|
||||
directoryState.value = members.value.length ? "list" : "empty";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -151,7 +146,7 @@ const loadMembers = async ({ append = false } = {}) => {
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
directoryState.value = "error";
|
||||
return;
|
||||
}
|
||||
@@ -181,7 +176,7 @@ const openMember = (item) =>
|
||||
: Promise.resolve(false);
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
directoryRequestController.abort();
|
||||
memberDirectoryRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-03;用途:按 personId 展示成员档案并进入其真实成员状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-page"
|
||||
@@ -15,7 +14,7 @@
|
||||
<view class="member-page__header">
|
||||
<PageHeader
|
||||
title="成员档案"
|
||||
:action="canPreviewEdit ? '编辑预览' : ''"
|
||||
:action="canPreviewEdit ? '编辑成员资料' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="toEdit"
|
||||
@@ -57,9 +56,13 @@
|
||||
</view>
|
||||
|
||||
<text class="member-section-title">基本资料</text>
|
||||
<view v-for="item in details" :key="item.label" class="member-info-row">
|
||||
<text>{{ item.label }}</text
|
||||
><text>{{ item.value || "未填写" }}</text>
|
||||
<view
|
||||
v-for="profileField in profileDetails"
|
||||
:key="profileField.label"
|
||||
class="member-info-row"
|
||||
>
|
||||
<text>{{ profileField.label }}</text
|
||||
><text>{{ profileField.value || "未填写" }}</text>
|
||||
</view>
|
||||
|
||||
<text class="member-section-title member-section-title--relation"
|
||||
@@ -84,18 +87,31 @@
|
||||
>
|
||||
<view
|
||||
role="button"
|
||||
aria-label="查看人生事服务状态"
|
||||
aria-label="查看人生大事"
|
||||
@click="toLifeEvents"
|
||||
><text>人生事(待开放)</text></view
|
||||
><text>人生大事</text></view
|
||||
>
|
||||
<view role="button" aria-label="查看重要证件" @click="openDocuments"
|
||||
><text>重要证件</text></view
|
||||
>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-if="canPreviewEdit"
|
||||
class="member-profile-action"
|
||||
role="button"
|
||||
aria-label="编辑成员资料"
|
||||
@click="toEdit"
|
||||
><text>制作成员编辑预览</text></view
|
||||
><text>编辑成员资料</text></view
|
||||
>
|
||||
<view
|
||||
v-if="member.canDisable"
|
||||
class="member-profile-action member-profile-action--danger"
|
||||
role="button"
|
||||
aria-label="停用这位成员"
|
||||
@click="requestDisableMember"
|
||||
><text>停用这位成员</text></view>
|
||||
<text v-else-if="member.disabledReason" class="member-disable-note">{{ member.disabledReason }}</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
@@ -114,7 +130,7 @@
|
||||
<text>{{ restrictedCopy.title }}</text>
|
||||
<text>{{ restrictedCopy.copy }}</text>
|
||||
<view class="member-restricted-action" @click="toTree"
|
||||
><text>返回上一位成员</text></view
|
||||
><text>返回上一个成员</text></view
|
||||
>
|
||||
</view>
|
||||
|
||||
@@ -126,6 +142,27 @@
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<PersonDocumentDialog
|
||||
ref="personDocumentDialog"
|
||||
:genealogy-id="genealogyId"
|
||||
:person-id="personId"
|
||||
:member="member"
|
||||
@transient-change="documentTransientOpen = $event"
|
||||
@busy-change="documentBusy = $event"
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="disableDialogVisible"
|
||||
eyebrow="成员状态"
|
||||
title="确认停用这位成员?"
|
||||
message="停用后不会从家谱中消失,但将不能继续作为正常人物参与新增关系。"
|
||||
:confirm-text="disablingMember ? '正在停用' : '确认停用'"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDisableMember"
|
||||
@cancel="disableDialogVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -133,82 +170,79 @@
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.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";
|
||||
import PersonDocumentDialog from "@/components/tree/PersonDocumentDialog.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import {
|
||||
consumeNavigationResult,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
} from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const memberState = ref("loading");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const personDocumentDialog = ref(null);
|
||||
const documentTransientOpen = ref(false);
|
||||
const documentBusy = ref(false);
|
||||
const disableDialogVisible = ref(false);
|
||||
const disablingMember = ref(false);
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const memberTrail = reactive([]);
|
||||
const trailIndex = ref(-1);
|
||||
const memberRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const memberReadRequestController = createRequestController();
|
||||
const memberDeletionRequestController = createRequestController();
|
||||
let memberLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
|
||||
const dictionaryLabel = (value, labels) => labels[String(value || "")] || "";
|
||||
const isDeceased = computed(() => member.value?.personStatus === "1");
|
||||
const details = computed(() => {
|
||||
const profileDetails = computed(() => {
|
||||
if (!member.value) return [];
|
||||
const protectedValue = (value) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : value;
|
||||
const visibleProfileValue = (profileValue) =>
|
||||
member.value.status === "forbidden" ? "按权限隐藏" : profileValue;
|
||||
return [
|
||||
{ label: "别名", value: member.value.aliasName },
|
||||
{ label: "字辈", value: member.value.generationName },
|
||||
{
|
||||
label: "性别",
|
||||
value: dictionaryLabel(member.value.sex, {
|
||||
0: "男",
|
||||
1: "女",
|
||||
2: "未知",
|
||||
}),
|
||||
value: member.value.sexLabel,
|
||||
},
|
||||
{
|
||||
label: "人物状态",
|
||||
value: dictionaryLabel(member.value.personStatus, {
|
||||
0: "健在",
|
||||
1: "已故",
|
||||
2: "未知",
|
||||
}),
|
||||
value: member.value.personStatusLabel,
|
||||
},
|
||||
{ label: "出生日期", value: protectedValue(member.value.birthDate) },
|
||||
{ label: "出生日期", value: visibleProfileValue(member.value.birthDate) },
|
||||
{
|
||||
label: "出生农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.birthLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
value: visibleProfileValue(member.value.birthLunarLabel),
|
||||
},
|
||||
{ label: "生卒信息", value: member.value.years },
|
||||
{ label: "出生地", value: protectedValue(member.value.birthplace) },
|
||||
{ label: "出生地", value: visibleProfileValue(member.value.birthplace) },
|
||||
...(isDeceased.value
|
||||
? [
|
||||
{ label: "逝世日期", value: protectedValue(member.value.deathDate) },
|
||||
{ label: "逝世日期", value: visibleProfileValue(member.value.deathDate) },
|
||||
{
|
||||
label: "逝世农历",
|
||||
value: protectedValue(
|
||||
dictionaryLabel(member.value.deathLunar, { 0: "否", 1: "是" }),
|
||||
),
|
||||
value: visibleProfileValue(member.value.deathLunarLabel),
|
||||
},
|
||||
{ label: "逝世地", value: protectedValue(member.value.deathPlace) },
|
||||
{ label: "安葬地", value: protectedValue(member.value.burialPlace) },
|
||||
{ label: "逝世地", value: visibleProfileValue(member.value.deathPlace) },
|
||||
{ label: "安葬地", value: visibleProfileValue(member.value.burialPlace) },
|
||||
]
|
||||
: []),
|
||||
{ label: "配偶", value: member.value.spouseNames },
|
||||
{ label: "所属支系", value: member.value.branch },
|
||||
{ label: "生平", value: protectedValue(member.value.biography) },
|
||||
{ label: "生平", value: visibleProfileValue(member.value.biography) },
|
||||
{ label: "备注", value: member.value.remark },
|
||||
];
|
||||
});
|
||||
@@ -235,7 +269,7 @@ const statusDescription = computed(
|
||||
({
|
||||
privacy: "部分资料仅向授权成员展示",
|
||||
deceased: "查看生平保留与纪念资料说明",
|
||||
forbidden: "当前账号只能查看有限身份信息",
|
||||
forbidden: "你只能查看部分身份信息",
|
||||
})[member.value?.status] || "查看成员状态说明",
|
||||
);
|
||||
const memberContextDescription = computed(
|
||||
@@ -247,11 +281,10 @@ const memberContextDescription = computed(
|
||||
error: "请重新选择成员",
|
||||
})[memberState.value] || "请重新选择成员",
|
||||
);
|
||||
|
||||
const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
const normalizedPersonId = String(nextPersonId || "");
|
||||
const activeLoad = ++loadSequence;
|
||||
const previous = {
|
||||
const activeLoadGeneration = ++memberLoadGeneration;
|
||||
const previousMemberView = {
|
||||
personId: personId.value,
|
||||
member: member.value,
|
||||
state: memberState.value,
|
||||
@@ -259,30 +292,41 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
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;
|
||||
personId.value = previousMemberView.personId;
|
||||
member.value = previousMemberView.member;
|
||||
memberState.value = previousMemberView.state;
|
||||
errorMessage.value = previousMemberView.errorMessage;
|
||||
genealogyName.value = previousMemberView.genealogyName;
|
||||
};
|
||||
|
||||
memberState.value = "loading";
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
const nextMember = await appApi.getPerson(
|
||||
const nextMember = await lineageApi.getPerson(
|
||||
genealogyId.value,
|
||||
normalizedPersonId,
|
||||
{ requestController: memberRequestController },
|
||||
{ requestController: memberReadRequestController },
|
||||
);
|
||||
if (activeLoad !== loadSequence) return false;
|
||||
if (activeLoadGeneration !== memberLoadGeneration) return false;
|
||||
if (personId.value && personId.value !== String(nextMember.id)) {
|
||||
personDocumentDialog.value?.reset();
|
||||
}
|
||||
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();
|
||||
if (
|
||||
activeLoadGeneration !== memberLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
if (
|
||||
preserveCurrent &&
|
||||
activeLoadGeneration === memberLoadGeneration
|
||||
) {
|
||||
restorePrevious();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (preserveCurrent) {
|
||||
@@ -291,7 +335,7 @@ const loadMember = async (nextPersonId, { preserveCurrent = false } = {}) => {
|
||||
}
|
||||
member.value = null;
|
||||
memberState.value = "error";
|
||||
errorMessage.value = error?.message || "这位成员不存在或已不属于当前家谱。";
|
||||
errorMessage.value = getRequestErrorMessage(error, "这位成员不存在或已不属于当前家谱。");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -337,53 +381,75 @@ const popMemberTrail = async () => {
|
||||
}
|
||||
|
||||
// 历史成员可能已删除或失去权限。移除失效项后,当前成员在数组中的
|
||||
// 索引同步左移,再继续寻找更早的有效成员,绝不创建新的 T03 页面。
|
||||
// 索引同步左移,再继续寻找更早的有效成员,绝不创建新的成员档案页面实例。
|
||||
memberTrail.splice(targetIndex, 1);
|
||||
trailIndex.value -= 1;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const hasTransientDialog = computed(() =>
|
||||
documentTransientOpen.value || disableDialogVisible.value,
|
||||
);
|
||||
const isMemberPageBusy = computed(() =>
|
||||
documentBusy.value || disablingMember.value,
|
||||
);
|
||||
const closeActiveTransient = () => {
|
||||
if (personDocumentDialog.value?.closeTransient()) return true;
|
||||
if (disableDialogVisible.value) {
|
||||
disableDialogVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
internalTrail: trailIndex.value > 0,
|
||||
transientOpen: hasTransientDialog.value && !isMemberPageBusy.value,
|
||||
internalTrail: trailIndex.value > 0 && !isMemberPageBusy.value,
|
||||
submitting: isMemberPageBusy.value,
|
||||
"close-transient": closeActiveTransient,
|
||||
"pop-internal-trail": popMemberTrail,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const initialPersonId = String(query.personId || "");
|
||||
if (query.state === "loading") return;
|
||||
if (query.state === "error" || !genealogyId.value || !initialPersonId) {
|
||||
if (!genealogyId.value || !initialPersonId) {
|
||||
memberState.value = "error";
|
||||
errorMessage.value = !initialPersonId
|
||||
? "没有指定成员,请从世系树重新选择。"
|
||||
: "当前家谱上下文无效,请重新进入。";
|
||||
: "这个页面已经过期,请返回后重新进入。";
|
||||
return;
|
||||
}
|
||||
void initializeMemberTrail(initialPersonId);
|
||||
});
|
||||
|
||||
onShow(() => {
|
||||
const result = consumeNavigationResult("T03");
|
||||
if (result?.operation === "member-open-requested" && result.entityId) {
|
||||
void openRelative(result.entityId);
|
||||
const navigationResult = consumeNavigationResult("T03");
|
||||
if (
|
||||
navigationResult?.operation === "member-open-requested" &&
|
||||
navigationResult.entityId
|
||||
) {
|
||||
void openRelative(navigationResult.entityId);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
result?.operation === "member-updated" &&
|
||||
result.refresh &&
|
||||
result.entityId === String(personId.value)
|
||||
navigationResult?.operation === "member-updated" &&
|
||||
navigationResult.refresh &&
|
||||
navigationResult.entityId === String(personId.value)
|
||||
) {
|
||||
void loadMember(result.entityId, { preserveCurrent: true });
|
||||
void loadMember(navigationResult.entityId, { preserveCurrent: true });
|
||||
}
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
memberRequestController.abort();
|
||||
pageActive = false;
|
||||
memberLoadGeneration += 1;
|
||||
memberReadRequestController.abort();
|
||||
memberDeletionRequestController.abort();
|
||||
});
|
||||
|
||||
const toEdit = () => {
|
||||
@@ -412,6 +478,35 @@ const toLifeEvents = () =>
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"T03",
|
||||
);
|
||||
const openDocuments = () => personDocumentDialog.value?.open();
|
||||
const requestDisableMember = () => {
|
||||
if (!member.value?.canDisable || disablingMember.value) return;
|
||||
disableDialogVisible.value = true;
|
||||
};
|
||||
const confirmDisableMember = async () => {
|
||||
if (!member.value?.canDisable || disablingMember.value) return;
|
||||
disablingMember.value = true;
|
||||
try {
|
||||
await lineageApi.deletePerson(genealogyId.value, personId.value, {
|
||||
requestController: memberDeletionRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
disableDialogVisible.value = false;
|
||||
member.value = {
|
||||
...member.value,
|
||||
canDisable: false,
|
||||
disabledReason: "成员已停用,最新档案状态暂时未刷新。",
|
||||
};
|
||||
await loadMember(personId.value, { preserveCurrent: true });
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
errorMessage.value = getRequestErrorMessage(error, "这位成员暂时无法停用,请稍后重试。");
|
||||
disableDialogVisible.value = false;
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) disablingMember.value = false;
|
||||
}
|
||||
};
|
||||
const toTree = requestBack;
|
||||
</script>
|
||||
|
||||
@@ -591,6 +686,11 @@ const toTree = requestBack;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-profile-action--danger { margin-top: 16rpx; color: #8f1b14; }
|
||||
.member-disable-note { display: block; margin-top: 14rpx; color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); line-height: 1.5; text-align: center; }
|
||||
.member-record-actions > view:last-child:nth-child(odd) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.member-restricted {
|
||||
padding-top: 14rpx;
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
<template>
|
||||
<view
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'rank-state--loading': rankState === 'loading',
|
||||
'rank-state--form': rankState === 'form',
|
||||
'rank-state--error': rankState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="rank-page__header"
|
||||
><PageHeader title="调整排行" custom-back @back="requestBack"
|
||||
/></view>
|
||||
<view class="rank-panel">
|
||||
<AppLoading
|
||||
v-if="rankState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认待调整人物。"
|
||||
/>
|
||||
|
||||
<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
|
||||
>
|
||||
|
||||
<view class="member-context">
|
||||
<text>当前成员</text
|
||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
<view class="rank-field">
|
||||
<text>排序值</text>
|
||||
<input
|
||||
v-model="sortOrder"
|
||||
type="number"
|
||||
:disabled="savingRank"
|
||||
placeholder="请输入整数,值越小越靠前"
|
||||
placeholder-class="rank-placeholder"
|
||||
@input="rankError = ''"
|
||||
/>
|
||||
</view>
|
||||
<text v-if="rankError" class="rank-error">{{ rankError }}</text>
|
||||
<view
|
||||
class="form-action"
|
||||
:class="{ 'form-action--disabled': savingRank }"
|
||||
@click="saveRank"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ savingRank ? "正在保存…" : "保存排行" }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="rank-result">
|
||||
<text class="form-eyebrow">暂时无法打开成员页面</text>
|
||||
<text class="form-title">暂时无法读取成员资料</text>
|
||||
<text class="form-copy">{{ errorMessage }}</text>
|
||||
<view class="form-action" @click="goBack">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>返回世系树</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, 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 {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { goBack, handleBackPress, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const sortOrder = ref("");
|
||||
const savingRank = ref(false);
|
||||
const rankError = ref("");
|
||||
const committedSortOrder = ref("");
|
||||
const memberRankReadRequestController = createRequestController();
|
||||
const memberRankSaveRequestController = createRequestController();
|
||||
let pageActive = true;
|
||||
let loadSequence = 0;
|
||||
|
||||
const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
rankState.value = "loading";
|
||||
try {
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberRankReadRequestController,
|
||||
});
|
||||
if (!pageActive || activeLoad !== loadSequence) return;
|
||||
member.value = personDetail;
|
||||
sortOrder.value = personDetail.sortOrder === null || personDetail.sortOrder === undefined
|
||||
? ""
|
||||
: String(personDetail.sortOrder);
|
||||
committedSortOrder.value = "";
|
||||
errorMessage.value = "";
|
||||
rankState.value = "form";
|
||||
} catch (error) {
|
||||
if (!pageActive || activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
member.value = null;
|
||||
errorMessage.value =
|
||||
getRequestErrorMessage(error, "当前成员资料暂不可用,请返回世系树后重试。");
|
||||
rankState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
const saveRank = async () => {
|
||||
if (savingRank.value || !member.value) return;
|
||||
const normalizedSortOrder = String(sortOrder.value).trim();
|
||||
if (!/^-?\d+$/.test(normalizedSortOrder)) {
|
||||
rankError.value = "请填写整数,例如 1、2、3。";
|
||||
return;
|
||||
}
|
||||
const numericSortOrder = Number(normalizedSortOrder);
|
||||
if (!Number.isSafeInteger(numericSortOrder)) {
|
||||
rankError.value = "排序值超出可保存范围";
|
||||
return;
|
||||
}
|
||||
savingRank.value = true;
|
||||
rankError.value = "";
|
||||
try {
|
||||
if (committedSortOrder.value !== normalizedSortOrder) {
|
||||
await lineageApi.updatePersonSortOrder(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
numericSortOrder,
|
||||
{ requestController: memberRankSaveRequestController },
|
||||
);
|
||||
if (!pageActive) return;
|
||||
committedSortOrder.value = normalizedSortOrder;
|
||||
}
|
||||
await returnTo("T01", {
|
||||
genealogyId: genealogyId.value,
|
||||
selectedId: personId.value,
|
||||
});
|
||||
} catch (error) {
|
||||
if (pageActive && !isRequestCancelled(error)) {
|
||||
rankError.value = committedSortOrder.value === normalizedSortOrder
|
||||
? "排行已经保存,但页面返回失败。请再次点击保存重试返回。"
|
||||
: getRequestErrorMessage(error, "排行尚未保存,请稍后重试。");
|
||||
}
|
||||
} finally {
|
||||
if (pageActive) savingRank.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const requestBack = () => {
|
||||
if (savingRank.value) return;
|
||||
return goBack();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "请从成员资料页重新进入排行调整。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
loadSequence += 1;
|
||||
memberRankReadRequestController.abort();
|
||||
memberRankSaveRequestController.abort();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.rank-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.rank-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.rank-panel {
|
||||
@include 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: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-context,
|
||||
.rank-field {
|
||||
@include 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-field text {
|
||||
color: $ink;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-context text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
line-height: 1.45;
|
||||
text-align: right;
|
||||
}
|
||||
.rank-field input {
|
||||
min-width: 0;
|
||||
min-height: 56rpx;
|
||||
padding: 0 14rpx;
|
||||
border: 1rpx solid rgba(143, 108, 63, 0.34);
|
||||
border-radius: 8rpx;
|
||||
background: rgba(255, 253, 247, 0.8);
|
||||
color: $ink;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
text-align: right;
|
||||
}
|
||||
.rank-placeholder { color: $ink-muted; }
|
||||
.rank-error { display: block; margin-top: 12rpx; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.form-action {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin-top: 22rpx;
|
||||
}
|
||||
.form-action--disabled { opacity: 0.58; pointer-events: none; }
|
||||
.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: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.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>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-08;用途:展示指定成员的隐私、纪念或无权限状态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="member-status-page"
|
||||
@@ -22,7 +21,7 @@
|
||||
<AppLoading
|
||||
v-if="statusState === 'loading'"
|
||||
text="正在读取成员状态"
|
||||
description="请稍候,正在读取人物详情中的状态字段。"
|
||||
description="请稍候,正在读取成员状态。"
|
||||
/>
|
||||
<view v-else class="status-card__copy">
|
||||
<text>{{ activeStatus.eyebrow }}</text>
|
||||
@@ -44,7 +43,7 @@
|
||||
@click="handleAction"
|
||||
>
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<text>{{ activeStatus.action }}</text>
|
||||
@@ -59,18 +58,18 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { goBack } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const statusState = ref("loading");
|
||||
const genealogyName = ref("汤氏家谱");
|
||||
const member = ref(null);
|
||||
const statusRequestController = createRequestController();
|
||||
const memberStatusRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
const states = {
|
||||
@@ -84,13 +83,11 @@ const states = {
|
||||
},
|
||||
available: {
|
||||
eyebrow: "人物状态",
|
||||
title: "暂无法展示状态说明",
|
||||
copy: "服务端返回了状态字段,但未提供可读名称或说明;本页不会向家人展示内部状态值。",
|
||||
guideTitle: "当前合同范围",
|
||||
title: "暂时没有可查看的成员状态",
|
||||
copy: "为保护隐私,这里只显示可以公开的信息。",
|
||||
guideTitle: "当前说明",
|
||||
guides: [
|
||||
"状态由服务端维护",
|
||||
"状态展示需要后端提供可读字典",
|
||||
"当前页不执行停用或任何状态写入",
|
||||
"暂时没有更多状态信息",
|
||||
],
|
||||
action: "返回成员档案",
|
||||
},
|
||||
@@ -121,12 +118,12 @@ const loadMember = async () => {
|
||||
const activeLoad = ++loadSequence;
|
||||
statusState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: statusRequestController,
|
||||
const personDetail = await lineageApi.getPerson(genealogyId.value, personId.value, {
|
||||
requestController: memberStatusRequestController,
|
||||
});
|
||||
if (activeLoad !== loadSequence) return;
|
||||
member.value = result;
|
||||
genealogyName.value = result.genealogyName;
|
||||
member.value = personDetail;
|
||||
genealogyName.value = personDetail.genealogyName;
|
||||
statusState.value = "available";
|
||||
} catch (error) {
|
||||
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
|
||||
@@ -138,7 +135,7 @@ const loadMember = async () => {
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (!hasValidContext.value || query.state === "error") {
|
||||
if (!hasValidContext.value) {
|
||||
statusState.value = "error";
|
||||
return;
|
||||
}
|
||||
@@ -147,7 +144,7 @@ onLoad((query) => {
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
statusRequestController.abort();
|
||||
memberStatusRequestController.abort();
|
||||
});
|
||||
|
||||
const handleAction = () => goBack();
|
||||
@@ -0,0 +1,866 @@
|
||||
<template>
|
||||
<view
|
||||
class="tree-page"
|
||||
:class="{
|
||||
'tree-state--tree': treeState === 'tree',
|
||||
'tree-state--landscape': treeState === 'landscape',
|
||||
'tree-state--empty': treeState === 'empty',
|
||||
'tree-state--error': treeState === 'error',
|
||||
}"
|
||||
>
|
||||
<ModulePageBackground module="tree" />
|
||||
<view class="tree-page__header">
|
||||
<PageHeader title="树状图" action="世系表格" @action="toPedigree" />
|
||||
</view>
|
||||
|
||||
<view v-if="treeState !== 'loading'" class="tree-toolbar">
|
||||
<view class="tree-toolbar__title">
|
||||
<text>世系成员</text>
|
||||
<text>{{ generationRangeLabel }}</text>
|
||||
</view>
|
||||
<view class="tree-toolbar__actions">
|
||||
<text
|
||||
v-if="treeState === 'tree' && treeHasDrifted && selectedMember"
|
||||
class="tree-recenter"
|
||||
role="button"
|
||||
aria-label="回到当前成员"
|
||||
hover-class="tree-action--pressed"
|
||||
@click="recenterSelectedMember"
|
||||
>回到当前</text
|
||||
>
|
||||
<text @click="treeState = 'landscape'">阅读提示</text>
|
||||
<text @click="toRank">调整排行</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="tree-stage"
|
||||
:class="{ 'tree-stage--lineage': treeState === 'tree' }"
|
||||
>
|
||||
<view
|
||||
v-if="treeState === 'tree'"
|
||||
class="generation-rail"
|
||||
:style="generationRailStyle"
|
||||
>
|
||||
<view
|
||||
v-for="row in generationRows"
|
||||
:key="row.generation"
|
||||
class="generation-band"
|
||||
:style="generationBandStyle(row)"
|
||||
>
|
||||
<view class="generation-band__line" aria-hidden="true" />
|
||||
<view class="generation-band__copy">
|
||||
<text>{{ row.label }}</text>
|
||||
<text>{{ row.summary }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="treeState === 'tree'" class="lineage-pan-cue">
|
||||
<text>同代分支可左右查看</text>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
class="tree-scroll"
|
||||
:class="{ 'tree-scroll--lineage': treeState === 'tree' }"
|
||||
scroll-x
|
||||
:scroll-left="treeState === 'tree' ? treeScrollLeft : 0"
|
||||
:show-scrollbar="false"
|
||||
:style="treeScrollStyle"
|
||||
@scroll="handleTreeScroll"
|
||||
>
|
||||
<view
|
||||
class="tree-canvas"
|
||||
:class="{ 'tree-canvas--state': treeState !== 'tree' }"
|
||||
:style="treeState === 'tree' ? treeMetricsStyle : undefined"
|
||||
>
|
||||
<AppLoading
|
||||
v-if="treeState === 'loading'"
|
||||
text="正在整理世系"
|
||||
description="请稍候,正在准备家谱成员关系。"
|
||||
/>
|
||||
|
||||
<template v-else-if="treeState === 'tree'">
|
||||
<view
|
||||
v-for="connector in lineageConnectors"
|
||||
:key="connector.id"
|
||||
class="lineage-connector"
|
||||
:class="{
|
||||
'lineage-connector--spouse': connector.kind === 'spouse',
|
||||
}"
|
||||
:style="connector.style"
|
||||
/>
|
||||
|
||||
<view
|
||||
v-for="member in layoutMembers"
|
||||
:key="member.id"
|
||||
:id="`tree-member-${member.id}`"
|
||||
class="member-node"
|
||||
:class="{
|
||||
'member-node--selected': selectedMember?.id === member.id,
|
||||
'member-node--spouse': Boolean(member.spouseOf),
|
||||
}"
|
||||
:style="nodeGridStyle(member)"
|
||||
@click="openMemberPanel(member)"
|
||||
>
|
||||
<view class="member-node__surface">
|
||||
<view class="member-node__portrait">
|
||||
<AppAvatar :sex="member.sex" />
|
||||
</view>
|
||||
<image
|
||||
class="member-node__divider"
|
||||
src="/static/assets/foundation/transparent/auth-divider-knot.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view class="member-node__copy">
|
||||
<text class="node-name">{{ member.name }}</text>
|
||||
<text class="node-relation">{{
|
||||
nodeRelationText(member)
|
||||
}}</text>
|
||||
<text class="node-years">{{ member.treeYears || member.years }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="tree-state-card">
|
||||
<image
|
||||
class="tree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="tree-state-card__content">
|
||||
<text class="tree-state-card__eyebrow">{{
|
||||
stateCopy.eyebrow
|
||||
}}</text>
|
||||
<text class="tree-state-card__title">{{ stateCopy.title }}</text>
|
||||
<text class="tree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="tree-state-card__action" @click="handleStateAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<MemberActionPanel
|
||||
:visible="memberActionPanelVisible"
|
||||
:member="selectedMember"
|
||||
:relation-actions="relationActions"
|
||||
:management-actions="managementActions"
|
||||
@close="closeMemberActionPanel"
|
||||
@view-profile="toMember"
|
||||
@select-action="openMemberAction"
|
||||
/>
|
||||
|
||||
<AppDialog
|
||||
:visible="unavailableActionVisible"
|
||||
eyebrow="服务状态"
|
||||
:title="unavailableAction?.label || '当前操作'"
|
||||
:message="
|
||||
unavailableAction?.unavailableCopy || '这项功能还在准备中,暂时无法使用。'
|
||||
"
|
||||
confirm-text="我知道了"
|
||||
@confirm="unavailableActionVisible = false"
|
||||
@cancel="unavailableActionVisible = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from "vue";
|
||||
import { onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||
import AppAvatar from "@/components/AppAvatar.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import MemberActionPanel from "@/components/tree/MemberActionPanel.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { openPage } from "@/utils/navigation/gateway.js";
|
||||
import {
|
||||
createTreeLayout,
|
||||
treeNodeGridStyle,
|
||||
} from "@/utils/tree/layout.js";
|
||||
import { memberRelationTypes } from "@/utils/tree/member-form.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const treeState = ref("loading");
|
||||
const selectedMember = 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);
|
||||
const treeHasDrifted = ref(false);
|
||||
let ignoreTreeScroll = false;
|
||||
let recenterTimer = null;
|
||||
const treeOverviewRequestController = createRequestController();
|
||||
// abort 负责停止当前任务,generation 负责拦截已经进入回调队列的旧响应;
|
||||
// 两者同时保留,避免快速切换家谱时旧世系覆盖新世系。
|
||||
let treeLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
let skipNextShowRefresh = true;
|
||||
const members = ref([]);
|
||||
|
||||
const memberActions = Object.freeze([
|
||||
{
|
||||
key: "VIEW_PROFILE",
|
||||
label: "查看资料",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T03",
|
||||
},
|
||||
{
|
||||
key: "ADD_FATHER",
|
||||
label: "添加父亲",
|
||||
shortLabel: "父亲",
|
||||
group: "RELATION",
|
||||
slot: "top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.FATHER,
|
||||
},
|
||||
{
|
||||
key: "ADD_MOTHER",
|
||||
label: "添加母亲",
|
||||
shortLabel: "母亲",
|
||||
group: "RELATION",
|
||||
slot: "left-top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.MOTHER,
|
||||
},
|
||||
{
|
||||
key: "ADD_SPOUSE",
|
||||
label: "添加配偶",
|
||||
shortLabel: "配偶",
|
||||
group: "RELATION",
|
||||
slot: "right-top",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SPOUSE,
|
||||
},
|
||||
{
|
||||
key: "ADD_SIBLING",
|
||||
label: "添加兄弟姐妹",
|
||||
shortLabel: "兄弟姐妹",
|
||||
group: "RELATION",
|
||||
slot: "left-bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SIBLING,
|
||||
},
|
||||
{
|
||||
key: "ADJUST_RANK",
|
||||
label: "调整排行",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T06",
|
||||
mode: "rank",
|
||||
},
|
||||
{
|
||||
key: "ADD_SON",
|
||||
label: "添加儿子",
|
||||
shortLabel: "儿子",
|
||||
group: "RELATION",
|
||||
slot: "bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.SON,
|
||||
},
|
||||
{
|
||||
key: "ADD_DAUGHTER",
|
||||
label: "添加女儿",
|
||||
shortLabel: "女儿",
|
||||
group: "RELATION",
|
||||
slot: "right-bottom",
|
||||
routeKey: "T04",
|
||||
relationType: memberRelationTypes.DAUGHTER,
|
||||
},
|
||||
{
|
||||
key: "BIND_INVITE",
|
||||
label: "邀请绑定",
|
||||
group: "MANAGEMENT",
|
||||
unavailableCopy:
|
||||
"邀请绑定暂未开放,请稍后再试。",
|
||||
},
|
||||
{
|
||||
key: "EDIT_PROFILE",
|
||||
label: "编辑信息",
|
||||
group: "MANAGEMENT",
|
||||
routeKey: "T05",
|
||||
},
|
||||
]);
|
||||
const relationActions = computed(() =>
|
||||
memberActions.filter((action) => action.group === "RELATION"),
|
||||
);
|
||||
const managementActions = computed(() =>
|
||||
memberActions.filter((action) => action.group === "MANAGEMENT"),
|
||||
);
|
||||
|
||||
const nodeRelationText = (member) => {
|
||||
const branch = String(member.branch || "").replace(/字辈$/, "");
|
||||
return [member.relation, branch].filter(Boolean).join(" · ");
|
||||
};
|
||||
const treeLayout = computed(() => createTreeLayout(members.value));
|
||||
const layoutMembers = computed(() => treeLayout.value.members);
|
||||
const treeMetrics = computed(() => treeLayout.value.metrics);
|
||||
const treeMetricsStyle = computed(() => ({
|
||||
width: `${treeMetrics.value.width}rpx`,
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
gridTemplateColumns: `repeat(${treeMetrics.value.columns}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
}));
|
||||
const generationRailStyle = computed(() => ({
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
gridTemplateRows: `repeat(${treeMetrics.value.rows}, ${treeMetrics.value.gridUnit}rpx)`,
|
||||
}));
|
||||
const treeScrollStyle = computed(() => ({
|
||||
height: `${treeMetrics.value.height}rpx`,
|
||||
}));
|
||||
const generationRows = computed(() => treeLayout.value.generationRows);
|
||||
const generationRangeLabel = computed(() => {
|
||||
const rows = generationRows.value;
|
||||
if (!rows.length) return "世系关系";
|
||||
const first = rows[0].generation;
|
||||
const last = rows[rows.length - 1].generation;
|
||||
return first === last ? `第 ${first} 世` : `第 ${first}—${last} 世`;
|
||||
});
|
||||
const generationBandStyle = (generationRow) => generationRow.bandStyle;
|
||||
const lineageConnectors = computed(() => treeLayout.value.connectors);
|
||||
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系阅读提示",
|
||||
title: "上下看世代,左右看同代分支",
|
||||
copy: "始祖位于上方,后代逐世向下展开;同一世代分支较多时,可左右拖动查看。",
|
||||
action: "进入世系树",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
copy: "录入首代成员后,即可继续补充子女、配偶与后代关系。",
|
||||
action: "录入首代成员",
|
||||
},
|
||||
error: {
|
||||
eyebrow: "世系暂不可用",
|
||||
title: "暂时无法读取成员关系",
|
||||
copy: "请从当前家谱重新进入;已经录入的成员资料不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
})[treeState.value] || {},
|
||||
);
|
||||
|
||||
const loadTree = async (query = {}) => {
|
||||
const generation = ++treeLoadGeneration;
|
||||
treeOverviewRequestController.abort();
|
||||
if (genealogyContext.isCurrentGenealogyInvalidated()) {
|
||||
genealogyId.value = "";
|
||||
members.value = [];
|
||||
treeState.value = "error";
|
||||
return;
|
||||
}
|
||||
genealogyId.value = String(
|
||||
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "",
|
||||
);
|
||||
if (!genealogyId.value) {
|
||||
members.value = [];
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const loadedMembers = await lineageApi.getTree(genealogyId.value, {
|
||||
requestController: treeOverviewRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== treeLoadGeneration) return;
|
||||
members.value = loadedMembers;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
selectedMember.value =
|
||||
layoutMembers.value.find(
|
||||
(member) => String(member.id) === String(query.selectedId),
|
||||
) ||
|
||||
layoutMembers.value[0] ||
|
||||
null;
|
||||
treeState.value = members.value.length ? "tree" : "empty";
|
||||
if (treeState.value === "tree" && selectedMember.value) {
|
||||
await nextTick();
|
||||
recenterSelectedMember();
|
||||
}
|
||||
} catch (error) {
|
||||
if (
|
||||
!pageActive ||
|
||||
generation !== treeLoadGeneration ||
|
||||
isRequestCancelled(error)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
selectedMember.value = null;
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
|
||||
onLoad(loadTree);
|
||||
onShow(() => {
|
||||
if (skipNextShowRefresh) {
|
||||
skipNextShowRefresh = false;
|
||||
return;
|
||||
}
|
||||
if (genealogyId.value) loadTree({ genealogyId: genealogyId.value });
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
pageActive = false;
|
||||
treeLoadGeneration += 1;
|
||||
treeOverviewRequestController.abort();
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
});
|
||||
|
||||
const handleTreeScroll = (event) => {
|
||||
if (ignoreTreeScroll || treeState.value !== "tree") return;
|
||||
const scrollLeft = Number(event?.detail?.scrollLeft || 0);
|
||||
currentTreeScrollLeft.value = scrollLeft;
|
||||
treeHasDrifted.value =
|
||||
Math.abs(scrollLeft - centeredTreeScrollLeft.value) > 80;
|
||||
};
|
||||
|
||||
const getFamilyPartner = (member) => {
|
||||
if (!member) return null;
|
||||
const partnerId =
|
||||
member.spouseOf ||
|
||||
layoutMembers.value.find(
|
||||
(candidate) => String(candidate.spouseOf || "") === String(member.id),
|
||||
)?.id;
|
||||
const partner = layoutMembers.value.find(
|
||||
(candidate) => String(candidate.id) === String(partnerId || ""),
|
||||
);
|
||||
return partner && partner.generation === member.generation ? partner : null;
|
||||
};
|
||||
|
||||
const recenterSelectedMember = async () => {
|
||||
if (!selectedMember.value) return;
|
||||
const spouse = getFamilyPartner(selectedMember.value);
|
||||
ignoreTreeScroll = true;
|
||||
const [scrollRect, nodeRect, spouseRect] = await new Promise((resolve) => {
|
||||
const query = uni.createSelectorQuery();
|
||||
query.select(".tree-scroll").boundingClientRect();
|
||||
query.select(`#tree-member-${selectedMember.value.id}`).boundingClientRect();
|
||||
if (spouse) {
|
||||
query.select(`#tree-member-${spouse.id}`).boundingClientRect();
|
||||
}
|
||||
query.exec(resolve);
|
||||
});
|
||||
if (scrollRect && nodeRect) {
|
||||
const targetLeft = spouseRect
|
||||
? Math.min(nodeRect.left, spouseRect.left)
|
||||
: nodeRect.left;
|
||||
const targetRight = spouseRect
|
||||
? Math.max(
|
||||
nodeRect.left + nodeRect.width,
|
||||
spouseRect.left + spouseRect.width,
|
||||
)
|
||||
: nodeRect.left + nodeRect.width;
|
||||
const centeredScrollLeft = Math.max(
|
||||
0,
|
||||
currentTreeScrollLeft.value +
|
||||
(targetLeft + targetRight) / 2 -
|
||||
(scrollRect.left + scrollRect.width / 2),
|
||||
);
|
||||
treeScrollLeft.value = centeredScrollLeft;
|
||||
currentTreeScrollLeft.value = centeredScrollLeft;
|
||||
centeredTreeScrollLeft.value = centeredScrollLeft;
|
||||
}
|
||||
treeHasDrifted.value = false;
|
||||
if (recenterTimer) clearTimeout(recenterTimer);
|
||||
recenterTimer = setTimeout(() => {
|
||||
ignoreTreeScroll = false;
|
||||
recenterTimer = null;
|
||||
}, 320);
|
||||
};
|
||||
|
||||
const nodeGridStyle = treeNodeGridStyle;
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "empty") {
|
||||
if (!genealogyId.value) return Promise.resolve(false);
|
||||
return openPage(
|
||||
"T04",
|
||||
{ genealogyId: genealogyId.value, mode: "first" },
|
||||
"T01",
|
||||
);
|
||||
}
|
||||
if (treeState.value === "error") {
|
||||
return loadTree({ genealogyId: genealogyId.value });
|
||||
}
|
||||
selectedMember.value = layoutMembers.value[0];
|
||||
treeState.value = "tree";
|
||||
};
|
||||
const toMember = () => {
|
||||
if (!selectedMember.value) return Promise.resolve(false);
|
||||
closeMemberActionPanel();
|
||||
return openPage(
|
||||
"T03",
|
||||
{ genealogyId: genealogyId.value, personId: String(selectedMember.value.id) },
|
||||
"T01",
|
||||
);
|
||||
};
|
||||
const toDirectory = () =>
|
||||
openPage("T07", { genealogyId: genealogyId.value }, "T01");
|
||||
const toPedigree = () =>
|
||||
openPage("T02", { genealogyId: genealogyId.value }, "T01");
|
||||
const toRank = () =>
|
||||
selectedMember.value
|
||||
? openPage(
|
||||
"T06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selectedMember.value.id),
|
||||
mode: "rank",
|
||||
},
|
||||
"T01",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const openMemberPanel = (member) => {
|
||||
selectedMember.value = member;
|
||||
memberActionPanelVisible.value = true;
|
||||
};
|
||||
const closeMemberActionPanel = () => {
|
||||
memberActionPanelVisible.value = false;
|
||||
};
|
||||
const openMemberAction = (action) => {
|
||||
if (!selectedMember.value) return Promise.resolve(false);
|
||||
closeMemberActionPanel();
|
||||
if (!action.routeKey) {
|
||||
unavailableAction.value = action;
|
||||
unavailableActionVisible.value = true;
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
const params = {
|
||||
genealogyId: genealogyId.value,
|
||||
personId: String(selectedMember.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">
|
||||
.tree-page {
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
background: $paper;
|
||||
}
|
||||
.tree-page__header,
|
||||
.tree-toolbar,
|
||||
.tree-stage {
|
||||
z-index: 1;
|
||||
}
|
||||
.tree-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18rpx 28rpx 12rpx;
|
||||
}
|
||||
.tree-toolbar__title text {
|
||||
display: block;
|
||||
}
|
||||
.tree-toolbar__title text:first-child {
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(16px, 28rpx, 20px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-toolbar__title text:last-child {
|
||||
margin-top: 5rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
}
|
||||
.tree-stage {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.tree-action--pressed {
|
||||
opacity: 0.72;
|
||||
}
|
||||
.tree-stage--lineage {
|
||||
display: grid;
|
||||
grid-template-columns: 72rpx minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
.tree-scroll {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.generation-rail {
|
||||
display: grid;
|
||||
z-index: 4;
|
||||
width: 72rpx;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-right: 1rpx solid rgba(143, 108, 63, 0.2);
|
||||
}
|
||||
.tree-scroll--lineage {
|
||||
grid-area: 1 / 2;
|
||||
min-width: 0;
|
||||
}
|
||||
.tree-canvas {
|
||||
display: grid;
|
||||
margin: 0 12rpx;
|
||||
}
|
||||
.tree-canvas--state {
|
||||
display: block;
|
||||
width: calc(100vw - 32rpx);
|
||||
}
|
||||
.lineage-pan-cue {
|
||||
grid-area: 1 / 2;
|
||||
align-self: start;
|
||||
justify-self: end;
|
||||
z-index: 3;
|
||||
margin: 8rpx 22rpx 0 0;
|
||||
color: #9a7748;
|
||||
font-size: clamp(13px, 20rpx, 16px);
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.generation-band {
|
||||
position: relative;
|
||||
display: grid;
|
||||
justify-self: center;
|
||||
z-index: 3;
|
||||
width: 62rpx;
|
||||
height: 104rpx;
|
||||
color: #8f6c3f;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
text-align: center;
|
||||
}
|
||||
.generation-band__line,
|
||||
.generation-band__copy {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.generation-band__line {
|
||||
align-self: stretch;
|
||||
justify-self: center;
|
||||
width: 0;
|
||||
margin: 3rpx 0;
|
||||
border-left: 1rpx solid rgba(183, 138, 66, 0.7);
|
||||
}
|
||||
.generation-band__copy {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
padding: 0 4rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.generation-band__copy text {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: upright;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.generation-band__copy text:last-child {
|
||||
font-size: clamp(11px, 14rpx, 13px);
|
||||
}
|
||||
.lineage-connector {
|
||||
align-self: stretch;
|
||||
justify-self: stretch;
|
||||
z-index: 1;
|
||||
background: #b78a42;
|
||||
}
|
||||
.lineage-connector--spouse {
|
||||
background: rgba(183, 117, 76, 0.82);
|
||||
}
|
||||
.member-node {
|
||||
position: relative;
|
||||
align-self: start;
|
||||
justify-self: start;
|
||||
z-index: 2;
|
||||
width: 160rpx;
|
||||
height: 224rpx;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
}
|
||||
.member-node__surface {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
padding: 9rpx 8rpx 8rpx;
|
||||
border: 1rpx solid #b78a42;
|
||||
border-radius: 14rpx;
|
||||
background: rgba(255, 252, 244, 0.96);
|
||||
box-shadow: 0 5rpx 12rpx rgba(103, 72, 33, 0.12);
|
||||
}
|
||||
.member-node__portrait {
|
||||
display: flex;
|
||||
width: 62rpx;
|
||||
height: 62rpx;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
border: 1rpx solid #c49a57;
|
||||
border-radius: 50%;
|
||||
background: #fffdf7;
|
||||
overflow: hidden;
|
||||
}
|
||||
.member-node__divider {
|
||||
width: 56rpx;
|
||||
height: 14rpx;
|
||||
margin-top: 3rpx;
|
||||
}
|
||||
.member-node__copy {
|
||||
width: 100%;
|
||||
margin-top: 1rpx;
|
||||
padding: 0;
|
||||
}
|
||||
.node-name,
|
||||
.node-relation,
|
||||
.node-years {
|
||||
display: block;
|
||||
}
|
||||
.node-name {
|
||||
display: -webkit-box;
|
||||
color: #6b2419;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
overflow: hidden;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
.node-relation {
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #644b2e;
|
||||
font-size: clamp(12px, 18rpx, 14px);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.node-years {
|
||||
overflow: hidden;
|
||||
margin-top: 2rpx;
|
||||
color: #695b4c;
|
||||
font-size: clamp(12px, 17rpx, 14px);
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-node--spouse .member-node__surface {
|
||||
border-color: #c39a61;
|
||||
background: rgba(255, 250, 241, 0.98);
|
||||
}
|
||||
.member-node--selected {
|
||||
z-index: 3;
|
||||
}
|
||||
.member-node--selected .member-node__surface {
|
||||
border-color: #c55344;
|
||||
box-shadow: 0 6rpx 14rpx rgba(159, 23, 15, 0.16);
|
||||
}
|
||||
.member-node--selected .member-node__portrait {
|
||||
border-color: #c55344;
|
||||
}
|
||||
.member-node--selected .node-name {
|
||||
color: $brand-red;
|
||||
}
|
||||
.tree-state-card {
|
||||
display: grid;
|
||||
z-index: 2;
|
||||
width: calc(100% - 48rpx);
|
||||
min-height: 360rpx;
|
||||
margin: 150rpx 24rpx 0;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
}
|
||||
.tree-state-card__skin {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__skin,
|
||||
.tree-state-card__content {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.tree-state-card__content {
|
||||
z-index: 1;
|
||||
padding: 62rpx 58rpx 42rpx;
|
||||
}
|
||||
.tree-state-card__eyebrow {
|
||||
display: block;
|
||||
color: $brand-red;
|
||||
font-size: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.tree-state-card__title {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.tree-state-card__copy {
|
||||
display: block;
|
||||
margin-top: 16rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.65;
|
||||
}
|
||||
.tree-state-card__action {
|
||||
display: grid;
|
||||
width: 360rpx;
|
||||
min-height: 70rpx;
|
||||
margin: 22rpx auto 0;
|
||||
}
|
||||
.tree-state-card__action image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tree-state-card__action image,
|
||||
.tree-state-card__action text {
|
||||
grid-area: 1 / 1;
|
||||
}
|
||||
.tree-state-card__action text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.tree-toolbar {
|
||||
padding-right: 20rpx;
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
.tree-toolbar__actions {
|
||||
gap: 14rpx;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- 页面编号:T-02;用途:世系谱表阅读、同世成员横向滑页。 -->
|
||||
<template>
|
||||
<view class="pedigree-page">
|
||||
<ModulePageBackground module="tree" />
|
||||
@@ -104,7 +103,7 @@
|
||||
<view v-else class="pedigree-state-card">
|
||||
<image
|
||||
class="pedigree-state-card__skin"
|
||||
src="/static/assets/modules/tree/transparent/t01-state-panel.png"
|
||||
src="/static/assets/modules/tree/transparent/state-panel.png"
|
||||
mode="scaleToFill"
|
||||
/>
|
||||
<view class="pedigree-state-card__content">
|
||||
@@ -113,7 +112,7 @@
|
||||
<text class="pedigree-state-card__copy">{{ stateCopy.copy }}</text>
|
||||
<view class="pedigree-state-card__action" @click="handleStateAction">
|
||||
<image
|
||||
src="/static/assets/foundation/transparent/a01-scroll-primary-v3.png"
|
||||
src="/static/assets/foundation/transparent/scroll-primary.png"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text>{{ stateCopy.action }}</text>
|
||||
@@ -142,12 +141,13 @@ import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { openPage } from "@/utils/navigation.js";
|
||||
isRequestCancelled
|
||||
} from "@/services/api/request-controller.js";
|
||||
import { lineageApi } from "@/services/api/lineage-service.js";
|
||||
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
|
||||
import { genealogyContext } from "@/utils/genealogy/context.js";
|
||||
import { openPage, returnTo } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const PEDIGREE_PAGE_SIZE = 5;
|
||||
const genealogyId = ref("");
|
||||
@@ -160,7 +160,7 @@ const detailVisible = ref(false);
|
||||
const detailMember = ref(null);
|
||||
const detailMessage = ref("");
|
||||
const pedigreeRequestController = createRequestController();
|
||||
const detailRequestController = createRequestController();
|
||||
const pedigreeDetailRequestController = createRequestController();
|
||||
let loadGeneration = 0;
|
||||
let detailLoadGeneration = 0;
|
||||
let pageActive = true;
|
||||
@@ -226,12 +226,6 @@ const pedigreeScrollStyle = computed(() => ({
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
landscape: {
|
||||
eyebrow: "世系谱阅读提示",
|
||||
title: "左右滑动查看成员",
|
||||
copy: "同一世代每页展示五位成员,继续向左滑动即可查看下一页。",
|
||||
action: "返回树状图",
|
||||
},
|
||||
empty: {
|
||||
eyebrow: "尚未建立世系",
|
||||
title: "从第一位先祖开始记录",
|
||||
@@ -261,18 +255,18 @@ const openMemberProfile = (member) =>
|
||||
);
|
||||
const closeMemberDetail = () => {
|
||||
detailVisible.value = false;
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
};
|
||||
const openMemberDetail = async (member) => {
|
||||
if (!member) return;
|
||||
const generation = ++detailLoadGeneration;
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
detailMember.value = member;
|
||||
detailMessage.value = "正在读取人物生平…";
|
||||
detailVisible.value = true;
|
||||
try {
|
||||
const detail = await appApi.getPerson(genealogyId.value, member.id, {
|
||||
requestController: detailRequestController,
|
||||
const detail = await lineageApi.getPerson(genealogyId.value, member.id, {
|
||||
requestController: pedigreeDetailRequestController,
|
||||
});
|
||||
if (generation !== detailLoadGeneration || !detailVisible.value) return;
|
||||
detailMember.value = detail;
|
||||
@@ -281,16 +275,15 @@ const openMemberDetail = async (member) => {
|
||||
"暂未填写生平说明。";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || generation !== detailLoadGeneration) return;
|
||||
detailMessage.value = error?.message || "人物生平读取失败,请稍后重试。";
|
||||
detailMessage.value = getRequestErrorMessage(error, "人物生平读取失败,请稍后重试。");
|
||||
}
|
||||
};
|
||||
const toTree = () => {
|
||||
const params = { genealogyId: genealogyId.value };
|
||||
if (selectedId.value) params.selectedId = selectedId.value;
|
||||
return openPage("T01", params, "T02");
|
||||
return returnTo("T01", params);
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (treeState.value === "landscape") return toTree();
|
||||
if (treeState.value === "empty") {
|
||||
return openPage(
|
||||
"T04",
|
||||
@@ -314,17 +307,13 @@ const loadPedigree = async (query = {}) => {
|
||||
treeState.value = "empty";
|
||||
return;
|
||||
}
|
||||
if (["landscape", "empty", "error"].includes(query.state)) {
|
||||
treeState.value = query.state;
|
||||
return;
|
||||
}
|
||||
treeState.value = "loading";
|
||||
try {
|
||||
const result = await appApi.getTree(genealogyId.value, {
|
||||
const treeMembers = await lineageApi.getTree(genealogyId.value, {
|
||||
requestController: pedigreeRequestController,
|
||||
});
|
||||
if (!pageActive || generation !== loadGeneration) return;
|
||||
members.value = result;
|
||||
members.value = treeMembers;
|
||||
genealogyContext.setCurrentGenealogyId(genealogyId.value);
|
||||
const selectedPage = pedigreePages.value.findIndex((page) =>
|
||||
page.members.some((member) => String(member?.id) === selectedId.value),
|
||||
@@ -336,7 +325,7 @@ const loadPedigree = async (query = {}) => {
|
||||
return;
|
||||
}
|
||||
members.value = [];
|
||||
treeLoadError.value = error?.message || "世系谱读取失败,请稍后重试。";
|
||||
treeLoadError.value = getRequestErrorMessage(error, "世系谱读取失败,请稍后重试。");
|
||||
treeState.value = "error";
|
||||
}
|
||||
};
|
||||
@@ -354,7 +343,7 @@ onUnload(() => {
|
||||
loadGeneration += 1;
|
||||
detailLoadGeneration += 1;
|
||||
pedigreeRequestController.abort();
|
||||
detailRequestController.abort();
|
||||
pedigreeDetailRequestController.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
||||
<!-- 页面编号:T-06;用途:展示指定成员的同辈排行调整入口。 -->
|
||||
<template>
|
||||
<view
|
||||
class="rank-page"
|
||||
:class="{
|
||||
'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="rank-page__header"
|
||||
><PageHeader title="调整排行" custom-back @back="goBack"
|
||||
/></view>
|
||||
<view class="rank-panel">
|
||||
<AppLoading
|
||||
v-if="rankState === 'loading'"
|
||||
text="正在读取成员资料"
|
||||
description="请稍候,正在确认待调整人物。"
|
||||
/>
|
||||
|
||||
<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
|
||||
>
|
||||
|
||||
<view class="member-context">
|
||||
<text>当前成员</text
|
||||
><text>第 {{ member.generation }} 世 · {{ member.branch }}</text>
|
||||
</view>
|
||||
<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>查看服务状态</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<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>{{
|
||||
rankState === "unavailable" ? "返回查看" : "返回世系树"
|
||||
}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, 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 {
|
||||
appApi,
|
||||
createRequestController,
|
||||
isRequestCancelled,
|
||||
} from "@/utils/api.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation.js";
|
||||
|
||||
const rankState = ref("loading");
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const member = ref(null);
|
||||
const errorMessage = ref("");
|
||||
const rankRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
|
||||
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 || "");
|
||||
if (!genealogyId.value || !personId.value || query.mode !== "rank") {
|
||||
rankState.value = "error";
|
||||
errorMessage.value = "当前排行入口无效,请从人物操作面板重新进入。";
|
||||
return;
|
||||
}
|
||||
void loadMember();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
loadSequence += 1;
|
||||
rankRequestController.abort();
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, goBack));
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import "../../styles/adaptive-frame-profiles.scss";
|
||||
|
||||
.rank-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.rank-page__header {
|
||||
z-index: 3;
|
||||
}
|
||||
.rank-panel {
|
||||
@include 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: clamp(14px, 22rpx, 17px);
|
||||
letter-spacing: 3rpx;
|
||||
}
|
||||
.form-title {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-family: "STKaiti", "KaiTi", serif;
|
||||
font-size: clamp(19px, 34rpx, 24px);
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.form-copy {
|
||||
display: block;
|
||||
margin-top: 10rpx;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(15px, 24rpx, 18px);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.member-context,
|
||||
.rank-notice {
|
||||
@include 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: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.member-context text:last-child,
|
||||
.rank-notice text:last-child {
|
||||
min-width: 0;
|
||||
color: $ink-muted;
|
||||
font-size: clamp(14px, 23rpx, 17px);
|
||||
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: clamp(15px, 24rpx, 18px);
|
||||
font-weight: 700;
|
||||
}
|
||||
.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>
|
||||
Reference in New Issue
Block a user