feat: 完成前端业务闭环与后端联调
This commit is contained in:
+133
-22
@@ -59,13 +59,27 @@
|
||||
}}</view></picker
|
||||
></view
|
||||
>
|
||||
<view class="upload-field">
|
||||
<view>
|
||||
<text>相关图片</text>
|
||||
<text>图片上传成功后,会随这条功德记录一起保存。</text>
|
||||
</view>
|
||||
<button class="upload-button" :disabled="uploading || submitting" @click="uploadImage">
|
||||
{{ uploading ? "上传中…" : "添加图片" }}
|
||||
</button>
|
||||
<view v-for="(receipt, index) in mediaReceipts" :key="`${receipt.ossId}-${index}`" class="upload-receipt">
|
||||
<text>已上传:{{ receipt.fileName || "图片" }}</text>
|
||||
<button :disabled="uploading || submitting" @click="removeImage(index)">移除</button>
|
||||
</view>
|
||||
<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"
|
||||
:disabled="submitting || uploading"
|
||||
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
|
||||
@click="saveMeritRecord"
|
||||
/></view>
|
||||
@@ -98,6 +112,7 @@
|
||||
<text>{{ item.title }}</text>
|
||||
<text>{{ item.donor }}{{ item.typeLabel ? ` · ${item.typeLabel}` : "" }}</text>
|
||||
<text v-if="item.time">{{ item.time }}</text>
|
||||
<text v-if="item.createTime">创建于 {{ formatMinuteTimestamp(item.createTime) }}</text>
|
||||
<text v-if="item.content">{{ item.content }}</text>
|
||||
</view>
|
||||
<view class="merit-card__amount">
|
||||
@@ -136,8 +151,20 @@
|
||||
<text>捐赠人:{{ detailTarget?.donor || "未署名" }}</text>
|
||||
<text>功德类型:{{ detailTarget?.typeLabel || "未填写" }}</text>
|
||||
<text>记录时间:{{ detailTarget?.time || "未填写" }}</text>
|
||||
<text v-if="detailTarget?.createTime">创建时间:{{ formatMinuteTimestamp(detailTarget.createTime) }}</text>
|
||||
<text>金额:¥{{ detailTarget?.amount || "0.00" }}</text>
|
||||
<text class="detail-content__body">{{ detailTarget?.content || "未填写记录内容" }}</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="file.fileName || '查看功德记录图片'"
|
||||
@click="previewMeritImage(file)"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</AppDialog>
|
||||
@@ -154,9 +181,9 @@
|
||||
/>
|
||||
<AppDialog
|
||||
:visible="deleteConfirmationVisible"
|
||||
title="删除这笔功德记录?"
|
||||
message="删除后无法恢复,请确认当前内容不再需要。"
|
||||
confirm-text="确认删除"
|
||||
title="将这笔功德记录移至回收站?"
|
||||
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
|
||||
confirm-text="移至回收站"
|
||||
cancel-text="保留记录"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@@ -174,9 +201,7 @@ 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 {
|
||||
MERIT_TYPE_OPTIONS as meritTypeOptions
|
||||
} from "@/services/api/life-record-contract.js";
|
||||
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
|
||||
import {
|
||||
createRequestController,
|
||||
isRequestCancelled
|
||||
@@ -184,16 +209,25 @@ import {
|
||||
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,
|
||||
pickAndUploadImage,
|
||||
} from "@/utils/media-upload.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation/gateway.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const meritTypeOptions = ref([]);
|
||||
const view = ref("list");
|
||||
const listState = ref("loading");
|
||||
const merits = ref([]);
|
||||
const saveNotice = ref("");
|
||||
const submitting = ref(false);
|
||||
const uploading = ref(false);
|
||||
const error = ref("");
|
||||
const uploadError = ref("");
|
||||
const mediaReceipts = ref([]);
|
||||
const discardVisible = ref(false);
|
||||
const deleteConfirmationVisible = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
@@ -224,15 +258,18 @@ const meritEditorDetailRequestController = createRequestController();
|
||||
const meritSaveRequestController = createRequestController();
|
||||
const meritDeletionRequestController = createRequestController();
|
||||
const meritDetailRequestController = createRequestController();
|
||||
const meritImageUploadRequestController = createRequestController();
|
||||
const meritTypeRequestController = createRequestController();
|
||||
const meritRecordCreateGuard = createNonIdempotentWriteGuard();
|
||||
let pageActive = true;
|
||||
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const isEdit = computed(() => Boolean(editingMerit.value));
|
||||
const formSnapshot = computed(() => JSON.stringify(form));
|
||||
const mediaOssIds = computed(() => mediaReceipts.value.map((item) => item.ossId).join(","));
|
||||
const formSnapshot = computed(() => JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }));
|
||||
const dirty = computed(() =>
|
||||
isEdit.value
|
||||
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
|
||||
: Object.values(form).some((value) => String(value).trim()),
|
||||
: Object.values(form).some((value) => String(value).trim()) || mediaReceipts.value.length > 0,
|
||||
);
|
||||
const meritTime = computed(() =>
|
||||
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
|
||||
@@ -240,11 +277,11 @@ const meritTime = computed(() =>
|
||||
const meritTypeIndex = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
meritTypeOptions.findIndex((item) => item.value === form.type),
|
||||
meritTypeOptions.value.findIndex((item) => item.value === form.type),
|
||||
),
|
||||
);
|
||||
const meritTypeLabel = computed(
|
||||
() => meritTypeOptions.find((item) => item.value === form.type)?.label || "",
|
||||
() => meritTypeOptions.value.find((item) => item.value === form.type)?.label || "",
|
||||
);
|
||||
const addCurrencyAmounts = (left, right) => {
|
||||
const [leftWhole, leftFraction] = left.split(".");
|
||||
@@ -285,9 +322,11 @@ const resetForm = () => {
|
||||
meritClock: "",
|
||||
content: "",
|
||||
});
|
||||
mediaReceipts.value = [];
|
||||
editingMerit.value = null;
|
||||
formBaseline.value = "";
|
||||
error.value = "";
|
||||
uploadError.value = "";
|
||||
};
|
||||
const loadMerits = async () => {
|
||||
if (!valid.value) return;
|
||||
@@ -305,8 +344,23 @@ const loadMerits = async () => {
|
||||
listState.value = "error";
|
||||
}
|
||||
};
|
||||
const openCreate = () => {
|
||||
const loadMeritTypes = async () => {
|
||||
meritTypeRequestController.abort();
|
||||
meritTypeOptions.value = await businessDictionaryApi.getBusinessDictionaryOptions(
|
||||
"gen_merit_type",
|
||||
{ requestController: meritTypeRequestController },
|
||||
);
|
||||
};
|
||||
const openCreate = async () => {
|
||||
if (!valid.value) return;
|
||||
if (!meritTypeOptions.value.length) {
|
||||
try {
|
||||
await loadMeritTypes();
|
||||
} catch (cause) {
|
||||
if (!isRequestCancelled(cause)) error.value = getRequestErrorMessage(cause, "功德类型暂时无法读取。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
saveNotice.value = "";
|
||||
resetForm();
|
||||
view.value = "form";
|
||||
@@ -329,7 +383,7 @@ const openEditMerit = async (merit) => {
|
||||
!detail.canEdit ||
|
||||
!detail.donor ||
|
||||
!detail.title ||
|
||||
!meritTypeOptions.some((item) => item.value === detail.type) ||
|
||||
!meritTypeOptions.value.some((item) => item.value === detail.type) ||
|
||||
!detail.amount ||
|
||||
!["0", "1"].includes(detail.status) ||
|
||||
!Number.isSafeInteger(detail.sortOrder)
|
||||
@@ -347,6 +401,10 @@ const openEditMerit = async (merit) => {
|
||||
meritClock: timeParts.clock,
|
||||
content: detail.content,
|
||||
});
|
||||
mediaReceipts.value = detail.mediaFiles.map((file) => ({
|
||||
ossId: String(file.ossId),
|
||||
fileName: file.fileName,
|
||||
}));
|
||||
editingMerit.value = {
|
||||
id: detail.id,
|
||||
sortOrder: detail.sortOrder,
|
||||
@@ -385,6 +443,11 @@ const closeMeritDetail = () => {
|
||||
detailState.value = "idle";
|
||||
detailError.value = "";
|
||||
};
|
||||
const previewMeritImage = (file) => {
|
||||
const urls = detailTarget.value?.mediaFiles?.map((mediaFile) => mediaFile.accessUrl).filter(Boolean) || [];
|
||||
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
|
||||
uni.previewImage({ current: file.accessUrl, urls });
|
||||
};
|
||||
const cancelCreate = () => {
|
||||
resetForm();
|
||||
view.value = "list";
|
||||
@@ -398,11 +461,33 @@ const selectMeritClock = (event) => {
|
||||
error.value = "";
|
||||
};
|
||||
const selectMeritType = (event) => {
|
||||
form.type = meritTypeOptions[Number(event.detail.value)]?.value || "";
|
||||
form.type = meritTypeOptions.value[Number(event.detail.value)]?.value || "";
|
||||
error.value = "";
|
||||
};
|
||||
const uploadImage = async () => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
uploading.value = true;
|
||||
uploadError.value = "";
|
||||
try {
|
||||
const receipt = await pickAndUploadImage({
|
||||
requestController: meritImageUploadRequestController,
|
||||
});
|
||||
if (!pageActive) return;
|
||||
mediaReceipts.value = [...mediaReceipts.value, receipt];
|
||||
} catch (cause) {
|
||||
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
|
||||
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
|
||||
} finally {
|
||||
if (pageActive) uploading.value = false;
|
||||
}
|
||||
};
|
||||
const removeImage = (index) => {
|
||||
if (uploading.value || submitting.value) return;
|
||||
mediaReceipts.value = mediaReceipts.value.filter((_, receiptIndex) => receiptIndex !== index);
|
||||
uploadError.value = "";
|
||||
};
|
||||
const saveMeritRecord = async () => {
|
||||
if (submitting.value || !valid.value) return;
|
||||
if (submitting.value || uploading.value || !valid.value) return;
|
||||
const donorName = form.donor.trim();
|
||||
const meritTitle = form.title.trim();
|
||||
const amountText = form.amount.trim();
|
||||
@@ -410,8 +495,8 @@ const saveMeritRecord = async () => {
|
||||
error.value = !donorName ? "请填写捐赠人" : "请填写功德标题";
|
||||
return;
|
||||
}
|
||||
if (amountText && !Number.isFinite(Number(amountText))) {
|
||||
error.value = "金额必须是数字";
|
||||
if (amountText && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(amountText)) {
|
||||
error.value = "金额应为 0 至 9999999999.99,最多保留两位小数";
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -420,6 +505,7 @@ const saveMeritRecord = async () => {
|
||||
meritType: form.type,
|
||||
meritContent: form.content,
|
||||
meritTime: meritTime.value,
|
||||
mediaOssIds: mediaOssIds.value,
|
||||
...(amountText ? { amount: Number(amountText) } : {}),
|
||||
...(editingMerit.value
|
||||
? {
|
||||
@@ -521,7 +607,10 @@ const requestBack = () =>
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (valid.value) loadMerits();
|
||||
if (valid.value) {
|
||||
void loadMeritTypes().catch(() => {});
|
||||
loadMerits();
|
||||
}
|
||||
else listState.value = "invalid";
|
||||
});
|
||||
onShow(() => {
|
||||
@@ -536,6 +625,8 @@ onUnload(() => {
|
||||
meritSaveRequestController.abort();
|
||||
meritDeletionRequestController.abort();
|
||||
meritDetailRequestController.abort();
|
||||
meritImageUploadRequestController.abort();
|
||||
meritTypeRequestController.abort();
|
||||
confirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -553,12 +644,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,
|
||||
.merit-card {
|
||||
@include adaptive-records-content;
|
||||
background-color: rgba($paper, 0.82);
|
||||
}
|
||||
.form-card {
|
||||
box-sizing: border-box;
|
||||
@@ -600,7 +693,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);
|
||||
@@ -614,6 +707,22 @@ onUnload(() => {
|
||||
.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:first-child > text { display: block; }
|
||||
.upload-field > view:first-child > text:first-child { color: $ink; font-size: clamp(14px, 23rpx, 17px); font-weight: 700; }
|
||||
.upload-field > view:first-child > 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(184, 35, 35, .38); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: clamp(14px, 22rpx, 17px); }
|
||||
.upload-field > text { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.error {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
@@ -648,7 +757,7 @@ onUnload(() => {
|
||||
.merit-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
.save-notice {
|
||||
display: block;
|
||||
@@ -707,9 +816,11 @@ onUnload(() => {
|
||||
}
|
||||
.merit-card__amount .app-button {
|
||||
width: 140rpx;
|
||||
min-height: 68rpx;
|
||||
min-height: 80rpx;
|
||||
}
|
||||
.detail-content { width: 100%; margin-top: 18rpx; text-align: left; }
|
||||
.detail-media { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-top: 16rpx; gap: 10rpx; }
|
||||
.detail-media image { width: 100%; height: 150rpx; border-radius: 8rpx; background: rgba(128, 89, 49, .12); }
|
||||
.detail-content > text { display: block; margin-top: 9rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.detail-content__body { padding-top: 10rpx; border-top: 1rpx solid rgba(142, 95, 41, .2); color: $ink !important; white-space: pre-wrap; }
|
||||
.detail-error { color: $brand-red !important; }
|
||||
|
||||
Reference in New Issue
Block a user