完成50%
This commit is contained in:
@@ -40,7 +40,7 @@
|
||||
<view class="person-card__copy">
|
||||
<text class="person-card__name">{{ person.name }}</text>
|
||||
<text class="person-card__meta"
|
||||
>{{ person.role }} · 第 {{ person.generation }} 世</text
|
||||
>{{ person.relation }} · 第 {{ person.generation }} 世</text
|
||||
>
|
||||
<text class="person-card__hint">查看人物档案</text>
|
||||
</view>
|
||||
@@ -48,8 +48,8 @@
|
||||
<AppButton
|
||||
class="people-primary-action"
|
||||
block
|
||||
label="新建人物"
|
||||
@click="showCreateNotice"
|
||||
label="填写人物预览"
|
||||
@click="createPersonPreview"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -70,52 +70,55 @@
|
||||
<view v-else class="people-state-card">
|
||||
<view class="people-state-card__copy">
|
||||
<text>{{
|
||||
peopleState === "empty" ? "还没有人物记录" : "人物录暂不可用"
|
||||
peopleState === "empty"
|
||||
? "还没有人物记录"
|
||||
: peopleState === "invalid"
|
||||
? "人物录入口无效"
|
||||
: "人物录暂不可用"
|
||||
}}</text>
|
||||
<text>{{
|
||||
peopleState === "empty"
|
||||
? "从第一位值得铭记的家人开始建立人物录。"
|
||||
: peopleState === "invalid"
|
||||
? "没有找到可访问的成员家谱,页面不会展示其他家谱人物。"
|
||||
: "请稍后重新进入,已有档案不会受到影响。"
|
||||
}}</text>
|
||||
</view>
|
||||
<AppButton
|
||||
:type="peopleState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="peopleState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="peopleState === 'error' ? '重新查看' : '新建人物'"
|
||||
@click="peopleState === 'error' ? restoreList() : showCreateNotice()"
|
||||
:label="peopleState === 'error' ? '重新查看' : peopleState === 'invalid' ? '返回上一页' : '填写人物预览'"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppToast :visible="toastVisible" message="新建人物将在后续功能阶段开放" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listTreeMemberPresentationFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const people = [
|
||||
{ id: 1, name: "汤文正", role: "家谱管理员", generation: 18 },
|
||||
{ id: 2, name: "汤淑华", role: "家族长辈", generation: 17 },
|
||||
{ id: 3, name: "汤文清", role: "青年代表", generation: 19 },
|
||||
];
|
||||
|
||||
const genealogyId = ref("");
|
||||
const people = ref([]);
|
||||
const peopleState = ref("ready");
|
||||
const keywordInput = ref("");
|
||||
const keyword = ref("");
|
||||
const toastVisible = ref(false);
|
||||
let toastTimer = null;
|
||||
const hasValidContext = computed(() => peopleState.value !== "invalid");
|
||||
|
||||
const filteredPeople = computed(() => {
|
||||
const value = keyword.value.trim().toLowerCase();
|
||||
if (!value) return people;
|
||||
return people.filter((person) =>
|
||||
`${person.name} ${person.role} 第${person.generation}世 ${person.generation}`
|
||||
if (!value) return people.value;
|
||||
return people.value.filter((person) =>
|
||||
`${person.name} ${person.relation} ${person.branch || ""} ${person.generationName || ""} 第${person.generation}世 ${person.generation}`
|
||||
.toLowerCase()
|
||||
.includes(value),
|
||||
);
|
||||
@@ -133,24 +136,49 @@ const clearSearch = () => {
|
||||
keyword.value = "";
|
||||
};
|
||||
const restoreList = () => {
|
||||
peopleState.value = "ready";
|
||||
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
|
||||
peopleState.value = people.value.length ? "ready" : "empty";
|
||||
};
|
||||
const openPerson = (person) =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/records/r02-person-detail?personId=${person.id}`,
|
||||
});
|
||||
const showCreateNotice = () => {
|
||||
uni.navigateTo({ url: "/pages/records/r02-person-detail?mode=create" });
|
||||
hasValidContext.value
|
||||
? openPage(
|
||||
"R02",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
mode: "view",
|
||||
personId: String(person.id),
|
||||
},
|
||||
"R01",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const createPersonPreview = () =>
|
||||
hasValidContext.value
|
||||
? openPage(
|
||||
"R02",
|
||||
{ genealogyId: genealogyId.value, mode: "create" },
|
||||
"R01",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const handleStateAction = () => {
|
||||
if (peopleState.value === "invalid") return goBack();
|
||||
if (peopleState.value === "error") return restoreList();
|
||||
return createPersonPreview();
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
people.value = [];
|
||||
peopleState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
|
||||
peopleState.value = ["empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "ready";
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
: people.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,33 +1,39 @@
|
||||
<!-- 页面编号:R-02;用途:人物录详情、同页编辑与受控状态。 -->
|
||||
<!-- 页面编号: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 ? '新建人物' : '编辑人物') : '人物详情'" /></view>
|
||||
<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'].includes(personState)">
|
||||
<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.role }} · 第 {{ person.generation }} 世</text>
|
||||
<text class="person-identity-card__hint">人物录档案</text>
|
||||
<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><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" />
|
||||
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
|
||||
</view>
|
||||
<view class="person-edit-action" @click="enterEdit"><AppButton block label="编辑人物" /></view>
|
||||
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="personState === 'edit'">
|
||||
@@ -41,103 +47,287 @@
|
||||
<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 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" @click="returnToPeople"><AppButton block label="返回人物录" /></view>
|
||||
<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" @click="personState === 'expired' ? returnToPeople() : restoreDetail()">
|
||||
<AppButton :type="personState === 'error' ? 'secondary' : 'primary'" block :label="personState === 'expired' ? '返回人物录' : '重新查看'" />
|
||||
<text>{{ personState === 'expired' ? '这份人物资料不存在或不属于当前家谱。' : '请返回人物录重新选择,页面不会回退到其他人物。' }}</text>
|
||||
</view>
|
||||
<view class="person-state-action"><AppButton type="secondary" block label="返回人物录" @click="returnToPeople" /></view>
|
||||
</view>
|
||||
</view>
|
||||
<AppToast :visible="toastVisible" message="人物资料已保存" />
|
||||
|
||||
<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 { onLoad } from "@dcloudio/uni-app";
|
||||
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 people = [
|
||||
{ id: "1", name: "汤文正", role: "家谱管理员", generation: "18", biography: "勤于修谱,常年整理族中旧照片与口述资料。", legacy: "参与维护汤氏家谱与家风家训。" },
|
||||
{ id: "2", name: "汤淑华", role: "家族长辈", generation: "17", biography: "熟悉家族往事,愿意为后辈讲述旧时记忆。", legacy: "长期参与家族节庆与敬老活动。" },
|
||||
{ id: "3", name: "汤文清", role: "青年代表", generation: "19", biography: "协助整理电子家谱和家族影像资料。", legacy: "推动年轻成员共同参与家谱维护。" },
|
||||
];
|
||||
const person = reactive({ ...people[0] });
|
||||
const draft = reactive({ name: "", role: "", generation: "", biography: "", legacy: "" });
|
||||
const errors = reactive({ name: "", role: "", generation: "" });
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const routeMode = ref("");
|
||||
const personState = ref("loading");
|
||||
const isCreateMode = ref(false);
|
||||
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 shortFields = [{ key: "name", label: "姓名" }, { key: "role", label: "身份" }, { key: "generation", label: "世代" }];
|
||||
const longFields = [{ key: "biography", label: "人物小传" }, { key: "legacy", label: "家族印记" }];
|
||||
const detailSections = computed(() => [{ title: "人物小传", copy: person.biography }, { title: "家族印记", copy: person.legacy }]);
|
||||
const copyToDraft = () => Object.assign(draft, { name: person.name, role: person.role, generation: person.generation, biography: person.biography, legacy: person.legacy });
|
||||
const clearErrors = () => Object.assign(errors, { name: "", role: "", generation: "" });
|
||||
const enterEdit = () => { copyToDraft(); clearErrors(); personState.value = "edit"; };
|
||||
const cancelEdit = () => { copyToDraft(); clearErrors(); personState.value = "detail"; };
|
||||
const savePerson = () => {
|
||||
|
||||
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();
|
||||
if (!String(draft.name).trim()) errors.name = "请填写姓名";
|
||||
if (!String(draft.role).trim()) errors.role = "请填写身份";
|
||||
if (!String(draft.generation).trim()) errors.generation = "请填写世代";
|
||||
if (errors.name || errors.role || errors.generation) return;
|
||||
Object.assign(person, { id: person.id || "new", ...draft, name: draft.name.trim(), role: draft.role.trim(), generation: String(draft.generation).trim() });
|
||||
isCreateMode.value = false;
|
||||
personState.value = "detail";
|
||||
personState.value = "edit";
|
||||
return true;
|
||||
};
|
||||
const showPreviewToast = () => {
|
||||
toastVisible.value = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => { toastVisible.value = false; toastTimer = null; }, 1800);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
toastTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
const returnToPeople = () => uni.reLaunch({ url: "/pages/records/r01-people-list" });
|
||||
const restoreDetail = () => { personState.value = "detail"; };
|
||||
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 = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/records/r08-growth-journal?personId=${person.id}`,
|
||||
});
|
||||
person.id
|
||||
? openPage(
|
||||
"R08",
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const toLifeEvents = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/records/r09-life-events?personId=${person.id}`,
|
||||
});
|
||||
person.id
|
||||
? openPage(
|
||||
"R09",
|
||||
{ genealogyId: genealogyId.value, personId: personId.value },
|
||||
"R02",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
|
||||
onLoad((query) => {
|
||||
isCreateMode.value = query.mode === "create";
|
||||
if (isCreateMode.value) {
|
||||
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: "",
|
||||
role: "",
|
||||
relation: "人物预览",
|
||||
generationName: "",
|
||||
generation: "",
|
||||
biography: "",
|
||||
legacy: "",
|
||||
remark: "",
|
||||
status: "",
|
||||
});
|
||||
copyToDraft();
|
||||
personState.value = "edit";
|
||||
return;
|
||||
}
|
||||
const selected = people.find((item) => item.id === String(query.personId || ""));
|
||||
if (selected) Object.assign(person, selected);
|
||||
|
||||
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();
|
||||
const requested = ["loading", "privacy", "expired", "error", "edit"].includes(query.state) ? query.state : "detail";
|
||||
personState.value = selected ? requested : "error";
|
||||
personState.value = ["expired", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "detail";
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<view class="gift-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="贺礼簿" action="新增" @action="createGift" />
|
||||
<PageHeader title="贺礼簿" :action="hasValidContext ? '填写预览' : ''" @action="createRelativePreview" />
|
||||
</view>
|
||||
<view v-if="giftState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
@@ -12,40 +12,42 @@
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<template v-if="giftState === 'ready' && giftBooks.length">
|
||||
<template v-if="giftState === 'ready' && relativeRecords.length">
|
||||
<view
|
||||
v-for="gift in giftBooks"
|
||||
:key="gift.id"
|
||||
v-for="record in relativeRecords"
|
||||
:key="record.relativeId"
|
||||
class="record-card"
|
||||
role="button"
|
||||
:aria-label="`查看${gift.title}`"
|
||||
@click="openGiftBook(gift)"
|
||||
:aria-label="`查看${record.eventName}`"
|
||||
@click="openRelative(record)"
|
||||
>
|
||||
<text class="record-card__tag">{{ gift.occasion }}</text>
|
||||
<text class="record-card__title">{{ gift.title }}</text>
|
||||
<text class="record-card__tag">{{ record.relationName }}</text>
|
||||
<text class="record-card__title">{{ record.eventName }}</text>
|
||||
<text class="record-card__copy">
|
||||
{{ gift.from }} · {{ gift.date }}
|
||||
{{ record.relativeName }} · {{ record.eventTime }} · 金额记录:{{ record.giftAmount }}
|
||||
</text>
|
||||
<text class="record-card__hint">查看并编辑贺礼</text>
|
||||
<text class="record-card__hint">查看往来记录</text>
|
||||
</view>
|
||||
<AppButton block label="新增贺礼" @click="createGift" />
|
||||
<AppButton block label="填写往来预览" @click="createRelativePreview" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{ giftState === "error" ? "贺礼簿暂不可用" : "还没有贺礼记录" }}
|
||||
{{ giftState === "error" ? "贺礼簿暂不可用" : giftState === "invalid" ? "贺礼簿入口无效" : "还没有往来记录" }}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
giftState === "error"
|
||||
? "请稍后重新查看,已有记录不会受到影响。"
|
||||
: giftState === "invalid"
|
||||
? "没有找到可访问的成员家谱,页面不会展示其他家谱记录。"
|
||||
: "从第一份家人之间的心意开始记录。"
|
||||
}}
|
||||
</text>
|
||||
<AppButton
|
||||
:type="giftState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="giftState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="giftState === 'error' ? '重新查看' : '新增贺礼'"
|
||||
@click="giftState === 'error' ? restoreGifts() : createGift()"
|
||||
:label="giftState === 'error' ? '重新查看' : giftState === 'invalid' ? '返回上一页' : '填写往来预览'"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -58,59 +60,63 @@ import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
const baseGifts = [
|
||||
{
|
||||
id: "301",
|
||||
title: "新春贺礼",
|
||||
occasion: "春节",
|
||||
from: "汤文正一家",
|
||||
date: "2024 年 2 月 10 日",
|
||||
},
|
||||
{
|
||||
id: "302",
|
||||
title: "寿宴礼单",
|
||||
occasion: "寿辰",
|
||||
from: "汤淑华",
|
||||
date: "2024 年 4 月 18 日",
|
||||
},
|
||||
{
|
||||
id: "303",
|
||||
title: "添丁祝福",
|
||||
occasion: "新生",
|
||||
from: "汤文清一家",
|
||||
date: "2024 年 6 月 2 日",
|
||||
},
|
||||
];
|
||||
const giftBooks = ref([...baseGifts]);
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listRelativeRecordFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const relativeRecords = ref([]);
|
||||
const giftState = ref("loading");
|
||||
const hasValidContext = computed(() => ["ready", "empty"].includes(giftState.value));
|
||||
const stateClasses = computed(() => ({
|
||||
"gift-state--loading": giftState.value === "loading",
|
||||
"gift-state--empty": giftState.value === "empty",
|
||||
"gift-state--error": giftState.value === "error",
|
||||
"relative-state--loading": giftState.value === "loading",
|
||||
"relative-state--empty": giftState.value === "empty",
|
||||
"relative-state--error": giftState.value === "error",
|
||||
"relative-state--invalid": giftState.value === "invalid",
|
||||
}));
|
||||
onLoad((query) => {
|
||||
const count = Math.max(
|
||||
1,
|
||||
Math.min(Number(query.count) || baseGifts.length, 50),
|
||||
);
|
||||
giftBooks.value = Array.from({ length: count }, (_, i) => ({
|
||||
...baseGifts[i % baseGifts.length],
|
||||
id: String(301 + i),
|
||||
title:
|
||||
count > 3 ? `${baseGifts[i % 3].title}(${i + 1})` : baseGifts[i].title,
|
||||
}));
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
relativeRecords.value = [];
|
||||
giftState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
|
||||
giftState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: "ready";
|
||||
: relativeRecords.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
const openGiftBook = (gift) =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/records/r04-gift-editor?mode=view&giftId=${gift.id}`,
|
||||
});
|
||||
const createGift = () =>
|
||||
uni.navigateTo({ url: "/pages/records/r04-gift-editor?mode=create" });
|
||||
const restoreGifts = () => {
|
||||
giftState.value = "ready";
|
||||
const openRelative = (record) =>
|
||||
openPage(
|
||||
"R04",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
mode: "view",
|
||||
relativeId: String(record.relativeId),
|
||||
},
|
||||
"R03",
|
||||
);
|
||||
const createRelativePreview = () =>
|
||||
hasValidContext.value
|
||||
? openPage(
|
||||
"R04",
|
||||
{ genealogyId: genealogyId.value, mode: "create" },
|
||||
"R03",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const restoreRelatives = () => {
|
||||
relativeRecords.value = listRelativeRecordFixtures(genealogyId.value);
|
||||
giftState.value = relativeRecords.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (giftState.value === "invalid") return goBack();
|
||||
if (giftState.value === "error") return restoreRelatives();
|
||||
return createRelativePreview();
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
+199
-106
@@ -1,4 +1,4 @@
|
||||
<!-- 页面编号:R-04;用途:贺礼查看、新增、编辑、保存与删除确认。 -->
|
||||
<!-- 页面编号:R-04;用途:人情往来详情与不写库的新增、编辑预览。 -->
|
||||
<template>
|
||||
<view class="gift-editor-page" :class="editorClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
@@ -6,165 +6,258 @@
|
||||
<PageHeader
|
||||
:title="
|
||||
mode === 'create'
|
||||
? '新增贺礼'
|
||||
? '往来预览'
|
||||
: mode === 'view'
|
||||
? '贺礼详情'
|
||||
: '编辑贺礼'
|
||||
? '往来详情'
|
||||
: '编辑预览'
|
||||
"
|
||||
:action="mode === 'view' ? '编辑' : ''"
|
||||
@action="mode = 'edit'"
|
||||
:action="mode === 'view' && editorState === 'ready' ? '制作预览' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="enterEdit"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="editorState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取贺礼"
|
||||
description="请稍候,正在整理这份礼仪记录。"
|
||||
text="正在读取往来记录"
|
||||
description="请稍候,正在核对当前家谱与记录身份。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view v-if="editorState === 'success'" class="state-card">
|
||||
<text>贺礼已保存</text>
|
||||
<text>这份心意已加入家族贺礼簿。</text>
|
||||
<AppButton block label="返回贺礼簿" @click="backToGifts" />
|
||||
<view v-if="editorState === 'preview'" class="state-card">
|
||||
<text>本地预览,尚未提交服务器</text>
|
||||
<text>这份往来内容只存在于当前页面,不会插入、修改或删除正式记录。</text>
|
||||
<view v-for="item in previewRows" :key="item.label">
|
||||
<text>{{ item.label }}</text><text>{{ item.value }}</text>
|
||||
</view>
|
||||
<AppButton block label="返回贺礼簿" @click="returnToRelatives" />
|
||||
</view>
|
||||
<view v-else-if="mode === 'view'" class="detail-card">
|
||||
<text>{{ giftForm.title }}</text>
|
||||
<view v-else-if="mode === 'view' && editorState === 'ready'" class="detail-card">
|
||||
<text>{{ relativeForm.eventName }}</text>
|
||||
<view v-for="item in detailRows" :key="item.label">
|
||||
<text>{{ item.label }}</text>
|
||||
<text>{{ item.value }}</text>
|
||||
</view>
|
||||
<AppButton block label="编辑贺礼" @click="mode = 'edit'" />
|
||||
<AppButton
|
||||
type="secondary"
|
||||
block
|
||||
label="删除记录"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
<AppButton block label="制作编辑预览" @click="enterEdit" />
|
||||
<AppButton type="secondary" block disabled label="删除暂未开放" />
|
||||
</view>
|
||||
<view v-else class="form-card">
|
||||
<view v-else-if="editorState === 'ready'" class="form-card">
|
||||
<text>
|
||||
{{ mode === "create" ? "记录一份家人心意" : "修改贺礼信息" }}
|
||||
{{ mode === "create" ? "填写一份人情往来预览" : "调整往来记录预览" }}
|
||||
</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field-row">
|
||||
<text>{{ field.label }}</text>
|
||||
<input
|
||||
v-model="giftForm[field.key]"
|
||||
v-model="relativeForm[field.key]"
|
||||
:type="field.key === 'giftAmount' ? 'digit' : 'text'"
|
||||
:placeholder="`请输入${field.label}`"
|
||||
/>
|
||||
<text v-if="giftErrors[field.key]">{{ giftErrors[field.key] }}</text>
|
||||
<text v-if="relativeErrors[field.key]">{{ relativeErrors[field.key] }}</text>
|
||||
</view>
|
||||
<text v-if="editorState === 'error'" class="save-error">
|
||||
保存失败,请检查内容后重试。
|
||||
</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="editorState === 'saving'"
|
||||
:label="editorState === 'saving' ? '正在保存' : '保存贺礼'"
|
||||
@click="saveGift"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="mode === 'edit'"
|
||||
type="secondary"
|
||||
block
|
||||
label="删除记录"
|
||||
@click="confirmDelete"
|
||||
:disabled="isSubmitting"
|
||||
:label="isSubmitting ? '正在生成预览' : '生成本地预览'"
|
||||
@click="saveRelative"
|
||||
/>
|
||||
<AppButton type="secondary" block label="取消填写" @click="requestBack" />
|
||||
</view>
|
||||
<view v-else class="state-card">
|
||||
<text>往来记录不可用</text>
|
||||
<text>记录不存在、缺少身份或不属于当前家谱,页面不会回退到其他记录。</text>
|
||||
<AppButton type="secondary" block label="返回贺礼簿" @click="returnToRelatives" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这份贺礼?"
|
||||
message="删除后将返回贺礼簿,本地演示记录不会继续显示。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留记录"
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃当前填写?"
|
||||
message="尚未提交的预览内容将从当前页面清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="deleteGift"
|
||||
@cancel="deleteVisible = false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
const records = [
|
||||
{
|
||||
id: "301",
|
||||
title: "新春贺礼",
|
||||
from: "汤文正一家",
|
||||
date: "2024-02-10",
|
||||
note: "新春团拜时赠予长辈的心意",
|
||||
},
|
||||
{
|
||||
id: "302",
|
||||
title: "寿宴礼单",
|
||||
from: "汤淑华",
|
||||
date: "2024-04-18",
|
||||
note: "汤老先生八十寿辰",
|
||||
},
|
||||
];
|
||||
const giftId = ref("");
|
||||
const mode = ref("create");
|
||||
const editorState = ref("ready");
|
||||
const deleteVisible = ref(false);
|
||||
const forceSaveFailure = ref(false);
|
||||
const giftForm = reactive({ title: "", from: "", date: "", note: "" });
|
||||
const giftErrors = reactive({ title: "", from: "", date: "" });
|
||||
import {
|
||||
findRelativeRecordFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const relativeId = ref("");
|
||||
const mode = ref("");
|
||||
const editorState = ref("loading");
|
||||
const isSubmitting = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const localRelativePreview = ref(null);
|
||||
const relativeForm = reactive({
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventTime: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
});
|
||||
const relativeErrors = reactive({
|
||||
relativeName: "",
|
||||
relationName: "",
|
||||
eventName: "",
|
||||
eventTime: "",
|
||||
giftAmount: "",
|
||||
recordContent: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "title", label: "贺礼名称" },
|
||||
{ key: "from", label: "赠送人" },
|
||||
{ key: "date", label: "日期" },
|
||||
{ key: "note", label: "备注" },
|
||||
{ key: "relativeName", label: "亲友姓名" },
|
||||
{ key: "relationName", label: "关系称谓" },
|
||||
{ key: "eventName", label: "礼仪事项" },
|
||||
{ key: "eventTime", label: "事项日期" },
|
||||
{ key: "giftAmount", label: "礼金金额" },
|
||||
{ key: "recordContent", label: "往来备注" },
|
||||
];
|
||||
let saveTimer = null;
|
||||
const baseline = ref("");
|
||||
let submitTimer = null;
|
||||
const editorClasses = computed(() => ({
|
||||
"gift-editor-state--saving": editorState.value === "saving",
|
||||
"gift-editor-state--error": editorState.value === "error",
|
||||
"relative-editor-state--preview": editorState.value === "preview",
|
||||
"relative-editor-state--invalid": editorState.value === "invalid",
|
||||
}));
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...relativeForm }));
|
||||
const isDirty = computed(() =>
|
||||
editorState.value === "preview" ||
|
||||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
|
||||
);
|
||||
const displayValue = (value) =>
|
||||
value === "" || value === null || value === undefined ? "未填写" : String(value);
|
||||
const detailRows = computed(() =>
|
||||
fields
|
||||
.slice(1)
|
||||
.map((f) => ({ label: f.label, value: giftForm[f.key] || "未填写" })),
|
||||
.map((field) => ({ label: field.label, value: displayValue(relativeForm[field.key]) })),
|
||||
);
|
||||
const previewRows = computed(() =>
|
||||
fields.map((field) => ({
|
||||
label: field.label,
|
||||
value: displayValue(localRelativePreview.value?.[field.key]),
|
||||
})),
|
||||
);
|
||||
const copyRecordToForm = (record) => {
|
||||
Object.assign(relativeForm, {
|
||||
relativeName: record.relativeName,
|
||||
relationName: record.relationName,
|
||||
eventName: record.eventName,
|
||||
eventTime: record.eventTime,
|
||||
giftAmount: String(record.giftAmount ?? ""),
|
||||
recordContent: record.recordContent,
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
};
|
||||
onLoad((query) => {
|
||||
giftId.value = String(query.giftId || "");
|
||||
mode.value = ["view", "edit"].includes(query.mode) ? query.mode : "create";
|
||||
forceSaveFailure.value = query.saveResult === "error";
|
||||
const selected = records.find((x) => x.id === giftId.value);
|
||||
if (selected) Object.assign(giftForm, selected);
|
||||
else if (mode.value !== "create") editorState.value = "error";
|
||||
if (query.state === "loading") editorState.value = "loading";
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
relativeId.value = String(query.relativeId || "");
|
||||
mode.value = String(query.mode || "");
|
||||
if (query.state === "loading") return;
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
|
||||
const isCreateContract = mode.value === "create" && !relativeId.value;
|
||||
const isEntityContract = ["view", "edit"].includes(mode.value) && Boolean(relativeId.value);
|
||||
if (!hasValidGenealogy || (!isCreateContract && !isEntityContract)) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (isCreateContract) {
|
||||
baseline.value = formSnapshot.value;
|
||||
editorState.value = "ready";
|
||||
return;
|
||||
}
|
||||
const selected = findRelativeRecordFixture(genealogyId.value, relativeId.value);
|
||||
if (!selected) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
copyRecordToForm(selected);
|
||||
editorState.value = "ready";
|
||||
});
|
||||
const validateGift = () => {
|
||||
giftErrors.title = giftForm.title.trim() ? "" : "请填写贺礼名称";
|
||||
giftErrors.from = giftForm.from.trim() ? "" : "请填写赠送人";
|
||||
giftErrors.date = giftForm.date.trim() ? "" : "请填写日期";
|
||||
return !giftErrors.title && !giftErrors.from && !giftErrors.date;
|
||||
const enterEdit = () => {
|
||||
if (mode.value !== "view" || editorState.value !== "ready") return false;
|
||||
mode.value = "edit";
|
||||
baseline.value = formSnapshot.value;
|
||||
return true;
|
||||
};
|
||||
const saveGift = () => {
|
||||
if (editorState.value === "saving" || !validateGift()) return;
|
||||
editorState.value = "saving";
|
||||
saveTimer = setTimeout(() => {
|
||||
editorState.value = forceSaveFailure.value ? "error" : "success";
|
||||
forceSaveFailure.value = false;
|
||||
}, 320);
|
||||
const validateRelative = () => {
|
||||
relativeErrors.relativeName = relativeForm.relativeName.trim() ? "" : "请填写亲友姓名";
|
||||
relativeErrors.relationName = "";
|
||||
relativeErrors.eventName = "";
|
||||
relativeErrors.eventTime = "";
|
||||
relativeErrors.recordContent = "";
|
||||
const amountInput = relativeForm.giftAmount.trim();
|
||||
relativeErrors.giftAmount =
|
||||
!amountInput || Number.isFinite(Number(amountInput))
|
||||
? ""
|
||||
: "礼金金额必须是数字";
|
||||
return !relativeErrors.relativeName && !relativeErrors.giftAmount;
|
||||
};
|
||||
const confirmDelete = () => {
|
||||
deleteVisible.value = true;
|
||||
const saveRelative = () => {
|
||||
if (isSubmitting.value || !validateRelative()) return false;
|
||||
isSubmitting.value = true;
|
||||
const snapshot = Object.freeze({
|
||||
relativeName: relativeForm.relativeName.trim(),
|
||||
relationName: relativeForm.relationName.trim(),
|
||||
eventName: relativeForm.eventName.trim(),
|
||||
eventTime: relativeForm.eventTime.trim(),
|
||||
giftAmount: relativeForm.giftAmount.trim()
|
||||
? Number(relativeForm.giftAmount)
|
||||
: null,
|
||||
recordContent: relativeForm.recordContent.trim(),
|
||||
});
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
localRelativePreview.value = snapshot;
|
||||
editorState.value = "preview";
|
||||
isSubmitting.value = false;
|
||||
submitTimer = null;
|
||||
}, 240);
|
||||
submitTimer = timer;
|
||||
return true;
|
||||
};
|
||||
const deleteGift = () => {
|
||||
deleteVisible.value = false;
|
||||
backToGifts();
|
||||
};
|
||||
const backToGifts = () =>
|
||||
uni.redirectTo({ url: "/pages/records/r03-gift-list" });
|
||||
const discardConfirmation = createDiscardConfirmation(
|
||||
(visible) => { discardVisible.value = visible; },
|
||||
);
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": discardConfirmation.request,
|
||||
});
|
||||
const returnToRelatives = () =>
|
||||
genealogyId.value
|
||||
? returnTo("R03", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
if (submitTimer) clearTimeout(submitTimer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
<!-- 页面编号:R-05;用途:礼仪活动列表、状态与创建入口。 -->
|
||||
<!-- 页面编号:R-05;用途:当前家谱的礼仪活动列表与本地创建预览入口。 -->
|
||||
<template>
|
||||
<view class="ritual-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="礼仪活动" action="新建" @action="createRitual" />
|
||||
<PageHeader
|
||||
title="礼仪活动"
|
||||
:action="hasValidContext ? '填写预览' : ''"
|
||||
@action="createCeremonyPreview"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="ritualState === 'loading'" class="page-loading">
|
||||
<view v-if="ceremonyState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在整理礼仪活动"
|
||||
description="请稍候,正在读取时间与地点。"
|
||||
description="请稍候,正在核对当前家谱的活动记录。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<template v-if="ritualState === 'ready' && rituals.length">
|
||||
<template v-if="ceremonyState === 'ready' && ceremonies.length">
|
||||
<view
|
||||
v-for="ritual in rituals"
|
||||
:key="ritual.id"
|
||||
v-for="ceremony in ceremonies"
|
||||
:key="ceremony.ceremonyId"
|
||||
class="record-card"
|
||||
@click="openRitual(ritual)"
|
||||
role="button"
|
||||
:aria-label="`查看${ceremony.ceremonyTitle}`"
|
||||
@click="openCeremony(ceremony)"
|
||||
>
|
||||
<text>{{ ritual.status }}</text>
|
||||
<text>{{ ritual.name }}</text>
|
||||
<text>{{ ritual.date }} · {{ ritual.place }}</text>
|
||||
<text>查看活动详情</text>
|
||||
<text>{{ ceremony.ceremonyType }}</text>
|
||||
<text>{{ ceremony.ceremonyTitle }}</text>
|
||||
<text>
|
||||
{{ ceremony.ceremonyTime }} ·
|
||||
{{ ceremony.location || "地点待定" }}
|
||||
</text>
|
||||
<text>查看活动与受邀信息</text>
|
||||
</view>
|
||||
<AppButton block label="新建礼仪" @click="createRitual" />
|
||||
<AppButton block label="填写礼仪预览" @click="createCeremonyPreview" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{ ritualState === "error" ? "礼仪活动暂不可用" : "还没有礼仪活动" }}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
ritualState === "error"
|
||||
? "请稍后重新查看,已有活动不会受到影响。"
|
||||
: "从一次祭祖、家宴或团拜开始安排。"
|
||||
}}
|
||||
</text>
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
:type="ritualState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="ceremonyState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="ritualState === 'error' ? '重新查看' : '新建礼仪'"
|
||||
@click="ritualState === 'error' ? restoreRituals() : createRitual()"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
@@ -54,53 +55,86 @@ import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
const base = [
|
||||
{
|
||||
id: "501",
|
||||
name: "清明祭祖",
|
||||
status: "报名中",
|
||||
date: "2025 年 4 月 4 日",
|
||||
place: "汤氏宗祠",
|
||||
},
|
||||
{
|
||||
id: "502",
|
||||
name: "中秋家宴",
|
||||
status: "筹备中",
|
||||
date: "2025 年 9 月 17 日",
|
||||
place: "祖居院落",
|
||||
},
|
||||
{
|
||||
id: "503",
|
||||
name: "新春团拜",
|
||||
status: "已结束",
|
||||
date: "2025 年 1 月 29 日",
|
||||
place: "家族礼堂",
|
||||
},
|
||||
];
|
||||
const rituals = ref([...base]);
|
||||
const ritualState = ref("loading");
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listCeremonyFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonies = ref([]);
|
||||
const ceremonyState = ref("loading");
|
||||
const hasValidContext = computed(() =>
|
||||
["ready", "empty"].includes(ceremonyState.value),
|
||||
);
|
||||
const stateClasses = computed(() => ({
|
||||
"ritual-state--loading": ritualState.value === "loading",
|
||||
"ritual-state--empty": ritualState.value === "empty",
|
||||
"ritual-state--error": ritualState.value === "error",
|
||||
"ceremony-state--loading": ceremonyState.value === "loading",
|
||||
"ceremony-state--empty": ceremonyState.value === "empty",
|
||||
"ceremony-state--error": ceremonyState.value === "error",
|
||||
"ceremony-state--invalid": ceremonyState.value === "invalid",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
const n = Math.max(1, Math.min(Number(q.count) || base.length, 50));
|
||||
rituals.value = Array.from({ length: n }, (_, i) => ({
|
||||
...base[i % 3],
|
||||
id: String(501 + i),
|
||||
name: n > 3 ? `${base[i % 3].name}(${i + 1})` : base[i].name,
|
||||
}));
|
||||
ritualState.value = ["loading", "empty", "error"].includes(q.state)
|
||||
? q.state
|
||||
: "ready";
|
||||
const stateCopy = computed(() => ({
|
||||
error: {
|
||||
title: "礼仪活动暂不可用",
|
||||
copy: "请稍后重新查看,已有活动不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
invalid: {
|
||||
title: "礼仪活动入口无效",
|
||||
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱活动。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
empty: {
|
||||
title: "还没有礼仪活动",
|
||||
copy: "可以先填写一份本地预览;正式创建仍需等待线上写接口启用。",
|
||||
action: "填写礼仪预览",
|
||||
},
|
||||
})[ceremonyState.value] || {
|
||||
title: "礼仪活动暂不可用",
|
||||
copy: "请返回上一页重新进入。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
const openRitual = (r) =>
|
||||
uni.navigateTo({ url: `/pages/records/r06-ritual-detail?ritualId=${r.id}` });
|
||||
const createRitual = () =>
|
||||
uni.navigateTo({ url: "/pages/records/r07-ritual-editor?mode=create" });
|
||||
const restoreRituals = () => {
|
||||
ritualState.value = "ready";
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
ceremonies.value = [];
|
||||
ceremonyState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
ceremonies.value = listCeremonyFixtures(genealogyId.value);
|
||||
ceremonyState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: ceremonies.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
const openCeremony = (ceremony) =>
|
||||
openPage(
|
||||
"R06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
ceremonyId: String(ceremony.ceremonyId),
|
||||
},
|
||||
"R05",
|
||||
);
|
||||
const createCeremonyPreview = () =>
|
||||
hasValidContext.value
|
||||
? openPage(
|
||||
"R07",
|
||||
{ genealogyId: genealogyId.value, mode: "create" },
|
||||
"R05",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const restoreCeremonies = () => {
|
||||
ceremonies.value = listCeremonyFixtures(genealogyId.value);
|
||||
ceremonyState.value = ceremonies.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (ceremonyState.value === "invalid") return goBack();
|
||||
if (ceremonyState.value === "error") return restoreCeremonies();
|
||||
return createCeremonyPreview();
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,129 +1,158 @@
|
||||
<!-- 页面编号:R-06;用途:礼仪详情、参与者与受控状态。 -->
|
||||
<!-- 页面编号:R-06;用途:当前家谱礼仪详情、受邀信息与受控状态。 -->
|
||||
<template>
|
||||
<view class="ritual-detail-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader
|
||||
title="礼仪详情"
|
||||
:action="ritualState === 'ready' ? '编辑' : ''"
|
||||
@action="editRitual"
|
||||
:action="ceremonyState === 'ready' ? '制作预览' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="editCeremony"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="ritualState === 'loading'" class="page-loading">
|
||||
<view v-if="ceremonyState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取礼仪详情"
|
||||
description="请稍候,正在整理活动与参与信息。"
|
||||
description="请稍候,正在核对活动身份与受邀信息。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<template v-if="ritualState === 'ready'">
|
||||
<template v-if="ceremonyState === 'ready' && ceremonyDetail">
|
||||
<view class="detail-card">
|
||||
<text>{{ ritualDetail.status }}</text>
|
||||
<text>{{ ritualDetail.name }}</text>
|
||||
<text>{{ ritualDetail.date }} · {{ ritualDetail.place }}</text>
|
||||
<text>{{ ritualDetail.description }}</text>
|
||||
<text>{{ ceremonyDetail.ceremonyType }}</text>
|
||||
<text>{{ ceremonyDetail.ceremonyTitle }}</text>
|
||||
<text>
|
||||
{{ ceremonyDetail.ceremonyTime }} ·
|
||||
{{ ceremonyDetail.location || "地点待定" }}
|
||||
</text>
|
||||
<text>{{ ceremonyDetail.ceremonyDesc || "暂无活动说明" }}</text>
|
||||
</view>
|
||||
<view class="participant-card">
|
||||
<view>
|
||||
<text>参与家人</text>
|
||||
<text>{{ participants.length }} 人</text>
|
||||
<text>受邀家人</text>
|
||||
<text>{{ invitees.length }} 人</text>
|
||||
</view>
|
||||
<view v-for="person in participants" :key="person.id">
|
||||
<text>{{ person.name }}</text>
|
||||
<text>{{ person.role }}</text>
|
||||
<view v-for="invitee in invitees" :key="invitee.inviteeUserId">
|
||||
<text>{{ invitee.displayName }} · {{ invitee.relationName }}</text>
|
||||
<text>{{ invitee.statusText }}</text>
|
||||
</view>
|
||||
<view v-if="!invitees.length"><text>尚无受邀记录</text><text>—</text></view>
|
||||
</view>
|
||||
<AppButton block label="编辑活动" @click="editRitual" />
|
||||
<AppButton block label="制作编辑预览" @click="editCeremony" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
:type="ritualState === 'error' ? 'secondary' : 'primary'"
|
||||
type="secondary"
|
||||
block
|
||||
:label="ritualState === 'error' ? '重新查看' : '返回礼仪列表'"
|
||||
@click="ritualState === 'error' ? restoreRitual() : backToRituals()"
|
||||
:label="ceremonyState === 'error' ? '重新查看' : '返回礼仪列表'"
|
||||
@click="ceremonyState === 'error' ? restoreCeremony() : returnToCeremonies()"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
const records = [
|
||||
{
|
||||
id: "501",
|
||||
name: "清明祭祖",
|
||||
status: "报名中",
|
||||
date: "2025 年 4 月 4 日",
|
||||
place: "汤氏宗祠",
|
||||
description: "缅怀先祖,整理祭扫礼序,并由长辈讲述家族往事。",
|
||||
},
|
||||
{
|
||||
id: "502",
|
||||
name: "中秋家宴",
|
||||
status: "筹备中",
|
||||
date: "2025 年 9 月 17 日",
|
||||
place: "祖居院落",
|
||||
description: "家人团聚,共叙近况并整理年度家族影像。",
|
||||
},
|
||||
];
|
||||
const ritualDetail = reactive({ ...records[0] });
|
||||
const ritualId = ref("501");
|
||||
const ritualState = ref("loading");
|
||||
const participants = ref([
|
||||
{ id: 1, name: "汤文正", role: "主理人" },
|
||||
{ id: 2, name: "汤淑华", role: "家族长辈" },
|
||||
{ id: 3, name: "汤文清", role: "影像记录" },
|
||||
]);
|
||||
import {
|
||||
findCeremonyFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
listTreeMemberPresentationFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const ceremonyDetail = ref(null);
|
||||
const memberOptions = ref([]);
|
||||
const ceremonyState = ref("loading");
|
||||
const invitees = computed(() => {
|
||||
const memberByAppUserId = new Map(
|
||||
memberOptions.value.map((member) => [String(member.appUserId), member]),
|
||||
);
|
||||
return (ceremonyDetail.value?.invitees || []).map((invitation) => {
|
||||
const member = memberByAppUserId.get(String(invitation.inviteeUserId));
|
||||
return {
|
||||
inviteeUserId: String(invitation.inviteeUserId),
|
||||
displayName: member?.name || "受邀成员信息不可用",
|
||||
relationName: member?.relation || "未完成同谱成员联接",
|
||||
statusText: invitation.inviteStatus
|
||||
? "受邀状态字典待后端确认"
|
||||
: "受邀状态未提供",
|
||||
};
|
||||
});
|
||||
});
|
||||
const stateClasses = computed(() => ({
|
||||
"ritual-state--expired": ritualState.value === "expired",
|
||||
"ritual-state--privacy": ritualState.value === "privacy",
|
||||
"ritual-state--error": ritualState.value === "error",
|
||||
"ceremony-state--expired": ceremonyState.value === "expired",
|
||||
"ceremony-state--error": ceremonyState.value === "error",
|
||||
}));
|
||||
const stateCopy = computed(
|
||||
() =>
|
||||
({
|
||||
expired: {
|
||||
title: "活动已失效",
|
||||
copy: "这项礼仪活动已取消或结束归档,请返回列表查看其他活动。",
|
||||
},
|
||||
privacy: {
|
||||
title: "活动信息未公开",
|
||||
copy: "当前活动只向受邀家人展示,请返回礼仪列表。",
|
||||
copy: "活动不存在或不属于当前家谱,页面不会回退到其他活动。",
|
||||
},
|
||||
error: {
|
||||
title: "礼仪详情暂不可用",
|
||||
copy: "请稍后重新查看,已有活动不会受到影响。",
|
||||
},
|
||||
})[ritualState.value] || {},
|
||||
})[ceremonyState.value] || {
|
||||
title: "礼仪详情暂不可用",
|
||||
copy: "缺少家谱或活动身份,请返回列表重新选择。",
|
||||
},
|
||||
);
|
||||
onLoad((q) => {
|
||||
ritualId.value = String(q.ritualId || "501");
|
||||
const selected = records.find((x) => x.id === ritualId.value);
|
||||
if (selected) Object.assign(ritualDetail, selected);
|
||||
ritualState.value = ["loading", "expired", "privacy", "error"].includes(
|
||||
q.state,
|
||||
)
|
||||
? q.state
|
||||
: selected
|
||||
? "ready"
|
||||
: "expired";
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
ceremonyId.value = String(query.ceremonyId || "");
|
||||
if (query.state === "loading") return;
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole) || !ceremonyId.value) {
|
||||
ceremonyState.value = "expired";
|
||||
return;
|
||||
}
|
||||
memberOptions.value = listTreeMemberPresentationFixtures(genealogyId.value);
|
||||
ceremonyDetail.value = findCeremonyFixture(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
);
|
||||
if (!ceremonyDetail.value) {
|
||||
ceremonyState.value = "expired";
|
||||
return;
|
||||
}
|
||||
ceremonyState.value = query.state === "error" ? "error" : "ready";
|
||||
});
|
||||
const editRitual = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages/records/r07-ritual-editor?mode=edit&ritualId=${ritualId.value}`,
|
||||
});
|
||||
const restoreRitual = () => {
|
||||
ritualState.value = "ready";
|
||||
const editCeremony = () =>
|
||||
ceremonyState.value === "ready" && ceremonyDetail.value
|
||||
? openPage(
|
||||
"R07",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
mode: "edit",
|
||||
ceremonyId: ceremonyId.value,
|
||||
},
|
||||
"R06",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const restoreCeremony = () => {
|
||||
ceremonyDetail.value = findCeremonyFixture(
|
||||
genealogyId.value,
|
||||
ceremonyId.value,
|
||||
);
|
||||
ceremonyState.value = ceremonyDetail.value ? "ready" : "expired";
|
||||
};
|
||||
const backToRituals = () =>
|
||||
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
|
||||
const requestBack = () => goBack();
|
||||
const returnToCeremonies = () =>
|
||||
genealogyId.value
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
<!-- 页面编号:R-07;用途:礼仪创建、编辑、校验、保存与删除确认。 -->
|
||||
<!-- 页面编号:R-07;用途:礼仪创建、编辑校验与不写库的本地预览。 -->
|
||||
<template>
|
||||
<view class="ritual-editor-page" :class="editorClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader :title="mode === 'create' ? '新建礼仪' : '编辑礼仪'" />
|
||||
<PageHeader
|
||||
:title="mode === 'create' ? '礼仪预览' : '编辑预览'"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
/>
|
||||
</view>
|
||||
<view class="page-content">
|
||||
<view v-if="editorState === 'success'" class="state-card">
|
||||
<text>礼仪活动已保存</text>
|
||||
<text>时间、地点与活动说明已整理完成。</text>
|
||||
<AppButton block label="返回礼仪列表" @click="backToRituals" />
|
||||
<view v-if="editorState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取礼仪资料"
|
||||
description="请稍候,正在核对当前家谱与活动身份。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view v-if="editorState === 'preview'" class="state-card preview-card">
|
||||
<text>本地预览,尚未提交服务器</text>
|
||||
<text>这份礼仪内容只存在于当前页面,不会新增、覆盖或删除正式活动。</text>
|
||||
<view v-for="item in previewRows" :key="item.label" class="preview-row">
|
||||
<text>{{ item.label }}</text>
|
||||
<text>{{ item.value }}</text>
|
||||
</view>
|
||||
<AppButton block :label="returnLabel" @click="returnAfterPreview" />
|
||||
</view>
|
||||
<view v-else class="form-card">
|
||||
<view v-else-if="editorState === 'ready'" class="form-card">
|
||||
<text>
|
||||
{{ mode === "create" ? "安排一次家族礼仪" : "修改活动信息" }}
|
||||
{{ mode === "create" ? "填写一份家族礼仪预览" : "调整活动预览" }}
|
||||
</text>
|
||||
<view v-for="field in fields" :key="field.key" class="field-row">
|
||||
<text>{{ field.label }}</text>
|
||||
@@ -32,110 +46,190 @@
|
||||
{{ ritualErrors[field.key] }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="editorState === 'error'" class="save-error">
|
||||
保存失败,请保留内容后重试。
|
||||
</text>
|
||||
<AppButton
|
||||
block
|
||||
:disabled="editorState === 'saving'"
|
||||
:label="editorState === 'saving' ? '正在保存' : '保存活动'"
|
||||
@click="saveRitual"
|
||||
label="生成本地预览"
|
||||
@click="createCeremonyPreview"
|
||||
/>
|
||||
<AppButton
|
||||
v-if="mode === 'edit'"
|
||||
type="secondary"
|
||||
block
|
||||
label="删除活动"
|
||||
@click="confirmDelete"
|
||||
label="取消填写"
|
||||
@click="requestBack"
|
||||
/>
|
||||
<AppButton v-if="mode === 'edit'" type="secondary" block disabled label="删除暂未开放" />
|
||||
</view>
|
||||
<view v-else class="state-card">
|
||||
<text>礼仪活动不可用</text>
|
||||
<text>活动不存在、缺少身份或不属于当前家谱,页面不会回退到其他活动。</text>
|
||||
<AppButton type="secondary" block label="返回礼仪列表" @click="returnToCeremonies" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="deleteVisible"
|
||||
eyebrow="删除确认"
|
||||
title="删除这项礼仪活动?"
|
||||
message="删除后将返回礼仪列表。"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="保留活动"
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃当前填写?"
|
||||
message="尚未提交的预览内容将从当前页面清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="deleteRitual"
|
||||
@cancel="deleteVisible = false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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 ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
const records = [
|
||||
{
|
||||
id: "501",
|
||||
name: "清明祭祖",
|
||||
date: "2025-04-04",
|
||||
place: "汤氏宗祠",
|
||||
description: "缅怀先祖,凝聚家人,共叙家风传承。",
|
||||
},
|
||||
];
|
||||
const ritualId = ref("");
|
||||
const mode = ref("create");
|
||||
const editorState = ref("ready");
|
||||
const deleteVisible = ref(false);
|
||||
const forceSaveFailure = ref(false);
|
||||
const ritualForm = reactive({ name: "", date: "", place: "", description: "" });
|
||||
import {
|
||||
findCeremonyFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const ceremonyId = ref("");
|
||||
const mode = ref("");
|
||||
const editorState = ref("loading");
|
||||
const discardVisible = ref(false);
|
||||
const localCeremonyPreview = ref(null);
|
||||
const ceremonyForm = reactive({
|
||||
ceremonyType: "",
|
||||
ceremonyTitle: "",
|
||||
ceremonyTime: "",
|
||||
location: "",
|
||||
ceremonyDesc: "",
|
||||
});
|
||||
const ritualErrors = reactive({
|
||||
name: "",
|
||||
date: "",
|
||||
place: "",
|
||||
description: "",
|
||||
ceremonyType: "",
|
||||
ceremonyTitle: "",
|
||||
ceremonyTime: "",
|
||||
location: "",
|
||||
ceremonyDesc: "",
|
||||
});
|
||||
const fields = [
|
||||
{ key: "name", label: "活动名称" },
|
||||
{ key: "date", label: "活动日期" },
|
||||
{ key: "place", label: "举办地点" },
|
||||
{ key: "description", label: "活动说明", long: true },
|
||||
{ key: "ceremonyType", label: "礼仪类型" },
|
||||
{ key: "ceremonyTitle", label: "活动标题" },
|
||||
{ key: "ceremonyTime", label: "活动时间" },
|
||||
{ key: "location", label: "举办地点" },
|
||||
{ key: "ceremonyDesc", label: "活动说明", long: true },
|
||||
];
|
||||
let saveTimer = null;
|
||||
const baseline = ref("");
|
||||
const editorClasses = computed(() => ({
|
||||
"ritual-editor-state--saving": editorState.value === "saving",
|
||||
"ritual-editor-state--error": editorState.value === "error",
|
||||
"ceremony-editor-state--preview": editorState.value === "preview",
|
||||
"ceremony-editor-state--invalid": editorState.value === "invalid",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
ritualId.value = String(q.ritualId || "");
|
||||
mode.value = q.mode === "edit" ? "edit" : "create";
|
||||
forceSaveFailure.value = q.saveResult === "error";
|
||||
const selected = records.find((x) => x.id === ritualId.value);
|
||||
if (selected) Object.assign(ritualForm, selected);
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...ceremonyForm }));
|
||||
const isDirty = computed(() =>
|
||||
editorState.value === "preview" ||
|
||||
(["create", "edit"].includes(mode.value) && formSnapshot.value !== baseline.value),
|
||||
);
|
||||
const previewRows = computed(() =>
|
||||
fields.map((field) => ({
|
||||
label: field.label,
|
||||
value: localCeremonyPreview.value?.[field.key] || "未填写",
|
||||
})),
|
||||
);
|
||||
const returnLabel = computed(() =>
|
||||
mode.value === "edit" ? "返回礼仪详情" : "返回礼仪列表",
|
||||
);
|
||||
const copyCeremonyToForm = (record) => {
|
||||
Object.assign(ceremonyForm, {
|
||||
ceremonyType: record.ceremonyType,
|
||||
ceremonyTitle: record.ceremonyTitle,
|
||||
ceremonyTime: record.ceremonyTime,
|
||||
location: record.location,
|
||||
ceremonyDesc: record.ceremonyDesc,
|
||||
});
|
||||
baseline.value = formSnapshot.value;
|
||||
};
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
ceremonyId.value = String(query.ceremonyId || "");
|
||||
mode.value = String(query.mode || "");
|
||||
if (query.state === "loading") return;
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
|
||||
const isCreateContract = mode.value === "create" && !ceremonyId.value;
|
||||
const isEditContract = mode.value === "edit" && Boolean(ceremonyId.value);
|
||||
if (!hasValidGenealogy || (!isCreateContract && !isEditContract)) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (isCreateContract) {
|
||||
baseline.value = formSnapshot.value;
|
||||
editorState.value = "ready";
|
||||
return;
|
||||
}
|
||||
const selected = findCeremonyFixture(genealogyId.value, ceremonyId.value);
|
||||
if (!selected) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
copyCeremonyToForm(selected);
|
||||
editorState.value = "ready";
|
||||
});
|
||||
const validateRitual = () => {
|
||||
for (const f of fields)
|
||||
ritualErrors[f.key] = String(ritualForm[f.key]).trim()
|
||||
? ""
|
||||
: `请填写${f.label}`;
|
||||
return fields.every((f) => !ritualErrors[f.key]);
|
||||
const validateCeremony = () => {
|
||||
ritualErrors.ceremonyType = ceremonyForm.ceremonyType.trim()
|
||||
? ""
|
||||
: "请填写礼仪类型";
|
||||
ritualErrors.ceremonyTitle = ceremonyForm.ceremonyTitle.trim()
|
||||
? ""
|
||||
: "请填写活动标题";
|
||||
return fields.every((field) => !ritualErrors[field.key]);
|
||||
};
|
||||
const saveRitual = () => {
|
||||
if (editorState.value === "saving" || !validateRitual()) return;
|
||||
editorState.value = "saving";
|
||||
saveTimer = setTimeout(() => {
|
||||
editorState.value = forceSaveFailure.value ? "error" : "success";
|
||||
forceSaveFailure.value = false;
|
||||
}, 320);
|
||||
const createCeremonyPreview = () => {
|
||||
if (!validateCeremony()) return false;
|
||||
// 线上写接口的枚举、成员权限与错误语义尚未形成完整合同,因此这里只生成
|
||||
// 与表单分离的不可变快照;后续接入真实写接口时由该入口唯一替换。
|
||||
localCeremonyPreview.value = Object.freeze({
|
||||
ceremonyType: ceremonyForm.ceremonyType.trim(),
|
||||
ceremonyTitle: ceremonyForm.ceremonyTitle.trim(),
|
||||
ceremonyTime: ceremonyForm.ceremonyTime.trim(),
|
||||
location: ceremonyForm.location.trim(),
|
||||
ceremonyDesc: ceremonyForm.ceremonyDesc.trim(),
|
||||
});
|
||||
editorState.value = "preview";
|
||||
return true;
|
||||
};
|
||||
const confirmDelete = () => {
|
||||
deleteVisible.value = true;
|
||||
};
|
||||
const deleteRitual = () => {
|
||||
deleteVisible.value = false;
|
||||
backToRituals();
|
||||
};
|
||||
const backToRituals = () =>
|
||||
uni.redirectTo({ url: "/pages/records/r05-ritual-list" });
|
||||
const discardConfirmation = createDiscardConfirmation(
|
||||
(visible) => { discardVisible.value = visible; },
|
||||
);
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"confirm-discard": discardConfirmation.request,
|
||||
});
|
||||
const returnToCeremonies = () =>
|
||||
genealogyId.value
|
||||
? returnTo("R05", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const returnAfterPreview = () =>
|
||||
mode.value === "edit" && ceremonyId.value
|
||||
? returnTo("R06", {
|
||||
genealogyId: genealogyId.value,
|
||||
ceremonyId: ceremonyId.value,
|
||||
})
|
||||
: returnToCeremonies();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,142 +1,277 @@
|
||||
<!-- 页面编号:R-08;用途:人物成长日志、时间轴与同页新增。 -->
|
||||
<!-- 页面编号:R-08;用途:当前家谱人物的成长日志与不写库的本地预览。 -->
|
||||
<template>
|
||||
<view class="timeline-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="成长日志" action="记录" @action="recordGrowth" />
|
||||
<PageHeader
|
||||
title="成长日志"
|
||||
:action="hasValidContext ? '记录预览' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="recordGrowth"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="timelineState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取成长日志"
|
||||
:description="`请稍候,正在整理${personName}的成长记录。`"
|
||||
description="请稍候,正在核对当前家谱与人物身份。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view class="person-lead">
|
||||
<text>{{ personName }}</text>
|
||||
<view v-if="memberRecord" class="person-lead">
|
||||
<text>{{ memberRecord.name }}</text>
|
||||
<text>成长中的每一个瞬间</text>
|
||||
</view>
|
||||
<view v-if="localGrowthPreview" class="preview-card">
|
||||
<text>本地预览 · 尚未提交</text>
|
||||
<text>{{ localGrowthPreview.recordTitle }}</text>
|
||||
<text>{{ localGrowthPreview.recordDate || "日期未填写" }}</text>
|
||||
<text>{{ localGrowthPreview.recordContent || "内容未填写" }}</text>
|
||||
</view>
|
||||
<template v-if="timelineState === 'ready' && growthRecords.length">
|
||||
<view
|
||||
v-for="(record, index) in growthRecords"
|
||||
:key="record.id"
|
||||
:key="record.recordId"
|
||||
class="timeline-card"
|
||||
>
|
||||
<text>第 {{ growthRecords.length - index }} 则</text>
|
||||
<text>{{ record.title }}</text>
|
||||
<text>{{ record.date }}</text>
|
||||
<text>{{ record.description }}</text>
|
||||
<text>{{ record.recordTitle }}</text>
|
||||
<text>{{ record.recordDate || "日期未填写" }}</text>
|
||||
<text>{{ record.recordContent || "内容未填写" }}</text>
|
||||
</view>
|
||||
<AppButton block label="记录成长" @click="recordGrowth" />
|
||||
<AppButton block label="记录成长预览" @click="recordGrowth" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{
|
||||
timelineState === "error" ? "成长日志暂不可用" : "还没有成长记录"
|
||||
}}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
timelineState === "error"
|
||||
? "请稍后重新查看,已有记录不会受到影响。"
|
||||
: "从第一次微笑、入园或毕业开始记录。"
|
||||
}}
|
||||
</text>
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
:type="timelineState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="timelineState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="timelineState === 'error' ? '重新查看' : '记录成长'"
|
||||
@click="
|
||||
timelineState === 'error'
|
||||
? (timelineState = 'ready')
|
||||
: recordGrowth()
|
||||
"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="成长日志"
|
||||
title="记录一个成长瞬间"
|
||||
confirm-text="保存记录"
|
||||
title="填写一份成长预览"
|
||||
confirm-text="生成预览"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="saveGrowth"
|
||||
@cancel="dialogVisible = false"
|
||||
@confirm="createGrowthPreview"
|
||||
@cancel="requestCloseEditor"
|
||||
>
|
||||
<view class="dialog-form">
|
||||
<input v-model="growthForm.title" placeholder="事件名称" />
|
||||
<input v-model="growthForm.date" placeholder="日期" />
|
||||
<input v-model="growthForm.recordTitle" placeholder="记录标题" />
|
||||
<input v-model="growthForm.recordDate" placeholder="日期(选填)" />
|
||||
<textarea
|
||||
v-model="growthForm.description"
|
||||
v-model="growthForm.recordContent"
|
||||
auto-height
|
||||
placeholder="写下当时的故事"
|
||||
placeholder="写下当时的故事(选填)"
|
||||
/>
|
||||
<text v-if="formError">{{ formError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppToast :visible="toastVisible" message="成长记录已保存" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃当前填写?"
|
||||
message="尚未提交的预览内容将从当前页面清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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";
|
||||
const personName = ref("汤小满");
|
||||
const growthRecords = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: "第一次叫爸爸",
|
||||
date: "2024 年 3 月",
|
||||
description: "家人共同听见了这声清晰的呼唤。",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "入园第一天",
|
||||
date: "2024 年 9 月",
|
||||
description: "背着小书包,勇敢地向家人挥手。",
|
||||
},
|
||||
]);
|
||||
import {
|
||||
findTreeMemberPresentationFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
listGrowthRecordFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const personId = ref("");
|
||||
const memberRecord = ref(null);
|
||||
const growthRecords = ref([]);
|
||||
const timelineState = ref("loading");
|
||||
const dialogVisible = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const formError = ref("");
|
||||
const growthForm = reactive({ title: "", date: "", description: "" });
|
||||
const localGrowthPreview = ref(null);
|
||||
const growthForm = reactive({
|
||||
recordTitle: "",
|
||||
recordDate: "",
|
||||
recordContent: "",
|
||||
});
|
||||
const editorBaseline = ref("");
|
||||
let timer = null;
|
||||
const stateClasses = computed(() => ({
|
||||
"timeline-state--loading": timelineState.value === "loading",
|
||||
"timeline-state--empty": timelineState.value === "empty",
|
||||
"timeline-state--error": timelineState.value === "error",
|
||||
"timeline-state--privacy": timelineState.value === "privacy",
|
||||
"timeline-state--invalid": timelineState.value === "invalid",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
personName.value = String(q.personName || "汤小满");
|
||||
timelineState.value = ["loading", "empty", "error"].includes(q.state)
|
||||
? q.state
|
||||
: "ready";
|
||||
const hasValidContext = computed(() =>
|
||||
["ready", "empty"].includes(timelineState.value),
|
||||
);
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...growthForm }));
|
||||
const growthDraftDirty = computed(() =>
|
||||
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
|
||||
);
|
||||
const stateCopy = computed(() => ({
|
||||
error: {
|
||||
title: "成长日志暂不可用",
|
||||
copy: "请稍后重新查看,已有记录不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
privacy: {
|
||||
title: "成长日志未公开",
|
||||
copy: "当前人物资料受隐私设置保护,页面不会展示或填写成长记录。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
invalid: {
|
||||
title: "成长日志入口无效",
|
||||
copy: "人物不存在、缺少身份或不属于当前家谱。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
empty: {
|
||||
title: "还没有成长记录",
|
||||
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
|
||||
action: "记录成长预览",
|
||||
},
|
||||
})[timelineState.value] || {
|
||||
title: "成长日志暂不可用",
|
||||
copy: "请返回上一页重新进入。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
const recordGrowth = () => {
|
||||
Object.assign(growthForm, { title: "", date: "", description: "" });
|
||||
formError.value = "";
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const saveGrowth = () => {
|
||||
if (!growthForm.title.trim() || !growthForm.date.trim()) {
|
||||
formError.value = "请填写事件名称和日期";
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
personId.value = String(query.personId || "");
|
||||
if (query.state === "loading") return;
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
memberRecord.value = findTreeMemberPresentationFixture(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
);
|
||||
if (
|
||||
!["owner", "member"].includes(access.accessRole) ||
|
||||
!memberRecord.value
|
||||
) {
|
||||
timelineState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
growthRecords.value.unshift({ id: Date.now(), ...growthForm });
|
||||
timelineState.value = "ready";
|
||||
if (["privacy", "forbidden"].includes(memberRecord.value.status)) {
|
||||
timelineState.value = "privacy";
|
||||
return;
|
||||
}
|
||||
growthRecords.value = listGrowthRecordFixtures(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
);
|
||||
timelineState.value = ["empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: growthRecords.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
const recordGrowth = () => {
|
||||
if (!hasValidContext.value) return false;
|
||||
Object.assign(growthForm, {
|
||||
recordTitle: "",
|
||||
recordDate: "",
|
||||
recordContent: "",
|
||||
});
|
||||
formError.value = "";
|
||||
editorBaseline.value = formSnapshot.value;
|
||||
dialogVisible.value = true;
|
||||
return true;
|
||||
};
|
||||
const createGrowthPreview = () => {
|
||||
formError.value = growthForm.recordTitle.trim() ? "" : "请填写记录标题";
|
||||
if (formError.value) return false;
|
||||
// 预览与正式列表分离:这里不生成服务端 ID,也不改写只读夹具。
|
||||
localGrowthPreview.value = Object.freeze({
|
||||
recordTitle: growthForm.recordTitle.trim(),
|
||||
recordDate: growthForm.recordDate.trim(),
|
||||
recordContent: growthForm.recordContent.trim(),
|
||||
});
|
||||
dialogVisible.value = false;
|
||||
toastVisible.value = true;
|
||||
timer = setTimeout(() => (toastVisible.value = false), 1800);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
timer = null;
|
||||
}, 1800);
|
||||
return true;
|
||||
};
|
||||
const closeEditor = () => {
|
||||
dialogVisible.value = false;
|
||||
formError.value = "";
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation(
|
||||
(visible) => { discardVisible.value = visible; },
|
||||
);
|
||||
const confirmDiscard = () => {
|
||||
discardConfirmation.confirm();
|
||||
closeEditor();
|
||||
};
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const requestCloseEditor = async () => {
|
||||
if (!growthDraftDirty.value) {
|
||||
closeEditor();
|
||||
return true;
|
||||
}
|
||||
const confirmed = await discardConfirmation.request();
|
||||
if (confirmed) closeEditor();
|
||||
return confirmed;
|
||||
};
|
||||
const restoreGrowthRecords = () => {
|
||||
growthRecords.value = listGrowthRecordFixtures(
|
||||
genealogyId.value,
|
||||
personId.value,
|
||||
);
|
||||
timelineState.value = growthRecords.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (["invalid", "privacy"].includes(timelineState.value)) return goBack();
|
||||
if (timelineState.value === "error") return restoreGrowthRecords();
|
||||
return recordGrowth();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (discardVisible.value) {
|
||||
cancelDiscard();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (dialogVisible.value) return requestCloseEditor();
|
||||
return runBackGuard({
|
||||
dirty: Boolean(localGrowthPreview.value),
|
||||
"confirm-discard": discardConfirmation.request,
|
||||
});
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@@ -180,12 +315,14 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-card,
|
||||
.preview-card,
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
padding: 34rpx 46rpx;
|
||||
@include adaptive.adaptive-records-content;
|
||||
}
|
||||
.timeline-card > text,
|
||||
.preview-card > text,
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
@@ -210,6 +347,23 @@ onUnmounted(() => {
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.preview-card > text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.preview-card > text:nth-child(2) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.preview-card > text:nth-child(n + 3) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding-top: 78rpx;
|
||||
|
||||
@@ -1,155 +1,72 @@
|
||||
<!-- 页面编号:R-09;用途:人物人生事时间轴与同页新增。 -->
|
||||
<!-- 页面编号:R-09;用途:校验人物身份并明确关闭缺失的线上服务。 -->
|
||||
<template>
|
||||
<view class="timeline-page" :class="stateClasses">
|
||||
<view class="service-page" :class="`service-state--${serviceState}`">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="人生事" action="新增" @action="createLifeEvent" />
|
||||
<PageHeader title="人生事" custom-back @back="requestBack" />
|
||||
</view>
|
||||
<view v-if="timelineState === 'loading'" class="page-loading">
|
||||
<view v-if="serviceState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取人生事"
|
||||
:description="`请稍候,正在整理${personName}的重要节点。`"
|
||||
text="正在核对人物身份"
|
||||
description="请稍候,页面正在确认当前家谱与人物。"
|
||||
/>
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view class="person-lead">
|
||||
<text>{{ personName }}</text>
|
||||
<text>值得回望的人生节点</text>
|
||||
</view>
|
||||
<template v-if="timelineState === 'ready' && lifeEvents.length">
|
||||
<view v-for="event in lifeEvents" :key="event.id" class="timeline-card">
|
||||
<text>{{ event.year }}</text>
|
||||
<text>{{ event.title }}</text>
|
||||
<text>{{ event.place }}</text>
|
||||
<text>{{ event.description }}</text>
|
||||
</view>
|
||||
<AppButton block label="新增人生事" @click="createLifeEvent" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{ timelineState === "error" ? "人生事暂不可用" : "还没有人生事" }}
|
||||
<view class="state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text v-if="personRecord && serviceState === 'unavailable'" class="person-name">
|
||||
当前人物:{{ personRecord.name }}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
timelineState === "error"
|
||||
? "请稍后重新查看,已有记录不会受到影响。"
|
||||
: "从毕业、成家或重要迁居开始记录。"
|
||||
}}
|
||||
</text>
|
||||
<AppButton
|
||||
:type="timelineState === 'error' ? 'secondary' : 'primary'"
|
||||
block
|
||||
:label="timelineState === 'error' ? '重新查看' : '新增人生事'"
|
||||
@click="
|
||||
timelineState === 'error'
|
||||
? (timelineState = 'ready')
|
||||
: createLifeEvent()
|
||||
"
|
||||
/>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton type="secondary" block label="返回上一页" @click="requestBack" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
eyebrow="人生事"
|
||||
title="记录一个人生节点"
|
||||
confirm-text="保存记录"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="saveLifeEvent"
|
||||
@cancel="dialogVisible = false"
|
||||
>
|
||||
<view class="dialog-form">
|
||||
<input v-model="lifeEventForm.title" placeholder="事件名称" />
|
||||
<input v-model="lifeEventForm.year" placeholder="年份或日期" />
|
||||
<input v-model="lifeEventForm.place" placeholder="地点(选填)" />
|
||||
<textarea
|
||||
v-model="lifeEventForm.description"
|
||||
auto-height
|
||||
placeholder="写下这段经历"
|
||||
/>
|
||||
<text v-if="formError">{{ formError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppToast :visible="toastVisible" message="人生事已保存" />
|
||||
</view>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import { computed, 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";
|
||||
const personName = ref("汤文清");
|
||||
const lifeEvents = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: "大学毕业",
|
||||
year: "2018 年 6 月",
|
||||
place: "杭州",
|
||||
description: "完成学业,带着家人的祝福走向新的生活。",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "结为连理",
|
||||
year: "2022 年 10 月",
|
||||
place: "汤氏祖居",
|
||||
description: "在家人见证下组成新的家庭。",
|
||||
},
|
||||
]);
|
||||
const timelineState = ref("loading");
|
||||
const dialogVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const formError = ref("");
|
||||
const lifeEventForm = reactive({
|
||||
title: "",
|
||||
year: "",
|
||||
place: "",
|
||||
description: "",
|
||||
});
|
||||
let timer = null;
|
||||
const stateClasses = computed(() => ({
|
||||
"timeline-state--loading": timelineState.value === "loading",
|
||||
"timeline-state--empty": timelineState.value === "empty",
|
||||
"timeline-state--error": timelineState.value === "error",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
personName.value = String(q.personName || "汤文清");
|
||||
timelineState.value = ["loading", "empty", "error"].includes(q.state)
|
||||
? q.state
|
||||
: "ready";
|
||||
});
|
||||
const createLifeEvent = () => {
|
||||
Object.assign(lifeEventForm, {
|
||||
title: "",
|
||||
year: "",
|
||||
place: "",
|
||||
description: "",
|
||||
});
|
||||
formError.value = "";
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const saveLifeEvent = () => {
|
||||
if (!lifeEventForm.title.trim() || !lifeEventForm.year.trim()) {
|
||||
formError.value = "请填写事件名称和年份";
|
||||
return;
|
||||
}
|
||||
lifeEvents.value.unshift({ id: Date.now(), ...lifeEventForm });
|
||||
timelineState.value = "ready";
|
||||
dialogVisible.value = false;
|
||||
toastVisible.value = true;
|
||||
timer = setTimeout(() => (toastVisible.value = false), 1800);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
import {
|
||||
findTreeMemberPresentationFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, handleBackPress } from "@/utils/navigation.js";
|
||||
|
||||
const serviceState = ref("loading");
|
||||
const personRecord = ref(null);
|
||||
const stateCopy = computed(() =>
|
||||
serviceState.value === "unavailable"
|
||||
? {
|
||||
title: "人生事件接口尚未开放",
|
||||
copy: "线上接口文档没有独立的人生事件资源。为避免把其他记录类型冒充人生事,本页暂不展示或提交数据。",
|
||||
}
|
||||
: {
|
||||
title: "人生事入口无效",
|
||||
copy: "人物不存在、缺少身份或不属于当前家谱,页面不会回退到其他人物。",
|
||||
},
|
||||
);
|
||||
onLoad((query) => {
|
||||
const genealogyId = String(query.genealogyId || "");
|
||||
const personId = String(query.personId || "");
|
||||
if (query.state === "loading") return;
|
||||
const access = getGenealogyFixtureAccess(genealogyId);
|
||||
personRecord.value = findTreeMemberPresentationFixture(genealogyId, personId);
|
||||
serviceState.value =
|
||||
["owner", "member"].includes(access.accessRole) && personRecord.value
|
||||
? "unavailable"
|
||||
: "invalid";
|
||||
});
|
||||
const requestBack = () => goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.timeline-page {
|
||||
.service-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
@@ -164,97 +81,32 @@ onUnmounted(() => {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.person-lead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8rpx 18rpx;
|
||||
min-height: 62rpx;
|
||||
align-items: center;
|
||||
padding: 0 20rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
background: url("/static/assets/modules/genealogy/transparent/section-divider.png")
|
||||
center/100% auto no-repeat;
|
||||
}
|
||||
.person-lead text:first-child {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-card,
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
padding: 34rpx 46rpx;
|
||||
min-height: 380rpx;
|
||||
padding: 78rpx 52rpx 50rpx;
|
||||
@include adaptive.adaptive-records-content;
|
||||
}
|
||||
.timeline-card > text,
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.timeline-card > text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.timeline-card > text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.timeline-card > text:nth-child(3) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.timeline-card > text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding-top: 78rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card > text { display: block; }
|
||||
.state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card > text:nth-child(2) {
|
||||
.state-card > text:last-of-type {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.person-name {
|
||||
color: $brand-red;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.dialog-form {
|
||||
width: 100%;
|
||||
margin: 18rpx 0;
|
||||
}
|
||||
.dialog-form input,
|
||||
.dialog-form textarea {
|
||||
width: 100%;
|
||||
min-height: 66rpx;
|
||||
margin-top: 8rpx;
|
||||
padding: 12rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
font-size: 22rpx;
|
||||
@include adaptive.adaptive-records-field;
|
||||
}
|
||||
.dialog-form text {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
+191
-186
@@ -1,246 +1,251 @@
|
||||
<!-- 页面编号:R-10;用途:家族备忘列表、完成状态与同页新增。 -->
|
||||
<!-- 页面编号:R-10;用途:当前家谱备忘列表与不写库的本地预览。 -->
|
||||
<template>
|
||||
<view class="memo-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="家族备忘" action="新增" @action="createMemo" />
|
||||
</view>
|
||||
<view v-if="memoState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在读取家族备忘"
|
||||
description="请稍候,正在整理待办事项。"
|
||||
<PageHeader
|
||||
title="家族备忘"
|
||||
:action="hasValidContext ? '填写预览' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="startMemoPreview"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="memoState === 'loading'" class="page-loading">
|
||||
<AppLoading text="正在读取家族备忘" description="请稍候,正在核对当前家谱的备忘记录。" />
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view v-if="localMemoPreview" class="preview-card">
|
||||
<text>本地预览 · 尚未提交</text>
|
||||
<text>{{ localMemoPreview.memoTitle }}</text>
|
||||
<text>{{ localMemoPreview.remindTime || "提醒时间未填写" }}</text>
|
||||
<text>{{ localMemoPreview.memoContent || "内容未填写" }}</text>
|
||||
</view>
|
||||
<template v-if="memoState === 'ready' && memos.length">
|
||||
<view
|
||||
v-for="memo in memos"
|
||||
:key="memo.id"
|
||||
class="memo-card"
|
||||
:class="{ 'memo-card--done': memo.done }"
|
||||
@click="toggleMemo(memo)"
|
||||
>
|
||||
<view v-for="memo in memos" :key="memo.memoId" class="memo-card">
|
||||
<view>
|
||||
<text>{{ memo.done ? "已完成" : "待办理" }}</text>
|
||||
<text>{{ memo.due }}</text>
|
||||
<text>{{ memo.completedLabel }}</text>
|
||||
<text>{{ memo.remindTime || "未设置提醒" }}</text>
|
||||
</view>
|
||||
<text>{{ memo.title }}</text>
|
||||
<text>{{ memo.description }}</text>
|
||||
<text>{{ memo.done ? "点击恢复待办" : "点击标记完成" }}</text>
|
||||
<text>{{ memo.memoTitle }}</text>
|
||||
<text>{{ memo.memoContent || "暂无备忘内容" }}</text>
|
||||
<text>状态仅展示,线上切换接口尚未确认</text>
|
||||
</view>
|
||||
<AppButton block label="新增备忘" @click="createMemo" />
|
||||
<AppButton block label="填写备忘预览" @click="startMemoPreview" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{ memoState === "error" ? "家族备忘暂不可用" : "还没有备忘" }}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
memoState === "error"
|
||||
? "请稍后重新查看,已有备忘不会受到影响。"
|
||||
: "把需要家人共同记住的事情写在这里。"
|
||||
}}
|
||||
</text>
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
:type="memoState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="memoState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="memoState === 'error' ? '重新查看' : '新增备忘'"
|
||||
@click="memoState === 'error' ? (memoState = 'ready') : createMemo()"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="家族备忘"
|
||||
title="新增一项备忘"
|
||||
confirm-text="保存备忘"
|
||||
title="填写一份备忘预览"
|
||||
confirm-text="生成预览"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="saveMemo"
|
||||
@cancel="dialogVisible = false"
|
||||
@confirm="createMemoPreview"
|
||||
@cancel="requestCloseEditor"
|
||||
>
|
||||
<view class="dialog-form">
|
||||
<input v-model="memoForm.title" placeholder="备忘标题" />
|
||||
<input v-model="memoForm.due" placeholder="截止日期或时间" />
|
||||
<textarea
|
||||
v-model="memoForm.description"
|
||||
auto-height
|
||||
placeholder="补充具体事项"
|
||||
/>
|
||||
<input v-model="memoForm.memoTitle" placeholder="备忘标题" />
|
||||
<input v-model="memoForm.remindTime" placeholder="提醒时间(选填)" />
|
||||
<textarea v-model="memoForm.memoContent" auto-height placeholder="补充具体事项(选填)" />
|
||||
<text v-if="formError">{{ formError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppToast :visible="toastVisible" message="备忘已更新" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃当前填写?"
|
||||
message="尚未提交的预览内容将从当前页面清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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";
|
||||
const memos = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: "修谱资料整理",
|
||||
due: "本月底前",
|
||||
description: "补充老照片中的人物姓名和拍摄时间。",
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "重阳敬老活动",
|
||||
due: "10 月 11 日上午",
|
||||
description: "在祠堂集合,并确认接送长辈的车辆。",
|
||||
done: true,
|
||||
},
|
||||
]);
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listMemoFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const memos = ref([]);
|
||||
const memoState = ref("loading");
|
||||
const dialogVisible = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const formError = ref("");
|
||||
const memoForm = reactive({ title: "", due: "", description: "" });
|
||||
const localMemoPreview = ref(null);
|
||||
const memoForm = reactive({ memoTitle: "", remindTime: "", memoContent: "" });
|
||||
const editorBaseline = ref("");
|
||||
let timer = null;
|
||||
|
||||
const stateClasses = computed(() => ({
|
||||
"memo-state--loading": memoState.value === "loading",
|
||||
"memo-state--empty": memoState.value === "empty",
|
||||
"memo-state--error": memoState.value === "error",
|
||||
"memo-state--invalid": memoState.value === "invalid",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
memoState.value = ["loading", "empty", "error"].includes(q.state)
|
||||
? q.state
|
||||
: "ready";
|
||||
const hasValidContext = computed(() => ["ready", "empty"].includes(memoState.value));
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...memoForm }));
|
||||
const memoDraftDirty = computed(() =>
|
||||
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
|
||||
);
|
||||
const stateCopy = computed(() => ({
|
||||
error: {
|
||||
title: "家族备忘暂不可用",
|
||||
copy: "请稍后重新查看,已有备忘不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
invalid: {
|
||||
title: "家族备忘入口无效",
|
||||
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱备忘。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
empty: {
|
||||
title: "还没有备忘",
|
||||
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
|
||||
action: "填写备忘预览",
|
||||
},
|
||||
})[memoState.value] || {
|
||||
title: "家族备忘暂不可用",
|
||||
copy: "请返回上一页重新进入。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
const showToast = () => {
|
||||
toastVisible.value = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => (toastVisible.value = false), 1800);
|
||||
};
|
||||
const toggleMemo = (memo) => {
|
||||
memo.done = !memo.done;
|
||||
showToast();
|
||||
};
|
||||
const createMemo = () => {
|
||||
Object.assign(memoForm, { title: "", due: "", description: "" });
|
||||
formError.value = "";
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const saveMemo = () => {
|
||||
if (!memoForm.title.trim()) {
|
||||
formError.value = "请填写备忘标题";
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
memoState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
memos.value.unshift({ id: Date.now(), ...memoForm, done: false });
|
||||
memoState.value = "ready";
|
||||
dialogVisible.value = false;
|
||||
showToast();
|
||||
memos.value = listMemoFixtures(genealogyId.value);
|
||||
memoState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: memos.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
|
||||
const startMemoPreview = () => {
|
||||
if (!hasValidContext.value) return false;
|
||||
Object.assign(memoForm, { memoTitle: "", remindTime: "", memoContent: "" });
|
||||
formError.value = "";
|
||||
editorBaseline.value = formSnapshot.value;
|
||||
dialogVisible.value = true;
|
||||
return true;
|
||||
};
|
||||
const createMemoPreview = () => {
|
||||
formError.value = memoForm.memoTitle.trim() ? "" : "请填写备忘标题";
|
||||
if (formError.value) return false;
|
||||
// 正式列表来自只读选择器;预览不生成 ID,也不会改变完成状态或记录数量。
|
||||
localMemoPreview.value = Object.freeze({
|
||||
memoTitle: memoForm.memoTitle.trim(),
|
||||
remindTime: memoForm.remindTime.trim(),
|
||||
memoContent: memoForm.memoContent.trim(),
|
||||
});
|
||||
dialogVisible.value = false;
|
||||
toastVisible.value = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
timer = null;
|
||||
}, 1800);
|
||||
return true;
|
||||
};
|
||||
const closeEditor = () => {
|
||||
dialogVisible.value = false;
|
||||
formError.value = "";
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation(
|
||||
(visible) => { discardVisible.value = visible; },
|
||||
);
|
||||
const confirmDiscard = () => {
|
||||
discardConfirmation.confirm();
|
||||
closeEditor();
|
||||
};
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const requestCloseEditor = async () => {
|
||||
if (!memoDraftDirty.value) {
|
||||
closeEditor();
|
||||
return true;
|
||||
}
|
||||
const confirmed = await discardConfirmation.request();
|
||||
if (confirmed) closeEditor();
|
||||
return confirmed;
|
||||
};
|
||||
const restoreMemos = () => {
|
||||
memos.value = listMemoFixtures(genealogyId.value);
|
||||
memoState.value = memos.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (memoState.value === "invalid") return goBack();
|
||||
if (memoState.value === "error") return restoreMemos();
|
||||
return startMemoPreview();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (discardVisible.value) {
|
||||
cancelDiscard();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (dialogVisible.value) return requestCloseEditor();
|
||||
return runBackGuard({
|
||||
dirty: Boolean(localMemoPreview.value),
|
||||
"confirm-discard": discardConfirmation.request,
|
||||
});
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.memo-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-loading,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-loading {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.memo-card,
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
padding: 34rpx 46rpx;
|
||||
@include adaptive.adaptive-records-content;
|
||||
}
|
||||
.memo-card > view {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 6rpx 18rpx;
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.memo-card > text,
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.memo-card > text:nth-child(2) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.memo-card > text:nth-child(3) {
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.memo-card > text:last-child {
|
||||
margin-top: 10rpx;
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.memo-card--done {
|
||||
opacity: 0.68;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding-top: 78rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.dialog-form {
|
||||
width: 100%;
|
||||
margin: 18rpx 0;
|
||||
}
|
||||
.dialog-form input,
|
||||
.dialog-form textarea {
|
||||
width: 100%;
|
||||
min-height: 68rpx;
|
||||
margin-top: 9rpx;
|
||||
padding: 13rpx 20rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
@include adaptive.adaptive-records-field;
|
||||
}
|
||||
.dialog-form text {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.memo-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header,.page-loading,.page-content { z-index: 1; }
|
||||
.page-loading { min-height: calc(100vh - 100rpx); }
|
||||
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
|
||||
.memo-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
|
||||
.memo-card > view { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; color: $brand-red; font-size: 20rpx; }
|
||||
.memo-card > text,.preview-card > text,.state-card > text { display: block; }
|
||||
.memo-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 8rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
|
||||
.memo-card > text:nth-child(3),.preview-card > text:nth-child(n + 3) { margin-top: 9rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
|
||||
.memo-card > text:last-child,.preview-card > text:first-child { margin-top: 10rpx; color: $brand-red; font-size: 20rpx; }
|
||||
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
|
||||
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
|
||||
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.dialog-form { width: 100%; margin: 18rpx 0; }
|
||||
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 68rpx; margin-top: 9rpx; padding: 13rpx 20rpx; box-sizing: border-box; color: $ink; font-size: 23rpx; @include adaptive.adaptive-records-field; }
|
||||
.dialog-form text { display: block; margin-top: 8rpx; color: $brand-red; font-size: 20rpx; }
|
||||
</style>
|
||||
|
||||
+225
-203
@@ -1,266 +1,288 @@
|
||||
<!-- 页面编号:R-11;用途:功德记录、贡献汇总与同页新增。 -->
|
||||
<!-- 页面编号:R-11;用途:当前家谱功德记录与不写库的本地预览。 -->
|
||||
<template>
|
||||
<view class="merit-page" :class="stateClasses">
|
||||
<ModulePageBackground module="records" />
|
||||
<view class="page-header">
|
||||
<PageHeader title="功德记录" action="新增" @action="createMerit" />
|
||||
</view>
|
||||
<view v-if="meritState === 'loading'" class="page-loading">
|
||||
<AppLoading
|
||||
text="正在整理功德记录"
|
||||
description="请稍候,正在读取家人对家族事务的支持。"
|
||||
<PageHeader
|
||||
title="功德记录"
|
||||
:action="hasValidContext ? '填写预览' : ''"
|
||||
custom-back
|
||||
@back="requestBack"
|
||||
@action="startMeritPreview"
|
||||
/>
|
||||
</view>
|
||||
<view v-if="meritState === 'loading'" class="page-loading">
|
||||
<AppLoading text="正在整理功德记录" description="请稍候,正在核对当前家谱的正式记录。" />
|
||||
</view>
|
||||
<view v-else class="page-content">
|
||||
<view v-if="meritState === 'ready'" class="merit-summary">
|
||||
<text>共同贡献</text>
|
||||
<text>{{ totalContribution }} 次</text>
|
||||
<text>每一次时间、物资与心力的付出都值得被记住</text>
|
||||
<view v-if="hasValidContext" class="merit-summary">
|
||||
<text>正式记录</text>
|
||||
<text>{{ totalContribution }} 条</text>
|
||||
<text>本地预览不计入正式记录数量</text>
|
||||
</view>
|
||||
<view v-if="localMeritPreview" class="preview-card">
|
||||
<text>本地预览 · 尚未提交</text>
|
||||
<text>{{ localMeritPreview.meritTitle }}</text>
|
||||
<text>{{ localMeritPreview.donorName }} · {{ localMeritPreview.meritTime || "时间未填写" }}</text>
|
||||
<text>{{ localMeritPreview.meritType || "类型未填写" }}</text>
|
||||
<text>金额数值(单位待确认):{{ formatMeritAmount(localMeritPreview.amount) }}</text>
|
||||
<text>{{ localMeritPreview.meritContent || "内容未填写" }}</text>
|
||||
</view>
|
||||
<template v-if="meritState === 'ready' && meritRecords.length">
|
||||
<view v-for="merit in meritRecords" :key="merit.id" class="merit-card">
|
||||
<text>{{ merit.category }}</text>
|
||||
<text>{{ merit.title }}</text>
|
||||
<text>{{ merit.contributor }} · {{ merit.date }}</text>
|
||||
<text>{{ merit.description }}</text>
|
||||
<view v-for="merit in meritRecords" :key="merit.meritId" class="merit-card">
|
||||
<text>{{ merit.meritTypeLabel || "类型未标注" }}</text>
|
||||
<text>{{ merit.meritTitle }}</text>
|
||||
<text>{{ merit.donorName }} · {{ merit.meritTime || "时间未填写" }}</text>
|
||||
<text>{{ merit.meritContent || "暂无记录内容" }}</text>
|
||||
</view>
|
||||
<AppButton block label="新增功德记录" @click="createMerit" />
|
||||
<AppButton block label="填写功德预览" @click="startMeritPreview" />
|
||||
</template>
|
||||
<view v-else class="state-card">
|
||||
<text>
|
||||
{{ meritState === "error" ? "功德记录暂不可用" : "还没有功德记录" }}
|
||||
</text>
|
||||
<text>
|
||||
{{
|
||||
meritState === "error"
|
||||
? "请稍后重新查看,已有记录不会受到影响。"
|
||||
: "记录第一份对家族事务的时间、物资或心力支持。"
|
||||
}}
|
||||
</text>
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton
|
||||
:type="meritState === 'error' ? 'secondary' : 'primary'"
|
||||
:type="meritState === 'empty' ? 'primary' : 'secondary'"
|
||||
block
|
||||
:label="meritState === 'error' ? '重新查看' : '新增记录'"
|
||||
@click="
|
||||
meritState === 'error' ? (meritState = 'ready') : createMerit()
|
||||
"
|
||||
:label="stateCopy.action"
|
||||
@click="handleStateAction"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="dialogVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="功德记录"
|
||||
title="记下一份家族贡献"
|
||||
confirm-text="保存记录"
|
||||
title="填写一份功德预览"
|
||||
confirm-text="生成预览"
|
||||
cancel-text="取消"
|
||||
show-cancel
|
||||
@confirm="saveMerit"
|
||||
@cancel="dialogVisible = false"
|
||||
@confirm="createMeritPreview"
|
||||
@cancel="requestCloseEditor"
|
||||
>
|
||||
<view class="dialog-form">
|
||||
<input v-model="meritForm.title" placeholder="贡献事项" />
|
||||
<input v-model="meritForm.contributor" placeholder="贡献人" />
|
||||
<input v-model="meritForm.date" placeholder="日期" />
|
||||
<textarea
|
||||
v-model="meritForm.description"
|
||||
auto-height
|
||||
placeholder="说明时间、物资或具体帮助"
|
||||
/>
|
||||
<input v-model="meritForm.meritTitle" placeholder="贡献事项" />
|
||||
<input v-model="meritForm.donorName" placeholder="贡献人" />
|
||||
<input v-model="meritForm.meritType" placeholder="贡献类型(选填)" />
|
||||
<input v-model="meritForm.meritTime" placeholder="时间(选填)" />
|
||||
<input v-model="meritForm.amount" type="digit" placeholder="金额数值(单位未明确,可不填)" />
|
||||
<textarea v-model="meritForm.meritContent" auto-height placeholder="说明时间、物资或具体帮助(选填)" />
|
||||
<text v-if="formError">{{ formError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppToast :visible="toastVisible" message="功德记录已保存" />
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
:close-on-mask="false"
|
||||
eyebrow="放弃确认"
|
||||
title="放弃当前填写?"
|
||||
message="尚未提交的预览内容将从当前页面清除。"
|
||||
confirm-text="确认放弃"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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";
|
||||
const meritRecords = ref([
|
||||
{
|
||||
id: 1,
|
||||
category: "共同修缮",
|
||||
title: "修缮祠堂",
|
||||
contributor: "汤氏家人共同参与",
|
||||
date: "2024 年春",
|
||||
description: "协助整理院落、修补门窗并登记旧物。",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "奖学助学",
|
||||
title: "支持后辈勤学",
|
||||
contributor: "家族教育小组",
|
||||
date: "2024 年夏",
|
||||
description: "为家族中努力求学的孩子提供书籍与经验分享。",
|
||||
},
|
||||
]);
|
||||
const totalContribution = computed(() => meritRecords.value.length);
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listMeritRecordFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const meritRecords = ref([]);
|
||||
const meritState = ref("loading");
|
||||
const dialogVisible = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const formError = ref("");
|
||||
const localMeritPreview = ref(null);
|
||||
const meritForm = reactive({
|
||||
title: "",
|
||||
contributor: "",
|
||||
date: "",
|
||||
description: "",
|
||||
category: "家族贡献",
|
||||
meritTitle: "",
|
||||
donorName: "",
|
||||
meritType: "",
|
||||
meritTime: "",
|
||||
amount: "",
|
||||
meritContent: "",
|
||||
});
|
||||
const editorBaseline = ref("");
|
||||
let timer = null;
|
||||
|
||||
const totalContribution = computed(() => meritRecords.value.length);
|
||||
const stateClasses = computed(() => ({
|
||||
"merit-state--loading": meritState.value === "loading",
|
||||
"merit-state--empty": meritState.value === "empty",
|
||||
"merit-state--error": meritState.value === "error",
|
||||
"merit-state--invalid": meritState.value === "invalid",
|
||||
}));
|
||||
onLoad((q) => {
|
||||
meritState.value = ["loading", "empty", "error"].includes(q.state)
|
||||
? q.state
|
||||
: "ready";
|
||||
const hasValidContext = computed(() => ["ready", "empty"].includes(meritState.value));
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...meritForm }));
|
||||
const meritDraftDirty = computed(() =>
|
||||
dialogVisible.value && formSnapshot.value !== editorBaseline.value,
|
||||
);
|
||||
const stateCopy = computed(() => ({
|
||||
error: {
|
||||
title: "功德记录暂不可用",
|
||||
copy: "请稍后重新查看,已有记录不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
invalid: {
|
||||
title: "功德记录入口无效",
|
||||
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱记录。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
empty: {
|
||||
title: "还没有功德记录",
|
||||
copy: "可以先生成一份本地预览;正式创建仍需等待线上写接口启用。",
|
||||
action: "填写功德预览",
|
||||
},
|
||||
})[meritState.value] || {
|
||||
title: "功德记录暂不可用",
|
||||
copy: "请返回上一页重新进入。",
|
||||
action: "返回上一页",
|
||||
});
|
||||
const createMerit = () => {
|
||||
Object.assign(meritForm, {
|
||||
title: "",
|
||||
contributor: "",
|
||||
date: "",
|
||||
description: "",
|
||||
category: "家族贡献",
|
||||
});
|
||||
formError.value = "";
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const saveMerit = () => {
|
||||
if (!meritForm.title.trim() || !meritForm.contributor.trim()) {
|
||||
formError.value = "请填写贡献事项和贡献人";
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
const access = getGenealogyFixtureAccess(genealogyId.value);
|
||||
if (!["owner", "member"].includes(access.accessRole)) {
|
||||
meritState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
meritRecords.value.unshift({ id: Date.now(), ...meritForm });
|
||||
meritState.value = "ready";
|
||||
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
|
||||
meritState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: meritRecords.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
});
|
||||
|
||||
const startMeritPreview = () => {
|
||||
if (!hasValidContext.value) return false;
|
||||
Object.assign(meritForm, {
|
||||
meritTitle: "",
|
||||
donorName: "",
|
||||
meritType: "",
|
||||
meritTime: "",
|
||||
amount: "",
|
||||
meritContent: "",
|
||||
});
|
||||
formError.value = "";
|
||||
editorBaseline.value = formSnapshot.value;
|
||||
dialogVisible.value = true;
|
||||
return true;
|
||||
};
|
||||
const formatMeritAmount = (amount) =>
|
||||
amount === null || amount === "" ? "未填写" : String(amount);
|
||||
const createMeritPreview = () => {
|
||||
const missing = [];
|
||||
if (!meritForm.meritTitle.trim()) missing.push("贡献事项");
|
||||
if (!meritForm.donorName.trim()) missing.push("贡献人");
|
||||
formError.value = missing.length ? `请填写${missing.join("和")}` : "";
|
||||
const amountInput = meritForm.amount.trim();
|
||||
if (!formError.value && amountInput && !Number.isFinite(Number(amountInput))) {
|
||||
formError.value = "金额必须是数字,单位仍待后端确认";
|
||||
}
|
||||
if (formError.value) return false;
|
||||
// 金额的单位、精度和取值规则尚未由后端明确,本地预览只保留原始输入。
|
||||
localMeritPreview.value = Object.freeze({
|
||||
meritTitle: meritForm.meritTitle.trim(),
|
||||
donorName: meritForm.donorName.trim(),
|
||||
meritType: meritForm.meritType.trim(),
|
||||
meritTime: meritForm.meritTime.trim(),
|
||||
amount: amountInput ? Number(amountInput) : null,
|
||||
meritContent: meritForm.meritContent.trim(),
|
||||
});
|
||||
dialogVisible.value = false;
|
||||
toastVisible.value = true;
|
||||
timer = setTimeout(() => (toastVisible.value = false), 1800);
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
timer = null;
|
||||
}, 1800);
|
||||
return true;
|
||||
};
|
||||
const closeEditor = () => {
|
||||
dialogVisible.value = false;
|
||||
formError.value = "";
|
||||
};
|
||||
const discardConfirmation = createDiscardConfirmation(
|
||||
(visible) => { discardVisible.value = visible; },
|
||||
);
|
||||
const confirmDiscard = () => {
|
||||
discardConfirmation.confirm();
|
||||
closeEditor();
|
||||
};
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
const requestCloseEditor = async () => {
|
||||
if (!meritDraftDirty.value) {
|
||||
closeEditor();
|
||||
return true;
|
||||
}
|
||||
const confirmed = await discardConfirmation.request();
|
||||
if (confirmed) closeEditor();
|
||||
return confirmed;
|
||||
};
|
||||
const restoreMeritRecords = () => {
|
||||
meritRecords.value = listMeritRecordFixtures(genealogyId.value);
|
||||
meritState.value = meritRecords.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (meritState.value === "invalid") return goBack();
|
||||
if (meritState.value === "error") return restoreMeritRecords();
|
||||
return startMeritPreview();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (discardVisible.value) {
|
||||
cancelDiscard();
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
if (dialogVisible.value) return requestCloseEditor();
|
||||
return runBackGuard({
|
||||
dirty: Boolean(localMeritPreview.value),
|
||||
"confirm-discard": discardConfirmation.request,
|
||||
});
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.merit-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.page-header,
|
||||
.page-loading,
|
||||
.page-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.page-loading {
|
||||
min-height: calc(100vh - 100rpx);
|
||||
}
|
||||
.page-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 18rpx 24rpx 72rpx;
|
||||
}
|
||||
.merit-summary,
|
||||
.merit-card,
|
||||
.state-card {
|
||||
box-sizing: border-box;
|
||||
padding: 34rpx 46rpx;
|
||||
@include adaptive.adaptive-records-content;
|
||||
}
|
||||
.merit-summary {
|
||||
text-align: center;
|
||||
}
|
||||
.merit-summary > text,
|
||||
.merit-card > text,
|
||||
.state-card > text {
|
||||
display: block;
|
||||
}
|
||||
.merit-summary > text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.merit-summary > text:nth-child(2) {
|
||||
margin-top: 5rpx;
|
||||
color: $ink;
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.merit-summary > text:last-child {
|
||||
margin-top: 9rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.merit-card > text:first-child {
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.merit-card > text:nth-child(2) {
|
||||
margin-top: 6rpx;
|
||||
color: $ink;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.merit-card > text:nth-child(3) {
|
||||
margin-top: 8rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.merit-card > text:last-child {
|
||||
margin-top: 9rpx;
|
||||
color: $ink;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.state-card {
|
||||
min-height: 340rpx;
|
||||
padding-top: 78rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.state-card > text:first-child {
|
||||
color: $ink;
|
||||
font-size: 35rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.state-card > text:nth-child(2) {
|
||||
margin-top: 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.65;
|
||||
}
|
||||
.state-card .app-button {
|
||||
margin-top: 28rpx;
|
||||
}
|
||||
.dialog-form {
|
||||
width: 100%;
|
||||
margin: 14rpx 0;
|
||||
}
|
||||
.dialog-form input,
|
||||
.dialog-form textarea {
|
||||
width: 100%;
|
||||
min-height: 62rpx;
|
||||
margin-top: 7rpx;
|
||||
padding: 11rpx 18rpx;
|
||||
box-sizing: border-box;
|
||||
color: $ink;
|
||||
font-size: 22rpx;
|
||||
@include adaptive.adaptive-records-field;
|
||||
}
|
||||
.dialog-form text {
|
||||
display: block;
|
||||
margin-top: 7rpx;
|
||||
color: $brand-red;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
.merit-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.page-header,.page-loading,.page-content { z-index: 1; }
|
||||
.page-loading { min-height: calc(100vh - 100rpx); }
|
||||
.page-content { display: flex; flex-direction: column; gap: 16rpx; padding: 18rpx 24rpx 72rpx; }
|
||||
.merit-summary,.merit-card,.preview-card,.state-card { box-sizing: border-box; padding: 34rpx 46rpx; @include adaptive.adaptive-records-content; }
|
||||
.merit-summary { text-align: center; }
|
||||
.merit-summary > text,.merit-card > text,.preview-card > text,.state-card > text { display: block; }
|
||||
.merit-summary > text:first-child,.merit-card > text:first-child,.preview-card > text:first-child { color: $brand-red; font-size: 21rpx; }
|
||||
.merit-summary > text:nth-child(2) { margin-top: 5rpx; color: $ink; font-size: 38rpx; font-weight: 700; }
|
||||
.merit-summary > text:last-child { margin-top: 9rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
|
||||
.merit-card > text:nth-child(2),.preview-card > text:nth-child(2) { margin-top: 6rpx; color: $ink; font-size: 31rpx; font-weight: 700; }
|
||||
.merit-card > text:nth-child(3),.preview-card > text:nth-child(3) { margin-top: 8rpx; color: $ink-muted; font-size: 21rpx; }
|
||||
.merit-card > text:last-child,.preview-card > text:last-child { margin-top: 9rpx; color: $ink; font-size: 23rpx; line-height: 1.55; }
|
||||
.state-card { min-height: 340rpx; padding-top: 78rpx; text-align: center; }
|
||||
.state-card > text:first-child { color: $ink; font-size: 35rpx; font-weight: 700; }
|
||||
.state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.state-card .app-button { margin-top: 28rpx; }
|
||||
.dialog-form { width: 100%; margin: 14rpx 0; }
|
||||
.dialog-form input,.dialog-form textarea { width: 100%; min-height: 62rpx; margin-top: 7rpx; padding: 11rpx 18rpx; box-sizing: border-box; color: $ink; font-size: 22rpx; @include adaptive.adaptive-records-field; }
|
||||
.dialog-form text { display: block; margin-top: 7rpx; color: $brand-red; font-size: 20rpx; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user