Files
jiapuapp/pages/records/r02-person-detail.vue
T
2026-07-23 08:24:07 +08:00

368 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 页面编号R-02用途人物录详情与不写库的人物资料预览 -->
<template>
<view class="person-detail-page" :class="`person-detail-state--${personState}`">
<ModulePageBackground module="records" />
<view class="person-detail-header">
<PageHeader
:title="personState === 'edit' ? (isCreateMode ? '人物预览' : '编辑预览') : '人物详情'"
custom-back
@back="requestBack"
/>
</view>
<view v-if="personState === 'loading'" class="person-detail-loading">
<AppLoading text="正在读取人物档案" description="请稍候,正在整理人物资料。" />
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy', 'preview'].includes(personState)">
<view class="person-identity-card">
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name || "待填写姓名" }}</text>
<text class="person-identity-card__meta">{{ person.relation || "人物预览" }} · {{ person.generation || "—" }} </text>
<text class="person-identity-card__hint">{{ personState === "preview" ? "本地预览 · 未提交" : "人物录档案" }}</text>
</view>
</view>
</template>
<template v-if="personState === 'detail'">
<view v-for="item in detailSections" :key="item.title" class="person-archive-card">
<view><text>{{ item.title }}</text><text>{{ item.copy || "未填写" }}</text></view>
</view>
<view class="person-related-actions">
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
</template>
<template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field">
<text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view>
<view v-for="field in longFields" :key="field.key" class="person-long-field">
<text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="生成本地预览" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消填写" /></view>
</view>
</template>
<view v-else-if="personState === 'preview'" class="person-state-card">
<view><text>本地预览尚未提交服务器</text><text>这份人物资料只存在于当前页面不会新增覆盖或刷新人物录</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<view><text>部分资料未公开</text><text>人物小传与档案备注受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else class="person-state-card">
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
<text>{{ personState === 'expired' ? '这份人物资料不存在或不属于当前家谱。' : '请返回人物录重新选择,页面不会回退到其他人物。' }}</text>
</view>
<view class="person-state-action"><AppButton type="secondary" block label="返回人物录" @click="returnToPeople" /></view>
</view>
</view>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const routeMode = ref("");
const personState = ref("loading");
const person = reactive({
id: "",
name: "",
relation: "",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
const draft = reactive({
name: "",
generationName: "",
generation: "",
biography: "",
remark: "",
});
const errors = reactive({ name: "", generation: "" });
const baseline = ref("");
const toastVisible = ref(false);
const discardVisible = ref(false);
let toastTimer = null;
const isCreateMode = computed(() => routeMode.value === "create");
const formSnapshot = computed(() => JSON.stringify({ ...draft }));
const isDirty = computed(() =>
personState.value === "preview" ||
(personState.value === "edit" && formSnapshot.value !== baseline.value),
);
const shortFields = [
{ key: "name", label: "姓名" },
{ key: "generationName", label: "字辈" },
{ key: "generation", label: "世代" },
];
const longFields = [
{ key: "biography", label: "人物小传" },
{ key: "remark", label: "档案备注" },
];
const detailSections = computed(() => [
{ title: "字辈", copy: person.generationName },
{ title: "人物小传", copy: person.biography },
{ title: "档案备注", copy: person.remark },
]);
const copyToDraft = () => {
Object.assign(draft, {
name: person.name,
generationName: person.generationName,
generation: String(person.generation || ""),
biography: person.biography,
remark: person.remark,
});
baseline.value = formSnapshot.value;
};
const clearErrors = () => Object.assign(errors, { name: "", generation: "" });
const enterEdit = () => {
if (personState.value !== "detail" || !person.id) return false;
copyToDraft();
clearErrors();
personState.value = "edit";
return true;
};
const showPreviewToast = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const validatePerson = () => {
errors.name = draft.name.trim() ? "" : "请填写姓名";
const generationInput = draft.generation.trim();
const generation = Number(generationInput);
errors.generation = !generationInput
? ""
: Number.isInteger(generation) && generation > 0
? ""
: "世代必须是正整数";
return !errors.name && !errors.generation;
};
const savePerson = () => {
clearErrors();
if (!validatePerson()) return false;
const localPersonPreview = {
name: draft.name.trim(),
generationName: draft.generationName.trim(),
generation: draft.generation.trim()
? String(Number(draft.generation))
: "",
biography: draft.biography.trim(),
remark: draft.remark.trim(),
};
Object.assign(person, localPersonPreview);
personState.value = "preview";
showPreviewToast();
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestDiscardConfirmation = discardConfirmation.request;
const cancelEdit = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
if (isCreateMode.value) return goBack();
copyToDraft();
clearErrors();
personState.value = "detail";
return true;
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (personState.value === "edit" && !isCreateMode.value) return cancelEdit();
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const returnToPeople = () =>
genealogyId.value
? returnTo("R01", { genealogyId: genealogyId.value })
: goBack();
const toGrowthJournal = () =>
person.id
? openPage(
"R08",
{ genealogyId: genealogyId.value, personId: personId.value },
"R02",
)
: Promise.resolve(false);
const toLifeEvents = () =>
person.id
? openPage(
"R09",
{ genealogyId: genealogyId.value, personId: personId.value },
"R02",
)
: Promise.resolve(false);
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
routeMode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = routeMode.value === "create" && !personId.value;
const isViewContract = routeMode.value === "view" && Boolean(personId.value);
if (!hasValidGenealogy || (!isCreateContract && !isViewContract)) {
personState.value = "error";
return;
}
if (isCreateContract) {
Object.assign(person, {
id: "",
name: "",
relation: "人物预览",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = findTreeMemberPresentationFixture(genealogyId.value, personId.value);
if (!selected) {
personState.value = "expired";
return;
}
Object.assign(person, {
id: selected.id,
name: selected.name,
relation: selected.relation,
generation: String(selected.generation),
status: selected.status,
});
if (["privacy", "forbidden"].includes(selected.status)) {
Object.assign(person, {
generationName: "",
biography: "",
remark: "",
});
personState.value = "privacy";
return;
}
Object.assign(person, {
generationName: selected.generationName || "",
biography: selected.summary || "",
remark: selected.note || "",
});
copyToDraft();
personState.value = ["expired", "error"].includes(query.state)
? query.state
: "detail";
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.person-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.person-detail-header,.person-detail-loading,.person-detail-content { z-index: 1; }
.person-detail-loading { min-height: calc(100vh - 100rpx); }
.person-detail-content { padding: 18rpx 24rpx 72rpx; }
.person-identity-card { @include adaptive.adaptive-records-person; display: flex; min-height: 190rpx; padding: 30rpx 10%; }
.person-identity-card__copy { display: flex; flex: 1; flex-direction: column; justify-content: center; }
.person-identity-card__copy text { display: block; }
.person-identity-card__name { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 38rpx; font-weight: 700; }
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card,.person-long-field,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; }
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.person-field { @include adaptive.adaptive-records-field; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; }
.person-field__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field input { width: 100%; min-width: 0; min-height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field textarea { width: 100%; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
.person-state-card text:last-child { margin-top: 16rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.6; }
.person-state-action { margin: 24rpx 48rpx 0; }
@media (min-width: 400px) { .person-detail-content { padding-right: 32rpx; padding-left: 32rpx; } }
@media (max-width: 340px) { .person-detail-content { padding-right: 18rpx; padding-left: 18rpx; } .person-identity-card__name { font-size: 35rpx; } }
</style>