Files
jiapuapp/pages/records/merit-records.vue
T
2026-09-13 17:45:52 +08:00

914 lines
31 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="merit-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>{{ isEdit ? "编辑功德记录" : "新建功德记录" }}</text>
<text class="form-copy">{{ isEdit ? "原有设置会保留。" : "红色 * 为必填项,其余内容可按需补充。" }}</text>
<view v-for="field in fields" :key="field.key" class="field"
><text
><text v-if="field.required" class="required-mark">*</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.inputType || 'text'"
:placeholder="field.placeholder || `请输入${field.label}`"
@input="error = ''"
/></view>
<view class="field field--picker"
><text>功德类型</text
><picker
:range="meritTypeOptions.map((item) => item.label)"
:value="meritTypeIndex"
@change="selectMeritType"
><view :class="{ placeholder: !form.type }">{{
meritTypeLabel || "请选择"
}}</view></picker
></view
>
<view class="field field--picker"
><text>记录日期</text
><picker mode="date" :value="form.meritDate" @change="selectMeritDate"
><view :class="{ placeholder: !form.meritDate }">{{
form.meritDate || "请选择"
}}</view></picker
></view
>
<view class="field field--picker"
><text>记录时间</text
><picker
mode="time"
:value="form.meritClock"
@change="selectMeritClock"
><view :class="{ placeholder: !form.meritClock }">{{
form.meritClock || "请选择"
}}</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 || uploading"
:label="submitting ? '正在提交' : isEdit ? '保存修改' : '提交功德记录'"
@click="saveMeritRecord"
/></view>
</view>
<view v-else-if="!valid" class="state-card"
><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
><AppButton block type="secondary" label="重新加载" @click="loadMerits"
/></view>
<view v-else-if="listState === 'empty'" class="state-card"
><text>还没有功德记录</text
><AppButton block label="新建功德记录" @click="openCreate"
/></view>
<view v-else class="merit-list">
<BatchManagementBar
v-if="deletableMerits.length"
resource-name="功德记录"
:active="meritBatch.selectionMode.value"
:selected-count="meritBatch.selectedCount.value"
:all-selected="meritBatch.allSelected.value"
:busy="meritBatch.deleting.value"
@start="meritBatch.enterSelectionMode"
@finish="meritBatch.exitSelectionMode"
@toggle-all="meritBatch.toggleAll"
@delete="meritBatch.requestDelete"
/>
<text v-if="meritBatch.notice.value" class="save-notice" role="status">{{ meritBatch.notice.value }}</text>
<text v-if="meritBatch.error.value" class="delete-error" role="alert">{{ meritBatch.error.value }}</text>
<text v-if="saveNotice" class="save-notice"
>{{ saveNotice }}</text
>
<text class="merit-summary"
> {{ merits.length }} 金额合计 ¥{{ totalAmount }}</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view v-for="item in merits" :key="item.id" class="merit-card" role="button" :aria-label="meritBatch.selectionMode.value && item.canDelete ? `${meritBatch.isSelected(item) ? '取消选择' : '选择'}${item.title}` : `查看${item.title}详情`" @click="handleMeritClick(item)">
<BatchSelectionMark
v-if="meritBatch.selectionMode.value && item.canDelete"
:selected="meritBatch.isSelected(item)"
:label="`功德记录:${item.title}`"
@toggle="meritBatch.toggleSelection(item)"
/>
<view class="merit-card__main">
<image
v-if="item.mediaFiles?.[0]?.accessUrl"
class="merit-card__cover"
:src="item.mediaFiles[0].accessUrl"
mode="aspectFill"
/>
<view class="merit-card__copy">
<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>
<view v-if="!meritBatch.selectionMode.value" class="merit-card__amount">
<text>¥{{ item.amount }}</text>
<AppButton
v-if="item.canEdit"
compact
type="secondary"
label="编辑"
@click.stop="openEditMerit(item)"
/>
<AppButton
v-if="item.canDelete"
compact
type="secondary"
label="删除"
@click.stop="requestDeleteMerit(item)"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="meritBatch.confirmationVisible.value"
title="将选中的功德记录移入回收站?"
:message="meritBatch.confirmationMessage.value"
:confirm-text="meritBatch.deleting.value ? '正在删除' : '移入回收站'"
cancel-text="继续选择"
show-cancel
:close-on-mask="false"
@confirm="meritBatch.confirmDelete"
@cancel="meritBatch.cancelDelete"
/>
<AppDialog
:visible="Boolean(detailTarget)"
eyebrow="功德详情"
:title="detailTarget?.title || '功德记录'"
confirm-text="关闭"
:close-on-mask="detailState !== 'loading'"
@confirm="closeMeritDetail"
@cancel="closeMeritDetail"
>
<view class="detail-content">
<AppLoading v-if="detailState === 'loading'" text="正在读取完整记录" />
<text v-else-if="detailState === 'error'" class="detail-error">{{ detailError }}</text>
<template v-else>
<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>
<AppDialog
:visible="discardVisible"
title="放弃功德记录?"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppDialog
:visible="deleteConfirmationVisible"
title="将这笔功德记录移至回收站?"
message="移入回收站后不再展示,家谱管理员可在保留期内恢复。"
confirm-text="移至回收站"
cancel-text="保留记录"
show-cancel
:close-on-mask="false"
@confirm="deleteMerit"
@cancel="closeDeleteConfirmation"
/>
</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 BatchManagementBar from "@/components/BatchManagementBar.vue";
import BatchSelectionMark from "@/components/BatchSelectionMark.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
import { useBatchDeletion } from "@/composables/use-batch-deletion.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 { 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);
const deleting = ref(false);
const deleteError = ref("");
const editingMerit = ref(null);
const detailTarget = ref(null);
const detailState = ref("idle");
const detailError = ref("");
const formBaseline = ref("");
const form = reactive({
donor: "",
title: "",
type: "",
amount: "",
meritDate: "",
meritClock: "",
content: "",
});
const fields = [
{ key: "donor", label: "捐赠人", required: true },
{ key: "title", label: "功德标题", required: true },
{ key: "amount", inputType: "digit", label: "金额" },
{ key: "content", label: "记录内容" },
];
const meritListRequestController = createRequestController();
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 meritBatch = useBatchDeletion({
items: merits,
deleteOne: (merit) =>
lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
requestController: meritDeletionRequestController,
}),
resourceName: "功德记录",
isActive: () => pageActive,
onEmpty: () => {
listState.value = "empty";
},
});
const deletableMerits = meritBatch.deletableItems;
const isEdit = computed(() => Boolean(editingMerit.value));
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()) || mediaReceipts.value.length > 0,
);
const meritTime = computed(() =>
form.meritDate ? `${form.meritDate} ${form.meritClock || "00:00"}:00` : "",
);
const meritTypeIndex = computed(() =>
Math.max(
0,
meritTypeOptions.value.findIndex((item) => item.value === form.type),
),
);
const meritTypeLabel = computed(
() => meritTypeOptions.value.find((item) => item.value === form.type)?.label || "",
);
const addCurrencyAmounts = (left, right) => {
const [leftWhole, leftFraction] = left.split(".");
const [rightWhole, rightFraction] = right.split(".");
let carry = Number(leftFraction) + Number(rightFraction);
const fraction = String(carry % 100).padStart(2, "0");
carry = Math.floor(carry / 100);
const digits = [];
let leftIndex = leftWhole.length - 1;
let rightIndex = rightWhole.length - 1;
while (leftIndex >= 0 || rightIndex >= 0 || carry) {
const sum = (Number(leftWhole[leftIndex--] || 0) + Number(rightWhole[rightIndex--] || 0) + carry);
digits.push(sum % 10);
carry = Math.floor(sum / 10);
}
return `${digits.reverse().join("")}.${fraction}`;
};
const totalAmount = computed(() =>
merits.value.reduce((total, item) => addCurrencyAmounts(total, item.amount), "0.00"),
);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const confirmDiscard = () => {
confirmation.confirm();
resetForm();
view.value = "list";
};
const cancelDiscard = confirmation.cancel;
const resetForm = () => {
Object.assign(form, {
donor: "",
title: "",
type: "",
amount: "",
meritDate: "",
meritClock: "",
content: "",
});
mediaReceipts.value = [];
editingMerit.value = null;
formBaseline.value = "";
error.value = "";
uploadError.value = "";
};
const loadMerits = async () => {
if (!valid.value) return;
meritListRequestController.abort();
listState.value = "loading";
try {
const rows = await lifeRecordApi.getMeritRecords(genealogyId.value, {
requestController: meritListRequestController,
});
if (!pageActive) return;
merits.value = rows;
meritBatch.exitSelectionMode();
listState.value = merits.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
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";
};
const splitMeritTime = (value) => {
const matched = String(value || "").trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
if (!matched) throw new Error("记录时间有误,暂时无法编辑。");
return { date: matched[1], clock: matched[2] };
};
const openEditMerit = async (merit) => {
if (!merit?.canEdit || !valid.value || submitting.value) return;
meritEditorDetailRequestController.abort();
listState.value = "loading";
try {
const detail = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
requestController: meritEditorDetailRequestController,
});
if (!pageActive) return;
if (
!detail.canEdit ||
!detail.donor ||
!detail.title ||
!meritTypeOptions.value.some((item) => item.value === detail.type) ||
!detail.amount ||
!["0", "1"].includes(detail.status) ||
!Number.isSafeInteger(detail.sortOrder)
) {
throw new Error("这条功德记录的信息不完整,暂未保存修改,以免覆盖原内容。");
}
const timeParts = splitMeritTime(detail.time);
resetForm();
Object.assign(form, {
donor: detail.donor,
title: detail.title,
type: detail.type,
amount: detail.amount,
meritDate: timeParts.date,
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,
status: detail.status,
};
formBaseline.value = formSnapshot.value;
listState.value = "ready";
view.value = "form";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openMeritDetail = async (merit) => {
if (!merit?.id || detailState.value === "loading") return;
detailTarget.value = merit;
detailState.value = "loading";
detailError.value = "";
meritDetailRequestController.abort();
try {
const loadedMerit = await lifeRecordApi.getMeritRecordDetail(genealogyId.value, merit.id, {
requestController: meritDetailRequestController,
});
if (!pageActive) return;
detailTarget.value = loadedMerit;
detailState.value = "ready";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
detailState.value = "error";
detailError.value = getRequestErrorMessage(cause, "完整记录读取失败,请稍后重试。");
}
};
const handleMeritClick = (merit) => {
if (meritBatch.selectionMode.value && merit?.canDelete) {
meritBatch.toggleSelection(merit);
return;
}
openMeritDetail(merit);
};
const closeMeritDetail = () => {
if (detailState.value === "loading") return;
detailTarget.value = null;
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";
};
const selectMeritDate = (event) => {
form.meritDate = event.detail.value || "";
error.value = "";
};
const selectMeritClock = (event) => {
form.meritClock = event.detail.value || "";
error.value = "";
};
const selectMeritType = (event) => {
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 || uploading.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 && !/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/.test(amountText)) {
error.value = "金额应为 0 至 9999999999.99,最多保留两位小数";
return;
}
const payload = {
donorName,
meritTitle,
meritType: form.type,
meritContent: form.content,
meritTime: meritTime.value,
mediaOssIds: mediaOssIds.value,
...(amountText ? { amount: Number(amountText) } : {}),
...(editingMerit.value
? {
sortOrder: editingMerit.value.sortOrder,
status: editingMerit.value.status,
}
: {}),
};
const createAttempt = editingMerit.value
? null
: meritRecordCreateGuard.begin(payload);
if (!editingMerit.value && createAttempt === null) {
error.value =
"上次提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
return;
}
submitting.value = true;
error.value = "";
try {
const wasEditing = Boolean(editingMerit.value);
if (editingMerit.value) {
await lifeRecordApi.updateMeritRecord(genealogyId.value, editingMerit.value.id, payload, {
requestController: meritSaveRequestController,
});
} else {
await lifeRecordApi.createMeritRecord(genealogyId.value, payload, {
requestController: meritSaveRequestController,
});
}
if (!pageActive) return;
resetForm();
view.value = "list";
saveNotice.value = wasEditing
? "功德记录已更新。"
: "功德记录已保存。";
await loadMerits();
} catch (cause) {
if (!pageActive) return;
if (
!editingMerit.value &&
meritRecordCreateGuard.recordFailure(createAttempt, cause)
) {
error.value =
"提交结果暂时无法确认,请先返回功德记录列表检查,避免重复创建。";
return;
}
if (!isRequestCancelled(cause))
error.value = getRequestErrorMessage(cause, "功德记录提交失败,请稍后重试。");
} finally {
if (pageActive) submitting.value = false;
}
};
const requestDeleteMerit = (merit) => {
if (!merit?.canDelete || deleting.value) return;
deleteError.value = "";
deleteTarget.value = merit;
deleteConfirmationVisible.value = true;
};
const closeDeleteConfirmation = () => {
if (deleting.value) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
};
const deleteMerit = async () => {
const merit = deleteTarget.value;
if (!merit?.canDelete || deleting.value) return;
deleting.value = true;
deleteError.value = "";
try {
await lifeRecordApi.deleteMeritRecord(genealogyId.value, merit.id, {
requestController: meritDeletionRequestController,
});
if (!pageActive) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
await loadMerits();
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
deleteError.value = getRequestErrorMessage(cause, "功德记录删除失败,请稍后重试。");
deleteConfirmationVisible.value = false;
} finally {
if (pageActive) deleting.value = false;
}
};
const requestBack = () => {
if (meritBatch.deleting.value) return true;
if (meritBatch.confirmationVisible.value) {
meritBatch.cancelDelete();
return true;
}
if (meritBatch.selectionMode.value) {
meritBatch.exitSelectionMode();
return true;
}
if (detailTarget.value) {
closeMeritDetail();
return true;
}
if (view.value !== "form") return goBack();
return runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
};
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
if (valid.value) {
void loadMeritTypes().catch(() => {});
loadMerits();
}
else listState.value = "invalid";
});
onShow(() => {
if (valid.value && view.value === "list" && listState.value !== "loading")
loadMerits();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
meritListRequestController.abort();
meritEditorDetailRequestController.abort();
meritSaveRequestController.abort();
meritDeletionRequestController.abort();
meritDetailRequestController.abort();
meritImageUploadRequestController.abort();
meritTypeRequestController.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.merit-page {
display: flex;
min-height: 100vh;
flex-direction: column;
background: $paper;
}
.page-header,
.page-content {
z-index: 1;
}
.page-content {
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;
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: 80rpx;
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: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;
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;
}
.merit-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.save-notice {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.merit-summary {
display: block;
color: $ink-muted;
font-size: clamp(14px, 22rpx, 17px);
}
.delete-error {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.merit-card {
display: flex;
min-height: 138rpx;
flex-direction: column;
align-items: stretch;
gap: 18rpx;
padding: 28rpx 32rpx;
}
.merit-card__main {
display: flex;
align-items: flex-start;
gap: 18rpx;
}
.merit-card__copy {
min-width: 0;
flex: 1;
}
.merit-card__cover {
width: 148rpx;
height: 148rpx;
flex: 0 0 148rpx;
border-radius: 8rpx;
background: rgba(128, 89, 49, .12);
}
.merit-card text {
display: block;
}
.merit-card__copy text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
overflow-wrap: anywhere;
}
.merit-card__copy text:not(:first-child) {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
overflow-wrap: anywhere;
}
.merit-card__amount {
display: flex;
width: 100%;
align-items: center;
gap: 10rpx;
}
.merit-card__amount > text {
margin-right: auto;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.merit-card__amount .app-button {
width: 140rpx;
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; }
</style>