完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+67 -43
View File
@@ -4,6 +4,7 @@
class="people-page"
:class="{
'people-state--ready': peopleState === 'ready',
'people-state--loading': peopleState === 'loading',
'people-state--empty': peopleState === 'empty',
'people-state--error': peopleState === 'error',
}"
@@ -12,7 +13,12 @@
<view class="people-page__header"><PageHeader title="人物录" /></view>
<view class="people-content">
<template v-if="peopleState === 'ready'">
<AppLoading
v-if="peopleState === 'loading'"
text="正在读取人物录"
description="请稍候,正在读取当前家谱的人物资料。"
/>
<template v-else-if="peopleState === 'ready'">
<view class="people-search">
<input
v-model="keywordInput"
@@ -30,9 +36,9 @@
>
</view>
<view v-if="filteredPeople.length" class="people-list">
<view v-if="people.length" class="people-list">
<view
v-for="person in filteredPeople"
v-for="person in people"
:key="person.id"
class="person-card"
@click="openPerson(person)"
@@ -46,10 +52,12 @@
</view>
</view>
<AppButton
v-if="hasMore"
class="people-primary-action"
type="secondary"
block
label="填写人物预览"
@click="createPersonPreview"
:label="loadingMore ? '正在加载…' : '加载更多人物'"
@click="loadMore"
/>
</view>
@@ -85,9 +93,10 @@
}}</text>
</view>
<AppButton
v-if="peopleState !== 'empty'"
:type="peopleState === 'empty' ? 'primary' : 'secondary'"
block
:label="peopleState === 'error' ? '重新查看' : peopleState === 'invalid' ? '返回上一页' : '填写人物预览'"
:label="peopleState === 'error' ? '重新查看' : '返回上一页'"
@click="handleStateAction"
/>
</view>
@@ -96,15 +105,13 @@
</template>
<script setup>
import { onLoad } from "@dcloudio/uni-app";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
getGenealogyFixtureAccess,
listTreeMemberPresentationFixtures,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { goBack, openPage } from "@/utils/navigation.js";
const genealogyId = ref("");
@@ -112,17 +119,13 @@ const people = ref([]);
const peopleState = ref("ready");
const keywordInput = ref("");
const keyword = ref("");
const hasValidContext = computed(() => peopleState.value !== "invalid");
const filteredPeople = computed(() => {
const value = keyword.value.trim().toLowerCase();
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),
);
});
const total = ref(0);
const pageNum = ref(1);
const loadingMore = ref(false);
const peopleRequestController = createRequestController();
let loadSequence = 0;
const hasValidContext = computed(() => Boolean(genealogyId.value));
const hasMore = computed(() => people.value.length < total.value);
const applySearch = () => {
if (keyword.value) {
@@ -130,14 +133,42 @@ const applySearch = () => {
return;
}
keyword.value = keywordInput.value.trim();
pageNum.value = 1;
void loadPeople();
};
const clearSearch = () => {
keywordInput.value = "";
keyword.value = "";
pageNum.value = 1;
void loadPeople();
};
const restoreList = () => {
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = people.value.length ? "ready" : "empty";
const loadPeople = async ({ append = false } = {}) => {
if (!hasValidContext.value) return;
const activeLoad = ++loadSequence;
if (append) loadingMore.value = true;
else peopleState.value = "loading";
try {
const result = await appApi.getPersonPage(
genealogyId.value,
{ pageNum: pageNum.value, pageSize: 10, keyword: keyword.value },
{ requestController: peopleRequestController },
);
if (activeLoad !== loadSequence) return;
people.value = append ? [...people.value, ...result.rows] : result.rows;
total.value = result.total;
peopleState.value = people.value.length ? "ready" : "empty";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
if (!append) people.value = [];
peopleState.value = "error";
} finally {
if (activeLoad === loadSequence) loadingMore.value = false;
}
};
const loadMore = () => {
if (loadingMore.value || !hasMore.value) return;
pageNum.value += 1;
void loadPeople({ append: true });
};
const openPerson = (person) =>
hasValidContext.value
@@ -151,34 +182,27 @@ const openPerson = (person) =>
"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();
if (peopleState.value === "error") {
pageNum.value = 1;
return loadPeople();
}
return goBack();
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
if (!hasValidContext.value || query.state === "error") {
people.value = [];
peopleState.value = "invalid";
return;
}
people.value = listTreeMemberPresentationFixtures(genealogyId.value);
peopleState.value = ["empty", "error"].includes(query.state)
? query.state
: people.value.length
? "ready"
: "empty";
void loadPeople();
});
onUnload(() => {
loadSequence += 1;
peopleRequestController.abort();
});
</script>
+63 -232
View File
@@ -4,7 +4,7 @@
<ModulePageBackground module="records" />
<view class="person-detail-header">
<PageHeader
:title="personState === 'edit' ? (isCreateMode ? '人物预览' : '编辑预览') : '人物详情'"
title="人物详情"
custom-back
@back="requestBack"
/>
@@ -15,12 +15,12 @@
</view>
<view v-else class="person-detail-content">
<template v-if="['detail', 'edit', 'privacy', 'preview'].includes(personState)">
<template v-if="personState === 'detail'">
<view class="person-identity-card">
<view class="person-identity-card__copy">
<text class="person-identity-card__name">{{ person.name || "待填写姓名" }}</text>
<text class="person-identity-card__meta">{{ person.relation || "人物预览" }} · {{ person.generation || "—" }} </text>
<text class="person-identity-card__hint">{{ personState === "preview" ? "本地预览 · 未提交" : "人物录档案" }}</text>
<text class="person-identity-card__hint">人物录档案</text>
</view>
</view>
</template>
@@ -33,35 +33,9 @@
<AppButton type="secondary" block label="成长日志" @click="toGrowthJournal" />
<AppButton type="secondary" block label="人生事(待开放)" @click="toLifeEvents" />
</view>
<view class="person-edit-action" @click="enterEdit"><AppButton block label="制作编辑预览" /></view>
<view class="person-edit-action" @click="toMemberProfile"><AppButton block label="查看成员档案" /></view>
</template>
<template v-else-if="personState === 'edit'">
<view v-for="field in shortFields" :key="field.key" class="person-field">
<text class="person-field__label">{{ field.label }}</text>
<input v-model="draft[field.key]" :type="field.key === 'generation' ? 'number' : 'text'" :placeholder="`请输入${field.label}`" />
<text v-if="errors[field.key]" class="person-field-error">{{ errors[field.key] }}</text>
</view>
<view v-for="field in longFields" :key="field.key" class="person-long-field">
<text class="person-long-field__label">{{ field.label }}</text>
<textarea v-model="draft[field.key]" auto-height :placeholder="`请输入${field.label}`" />
</view>
<view class="person-edit-actions">
<view class="person-save-action" @click="savePerson"><AppButton block label="生成本地预览" /></view>
<view class="person-cancel-action" @click="cancelEdit"><AppButton type="secondary" block label="取消填写" /></view>
</view>
</template>
<view v-else-if="personState === 'preview'" class="person-state-card">
<view><text>本地预览尚未提交服务器</text><text>这份人物资料只存在于当前页面不会新增覆盖或刷新人物录</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else-if="personState === 'privacy'" class="person-state-card">
<view><text>部分资料未公开</text><text>人物小传与档案备注受隐私设置保护当前只展示公开身份</text></view>
<view class="person-state-action"><AppButton block label="返回人物录" @click="returnToPeople" /></view>
</view>
<view v-else class="person-state-card">
<view>
<text>{{ personState === 'expired' ? '人物档案已失效' : '人物档案暂不可用' }}</text>
@@ -71,173 +45,66 @@
</view>
</view>
<AppToast :visible="toastVisible" message="已生成本地预览,尚未保存" />
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } 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 { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
openPage,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const routeMode = ref("");
const personState = ref("loading");
const personRequestController = createRequestController();
let loadSequence = 0;
const person = reactive({
id: "",
name: "",
relation: "",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
const draft = reactive({
name: "",
generationName: "",
generation: "",
aliasName: "",
sex: "",
personStatus: "",
birthDate: "",
birthLunar: "",
birthplace: "",
deathDate: "",
deathLunar: "",
deathPlace: "",
burialPlace: "",
spouseNames: "",
biography: "",
remark: "",
});
const errors = reactive({ name: "", generation: "" });
const baseline = ref("");
const toastVisible = ref(false);
const discardVisible = ref(false);
let toastTimer = null;
const isCreateMode = computed(() => routeMode.value === "create");
const formSnapshot = computed(() => JSON.stringify({ ...draft }));
const isDirty = computed(() =>
personState.value === "preview" ||
(personState.value === "edit" && formSnapshot.value !== baseline.value),
);
const shortFields = [
{ key: "name", label: "姓名" },
{ key: "generationName", label: "字辈" },
{ key: "generation", label: "世代" },
];
const longFields = [
{ key: "biography", label: "人物小传" },
{ key: "remark", label: "档案备注" },
];
const detailSections = computed(() => [
{ title: "别名", copy: person.aliasName },
{ title: "字辈", copy: person.generationName },
{ title: "性别(字典值)", copy: person.sex },
{ title: "人物状态(字典值)", copy: person.personStatus },
{ title: "出生日期", copy: person.birthDate },
{ title: "出生农历", copy: person.birthLunar },
{ title: "出生地", copy: person.birthplace },
{ title: "逝世日期", copy: person.deathDate },
{ title: "逝世农历", copy: person.deathLunar },
{ title: "逝世地", copy: person.deathPlace },
{ title: "安葬地", copy: person.burialPlace },
{ title: "配偶", copy: person.spouseNames },
{ title: "人物小传", copy: person.biography },
{ title: "档案备注", copy: person.remark },
]);
const copyToDraft = () => {
Object.assign(draft, {
name: person.name,
generationName: person.generationName,
generation: String(person.generation || ""),
biography: person.biography,
remark: person.remark,
});
baseline.value = formSnapshot.value;
};
const clearErrors = () => Object.assign(errors, { name: "", generation: "" });
const enterEdit = () => {
if (personState.value !== "detail" || !person.id) return false;
copyToDraft();
clearErrors();
personState.value = "edit";
return true;
};
const showPreviewToast = () => {
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
toastVisible.value = false;
toastTimer = null;
}, 1800);
};
const validatePerson = () => {
errors.name = draft.name.trim() ? "" : "请填写姓名";
const generationInput = draft.generation.trim();
const generation = Number(generationInput);
errors.generation = !generationInput
? ""
: Number.isInteger(generation) && generation > 0
? ""
: "世代必须是正整数";
return !errors.name && !errors.generation;
};
const savePerson = () => {
clearErrors();
if (!validatePerson()) return false;
const localPersonPreview = {
name: draft.name.trim(),
generationName: draft.generationName.trim(),
generation: draft.generation.trim()
? String(Number(draft.generation))
: "",
biography: draft.biography.trim(),
remark: draft.remark.trim(),
};
Object.assign(person, localPersonPreview);
personState.value = "preview";
showPreviewToast();
return true;
};
const discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
);
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const requestDiscardConfirmation = discardConfirmation.request;
const cancelEdit = async () => {
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
if (isCreateMode.value) return goBack();
copyToDraft();
clearErrors();
personState.value = "detail";
return true;
};
const requestBack = () => {
if (discardVisible.value) {
cancelDiscard();
return Promise.resolve(true);
}
if (personState.value === "edit" && !isCreateMode.value) return cancelEdit();
return runBackGuard({
dirty: isDirty.value,
"confirm-discard": requestDiscardConfirmation,
});
};
const requestBack = () => goBack();
const returnToPeople = () =>
genealogyId.value
@@ -259,74 +126,45 @@ const toLifeEvents = () =>
"R02",
)
: Promise.resolve(false);
const toMemberProfile = () =>
person.id
? openPage(
"T03",
{ genealogyId: genealogyId.value, personId: person.id },
"R02",
)
: Promise.resolve(false);
const loadPerson = async () => {
const activeLoad = ++loadSequence;
personState.value = "loading";
try {
const result = await appApi.getPerson(genealogyId.value, personId.value, {
requestController: personRequestController,
});
if (activeLoad !== loadSequence) return;
Object.assign(person, result);
personState.value = "detail";
} catch (error) {
if (activeLoad !== loadSequence || isRequestCancelled(error)) return;
personState.value = "expired";
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
personId.value = String(query.personId || "");
routeMode.value = String(query.mode || "");
if (query.state === "loading") return;
const access = getGenealogyFixtureAccess(genealogyId.value);
const hasValidGenealogy = ["owner", "member"].includes(access.accessRole);
const isCreateContract = routeMode.value === "create" && !personId.value;
const isViewContract = routeMode.value === "view" && Boolean(personId.value);
if (!hasValidGenealogy || (!isCreateContract && !isViewContract)) {
if (query.mode !== "view" || !genealogyId.value || !personId.value || query.state === "error") {
personState.value = "error";
return;
}
if (isCreateContract) {
Object.assign(person, {
id: "",
name: "",
relation: "人物预览",
generationName: "",
generation: "",
biography: "",
remark: "",
status: "",
});
copyToDraft();
personState.value = "edit";
return;
}
const selected = findTreeMemberPresentationFixture(genealogyId.value, personId.value);
if (!selected) {
personState.value = "expired";
return;
}
Object.assign(person, {
id: selected.id,
name: selected.name,
relation: selected.relation,
generation: String(selected.generation),
status: selected.status,
});
if (["privacy", "forbidden"].includes(selected.status)) {
Object.assign(person, {
generationName: "",
biography: "",
remark: "",
});
personState.value = "privacy";
return;
}
Object.assign(person, {
generationName: selected.generationName || "",
biography: selected.summary || "",
remark: selected.note || "",
});
copyToDraft();
personState.value = ["expired", "error"].includes(query.state)
? query.state
: "detail";
void loadPerson();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
onUnload(() => {
loadSequence += 1;
personRequestController.abort();
});
</script>
@@ -344,19 +182,12 @@ onUnmounted(() => {
.person-identity-card__meta { margin-top: 8rpx; color: $ink-muted; font-size: 25rpx; font-weight: 600; }
.person-identity-card__hint { margin-top: 8rpx; color: #806a51; font-size: 21rpx; }
.person-archive-card { min-height: 154rpx; margin-top: 14rpx; padding: 32rpx 42rpx; box-sizing: border-box; }
.person-archive-card,.person-long-field,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card,.person-state-card { @include adaptive.adaptive-records-content; }
.person-archive-card text { display: block; }
.person-archive-card text:first-child,.person-long-field__label { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:first-child { color: $brand-red; font-size: 24rpx; font-weight: 700; }
.person-archive-card text:last-child { margin-top: 11rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
.person-edit-action { margin-top: 20rpx; }
.person-related-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14rpx; margin-top: 18rpx; }
.person-field { @include adaptive.adaptive-records-field; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 8rpx 24rpx; min-height: 92rpx; margin-top: 12rpx; padding: 18rpx 28rpx; }
.person-field__label { color: $ink; font-size: 23rpx; font-weight: 700; }
.person-field input { width: 100%; min-width: 0; min-height: 56rpx; color: $ink; font-size: 23rpx; text-align: right; }
.person-field-error { grid-column: 1 / -1; display: block; color: $brand-red; font-size: 21rpx; text-align: right; }
.person-long-field { display: flex; flex-direction: column; min-height: 210rpx; margin-top: 14rpx; padding: 28rpx 38rpx; box-sizing: border-box; }
.person-long-field textarea { width: 100%; min-height: 112rpx; margin-top: 14rpx; color: $ink; font-size: 23rpx; line-height: 1.5; }
.person-edit-actions { display: flex; flex-direction: column; gap: 14rpx; margin-top: 20rpx; }
.person-state-card { display: flex; flex-direction: column; min-height: 300rpx; margin-top: 26rpx; padding: 72rpx 54rpx 42rpx; box-sizing: border-box; text-align: center; }
.person-state-card text { display: block; }
.person-state-card text:first-child { color: $ink; font-family: STKaiti,KaiTi,serif; font-size: 34rpx; font-weight: 700; }
+29 -179
View File
@@ -1,200 +1,50 @@
<!-- 页面编号R-03用途贺礼簿列表空态失败与新增入口 -->
<!-- 页面编号R-03用途亲友往来入口列表 DTO 缺失时不展示 fixture 记录 -->
<template>
<view class="gift-page" :class="stateClasses">
<view class="gift-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="贺礼簿" :action="hasValidContext ? '填写预览' : ''" @action="createRelativePreview" />
</view>
<view v-if="giftState === 'loading'" class="page-loading">
<AppLoading
text="正在整理贺礼簿"
description="请稍候,正在读取家人的礼仪往来。"
/>
</view>
<view v-else class="page-content">
<template v-if="giftState === 'ready' && relativeRecords.length">
<view
v-for="record in relativeRecords"
:key="record.relativeId"
class="record-card"
role="button"
:aria-label="`查看${record.eventName}`"
@click="openRelative(record)"
>
<text class="record-card__tag">{{ record.relationName }}</text>
<text class="record-card__title">{{ record.eventName }}</text>
<text class="record-card__copy">
{{ record.relativeName }} · {{ record.eventTime }} · 金额记录{{ record.giftAmount }}
</text>
<text class="record-card__hint">查看往来记录</text>
</view>
<AppButton block label="填写往来预览" @click="createRelativePreview" />
</template>
<view v-else class="state-card">
<text>
{{ giftState === "error" ? "贺礼簿暂不可用" : giftState === "invalid" ? "贺礼簿入口无效" : "还没有往来记录" }}
</text>
<text>
{{
giftState === "error"
? "请稍后重新查看,已有记录不会受到影响。"
: giftState === "invalid"
? "没有找到可访问的成员家谱,页面不会展示其他家谱记录。"
: "从第一份家人之间的心意开始记录。"
}}
</text>
<AppButton
:type="giftState === 'empty' ? 'primary' : 'secondary'"
block
:label="giftState === 'error' ? '重新查看' : giftState === 'invalid' ? '返回上一页' : '填写往来预览'"
@click="handleStateAction"
/>
<view class="page-header"><PageHeader title="贺礼簿" :action="hasValidContext ? '新建' : ''" @action="createRelative" /></view>
<view class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
</template>
<script setup>
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";
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(() => ({
"relative-state--loading": giftState.value === "loading",
"relative-state--empty": giftState.value === "empty",
"relative-state--error": giftState.value === "error",
"relative-state--invalid": giftState.value === "invalid",
}));
const hasValidContext = ref(false);
const stateCopy = computed(() => hasValidContext.value
? { title: "贺礼簿待后端字段合同", copy: "往来列表没有声明记录 ID、关系、事项、时间、金额或备注字段;页面已停止展示本地记录。", action: "新建往来记录" }
: { title: "贺礼簿入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱记录。", action: "返回上一页" },
);
onLoad((query) => {
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
: relativeRecords.value.length
? "ready"
: "empty";
genealogyId.value = String(query?.genealogyId || "");
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
});
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();
};
const createRelative = () => hasValidContext.value
? openPage("R04", { genealogyId: genealogyId.value, mode: "create" }, "R03")
: Promise.resolve(false);
const handleStateAction = () => hasValidContext.value ? createRelative() : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-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;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 34rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card__tag {
color: $brand-red;
font-size: 21rpx;
font-weight: 700;
}
.record-card__title {
margin-top: 6rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.record-card__copy {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card__hint {
margin-top: 10rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
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;
}
.gift-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card > text { display: block; }
.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; }
</style>
+92 -314
View File
@@ -1,130 +1,47 @@
<!-- 页面编号R-04用途人情往来详情与不写库的新增编辑预览 -->
<!-- 页面编号R-04用途 Apifox 已声明字段创建亲友往来记录 -->
<template>
<view class="gift-editor-page" :class="editorClasses">
<view class="gift-editor-page" :class="`relative-editor-state--${editorState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="
mode === 'create'
? '往来预览'
: mode === 'view'
? '往来详情'
: '编辑预览'
"
:action="mode === 'view' && editorState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="enterEdit"
/>
</view>
<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">
<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' && 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="enterEdit" />
<AppButton type="secondary" block disabled label="删除暂未开放" />
</view>
<view v-else-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份人情往来预览" : "调整往来记录预览" }}
</text>
<view class="page-header"><PageHeader title="新建往来记录" custom-back @back="requestBack" /></view>
<view class="page-content">
<view v-if="editorState === 'form'" class="form-card">
<text>记录一份家人往来</text>
<text class="form-copy">页面只发送已声明且可映射的字段媒体需要 `mediaOssIds`没有上传 owner 时不提交</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<input
v-model="relativeForm[field.key]"
:type="field.key === 'giftAmount' ? 'digit' : 'text'"
:placeholder="`请输入${field.label}`"
/>
<text v-if="relativeErrors[field.key]">{{ relativeErrors[field.key] }}</text>
<textarea v-if="field.key === 'recordContent'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="submitError = ''" />
<input v-else v-model="form[field.key]" :type="field.key === 'giftAmount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="submitError = ''" />
</view>
<AppButton
block
:disabled="isSubmitting"
:label="isSubmitting ? '正在生成预览' : '生成本地预览'"
@click="saveRelative"
/>
<AppButton type="secondary" block label="取消填写" @click="requestBack" />
<text v-if="submitError" class="save-error">{{ submitError }}</text>
<AppButton block :disabled="isSubmitting" :label="isSubmitting ? '正在提交' : '提交往来记录'" @click="saveRelative" />
</view>
<view v-else class="state-card">
<text>往来记录不可用</text>
<text>记录不存在缺少身份或不属于当前家谱页面不会回退到其他记录</text>
<AppButton type="secondary" block label="返回贺礼簿" @click="returnToRelatives" />
<text>{{ resultCopy.title }}</text>
<text>{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
<AppDialog
:visible="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog :visible="discardVisible" :close-on-mask="false" eyebrow="放弃确认" title="放弃当前填写?" message="尚未提交的内容将从当前页面清除。" confirm-text="确认放弃" cancel-text="继续填写" show-cancel @confirm="confirmDiscard" @cancel="cancelDiscard" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findRelativeRecordFixture,
getGenealogyFixtureAccess,
} from "@/data/mock.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const relativeId = ref("");
const mode = ref("");
const editorState = ref("loading");
const editorState = ref("form");
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 submitError = ref("");
const form = reactive({ relativeName: "", relationName: "", eventName: "", eventTime: "", giftAmount: "", recordContent: "" });
const fields = [
{ key: "relativeName", label: "亲友姓名" },
{ key: "relationName", label: "关系称谓" },
@@ -133,221 +50,82 @@ const fields = [
{ key: "giftAmount", label: "礼金金额" },
{ key: "recordContent", label: "往来备注" },
];
const baseline = ref("");
let submitTimer = null;
const editorClasses = computed(() => ({
"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((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) => {
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 enterEdit = () => {
if (mode.value !== "view" || editorState.value !== "ready") return false;
mode.value = "edit";
baseline.value = formSnapshot.value;
return true;
};
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 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 discardConfirmation = createDiscardConfirmation(
(visible) => { discardVisible.value = visible; },
const requestController = createRequestController();
const isDirty = computed(() => Object.values(form).some((value) => String(value).trim()));
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const resultCopy = computed(() => editorState.value === "success"
? { title: "往来记录已提交服务端", copy: "服务端已返回成功信封。列表仍缺条目 DTO,返回后不会生成本地记录。", action: "返回贺礼簿" }
: { title: "往来记录入口无效", copy: "当前只有新建请求可映射;详情和修改缺可靠条目 DTO/记录 ID 来源,页面不从 fixture 进入。", action: "返回上一页" },
);
const discardConfirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const requestDiscardConfirmation = discardConfirmation.request;
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 (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
});
const saveRelative = async () => {
if (isSubmitting.value || !hasValidContext.value) return;
const relativeName = form.relativeName.trim();
const giftAmountText = form.giftAmount.trim();
if (!relativeName) {
submitError.value = "请填写亲友姓名";
return;
}
if (giftAmountText && !Number.isFinite(Number(giftAmountText))) {
submitError.value = "礼金金额必须是数字";
return;
}
isSubmitting.value = true;
submitError.value = "";
try {
await appApi.createRelativeRecord(genealogyId.value, {
relativeName,
relationName: form.relationName,
eventName: form.eventName,
eventTime: form.eventTime,
...(giftAmountText ? { giftAmount: Number(giftAmountText) } : {}),
recordContent: form.recordContent,
}, { requestController });
Object.keys(form).forEach((key) => { form[key] = ""; });
editorState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "往来记录提交失败,请稍后重试。";
} finally {
isSubmitting.value = false;
}
};
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const handleResultAction = () => editorState.value === "success"
? returnTo("R03", { genealogyId: genealogyId.value })
: goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { requestController.abort(); discardConfirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.gift-editor-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 {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.detail-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.detail-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row,
.detail-card > view {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 12rpx 20rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child,
.detail-card > view > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.detail-card > view > text:last-child {
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button,
.detail-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.gift-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.form-card, .state-card { box-sizing: border-box; padding: 46rpx; @include adaptive.adaptive-records-content; }
.form-card > text:first-child, .state-card > text:first-child { display: block; color: $ink; font-size: 34rpx; font-weight: 700; }
.form-copy { display: block; margin-top: 12rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.55; }
.field-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 12rpx 20rpx; min-height: 82rpx; margin-top: 14rpx; padding: 14rpx 24rpx; box-sizing: border-box; @include adaptive.adaptive-records-field; }
.field-row > text { color: $ink; font-size: 23rpx; font-weight: 700; }
.field-row input, .field-row textarea { min-width: 0; color: $ink; font-size: 23rpx; text-align: right; }
.field-row textarea { min-height: 72rpx; text-align: left; }
.save-error { display: block; margin-top: 14rpx; color: $brand-red; font-size: 22rpx; }
.form-card .app-button { margin-top: 18rpx; }
.state-card { min-height: 340rpx; padding-top: 80rpx; text-align: center; }
.state-card > text:nth-child(2) { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.state-card .app-button { margin-top: 28rpx; }
</style>
+16 -201
View File
@@ -1,216 +1,31 @@
<!-- 页面编号R-05用途当前家谱的礼仪活动列表与本地创建预览入口 -->
<!-- 页面编号R-05用途礼仪活动入口活动列表 operation 未声明 -->
<template>
<view class="ritual-page" :class="stateClasses">
<view class="ritual-list-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪活动"
:action="hasValidContext ? '填写预览' : ''"
@action="createCeremonyPreview"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在整理礼仪活动"
description="请稍候,正在核对当前家谱的活动记录。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonies.length">
<view
v-for="ceremony in ceremonies"
:key="ceremony.ceremonyId"
class="record-card"
role="button"
:aria-label="`查看${ceremony.ceremonyTitle}`"
@click="openCeremony(ceremony)"
>
<text>{{ ceremony.ceremonyType }}</text>
<text>{{ ceremony.ceremonyTitle }}</text>
<text>
{{ ceremony.ceremonyTime }} ·
{{ ceremony.location || "地点待定" }}
</text>
<text>查看活动与受邀信息</text>
</view>
<AppButton block label="填写礼仪预览" @click="createCeremonyPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="ceremonyState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToFamily" /></view>
<view class="page-content"><view class="state-card"><text>礼仪活动列表暂未开放</text><text>Apifox 只有活动详情修改献礼和删除相关 operation没有活动列表或新建活动 owner页面不再展示本地礼仪活动</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></view>
</view>
</template>
<script setup>
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";
import {
getGenealogyFixtureAccess,
listCeremonyFixtures,
} from "@/data/mock.js";
import { goBack, openPage } from "@/utils/navigation.js";
import { goBack, returnTo } 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(() => ({
"ceremony-state--loading": ceremonyState.value === "loading",
"ceremony-state--empty": ceremonyState.value === "empty",
"ceremony-state--error": ceremonyState.value === "error",
"ceremony-state--invalid": ceremonyState.value === "invalid",
}));
const stateCopy = computed(() => ({
error: {
title: "礼仪活动暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
action: "重新查看",
},
invalid: {
title: "礼仪活动入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱活动。",
action: "返回上一页",
},
empty: {
title: "还没有礼仪活动",
copy: "可以先填写一份本地预览;正式创建仍需等待线上写接口启用。",
action: "填写礼仪预览",
},
})[ceremonyState.value] || {
title: "礼仪活动暂不可用",
copy: "请返回上一页重新进入。",
action: "返回上一页",
});
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();
};
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToFamily = () => hasValidContext.value ? returnTo("F01", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-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;
}
.record-card,
.state-card {
box-sizing: border-box;
@include adaptive.adaptive-records-content;
}
.record-card {
min-height: 190rpx;
padding: 32rpx 46rpx;
}
.record-card > text,
.state-card > text {
display: block;
}
.record-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.record-card > text:nth-child(2) {
margin-top: 6rpx;
color: $ink;
font-size: 31rpx;
font-weight: 700;
}
.record-card > text:nth-child(3) {
margin-top: 9rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.5;
}
.record-card > text:last-child {
margin-top: 9rpx;
color: $brand-red;
font-size: 21rpx;
}
.state-card {
min-height: 340rpx;
padding: 78rpx 52rpx 50rpx;
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;
}
.ritual-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.page-header, .page-content { z-index: 1; }
.page-content { padding: 18rpx 24rpx 72rpx; }
.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }
.state-card text { display: block; }
.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; }
</style>
+9 -240
View File
@@ -1,252 +1,21 @@
<!-- 页面编号R-06用途当前家谱礼仪详情受邀信息与受控状态 -->
<!-- 页面编号R-06用途礼仪详情详情响应无展示 DTO 时保持关闭 -->
<template>
<view class="ritual-detail-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="礼仪详情"
:action="ceremonyState === 'ready' ? '制作预览' : ''"
custom-back
@back="requestBack"
@action="editCeremony"
/>
</view>
<view v-if="ceremonyState === 'loading'" class="page-loading">
<AppLoading
text="正在读取礼仪详情"
description="请稍候,正在核对活动身份与受邀信息。"
/>
</view>
<view v-else class="page-content">
<template v-if="ceremonyState === 'ready' && ceremonyDetail">
<view class="detail-card">
<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>{{ invitees.length }} </text>
</view>
<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="editCeremony" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
type="secondary"
block
:label="ceremonyState === 'error' ? '重新查看' : '返回礼仪列表'"
@click="ceremonyState === 'error' ? restoreCeremony() : returnToCeremonies()"
/>
</view>
</view>
</view>
<view class="ritual-detail-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪详情" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text><AppButton block :label="stateCopy.action" @click="returnToList" /></view></view></view>
</template>
<script setup>
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";
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(() => ({
"ceremony-state--expired": ceremonyState.value === "expired",
"ceremony-state--error": ceremonyState.value === "error",
}));
const stateCopy = computed(
() =>
({
expired: {
title: "活动已失效",
copy: "活动不存在或不属于当前家谱,页面不会回退到其他活动。",
},
error: {
title: "礼仪详情暂不可用",
copy: "请稍后重新查看,已有活动不会受到影响。",
},
})[ceremonyState.value] || {
title: "礼仪详情暂不可用",
copy: "缺少家谱或活动身份,请返回列表重新选择。",
},
);
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 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 requestBack = () => goBack();
const returnToCeremonies = () =>
genealogyId.value
? returnTo("R05", { genealogyId: genealogyId.value })
: goBack();
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const ceremonyId = ref("");
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(ceremonyId.value));
const stateCopy = computed(() => valid.value ? { title: "礼仪详情待后端字段合同", copy: "详情接口没有声明活动类型、标题、时间、地点、说明或受邀人字段;页面不再展示本地礼仪详情。", action: "返回礼仪活动" } : { title: "礼仪入口无效", copy: "没有取得有效家谱或活动标识。", action: "返回上一页" });
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); ceremonyId.value = String(query?.ceremonyId || ""); });
const returnToList = () => /^[1-9]\d*$/.test(genealogyId.value) ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-detail-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;
}
.detail-card,
.participant-card,
.state-card {
box-sizing: border-box;
padding: 42rpx 46rpx;
@include adaptive.adaptive-records-content;
}
.detail-card > text {
display: block;
}
.detail-card > text:first-child {
color: $brand-red;
font-size: 21rpx;
}
.detail-card > text:nth-child(2) {
margin-top: 8rpx;
color: $ink;
font-size: 36rpx;
font-weight: 700;
}
.detail-card > text:nth-child(3) {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
}
.detail-card > text:last-child {
margin-top: 20rpx;
color: $ink;
font-size: 24rpx;
line-height: 1.7;
}
.participant-card > view {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 8rpx 18rpx;
min-height: 52rpx;
align-items: center;
color: $ink;
font-size: 23rpx;
}
.participant-card > view:first-child {
color: $brand-red;
font-weight: 700;
}
.participant-card > view + view {
margin-top: 9rpx;
padding-top: 9rpx;
border-top: 1px solid rgba(136, 84, 42, 0.18);
}
.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) {
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }.page-header,.page-content { z-index: 1; }.page-content { padding: 18rpx 24rpx 72rpx; }.state-card { box-sizing: border-box; min-height: 340rpx; padding: 78rpx 52rpx 50rpx; text-align: center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.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; }
</style>
+9 -311
View File
@@ -1,321 +1,19 @@
<!-- 页面编号R-07用途礼仪创建编辑校验与不写库的本地预览 -->
<!-- 页面编号R-07用途礼仪活动创建/编辑创建 owner 缺失编辑无可靠详情来源 -->
<template>
<view class="ritual-editor-page" :class="editorClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
:title="mode === 'create' ? '礼仪预览' : '编辑预览'"
custom-back
@back="requestBack"
/>
</view>
<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-if="editorState === 'ready'" class="form-card">
<text>
{{ mode === "create" ? "填写一份家族礼仪预览" : "调整活动预览" }}
</text>
<view v-for="field in fields" :key="field.key" class="field-row">
<text>{{ field.label }}</text>
<textarea
v-if="field.long"
v-model="ritualForm[field.key]"
auto-height
:placeholder="`请输入${field.label}`"
/>
<input
v-else
v-model="ritualForm[field.key]"
:placeholder="`请输入${field.label}`"
/>
<text v-if="ritualErrors[field.key]">
{{ ritualErrors[field.key] }}
</text>
</view>
<AppButton
block
label="生成本地预览"
@click="createCeremonyPreview"
/>
<AppButton
type="secondary"
block
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="discardVisible"
:close-on-mask="false"
eyebrow="放弃确认"
title="放弃当前填写?"
message="尚未提交的预览内容将从当前页面清除。"
confirm-text="确认放弃"
cancel-text="继续填写"
show-cancel
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
<view class="ritual-editor-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="礼仪活动" custom-back @back="returnToList" /></view><view class="page-content"><view class="state-card"><text>礼仪活动维护暂未开放</text><text>当前没有新建活动 operation修改虽有 operation但没有可消费的详情 DTO 或可靠活动 ID 来源本页不再生成本地创建或编辑预览</text><AppButton block :label="hasValidContext ? '返回礼仪活动' : '返回上一页'" @click="returnToList" /></view></view></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { 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";
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({
ceremonyType: "",
ceremonyTitle: "",
ceremonyTime: "",
location: "",
ceremonyDesc: "",
});
const fields = [
{ key: "ceremonyType", label: "礼仪类型" },
{ key: "ceremonyTitle", label: "活动标题" },
{ key: "ceremonyTime", label: "活动时间" },
{ key: "location", label: "举办地点" },
{ key: "ceremonyDesc", label: "活动说明", long: true },
];
const baseline = ref("");
const editorClasses = computed(() => ({
"ceremony-editor-state--preview": editorState.value === "preview",
"ceremony-editor-state--invalid": editorState.value === "invalid",
}));
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 validateCeremony = () => {
ritualErrors.ceremonyType = ceremonyForm.ceremonyType.trim()
? ""
: "请填写礼仪类型";
ritualErrors.ceremonyTitle = ceremonyForm.ceremonyTitle.trim()
? ""
: "请填写活动标题";
return fields.every((field) => !ritualErrors[field.key]);
};
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 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(() => {
discardConfirmation.dispose();
});
import { goBack, returnTo } from "@/utils/navigation.js";
const genealogyId = ref(""); const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); });
const returnToList = () => hasValidContext.value ? returnTo("R05", { genealogyId: genealogyId.value }) : goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.ritual-editor-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.state-card {
box-sizing: border-box;
padding: 46rpx;
@include adaptive.adaptive-records-content;
}
.form-card > text:first-child,
.state-card > text:first-child {
display: block;
color: $ink;
font-size: 34rpx;
font-weight: 700;
}
.field-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 10rpx 18rpx;
min-height: 82rpx;
margin-top: 14rpx;
padding: 14rpx 24rpx;
box-sizing: border-box;
@include adaptive.adaptive-records-field;
}
.field-row > text:first-child {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.field-row input,
.field-row textarea {
width: auto;
min-width: 0;
color: $ink;
font-size: 23rpx;
text-align: right;
}
.field-row textarea {
min-height: 76rpx;
line-height: 1.5;
}
.field-row > text:last-child {
grid-column: 1/-1;
color: $brand-red;
font-size: 20rpx;
text-align: right;
}
.form-card .app-button {
margin-top: 18rpx;
}
.save-error {
display: block;
margin-top: 14rpx;
color: $brand-red;
font-size: 22rpx;
}
.state-card {
min-height: 340rpx;
padding-top: 80rpx;
text-align: center;
}
.state-card > text:nth-child(2) {
display: block;
margin-top: 18rpx;
color: $ink-muted;
font-size: 24rpx;
}
.state-card .app-button {
margin-top: 28rpx;
}
.ritual-editor-page { display:flex; min-height:100vh; flex-direction:column; background:$paper; }.page-header,.page-content { z-index:1; }.page-content { padding:18rpx 24rpx 72rpx; }.state-card { box-sizing:border-box; min-height:340rpx; padding:78rpx 52rpx 50rpx; text-align:center; @include adaptive.adaptive-records-content; }.state-card text { display:block; }.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; }
</style>
+13 -398
View File
@@ -1,407 +1,22 @@
<!-- 页面编号R-08用途当前家谱人物的成长日志与不写库的本地预览 -->
<!-- 页面编号R-08用途成长记录创建列表 DTO 未声明创建仅提交可映射字段 -->
<template>
<view class="timeline-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="成长日志"
:action="hasValidContext ? '记录预览' : ''"
custom-back
@back="requestBack"
@action="recordGrowth"
/>
</view>
<view v-if="timelineState === 'loading'" class="page-loading">
<AppLoading
text="正在读取成长日志"
description="请稍候,正在核对当前家谱与人物身份。"
/>
</view>
<view v-else class="page-content">
<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.recordId"
class="timeline-card"
>
<text> {{ growthRecords.length - index }} </text>
<text>{{ record.recordTitle }}</text>
<text>{{ record.recordDate || "日期未填写" }}</text>
<text>{{ record.recordContent || "内容未填写" }}</text>
</view>
<AppButton block label="记录成长预览" @click="recordGrowth" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="timelineState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="成长日志"
title="填写一份成长预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createGrowthPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<input v-model="growthForm.recordTitle" placeholder="记录标题" />
<input v-model="growthForm.recordDate" placeholder="日期(选填)" />
<textarea
v-model="growthForm.recordContent"
auto-height
placeholder="写下当时的故事(选填)"
/>
<text v-if="formError">{{ formError }}</text>
</view>
</AppDialog>
<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>
<view class="record-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="成长日志" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建成长记录</text><text class="form-copy">列表和详情没有展示 DTO;本页只提交标题、日期和内容,不猜测人物绑定、类型、提醒或媒体字段。</text><view class="field"><text>记录标题</text><input v-model="form.title" maxlength="40" placeholder="请输入记录标题" @input="error = ''" /></view><view class="field"><text>记录日期</text><input v-model="form.date" placeholder="例如:2026-07-24" @input="error = ''" /></view><view class="field"><text>记录内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录成长片段" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交成长记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃成长记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
findTreeMemberPresentationFixture,
getGenealogyFixtureAccess,
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 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",
}));
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: "返回上一页",
});
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;
}
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;
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();
});
import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref(""); const state = ref("form"); const submitting = ref(false); const error = ref(""); const discardVisible = ref(false); const form = reactive({ title: "", date: "", content: "" }); const controller = createRequestController();
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value)); const dirty = computed(() => Object.values(form).some((value) => value.trim()));
const result = computed(() => state.value === "success" ? { title: "成长记录已提交服务端", copy: "服务端已返回成功信封;列表仍缺条目 DTO,不生成本地记录。", action: "返回记录首页" } : { title: "成长日志入口无效", copy: "没有取得有效家谱标识。", action: "返回上一页" });
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; }); const confirmDiscard = confirmation.confirm; const cancelDiscard = confirmation.cancel;
onLoad((query) => { genealogyId.value = String(query?.genealogyId || ""); if (!valid.value) state.value = "invalid"; });
const submit = async () => { if (submitting.value || !valid.value) return; const recordTitle = form.title.trim(); if (!recordTitle) { error.value = "请填写记录标题"; return; } submitting.value = true; error.value = ""; try { await appApi.createGrowthRecord(genealogyId.value, { recordTitle, recordDate: form.date, recordContent: form.content }, { requestController: controller }); Object.keys(form).forEach((key) => { form[key] = ""; }); state.value = "success"; } catch (cause) { if (!isRequestCancelled(cause)) error.value = cause?.message || "成长记录提交失败,请稍后重试。"; } finally { submitting.value = false; } };
const requestBack = () => runBackGuard({ transientOpen: discardVisible.value, dirty: dirty.value, submitting: submitting.value, "close-transient": cancelDiscard, "block-submitting": () => true, "confirm-discard": confirmation.request }); const resultAction = () => state.value === "success" ? returnTo("R08", { genealogyId: genealogyId.value }) : goBack();
onBackPress((event) => handleBackPress(event, requestBack)); onUnmounted(() => { controller.abort(); confirmation.dispose(); });
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.timeline-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;
}
.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,
.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;
}
.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;
}
.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;
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: 70rpx;
margin-top: 10rpx;
padding: 14rpx 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;
}
.record-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+4 -105
View File
@@ -1,112 +1,11 @@
<!-- 页面编号R-09用途校验人物身份并明确关闭缺失的线上服务 -->
<!-- 页面编号R-09用途人生大事当前没有独立业务 operation -->
<template>
<view class="service-page" :class="`service-state--${serviceState}`">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader title="人生事" custom-back @back="requestBack" />
</view>
<view v-if="serviceState === 'loading'" class="page-loading">
<AppLoading
text="正在核对人物身份"
description="请稍候,页面正在确认当前家谱与人物。"
/>
</view>
<view v-else class="page-content">
<view class="state-card">
<text>{{ stateCopy.title }}</text>
<text v-if="personRecord && serviceState === 'unavailable'" class="person-name">
当前人物{{ personRecord.name }}
</text>
<text>{{ stateCopy.copy }}</text>
<AppButton type="secondary" block label="返回上一页" @click="requestBack" />
</view>
</view>
</view>
<view class="life-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="人生大事" custom-back @back="returnToRecords" /></view><view class="page-content"><view class="state-card"><text>人生大事暂未开放</text><text>已在 Apifox APP/PC 目录检索人生life没有独立人生事件读取或写入 operation页面不借成长备忘人物资料或参考项目伪造保存</text><AppButton block :label="hasValidContext ? '返回记录首页' : '返回上一页'" @click="returnToRecords" /></view></view></view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onBackPress, 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";
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));
import { computed,ref } from "vue"; import { onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToRecords=()=>hasValidContext.value?returnTo("R01",{genealogyId:genealogyId.value}):goBack();
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.service-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 {
padding: 18rpx 24rpx 72rpx;
}
.state-card {
box-sizing: border-box;
min-height: 380rpx;
padding: 78rpx 52rpx 50rpx;
@include adaptive.adaptive-records-content;
text-align: center;
}
.state-card > text { display: block; }
.state-card > text:first-child {
color: $ink;
font-size: 35rpx;
font-weight: 700;
}
.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;
}
.life-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-records-content}.state-card text{display:block}.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}
</style>
+6 -244
View File
@@ -1,251 +1,13 @@
<!-- 页面编号R-10用途当前家谱备忘列表与不写库的本地预览 -->
<!-- 页面编号R-10用途家族备忘创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="memo-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<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.memoId" class="memo-card">
<view>
<text>{{ memo.completedLabel }}</text>
<text>{{ memo.remindTime || "未设置提醒" }}</text>
</view>
<text>{{ memo.memoTitle }}</text>
<text>{{ memo.memoContent || "暂无备忘内容" }}</text>
<text>状态仅展示线上切换接口尚未确认</text>
</view>
<AppButton block label="填写备忘预览" @click="startMemoPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="memoState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="家族备忘"
title="填写一份备忘预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMemoPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<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>
<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>
<view class="memo-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="家族备忘" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建家族备忘</text><text class="form-copy">列表和详情没有可消费字段;本页仅提交标题、提醒时间和内容,不猜测完成状态或媒体。</text><view class="field"><text>备忘标题</text><input v-model="form.title" maxlength="40" placeholder="请输入备忘标题" @input="error = ''" /></view><view class="field"><text>提醒时间</text><input v-model="form.time" placeholder="例如:2026-07-24 09:00" @input="error = ''" /></view><view class="field"><text>备忘内容</text><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录需要提醒的事情" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交备忘'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃家族备忘?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
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 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",
}));
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: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
memoState.value = "invalid";
return;
}
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();
});
import { computed, onUnmounted, reactive, ref } from "vue"; import { onBackPress, onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({title:"",time:"",content:""});const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"备忘已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地备忘。",action:"返回备忘"}:{title:"备忘入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const memoTitle=form.title.trim();if(!memoTitle){error.value="请填写备忘标题";return;}submitting.value=true;error.value="";try{await appApi.createMemo(genealogyId.value,{memoTitle,remindTime:form.time,memoContent:form.content},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"备忘提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R10",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.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,.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; }
.memo-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>
+6 -281
View File
@@ -1,288 +1,13 @@
<!-- 页面编号R-11用途当前家谱功德记录与不写库的本地预览 -->
<!-- 页面编号R-11用途功德记录创建列表 DTO 缺失时不展示 fixture -->
<template>
<view class="merit-page" :class="stateClasses">
<ModulePageBackground module="records" />
<view class="page-header">
<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="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.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="startMeritPreview" />
</template>
<view v-else class="state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton
:type="meritState === 'empty' ? 'primary' : 'secondary'"
block
:label="stateCopy.action"
@click="handleStateAction"
/>
</view>
</view>
<AppDialog
:visible="dialogVisible"
:close-on-mask="false"
eyebrow="功德记录"
title="填写一份功德预览"
confirm-text="生成预览"
cancel-text="取消"
show-cancel
@confirm="createMeritPreview"
@cancel="requestCloseEditor"
>
<view class="dialog-form">
<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>
<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>
<view class="merit-page"><ModulePageBackground module="records" /><view class="page-header"><PageHeader title="功德记录" custom-back @back="requestBack" /></view><view class="page-content"><view v-if="state === 'form'" class="form-card"><text>新建功德记录</text><text class="form-copy">列表没有展示 DTO;本页仅提交已声明的捐赠人、标题、类型、金额、时间和内容,不猜测状态或排序。</text><view v-for="field in fields" :key="field.key" class="field"><text>{{ field.label }}</text><textarea v-if="field.key === 'content'" v-model="form[field.key]" auto-height :placeholder="`请输入${field.label}`" @input="error = ''" /><input v-else v-model="form[field.key]" :type="field.key === 'amount' ? 'digit' : 'text'" :placeholder="`请输入${field.label}`" @input="error = ''" /></view><text v-if="error" class="error">{{ error }}</text><AppButton block :disabled="submitting" :label="submitting ? '正在提交' : '提交功德记录'" @click="submit" /></view><view v-else class="state-card"><text>{{ result.title }}</text><text>{{ result.copy }}</text><AppButton block :label="result.action" @click="resultAction" /></view></view><AppDialog :visible="discardVisible" title="放弃功德记录?" message="尚未提交的内容将被清除。" confirm-text="放弃并返回" cancel-text="继续填写" show-cancel :close-on-mask="false" @confirm="confirmDiscard" @cancel="cancelDiscard" /></view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
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({
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",
}));
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: "返回上一页",
});
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
const access = getGenealogyFixtureAccess(genealogyId.value);
if (!["owner", "member"].includes(access.accessRole)) {
meritState.value = "invalid";
return;
}
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;
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();
});
import { computed,onUnmounted,reactive,ref } from "vue"; import { onBackPress,onLoad } from "@dcloudio/uni-app"; import AppButton from "@/components/AppButton.vue"; import AppDialog from "@/components/AppDialog.vue"; import ModulePageBackground from "@/components/ModulePageBackground.vue"; import PageHeader from "@/components/PageHeader.vue"; import { appApi,createRequestController,isRequestCancelled } from "@/utils/api.js"; import { createDiscardConfirmation } from "@/utils/discard-confirmation.js"; import { goBack,handleBackPress,returnTo,runBackGuard } from "@/utils/navigation.js";
const genealogyId=ref("");const state=ref("form");const submitting=ref(false);const error=ref("");const discardVisible=ref(false);const form=reactive({donor:"",title:"",type:"",amount:"",time:"",content:""});const fields=[{key:"donor",label:"捐赠人"},{key:"title",label:"功德标题"},{key:"type",label:"功德类型"},{key:"amount",label:"金额"},{key:"time",label:"记录时间"},{key:"content",label:"记录内容"}];const controller=createRequestController();const valid=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));const dirty=computed(()=>Object.values(form).some((value)=>value.trim()));const result=computed(()=>state.value==="success"?{title:"功德记录已提交服务端",copy:"服务端已返回成功信封;列表仍缺条目 DTO,不生成本地功德记录。",action:"返回功德记录"}:{title:"功德记录入口无效",copy:"没有取得有效家谱标识。",action:"返回上一页"});const confirmation=createDiscardConfirmation((visible)=>{discardVisible.value=visible;});const confirmDiscard=confirmation.confirm;const cancelDiscard=confirmation.cancel;
onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");if(!valid.value)state.value="invalid";});const submit=async()=>{if(submitting.value||!valid.value)return;const donorName=form.donor.trim();const meritTitle=form.title.trim();const amountText=form.amount.trim();if(!donorName||!meritTitle){error.value=!donorName?"请填写捐赠人":"请填写功德标题";return;}if(amountText&&!Number.isFinite(Number(amountText))){error.value="金额必须是数字";return;}submitting.value=true;error.value="";try{await appApi.createMeritRecord(genealogyId.value,{donorName,meritTitle,meritType:form.type,meritContent:form.content,meritTime:form.time,...(amountText?{amount:Number(amountText)}:{})},{requestController:controller});Object.keys(form).forEach((key)=>{form[key]="";});state.value="success";}catch(cause){if(!isRequestCancelled(cause))error.value=cause?.message||"功德记录提交失败,请稍后重试。";}finally{submitting.value=false;}};const requestBack=()=>runBackGuard({transientOpen:discardVisible.value,dirty:dirty.value,submitting:submitting.value,"close-transient":cancelDiscard,"block-submitting":()=>true,"confirm-discard":confirmation.request});const resultAction=()=>state.value==="success"?returnTo("R11",{genealogyId:genealogyId.value}):goBack();onBackPress((event)=>handleBackPress(event,requestBack));onUnmounted(()=>{controller.abort();confirmation.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,.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; }
.merit-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.form-card,.state-card{box-sizing:border-box;padding:46rpx;@include adaptive.adaptive-records-content}.form-card>text:first-child,.state-card>text:first-child{display:block;color:$ink;font-size:34rpx;font-weight:700}.form-copy{display:block;margin-top:12rpx;color:$ink-muted;font-size:22rpx;line-height:1.55}.field{margin-top:16rpx}.field>text{display:block;margin:0 8rpx 8rpx;color:$ink;font-size:23rpx;font-weight:700}.field input,.field textarea{@include adaptive.adaptive-records-field;box-sizing:border-box;width:100%;min-height:76rpx;padding:16rpx 22rpx;color:$ink;font-size:23rpx}.field textarea{min-height:150rpx}.error{display:block;margin-top:12rpx;color:$brand-red;font-size:22rpx}.form-card .app-button{margin-top:20rpx}.state-card{min-height:340rpx;padding-top:80rpx;text-align:center}.state-card>text:nth-child(2){display:block;margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
</style>