feat: 完成前端业务闭环与后端联调

This commit is contained in:
2026-08-24 23:37:56 +08:00
parent c59e36f933
commit 9ad572907b
175 changed files with 10711 additions and 1740 deletions
+42 -28
View File
@@ -3,7 +3,7 @@
<ModulePageBackground module="records" />
<view class="page-header"
><PageHeader
title="家族备忘"
:title="pageTitle"
:action="valid && view === 'list' ? '新建' : ''"
custom-back
@back="requestBack"
@@ -11,14 +11,14 @@
/></view>
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>{{ isEdit ? '编辑家族备忘' : '新建家族备忘' }}</text>
<text>{{ isEdit ? `编辑${recordName}` : `新建${recordName}` }}</text>
<text class="form-copy">{{ isEdit ? '原有关联图片、完成状态和排序会随本次保存保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<view class="field"
><text><text class="required-mark">*</text>备忘标题</text
><text><text class="required-mark">*</text>{{ isBenefactorMode ? "恩人姓名" : "备忘标题" }}</text
><input
v-model="form.memoTitle"
maxlength="40"
placeholder="请输入备忘标题"
:placeholder="isBenefactorMode ? '请输入恩人姓名' : '请输入备忘标题'"
@input="error = ''"
/></view>
<picker mode="date" :value="form.remindDate" @change="selectRemindDate"
@@ -37,12 +37,12 @@
></picker
>
<view class="field field--textarea"
><text>备忘内容</text
><text>{{ isBenefactorMode ? "恩人事迹" : "备忘内容" }}</text
><textarea
v-model="form.memoContent"
auto-height
maxlength="1200"
placeholder="记录需要提醒的事情"
:placeholder="isBenefactorMode ? '记录恩人事迹与家族渊源' : '记录需要提醒的事情'"
@input="error = ''"
/>
</view>
@@ -50,7 +50,7 @@
<view
><text>相关图片</text
><text
>图片上传成功后会随备忘一起保存</text
>图片上传成功后会随{{ recordName }}一起保存</text
></view
>
<button
@@ -74,34 +74,35 @@
label="取消"
@click="cancelCreate" /><AppButton
:disabled="submitting || uploading"
:label="submitting ? '正在提交' : '提交备忘'"
:label="submitting ? '正在提交' : `提交${recordName}`"
@click="saveMemo"
/></view>
</view>
<view v-else-if="!valid" class="state-card"
><text>暂时无法打开家族备忘</text
><text>暂时无法打开{{ recordName }}</text
><AppButton block label="返回上一页" @click="requestBack"
/></view>
<view v-else-if="listState === 'loading'" class="state-card"
><AppLoading text="正在读取家族备忘"
><AppLoading :text="`正在读取${recordName}`"
/></view>
<view v-else-if="listState === 'error'" class="state-card"
><text>暂时无法读取家族备忘</text
><text>暂时无法读取{{ recordName }}</text
><AppButton block type="secondary" label="重新加载" @click="loadMemos"
/></view>
<view v-else-if="listState === 'empty'" class="state-card"
><text>还没有家族备忘</text
><AppButton block label="新建备忘" @click="openCreate"
><text>还没有{{ recordName }}</text
><AppButton block :label="`新建${recordName}`" @click="openCreate"
/></view>
<view v-else class="memo-list">
<text v-if="saveNotice" class="save-notice"
>备忘已保存</text
>{{ recordName }}已保存</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in memos" :key="item.id" class="memo-card" role="button" :aria-label="`查看${item.title}详情`" @click="openMemoDetail(item)">
<view class="memo-card__copy">
<text>{{ item.title }}</text>
<text v-if="item.remindTime">{{ item.remindTime }}</text>
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
<text v-if="item.content" class="memo-card__content">{{ item.content }}</text>
</view>
<view v-if="item.canEdit || item.canDelete" class="memo-card__actions">
@@ -125,29 +126,30 @@
</view>
<AppDialog
:visible="Boolean(detailTarget)"
eyebrow="备忘详情"
:title="detailTarget?.title || '家族备忘'"
:eyebrow="`${recordName}详情`"
:title="detailTarget?.title || recordName"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="closeMemoDetail"
@cancel="closeMemoDetail"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整备忘" />
<AppLoading v-if="detailState === 'loading'" :text="`正在读取完整${recordName}`" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else>
<text>提醒时间{{ detailTarget?.remindTime || "未设置" }}</text>
<text>完成状态{{ detailTarget?.completed === "1" ? "已完成" : "未完成" }}</text>
<text class="detail-content__body">{{ detailTarget?.content || "未填写备忘内容" }}</text>
<text v-if="detailTarget?.createTime">创建时间{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
<text class="detail-content__body">{{ detailTarget?.content || `未填写${recordName}内容` }}</text>
<view v-if="detailTarget?.mediaFiles?.length" class="detail-media">
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" aria-label="查看备忘图片" @click="previewDetailMedia(file)" />
<image v-for="file in detailTarget.mediaFiles" :key="file.fileId" :src="file.accessUrl" mode="aspectFill" role="button" :aria-label="`查看${recordName}图片`" @click="previewDetailMedia(file)" />
</view>
</template>
</view>
</AppDialog>
<AppDialog
:visible="discardVisible"
title="放弃家族备忘?"
:title="`放弃${recordName}`"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
@@ -158,10 +160,10 @@
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这条家族备忘?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
cancel-text="保留备忘"
:title="`将这条${recordName}移至回收站?`"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
:cancel-text="`保留${recordName}`"
show-cancel
:close-on-mask="false"
@confirm="deleteMemo"
@@ -182,9 +184,11 @@ import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { MEMO_TYPE } from "@/services/api/life-record-contract.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 { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
isImagePickCancelled,
@@ -193,6 +197,7 @@ import {
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
const genealogyId = ref("");
const memoType = ref(MEMO_TYPE.GENERAL);
const view = ref("list");
const listState = ref("loading");
const memos = ref([]);
@@ -228,6 +233,9 @@ const memoDetailRequestController = createRequestController();
const memoCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isBenefactorMode = computed(() => memoType.value === MEMO_TYPE.BENEFACTOR);
const recordName = computed(() => isBenefactorMode.value ? "家族恩人" : "家族备忘");
const pageTitle = computed(() => recordName.value);
const isEdit = computed(() => Boolean(editingMemo.value));
const remindTime = computed(() =>
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
@@ -276,7 +284,7 @@ const loadMemos = async () => {
requestController: memoListRequestController,
});
if (!pageActive) return;
memos.value = rows;
memos.value = rows.filter((memo) => memo.memoType === memoType.value);
listState.value = memos.value.length ? "ready" : "empty";
if (pendingMemoId.value) {
const target = memos.value.find((item) => item.id === pendingMemoId.value);
@@ -413,6 +421,7 @@ const saveMemo = async () => {
return;
}
const payload = {
memoType: memoType.value,
memoTitle: form.memoTitle,
remindTime: remindTime.value,
memoContent: form.memoContent,
@@ -510,6 +519,9 @@ const requestBack = () =>
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
memoType.value = query?.memoType === MEMO_TYPE.BENEFACTOR
? MEMO_TYPE.BENEFACTOR
: MEMO_TYPE.GENERAL;
pendingMemoId.value = /^[1-9]\d*$/.test(String(query?.memoId || "")) ? String(query.memoId) : "";
if (valid.value) loadMemos();
else listState.value = "invalid";
@@ -544,12 +556,14 @@ onUnload(() => {
z-index: 1;
}
.page-content {
padding: 18rpx 24rpx 72rpx;
flex: 1;
padding: 18rpx 24rpx calc(72rpx + env(safe-area-inset-bottom));
}
.form-card,
.state-card,
.memo-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -674,7 +688,7 @@ onUnload(() => {
.memo-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
@@ -707,7 +721,7 @@ onUnload(() => {
}
.memo-card__actions .app-button {
width: 140rpx;
min-height: 68rpx;
min-height: 80rpx;
}
.memo-card text {
display: block;