完成70%
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user