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
+728
View File
@@ -0,0 +1,728 @@
<template>
<view class="member-page">
<GenealogyPageBackground />
<view class="page-header">
<PageHeader title="家谱成员" custom-back @back="requestBack" />
</view>
<view class="page-content">
<view v-if="!hasValidGenealogyId" class="state-card">
<text>暂时无法打开成员页面</text>
<text>没有找到家谱信息请返回家谱总览后重新进入</text>
<AppButton block label="返回家谱总览" @click="returnToOverview" />
</view>
<view v-else-if="memberListState === 'loading'" class="state-card">
<AppLoading text="正在读取家谱成员" description="请稍候,正在同步成员和管理权限。" />
</view>
<view v-else-if="memberListState === 'error'" class="state-card">
<text>暂时无法读取成员</text>
<text>{{ memberListError || "请检查网络后重试。" }}</text>
<AppButton block type="secondary" label="重新加载" @click="loadMembers" />
</view>
<view v-else-if="memberListState === 'empty'" class="state-card">
<text>{{ hasLeftGenealogy ? "已退出这部家谱" : "还没有成员记录" }}</text>
<text>{{ hasLeftGenealogy ? "你的退出操作已经完成。" : "家人加入后,会显示在这里。" }}</text>
<AppButton
block
:label="hasLeftGenealogy ? '返回我的家谱' : '返回家谱总览'"
@click="returnToOverview"
/>
</view>
<view v-else class="member-list">
<view class="member-intro">
<text> {{ members.length }} 位成员</text>
<text>这里管理的是已加入家谱的账号成员家谱中的人物资料请到世系树维护</text>
</view>
<text v-if="feedbackMessage" class="page-feedback" role="status">
{{ feedbackMessage }}
</text>
<view
v-for="member in members"
:key="member.memberId"
class="member-card"
>
<view class="member-card__heading">
<view class="member-card__identity">
<text>{{ member.memberName }}</text>
<text
v-if="member.appUserNickName && member.appUserNickName !== member.memberName"
>
账号昵称{{ member.appUserNickName }}
</text>
</view>
<text class="role-badge">{{ roleLabel(member.roleType) }}</text>
</view>
<view class="member-card__details">
<text>与家谱关系{{ member.relationName || "未填写" }}</text>
<text>世系人物{{ lineageLabel(member) }}</text>
<text v-if="member.joinTime">
加入时间{{ formatMinuteTimestamp(member.joinTime) }}
</text>
</view>
<view v-if="hasMemberActions(member)" class="member-card__actions">
<AppButton
v-if="member.capabilities.canEdit"
compact
type="secondary"
label="编辑资料"
@click="openMemberEditor(member)"
/>
<AppButton
v-if="member.capabilities.canEdit && member.lineagePersonId"
compact
type="secondary"
label="解除人物绑定"
@click="openConfirmation('unlink', member)"
/>
<AppButton
v-if="member.capabilities.canTransferOwner"
compact
type="secondary"
label="转让谱主"
@click="openConfirmation('transfer', member)"
/>
<AppButton
v-if="member.capabilities.canRemove"
compact
type="secondary"
label="移出家谱"
@click="openConfirmation('remove', member)"
/>
<AppButton
v-if="member.capabilities.canLeave"
compact
type="secondary"
label="退出家谱"
@click="openConfirmation('leave', member)"
/>
</view>
<text v-else class="member-card__readonly">当前账号只能查看这位成员</text>
</view>
</view>
</view>
<AppDialog
:visible="Boolean(editTarget)"
eyebrow="成员资料"
:title="`编辑${editTarget?.memberName || '成员'}`"
message="称呼和关系需要填写;角色、世系人物请从已有选项中选择。"
:confirm-text="operationPending ? '正在保存' : '保存修改'"
cancel-text="取消"
show-cancel
:close-on-mask="!operationPending"
@confirm="saveEdit"
@cancel="closeEdit"
>
<view class="edit-form">
<label class="edit-field">
<text>成员称呼</text>
<input v-model.trim="editForm.memberName" maxlength="50" placeholder="例如:王叔叔" />
</label>
<label class="edit-field">
<text>与家谱关系</text>
<input v-model.trim="editForm.relationName" maxlength="100" placeholder="例如:本族成员" />
</label>
<picker
:disabled="editTarget?.roleType === GENEALOGY_MEMBER_ROLE.OWNER"
:range="roleOptions.map((roleOption) => roleOption.label)"
:value="roleIndex"
@change="selectRole"
>
<view class="edit-field edit-field--picker">
<text>成员角色</text><text>{{ roleLabel(editForm.roleType) }}</text>
</view>
</picker>
<picker :disabled="personOptionsState === 'loading'" :range="personOptionLabels" :value="personOptionIndex" @change="selectPerson">
<view class="edit-field edit-field--picker">
<text>绑定世系人物</text>
<text>{{ selectedPersonLabel }}</text>
</view>
</picker>
<text v-if="personOptionsState === 'loading'" class="edit-hint">正在读取世系人物</text>
<text v-else-if="personOptionsState === 'error'" class="edit-error">人物选项暂时无法读取本次可先修改其他资料</text>
<text v-if="editError" class="edit-error" role="alert">{{ editError }}</text>
</view>
</AppDialog>
<AppDialog
:visible="Boolean(confirmTarget)"
eyebrow="请确认"
:title="confirmationCopy.title"
:message="confirmationCopy.message"
:confirm-text="operationPending ? '正在处理' : confirmationCopy.confirm"
cancel-text="取消"
show-cancel
:close-on-mask="!operationPending"
@confirm="runConfirmedOperation"
@cancel="closeConfirmation"
>
<text v-if="operationError" class="edit-error" role="alert">{{ operationError }}</text>
</AppDialog>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import GenealogyPageBackground from "@/components/genealogy/PageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
GENEALOGY_MEMBER_ROLE,
GENEALOGY_MEMBER_ROLE_LABELS
} from "@/services/api/genealogy-member-contract.js";
import {
createRequestController,
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 { formatMinuteTimestamp } from "@/utils/display-time.js";
import { handleBackPress, returnTo, runBackGuard } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const members = ref([]);
const memberListState = ref("loading");
const memberListError = ref("");
const feedbackMessage = ref("");
const editTarget = ref(null);
const confirmTarget = ref(null);
const editError = ref("");
const operationError = ref("");
const operationPending = ref(false);
const hasLeftGenealogy = ref(false);
const personOptions = ref([]);
const personOptionsState = ref("idle");
const memberListController = createRequestController();
const personOptionsController = createRequestController();
const memberUpdateController = createRequestController();
const membershipOperationController = createRequestController();
let isPageActive = true;
const editForm = reactive({
memberName: "",
relationName: "",
roleType: GENEALOGY_MEMBER_ROLE.MEMBER,
lineagePersonId: "",
});
const hasValidGenealogyId = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isOwnerViewer = computed(() =>
members.value.some((member) => member.capabilities.canTransferOwner),
);
const roleOptions = computed(() => {
const options = [
...(isOwnerViewer.value
? [{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.ADMIN], value: GENEALOGY_MEMBER_ROLE.ADMIN }]
: []),
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.EDITOR], value: GENEALOGY_MEMBER_ROLE.EDITOR },
{ label: GENEALOGY_MEMBER_ROLE_LABELS[GENEALOGY_MEMBER_ROLE.MEMBER], value: GENEALOGY_MEMBER_ROLE.MEMBER },
];
const currentRole = editTarget.value?.roleType;
if (currentRole && !options.some((option) => option.value === currentRole)) {
options.unshift({
label: GENEALOGY_MEMBER_ROLE_LABELS[currentRole] || "当前角色",
value: currentRole,
});
}
return options;
});
const roleIndex = computed(() =>
Math.max(
0,
roleOptions.value.findIndex(
(roleOption) => roleOption.value === editForm.roleType,
),
),
);
const personOptionLabels = computed(() => [
"保持当前绑定",
...personOptions.value.map(
(person) =>
`${person.name}${person.generation ? ` · 第${person.generation}` : ""}`,
),
]);
const personOptionIndex = computed(() => {
if (!editForm.lineagePersonId || editForm.lineagePersonId === editTarget.value?.lineagePersonId) return 0;
const index = personOptions.value.findIndex(
(person) => person.id === editForm.lineagePersonId,
);
return index < 0 ? 0 : index + 1;
});
const selectedPersonLabel = computed(
() => personOptionLabels.value[personOptionIndex.value] || "保持当前绑定",
);
const confirmationCopy = computed(() => {
const memberName = confirmTarget.value?.member?.memberName || "这位成员";
const copyByOperation = {
unlink: {
title: "解除人物绑定?",
message: `解除后,“${memberName}”的账号仍在家谱中,但不再对应世系树人物。`,
confirm: "确认解除",
},
remove: {
title: "将成员移出家谱?",
message: `移出后,“${memberName}”将不能再以成员身份访问这部家谱。`,
confirm: "确认移出",
},
leave: {
title: "退出这部家谱?",
message: "退出后,你将不能再查看仅对成员开放的内容。",
confirm: "确认退出",
},
transfer: {
title: "转让谱主身份?",
message: `转让后,“${memberName}”将成为新谱主,你会变为管理员。`,
confirm: "确认转让",
},
};
return copyByOperation[confirmTarget.value?.kind] || {
title: "确认操作?",
message: "请确认是否继续。",
confirm: "确认",
};
});
const roleLabel = (role) => GENEALOGY_MEMBER_ROLE_LABELS[role] || "成员";
const lineageLabel = (member) =>
member.lineagePersonName || (member.lineagePersonId ? "已绑定" : "未绑定");
const hasMemberActions = (member) =>
Object.values(member.capabilities).some(Boolean);
const loadMembers = async () => {
if (!hasValidGenealogyId.value) return;
memberListController.abort();
memberListState.value = "loading";
memberListError.value = "";
try {
const loadedMembers = await genealogyMemberApi.getMembers(genealogyId.value, {
requestController: memberListController,
});
if (!isPageActive) return;
members.value = loadedMembers;
memberListState.value = members.value.length ? "ready" : "empty";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
memberListError.value = getRequestErrorMessage(error, "成员加载失败,请稍后重试。");
memberListState.value = "error";
}
};
const loadPersonOptions = async () => {
if (personOptionsState.value === "loading" || personOptionsState.value === "ready") return;
personOptionsState.value = "loading";
try {
const loadedPersonOptions = await lineageApi.getLineagePersonOptions(genealogyId.value, {
requestController: personOptionsController,
});
if (!isPageActive) return;
personOptions.value = loadedPersonOptions;
personOptionsState.value = "ready";
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
personOptionsState.value = "error";
}
};
const openMemberEditor = (member) => {
if (!member.capabilities.canEdit || operationPending.value) return;
editTarget.value = member;
editForm.memberName = member.memberName;
editForm.relationName = member.relationName;
editForm.roleType = member.roleType;
editForm.lineagePersonId = member.lineagePersonId || "";
editError.value = "";
void loadPersonOptions();
};
const closeEdit = () => {
if (operationPending.value) return;
editTarget.value = null;
editError.value = "";
};
const selectRole = (event) => {
editForm.roleType = roleOptions.value[Number(event.detail.value)]?.value || editForm.roleType;
};
const selectPerson = (event) => {
const index = Number(event.detail.value);
editForm.lineagePersonId = index > 0 ? personOptions.value[index - 1]?.id || "" : editTarget.value?.lineagePersonId || "";
};
const saveEdit = async () => {
const target = editTarget.value;
if (!target || operationPending.value) return;
if (!editForm.memberName.trim()) {
editError.value = "请填写成员称呼。";
return;
}
operationPending.value = true;
editError.value = "";
try {
const updated = await genealogyMemberApi.updateMember(
genealogyId.value,
target.memberId,
{
memberName: editForm.memberName,
relationName: editForm.relationName,
...(editForm.roleType !== target.roleType
? { roleType: editForm.roleType }
: {}),
...(editForm.lineagePersonId &&
editForm.lineagePersonId !== target.lineagePersonId
? { lineagePersonId: editForm.lineagePersonId }
: {}),
},
{ requestController: memberUpdateController },
);
if (!isPageActive) return;
members.value = members.value.map((member) =>
member.memberId === updated.memberId ? updated : member,
);
feedbackMessage.value = `${updated.memberName}”的成员资料已更新。`;
editTarget.value = null;
} catch (error) {
if (!isPageActive || isRequestCancelled(error)) return;
editError.value = getRequestErrorMessage(error, "保存失败,请稍后重试。");
} finally {
if (isPageActive) operationPending.value = false;
}
};
const openConfirmation = (kind, member) => {
if (operationPending.value) return;
confirmTarget.value = { kind, member };
operationError.value = "";
};
const closeConfirmation = () => {
if (operationPending.value) return;
confirmTarget.value = null;
operationError.value = "";
};
const runConfirmedOperation = async () => {
const target = confirmTarget.value;
if (!target || operationPending.value) return;
operationPending.value = true;
operationError.value = "";
let operationCommitted = false;
try {
const requestOptions = {
requestController: membershipOperationController,
};
switch (target.kind) {
case "unlink":
await genealogyMemberApi.unlinkMemberLineagePerson(
genealogyId.value,
target.member.memberId,
requestOptions,
);
break;
case "remove":
await genealogyMemberApi.removeMember(
genealogyId.value,
target.member.memberId,
requestOptions,
);
break;
case "leave":
await genealogyMemberApi.leaveGenealogy(genealogyId.value, requestOptions);
break;
case "transfer":
await genealogyMemberApi.transferGenealogyOwner(
genealogyId.value,
target.member.memberId,
requestOptions,
);
break;
default:
throw new Error(`不支持的成员操作:${target.kind}`);
}
operationCommitted = true;
if (!isPageActive) return;
confirmTarget.value = null;
if (target.kind === "leave") {
hasLeftGenealogy.value = true;
members.value = [];
memberListState.value = "empty";
await returnTo("G01");
return;
}
feedbackMessage.value =
({
unlink: "人物绑定已解除。",
remove: "成员已移出家谱。",
transfer: "谱主身份已转让。",
})[target.kind] || "操作已完成。";
await loadMembers();
} catch (error) {
if (!isPageActive) return;
if (operationCommitted && target.kind === "leave") {
hasLeftGenealogy.value = true;
members.value = [];
memberListState.value = "empty";
confirmTarget.value = null;
return;
}
if (isRequestCancelled(error)) return;
operationError.value = getRequestErrorMessage(error, "操作失败,请稍后重试。");
} finally {
if (isPageActive) operationPending.value = false;
}
};
const returnToOverview = () =>
hasLeftGenealogy.value
? returnTo("G01")
: returnTo("G05", { genealogyId: genealogyId.value });
const requestBack = () =>
runBackGuard({
transientOpen: Boolean(editTarget.value || confirmTarget.value),
submitting: operationPending.value,
"close-transient": () =>
editTarget.value ? closeEdit() : closeConfirmation(),
"block-submitting": () => true,
});
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (hasValidGenealogyId.value) void loadMembers();
});
onShow(() => {
if (
!hasLeftGenealogy.value &&
hasValidGenealogyId.value &&
memberListState.value !== "loading" &&
!operationPending.value
) {
void loadMembers();
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
isPageActive = false;
memberListController.abort();
personOptionsController.abort();
memberUpdateController.abort();
membershipOperationController.abort();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.member-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.state-card,
.member-intro,
.member-card {
box-sizing: border-box;
@include adaptive.adaptive-genealogy-state-panel;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
text-align: center;
}
.state-card > text {
display: block;
}
.state-card > text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.state-card > text:nth-child(2) {
margin-top: 18rpx;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.member-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.member-intro {
padding: 24rpx 28rpx;
}
.member-intro text {
display: block;
}
.member-intro text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(18px, 31rpx, 22px);
font-weight: 700;
}
.member-intro text:last-child {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.page-feedback {
display: block;
padding: 18rpx 22rpx;
border: 1rpx solid rgba(66, 107, 88, 0.32);
border-radius: 12rpx;
background: rgba(66, 107, 88, 0.08);
color: #426b58;
font-size: clamp(14px, 23rpx, 17px);
}
.member-card {
padding: 26rpx 28rpx;
}
.member-card__heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
}
.member-card__identity {
min-width: 0;
}
.member-card__identity text {
display: block;
overflow-wrap: anywhere;
}
.member-card__identity text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(18px, 32rpx, 23px);
font-weight: 700;
}
.member-card__identity text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.role-badge {
flex: 0 0 auto;
padding: 7rpx 14rpx;
border: 1rpx solid rgba(174, 113, 39, 0.38);
border-radius: 999rpx;
background: rgba(205, 161, 84, 0.12);
color: #8f160f;
font-size: clamp(13px, 21rpx, 16px);
}
.member-card__details {
margin-top: 18rpx;
}
.member-card__details text,
.member-card__readonly {
display: block;
margin-top: 7rpx;
overflow-wrap: anywhere;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.5;
}
.member-card__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 12rpx;
margin-top: 22rpx;
}
.member-card__actions .app-button {
width: auto;
min-width: 138rpx;
}
.member-card__readonly {
margin-top: 18rpx;
}
.edit-form {
width: 100%;
margin-top: 22rpx;
text-align: left;
}
.edit-field {
@include adaptive.adaptive-genealogy-form-field;
display: flex;
width: 100%;
min-height: 86rpx;
box-sizing: border-box;
align-items: center;
justify-content: space-between;
gap: 18rpx;
margin-top: 12rpx;
padding: 14rpx 18rpx;
}
.edit-field > text:first-child {
flex: 0 0 auto;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.edit-field input,
.edit-field > text:last-child {
min-width: 0;
flex: 1;
overflow-wrap: anywhere;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
text-align: right;
}
.edit-field--picker > text:last-child {
color: #8f160f;
}
.edit-hint,
.edit-error {
display: block;
width: 100%;
margin-top: 12rpx;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.5;
text-align: left;
}
.edit-hint {
color: $ink-muted;
}
.edit-error {
color: $brand-red;
}
</style>