feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+623
View File
@@ -0,0 +1,623 @@
<template>
<view class="life-page">
<ModulePageBackground module="records" />
<view class="page-header">
<PageHeader
title="人生大事"
:action="valid && view === 'list' ? '新建' : ''"
custom-back
@back="requestBack"
@action="openCreate"
/>
</view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>{{ editingEvent ? "修改人生大事" : "新建人生大事" }}</text>
<text class="form-copy">红色 * 为必填项请按实际情况选择事件类型和日期</text>
<view class="field field--picker">
<text><text class="required-mark">*</text>事件类型</text>
<picker :range="eventTypeLabels" :value="eventTypeIndex" @change="selectEventType">
<view :class="{ placeholder: !form.eventType }">{{ eventTypeLabel || "请选择" }}</view>
</picker>
</view>
<view class="field">
<text><text class="required-mark">*</text>事件标题</text>
<input v-model="form.eventTitle" placeholder="请输入事件标题" @input="error = ''" />
</view>
<view class="field field--picker">
<text><text class="required-mark">*</text>发生日期</text>
<picker mode="date" :value="form.eventDate" @change="selectEventDate">
<view :class="{ placeholder: !form.eventDate }">{{ form.eventDate || "请选择" }}</view>
</picker>
</view>
<view class="field field--picker">
<text><text class="required-mark">*</text>日期填写方式</text>
<picker :range="datePrecisionLabels" :value="datePrecisionIndex" @change="selectDatePrecision">
<view :class="{ placeholder: !form.datePrecision }">{{ datePrecisionLabel || "请选择" }}</view>
</picker>
</view>
<view class="field field--textarea">
<text>事件内容</text>
<textarea v-model="form.eventContent" auto-height placeholder="记录这段家族记忆" @input="error = ''" />
</view>
<view class="field">
<text>发生地点</text>
<input v-model="form.eventPlace" placeholder="请输入地点" @input="error = ''" />
</view>
<view class="field">
<text>资料来源</text>
<input v-model="form.sourceDescription" placeholder="例如:家谱手稿" @input="error = ''" />
</view>
<view class="upload-field">
<view>
<text>相关图片</text>
<text>图片上传成功后会随这条记录一起保存</text>
</view>
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
{{ uploading ? "上传中" : "添加图片" }}
</button>
<text v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`">
已上传{{ receipt.fileName || "图片" }}
</text>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<text v-if="error" class="error">{{ error }}</text>
<view class="form-actions">
<AppButton type="secondary" label="取消" @click="cancelCreate" />
<AppButton :disabled="submitting || uploading" :label="submitting ? '正在保存' : editingEvent ? '保存修改' : '提交人生大事'" @click="saveLifeEvent" />
</view>
</view>
<view v-else-if="!valid" class="state-card">
<text>暂时无法打开人生大事</text>
<text>未获取到当前人物请从人物档案重新进入</text>
<AppButton block label="返回上一页" @click="requestBack" />
</view>
<view v-else-if="listState === 'loading'" class="state-card">
<AppLoading text="正在读取人生大事" />
</view>
<view v-else-if="listState === 'error'" class="state-card">
<text>暂时无法读取人生大事</text>
<text>请检查网络后重新加载</text>
<AppButton block type="secondary" label="重新加载" @click="loadEvents" />
</view>
<view v-else-if="listState === 'empty'" class="state-card">
<text>还没有人生大事</text>
<AppButton block label="新建人生大事" @click="openCreate" />
</view>
<view v-else class="event-list">
<text v-if="saveNotice" class="save-notice">人生大事已保存</text>
<view v-for="item in events" :key="item.id" class="event-card">
<text>{{ item.title }}</text>
<text>{{ eventTypeText(item.type) }} · {{ formatEventDate(item) }}</text>
<text v-if="item.content">{{ item.content }}</text>
<text v-if="item.place">地点{{ item.place }}</text>
<text v-if="item.sourceDescription">来源{{ item.sourceDescription }}</text>
<text v-if="item.mediaFiles.length">已关联 {{ item.mediaFiles.length }} 张图片</text>
<view v-if="item.canEdit || item.canDelete" class="event-card__actions">
<AppButton v-if="item.canEdit" compact type="secondary" label="修改" @click="openEdit(item)" />
<AppButton v-if="item.canDelete" compact type="secondary" label="删除" @click="requestDelete(item)" />
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃人生大事?"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="deleteVisible"
eyebrow="删除人生大事"
title="确认删除这条记录?"
message="删除后无法恢复,请确认不再需要这条记录。"
:confirm-text="deleting ? '正在删除' : '确认删除'"
cancel-text="取消"
show-cancel
:close-on-mask="false"
@confirm="confirmDelete"
@cancel="deleteVisible = false"
/>
</view>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
LIFE_EVENT_DATE_PRECISION_OPTIONS as datePrecisionOptions,
LIFE_EVENT_TYPE_OPTIONS as eventTypeOptions
} from "@/services/api/life-record-contract.js";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const personId = ref("");
const view = ref("list");
const listState = ref("loading");
const events = ref([]);
const saveNotice = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const mediaReceipts = ref([]);
const editingEvent = ref(null);
const deleteTarget = ref(null);
const deleteVisible = ref(false);
const deleting = ref(false);
const form = reactive({
eventType: "",
eventTitle: "",
eventDate: "",
datePrecision: "",
eventContent: "",
eventPlace: "",
sourceDescription: "",
});
const eventListController = createRequestController();
const eventImageUploadController = createRequestController();
const eventSaveController = createRequestController();
const eventDeleteController = createRequestController();
const lifeEventCreateGuard = createNonIdempotentWriteGuard();
let isPageActive = true;
const valid = computed(
() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(personId.value),
);
const eventTypeLabels = computed(() => eventTypeOptions.map((item) => item.label));
const datePrecisionLabels = computed(() => datePrecisionOptions.map((item) => item.label));
const eventTypeIndex = computed(() => Math.max(0, eventTypeOptions.findIndex((item) => item.value === form.eventType)));
const datePrecisionIndex = computed(() => Math.max(0, datePrecisionOptions.findIndex((item) => item.value === form.datePrecision)));
const eventTypeLabel = computed(() => eventTypeText(form.eventType));
const datePrecisionLabel = computed(() => datePrecisionText(form.datePrecision));
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId));
const dirty = computed(
() => Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const eventTypeText = (value) =>
eventTypeOptions.find((item) => item.value === value)?.label || "未分类";
const datePrecisionText = (value) =>
datePrecisionOptions.find((item) => item.value === value)?.label || "";
const formatEventDate = (event) => {
if (event.datePrecision === "YEAR") return event.date.slice(0, 4);
if (event.datePrecision === "MONTH") return event.date.slice(0, 7);
return event.date;
};
const resetForm = () => {
Object.assign(form, {
eventType: "",
eventTitle: "",
eventDate: "",
datePrecision: "",
eventContent: "",
eventPlace: "",
sourceDescription: "",
});
mediaReceipts.value = [];
editingEvent.value = null;
error.value = "";
uploadError.value = "";
};
const confirmDiscard = () => {
confirmation.confirm();
resetForm();
view.value = "list";
};
const cancelDiscard = confirmation.cancel;
const loadEvents = async () => {
if (!valid.value) return;
eventListController.abort();
listState.value = "loading";
try {
const rows = await lifeRecordApi.getLifeEvents(genealogyId.value, personId.value, {
requestController: eventListController,
});
if (!isPageActive) return;
events.value = rows;
listState.value = rows.length ? "ready" : "empty";
} catch (cause) {
if (!isPageActive || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => {
if (!valid.value) return;
saveNotice.value = false;
resetForm();
view.value = "form";
};
const openEdit = (item) => {
if (!item?.canEdit || submitting.value || uploading.value) return;
saveNotice.value = false;
resetForm();
editingEvent.value = item;
Object.assign(form, {
eventType: item.type,
eventTitle: item.title,
eventDate: item.date,
datePrecision: item.datePrecision,
eventContent: item.content,
eventPlace: item.place,
sourceDescription: item.sourceDescription,
});
mediaReceipts.value = item.mediaFiles.map((file) => ({ ossId: file.ossId, fileName: file.fileName }));
view.value = "form";
};
const cancelCreate = () => {
resetForm();
view.value = "list";
};
const selectEventType = (event) => {
form.eventType = eventTypeOptions[Number(event.detail.value)]?.value || "";
error.value = "";
};
const selectEventDate = (event) => {
form.eventDate = event.detail.value || "";
error.value = "";
};
const selectDatePrecision = (event) => {
form.datePrecision = datePrecisionOptions[Number(event.detail.value)]?.value || "";
error.value = "";
};
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const uploadReceipt = await pickAndUploadImage({
requestController: eventImageUploadController,
});
if (!isPageActive) return;
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
} catch (cause) {
if (
isPageActive &&
!isImagePickCancelled(cause) &&
!isRequestCancelled(cause)
) {
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
}
} finally {
if (isPageActive) uploading.value = false;
}
};
const saveLifeEvent = async () => {
if (submitting.value || uploading.value || !valid.value) return;
if (!form.eventType || !form.eventTitle.trim() || !form.eventDate || !form.datePrecision) {
error.value = "请填写事件类型、标题和发生日期,并选择日期填写方式。";
return;
}
const payload = {
eventType: form.eventType,
eventTitle: form.eventTitle,
eventDate: form.eventDate,
datePrecision: form.datePrecision,
eventContent: form.eventContent,
eventPlace: form.eventPlace,
sourceDescription: form.sourceDescription,
mediaOssIds: mediaOssIds.value,
};
const createAttempt = editingEvent.value
? null
: lifeEventCreateGuard.begin(payload);
if (!editingEvent.value && createAttempt === null) {
error.value =
"上次提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
return;
}
submitting.value = true;
error.value = "";
try {
if (editingEvent.value) {
await lifeRecordApi.updateLifeEvent(
genealogyId.value,
personId.value,
editingEvent.value.id,
payload,
{ requestController: eventSaveController },
);
} else {
await lifeRecordApi.createLifeEvent(genealogyId.value, personId.value, payload, {
requestController: eventSaveController,
});
}
if (!isPageActive) return;
resetForm();
view.value = "list";
saveNotice.value = true;
await loadEvents();
} catch (cause) {
if (!isPageActive) return;
if (
!editingEvent.value &&
lifeEventCreateGuard.recordFailure(createAttempt, cause)
) {
error.value =
"提交结果暂时无法确认,请先返回人生大事列表检查,避免重复创建。";
return;
}
if (!isRequestCancelled(cause))
error.value = getRequestErrorMessage(cause, "人生大事提交失败,请稍后重试。");
} finally {
if (isPageActive) submitting.value = false;
}
};
const requestDelete = (item) => {
if (!item?.canDelete || deleting.value) return;
deleteTarget.value = item;
deleteVisible.value = true;
};
const confirmDelete = async () => {
if (!deleteTarget.value?.canDelete || deleting.value) return;
const event = deleteTarget.value;
deleting.value = true;
try {
await lifeRecordApi.deleteLifeEvent(
genealogyId.value,
personId.value,
event.id,
{ requestController: eventDeleteController },
);
if (!isPageActive) return;
deleteVisible.value = false;
deleteTarget.value = null;
await loadEvents();
} catch (cause) {
if (isPageActive && !isRequestCancelled(cause)) {
error.value = getRequestErrorMessage(cause, "这条人生大事删除失败,请稍后重试。");
deleteVisible.value = false;
}
} finally {
if (isPageActive) deleting.value = false;
}
};
const requestBack = () =>
view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
personId.value = String(query?.personId || "");
resetForm();
if (valid.value) loadEvents();
else listState.value = "invalid";
});
onShow(() => {
if (valid.value && view.value === "list" && listState.value !== "loading") loadEvents();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
isPageActive = false;
eventListController.abort();
eventImageUploadController.abort();
eventSaveController.abort();
eventDeleteController.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.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;
}
.form-card,
.state-card,
.event-card {
@include adaptive-records-content;
}
.form-card {
box-sizing: border-box;
padding: 46rpx;
}
.form-card > text:first-child {
display: block;
color: $ink;
font-size: clamp(19px, 34rpx, 24px);
font-weight: 700;
}
.form-copy {
display: block;
margin-top: 12rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.field {
margin-top: 16rpx;
}
.field > text {
display: block;
margin: 0 8rpx 8rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
transform: translateY(-3rpx);
}
.field input,
.field textarea,
.field--picker picker > view {
@include adaptive-records-field;
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.field textarea {
min-height: 150rpx;
}
.field--picker picker {
display: block;
}
.field--picker .placeholder {
color: $ink-muted;
}
.upload-field {
display: grid;
gap: 12rpx;
margin-top: 16rpx;
padding: 18rpx 22rpx;
@include adaptive-records-field;
}
.upload-field > view > text {
display: block;
}
.upload-field > view > text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.upload-field > view > text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
line-height: 1.45;
}
.upload-button {
justify-self: start;
min-height: 88rpx;
margin: 0;
padding: 0 20rpx;
border: 1rpx solid rgba(159, 23, 15, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-field > text,
.error {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.error {
display: block;
margin-top: 12rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.form-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.45fr);
gap: 16rpx;
margin-top: 20rpx;
}
.form-actions .app-button {
width: 100%;
min-width: 0;
}
.state-card {
display: flex;
min-height: 320rpx;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 42rpx;
color: $ink;
font-size: clamp(16px, 28rpx, 20px);
text-align: center;
}
.state-card .app-button {
width: 100%;
margin-top: 24rpx;
}
.state-card text {
display: block;
}
.state-card text:nth-child(2) {
margin-top: 14rpx;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
line-height: 1.55;
}
.event-list {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.save-notice {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.event-card {
min-height: 128rpx;
padding: 28rpx 32rpx;
}
.event-card text {
display: block;
}
.event-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.event-card text:not(:first-child) {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
overflow-wrap: anywhere;
}
.event-card__actions {
display: flex;
gap: 12rpx;
justify-content: flex-end;
margin-top: 18rpx;
}
.event-card__actions .app-button { width: 154rpx; min-height: 66rpx; }
</style>