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
+91 -23
View File
@@ -13,7 +13,7 @@
<view class="page-content">
<view v-if="view === 'form'" class="form-card">
<text>{{ isEdit ? '编辑成长记录' : '新建成长记录' }}</text>
<text class="form-copy">{{ isEdit ? '原有图片和相关设置会保留。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<text class="form-copy">{{ isEdit ? '原有图片、视频和相关设置会保留,也可以逐项移除。' : '红色 * 为必填项,其余内容可按需补充。' }}</text>
<view class="field field--picker">
<text>关联人物</text>
<picker
@@ -106,23 +106,32 @@
>
<view class="upload-field">
<view
><text>相关图片</text
><text>相关图片或视频</text
><text
>图片上传成功后会随这条记录一起保存</text
>媒体上传成功后会随这条记录一起保存</text
></view
>
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadImage"
>
{{ uploading ? "上传中…" : "添加图片" }}
</button>
<text
<view class="upload-actions">
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadImage"
>添加图片</button>
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadVideo"
>添加视频</button>
</view>
<text v-if="uploading">正在上传媒体</text>
<view
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
>已上传{{ receipt.fileName || "图片" }}</text
class="upload-receipt"
>
<text>已上传{{ receipt.fileName || "媒体文件" }}</text>
<button :disabled="uploading || submitting" @click="removeMedia(index)">移除</button>
</view>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<text v-if="error" class="error">{{ error }}</text>
@@ -184,6 +193,7 @@
><text
>{{ growthRecordTypeLabel(item)
}}{{ item.date ? ` · ${item.date}` : "" }}</text
><text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
<AppButton
@@ -222,9 +232,9 @@
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="删除这条成长记录?"
message="删除后无法恢复,请确认当前内容不再需要。"
confirm-text="确认删除"
title="这条成长记录移至回收站"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留记录"
show-cancel
:close-on-mask="false"
@@ -252,10 +262,13 @@ import { lifeRecordApi } from "@/services/api/life-record-service.js";
import { lineageApi } from "@/services/api/lineage-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,
isVideoPickCancelled,
pickAndUploadImage,
pickAndUploadVideo,
} from "@/utils/media-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
@@ -358,7 +371,11 @@ const formSnapshot = computed(() =>
const dirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Object.values(form).some((value) => String(value).trim()) ||
: Object.entries(form).some(
([field, value]) =>
field !== "lineagePersonId" && String(value).trim(),
) ||
form.lineagePersonId !== defaultLineagePersonId.value ||
mediaReceipts.value.length > 0,
);
const confirmation = createDiscardConfirmation((visible) => {
@@ -636,6 +653,28 @@ const saveGrowthRecord = async () => {
if (pageActive) submitting.value = false;
}
};
const uploadVideo = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const uploadReceipt = await pickAndUploadVideo({
requestController: growthMediaUploadRequestController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
} catch (cause) {
if (pageActive && !isVideoPickCancelled(cause) && !isRequestCancelled(cause))
uploadError.value = getRequestErrorMessage(cause, "视频上传失败,请稍后重试。");
} finally {
if (pageActive) uploading.value = false;
}
};
const removeMedia = (index) => {
if (uploading.value || submitting.value) return;
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
uploadError.value = "";
};
const requestDeleteRecord = (record) => {
if (!record?.canDelete || deleting.value) return;
deleteError.value = "";
@@ -726,12 +765,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,
.record-card {
@include adaptive-records-content;
background-color: rgba($paper, 0.82);
}
.form-card {
box-sizing: border-box;
@@ -773,7 +814,7 @@ onUnload(() => {
@include adaptive-records-field;
box-sizing: border-box;
width: 100%;
min-height: 76rpx;
min-height: 80rpx;
padding: 16rpx 22rpx;
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
@@ -801,15 +842,15 @@ onUnload(() => {
padding: 18rpx 22rpx;
@include adaptive-records-field;
}
.upload-field > view > text {
.upload-field > view:first-child > text {
display: block;
}
.upload-field > view > text:first-child {
.upload-field > view:first-child > text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.upload-field > view > text:last-child {
.upload-field > view:first-child > text:last-child {
margin-top: 6rpx;
color: $ink-muted;
font-size: clamp(13px, 20rpx, 16px);
@@ -875,13 +916,40 @@ onUnload(() => {
.record-list {
display: flex;
flex-direction: column;
gap: 16rpx;
gap: 20rpx;
}
.save-notice {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-actions {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.upload-receipt {
display: flex;
align-items: center;
gap: 12rpx;
}
.upload-receipt > text {
min-width: 0;
flex: 1;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.upload-receipt > button {
min-height: 72rpx;
margin: 0;
padding: 0 16rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(13px, 20rpx, 16px);
}
.upload-receipt > button::after { border: 0; }
.delete-error {
display: block;
color: $brand-red;
@@ -892,7 +960,7 @@ onUnload(() => {
@include adaptive-records-field;
display: flex;
box-sizing: border-box;
min-height: 78rpx;
min-height: 80rpx;
align-items: center;
justify-content: space-between;
padding: 14rpx 18rpx;