feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
@@ -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;
}
+310
View File
@@ -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();
+866
View File
@@ -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
-248
View File
@@ -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>