Files
jiapuapp/pages/records/growth-journal.vue
T

1001 lines
32 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="record-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 class="field field--picker">
<text>关联人物</text>
<picker
:range="personOptionLabels"
:value="personOptionIndex"
:disabled="personOptionsState !== 'ready'"
@change="selectLineagePerson"
>
<view :class="{ placeholder: personOptionsState !== 'ready' }">{{
personOptionLabel
}}</view>
</picker>
<text v-if="personOptionsState === 'loading'" class="person-option-note"
>正在获取家谱成员</text
>
<text v-else-if="personOptionsState === 'error'" class="person-option-note"
>暂时无法选择关联成员这条记录不会关联成员</text
>
</view>
<view class="field field--picker">
<text>记录类型</text>
<picker
:range="growthTypeLabels"
:value="growthTypeIndex"
:disabled="growthTypeOptionsState !== 'ready' || !growthTypeOptions.length"
@change="selectGrowthType"
><view :class="{ placeholder: !form.recordType }">{{ growthTypeLabel }}</view></picker>
</view>
<text v-if="growthTypeOptionsState === 'loading'" class="person-option-note">正在获取记录类型</text>
<text v-else-if="growthTypeOptionsState === 'error'" class="person-option-note">暂时无法获取记录类型可不选类型继续填写</text>
<view class="field"
><text><text class="required-mark">*</text>记录标题</text
><input
v-model="form.recordTitle"
placeholder="请输入记录标题"
@input="error = ''"
/></view>
<view class="field field--textarea"
><text>记录内容</text
><textarea
v-model="form.recordContent"
auto-height
placeholder="记录成长片段"
@input="error = ''"
/>
</view>
<view class="field field--picker"
><text>记录日期</text
><picker
mode="date"
:value="form.recordDate"
@change="selectRecordDate"
><view :class="{ placeholder: !form.recordDate }">{{
form.recordDate || "请选择"
}}</view></picker
></view
>
<view class="field field--picker"
><text>记录时间</text
><picker
mode="time"
:value="form.recordClock"
@change="selectRecordClock"
><view :class="{ placeholder: !form.recordClock }">{{
form.recordClock || "请选择"
}}</view></picker
></view
>
<view class="field field--picker"
><text>提醒日期</text
><picker
mode="date"
:value="form.remindDate"
@change="selectRemindDate"
><view :class="{ placeholder: !form.remindDate }">{{
form.remindDate || "请选择"
}}</view></picker
></view
>
<view class="field field--picker"
><text>提醒时间</text
><picker
mode="time"
:value="form.remindClock"
@change="selectRemindClock"
><view :class="{ placeholder: !form.remindClock }">{{
form.remindClock || "请选择"
}}</view></picker
></view
>
<view class="upload-field">
<view
><text>相关图片或视频</text
><text
>媒体上传成功后会随这条记录一起保存</text
></view
>
<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}`"
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>
<view class="form-actions"
><AppButton
type="secondary"
label="取消"
@click="cancelCreate" /><AppButton
:disabled="submitting || uploading"
:label="submitting ? '正在提交' : '提交成长记录'"
@click="saveGrowthRecord"
/></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="loadRecords"
/></view>
<view v-else-if="listState === 'empty'" class="state-card"
><text>还没有成长记录</text
><AppButton block label="新建成长记录" @click="openCreate"
/></view>
<view v-else class="record-list">
<text v-if="saveNotice" class="save-notice"
>成长记录已保存</text
>
<text v-if="deleteError" class="delete-error">{{ deleteError }}</text>
<view class="record-filters">
<picker :range="filterPersonLabels" :value="filterPersonIndex" @change="selectFilterPerson">
<view><text>人物</text><text>{{ filterPersonLabels[filterPersonIndex] }}</text></view>
</picker>
<picker :range="filterTypeLabels" :value="filterTypeIndex" @change="selectFilterType">
<view><text>类型</text><text>{{ filterTypeLabels[filterTypeIndex] }}</text></view>
</picker>
</view>
<text v-if="!filteredRecords.length" class="filter-empty">没有符合当前筛选的成长记录</text>
<view
v-for="item in filteredRecords"
:key="item.id"
class="record-card"
role="button"
:aria-label="`查看${item.title}详情`"
@click="openRecordDetail(item)"
>
<view
><text>{{ item.title }}</text
><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
v-if="item.canEdit && personOptionsState === 'ready' && growthTypeOptionsState === 'ready'"
compact
type="secondary"
label="编辑"
@click.stop="openEdit(item)"
/>
<AppButton
v-if="item.canDelete"
compact
type="secondary"
label="删除"
@click.stop="requestDeleteRecord(item)"
/>
</view>
</view>
</view>
<GrowthRecordDetailDialog
ref="growthRecordDetailDialog"
:genealogy-id="genealogyId"
@busy-change="detailBusy = $event"
@transient-change="detailTransientOpen = $event"
/>
<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="deleteRecord"
@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 GrowthRecordDetailDialog from "@/components/records/GrowthRecordDetailDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { businessDictionaryApi } from "@/services/api/business-dictionary-service.js";
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";
const genealogyId = ref("");
const personId = ref("");
const view = ref("list");
const listState = ref("loading");
const records = ref([]);
const saveNotice = ref(false);
const submitting = ref(false);
const uploading = ref(false);
const error = ref("");
const uploadError = ref("");
const discardVisible = ref(false);
const editingRecord = ref(null);
const formBaseline = ref("");
const deleteConfirmationVisible = ref(false);
const deleteTarget = ref(null);
const deleting = ref(false);
const deleteError = ref("");
const filterPersonId = ref("");
const filterType = ref("");
const growthRecordDetailDialog = ref(null);
const detailTransientOpen = ref(false);
const detailBusy = ref(false);
const personOptionsState = ref("loading");
const personOptions = ref([{ value: "", label: "不关联人物" }]);
const defaultLineagePersonId = ref("");
const growthTypeOptionsState = ref("loading");
const growthTypeOptions = ref([]);
const form = reactive({
lineagePersonId: "",
recordType: "",
recordTitle: "",
recordContent: "",
recordDate: "",
recordClock: "",
remindDate: "",
remindClock: "",
});
const mediaReceipts = ref([]);
const recordListRequestController = createRequestController();
const growthEditorDetailRequestController = createRequestController();
const growthMediaUploadRequestController = createRequestController();
const growthRecordSaveRequestController = createRequestController();
const personOptionsController = createRequestController();
const growthTypeRequestController = createRequestController();
const growthRecordDeleteController = createRequestController();
const growthRecordCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
const shouldIgnoreRequestFailure = (cause) =>
!pageActive || isRequestCancelled(cause);
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
const isEdit = computed(() => Boolean(editingRecord.value));
const personOptionLabels = computed(() =>
personOptions.value.map((item) => item.label),
);
const personOptionIndex = computed(() =>
Math.max(
0,
personOptions.value.findIndex((item) => item.value === form.lineagePersonId),
),
);
const personOptionLabel = computed(() =>
personOptions.value[personOptionIndex.value]?.label || "不关联人物",
);
const growthTypeLabels = computed(() => growthTypeOptions.value.map((item) => item.label));
const growthTypeIndex = computed(() => Math.max(0, growthTypeOptions.value.findIndex((item) => item.value === form.recordType)));
const growthTypeLabel = computed(() => growthTypeOptions.value[growthTypeIndex.value]?.label || "请选择记录类型");
const growthRecordTypeLabel = (record) =>
growthTypeOptions.value.find((option) => option.value === record.type)?.label ||
record.typeLabel ||
record.personName;
const filterPersonOptions = computed(() => [
{ value: "", label: "全部人物" },
{ value: "NONE", label: "未关联人物" },
...personOptions.value.filter((item) => item.value),
]);
const filterPersonLabels = computed(() => filterPersonOptions.value.map((item) => item.label));
const filterPersonIndex = computed(() => Math.max(0, filterPersonOptions.value.findIndex((item) => item.value === filterPersonId.value)));
const filterTypeOptions = computed(() => [{ value: "", label: "全部类型" }, ...growthTypeOptions.value]);
const filterTypeLabels = computed(() => filterTypeOptions.value.map((item) => item.label));
const filterTypeIndex = computed(() => Math.max(0, filterTypeOptions.value.findIndex((item) => item.value === filterType.value)));
const filteredRecords = computed(() => records.value.filter((item) =>
(!filterPersonId.value || (filterPersonId.value === "NONE" ? !item.lineagePersonId : item.lineagePersonId === filterPersonId.value)) &&
(!filterType.value || item.type === filterType.value),
));
const mediaOssIds = computed(() =>
mediaReceipts.value.map((item) => item.ossId).join(","),
);
const recordTime = computed(() =>
form.recordDate ? `${form.recordDate} ${form.recordClock || "00:00"}:00` : "",
);
const remindTime = computed(() =>
form.remindDate ? `${form.remindDate} ${form.remindClock || "00:00"}:00` : "",
);
const formSnapshot = computed(() =>
JSON.stringify({ ...form, mediaOssIds: mediaOssIds.value }),
);
const dirty = computed(() =>
isEdit.value
? Boolean(formBaseline.value) && formSnapshot.value !== formBaseline.value
: Object.entries(form).some(
([field, value]) =>
field !== "lineagePersonId" && String(value).trim(),
) ||
form.lineagePersonId !== defaultLineagePersonId.value ||
mediaReceipts.value.length > 0,
);
const confirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const confirmDiscard = () => {
confirmation.confirm();
resetForm();
view.value = "list";
};
const cancelDiscard = confirmation.cancel;
const resetForm = () => {
Object.assign(form, {
lineagePersonId: defaultLineagePersonId.value,
recordType: "",
recordTitle: "",
recordContent: "",
recordDate: "",
recordClock: "",
remindDate: "",
remindClock: "",
});
mediaReceipts.value = [];
editingRecord.value = null;
formBaseline.value = "";
error.value = "";
uploadError.value = "";
};
const loadPersonOptions = async () => {
if (!valid.value) return;
personOptionsState.value = "loading";
try {
const rows = await lineageApi.getLineagePersonOptions(genealogyId.value, {
requestController: personOptionsController,
});
if (!pageActive) return;
const options = rows.map((item) => ({
value: item.id,
label: item.generation ? `第 ${item.generation} 世 · ${item.name}` : item.name,
}));
personOptions.value = [{ value: "", label: "不关联人物" }, ...options];
defaultLineagePersonId.value = options.some((item) => item.value === personId.value)
? personId.value
: "";
form.lineagePersonId = defaultLineagePersonId.value;
personOptionsState.value = "ready";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
personOptions.value = [{ value: "", label: "暂时无法选择关联成员" }];
defaultLineagePersonId.value = "";
form.lineagePersonId = "";
personOptionsState.value = "error";
}
};
const loadGrowthTypes = async () => {
growthTypeOptionsState.value = "loading";
try {
const growthTypes = await businessDictionaryApi.getBusinessDictionaryOptions("gen_growth_record_type", {
requestController: growthTypeRequestController,
});
if (!pageActive) return;
growthTypeOptions.value = growthTypes;
growthTypeOptionsState.value = "ready";
} catch (cause) {
if (shouldIgnoreRequestFailure(cause)) return;
growthTypeOptions.value = [];
growthTypeOptionsState.value = "error";
}
};
const loadRecords = async () => {
if (!valid.value) return;
recordListRequestController.abort();
listState.value = "loading";
try {
const rows = await lifeRecordApi.getGrowthRecords(genealogyId.value, {
requestController: recordListRequestController,
});
if (!pageActive) return;
records.value = rows;
listState.value = records.value.length ? "ready" : "empty";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => {
if (!valid.value) return;
saveNotice.value = false;
resetForm();
view.value = "form";
};
const splitDateTime = (value, label) => {
if (!value) return { date: "", clock: "" };
const matched = String(value).trim().match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})/);
if (!matched) throw new Error(`${label}有误,暂时无法编辑。`);
return { date: matched[1], clock: matched[2] };
};
const openEdit = async (record) => {
if (
!record?.canEdit ||
!valid.value ||
submitting.value ||
uploading.value ||
personOptionsState.value !== "ready" ||
growthTypeOptionsState.value !== "ready"
) {
return;
}
growthEditorDetailRequestController.abort();
listState.value = "loading";
try {
const detail = await lifeRecordApi.getGrowthRecordDetail(genealogyId.value, record.id, "", {
requestController: growthEditorDetailRequestController,
});
if (!pageActive) return;
if (
!detail.canEdit ||
!detail.content ||
!["0", "1"].includes(detail.status) ||
!Number.isSafeInteger(detail.sortOrder) ||
(detail.lineagePersonId && !personOptions.value.some((item) => item.value === detail.lineagePersonId)) ||
(detail.type && !growthTypeOptions.value.some((item) => item.value === detail.type))
) {
throw new Error("这条记录的信息不完整,暂未保存修改,以免覆盖原内容。");
}
const recordTimeParts = splitDateTime(detail.recordDate, "记录时间");
const remindTimeParts = splitDateTime(detail.remindTime, "提醒时间");
resetForm();
Object.assign(form, {
lineagePersonId: detail.lineagePersonId || "",
recordType: detail.type,
recordTitle: detail.title,
recordContent: detail.content,
recordDate: recordTimeParts.date,
recordClock: recordTimeParts.clock,
remindDate: remindTimeParts.date,
remindClock: remindTimeParts.clock,
});
mediaReceipts.value = detail.mediaFiles.map((file) => ({
ossId: file.ossId,
fileName: file.fileName,
}));
editingRecord.value = {
id: detail.id,
sortOrder: detail.sortOrder,
status: detail.status,
};
formBaseline.value = formSnapshot.value;
view.value = "form";
listState.value = "ready";
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const cancelCreate = () => {
resetForm();
view.value = "list";
};
const selectRecordDate = (event) => {
form.recordDate = event.detail.value || "";
error.value = "";
};
const selectRecordClock = (event) => {
form.recordClock = event.detail.value || "";
error.value = "";
};
const selectRemindDate = (event) => {
form.remindDate = event.detail.value || "";
error.value = "";
};
const selectRemindClock = (event) => {
form.remindClock = event.detail.value || "";
error.value = "";
};
const selectLineagePerson = (event) => {
form.lineagePersonId =
personOptions.value[Number(event.detail.value)]?.value || "";
error.value = "";
};
const selectGrowthType = (event) => {
form.recordType = growthTypeOptions.value[Number(event.detail.value)]?.value || "";
error.value = "";
};
const selectFilterPerson = (event) => {
filterPersonId.value = filterPersonOptions.value[Number(event.detail.value)]?.value || "";
};
const selectFilterType = (event) => {
filterType.value = filterTypeOptions.value[Number(event.detail.value)]?.value || "";
};
const openRecordDetail = (record) =>
growthRecordDetailDialog.value?.open({
...record,
typeLabel: growthRecordTypeLabel(record),
});
const uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
const uploadReceipt = await pickAndUploadImage({
requestController: growthMediaUploadRequestController,
});
if (!pageActive) return;
mediaReceipts.value = [...mediaReceipts.value, uploadReceipt];
} catch (cause) {
if (pageActive && !isImagePickCancelled(cause) && !isRequestCancelled(cause))
uploadError.value = getRequestErrorMessage(cause, "图片上传失败,请稍后重试。");
} finally {
if (pageActive) uploading.value = false;
}
};
const saveGrowthRecord = async () => {
if (submitting.value || uploading.value || !valid.value) return;
if (!form.recordTitle.trim()) {
error.value = "请填写记录标题";
return;
}
const { recordClock, remindDate, remindClock, ...recordForm } = form;
const payload = {
...recordForm,
recordDate: recordTime.value,
remindTime: remindTime.value,
mediaOssIds: mediaOssIds.value,
...(editingRecord.value
? {
sortOrder: editingRecord.value.sortOrder,
status: editingRecord.value.status,
}
: {}),
};
const createAttempt = editingRecord.value
? null
: growthRecordCreateGuard.begin(payload);
if (!editingRecord.value && createAttempt === null) {
error.value =
"上次提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
return;
}
submitting.value = true;
error.value = "";
try {
if (editingRecord.value) {
await lifeRecordApi.updateGrowthRecord(
genealogyId.value,
editingRecord.value.id,
payload,
{ requestController: growthRecordSaveRequestController },
);
} else {
await lifeRecordApi.createGrowthRecord(genealogyId.value, payload, {
requestController: growthRecordSaveRequestController,
});
}
if (!pageActive) return;
resetForm();
view.value = "list";
saveNotice.value = true;
await loadRecords();
} catch (cause) {
if (!pageActive) return;
if (
!editingRecord.value &&
growthRecordCreateGuard.recordFailure(createAttempt, cause)
) {
error.value =
"提交结果暂时无法确认,请先返回成长记录列表检查,避免重复创建。";
return;
}
if (!isRequestCancelled(cause))
error.value = getRequestErrorMessage(cause, "成长记录提交失败,请稍后重试。");
} finally {
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 = "";
deleteTarget.value = record;
deleteConfirmationVisible.value = true;
};
const closeDeleteConfirmation = () => {
if (deleting.value) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
};
const deleteRecord = async () => {
const record = deleteTarget.value;
if (!record?.canDelete || deleting.value) return;
deleting.value = true;
deleteError.value = "";
try {
await lifeRecordApi.deleteGrowthRecord(genealogyId.value, record.id, {
requestController: growthRecordDeleteController,
});
if (!pageActive) return;
deleteConfirmationVisible.value = false;
deleteTarget.value = null;
await loadRecords();
} catch (cause) {
if (!pageActive || isRequestCancelled(cause)) return;
deleteError.value = getRequestErrorMessage(cause, "成长记录删除失败,请稍后重试。");
deleteConfirmationVisible.value = false;
} finally {
if (pageActive) deleting.value = false;
}
};
const requestBack = () =>
detailTransientOpen.value
? (growthRecordDetailDialog.value?.closeTransient(), true)
: view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.value || detailBusy.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
personId.value = /^[1-9]\d*$/.test(String(query?.personId || ""))
? String(query.personId)
: "";
resetForm();
if (valid.value) {
loadRecords();
loadPersonOptions();
loadGrowthTypes();
}
else listState.value = "invalid";
});
onShow(() => {
if (valid.value && view.value === "list" && listState.value !== "loading")
loadRecords();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
pageActive = false;
recordListRequestController.abort();
growthEditorDetailRequestController.abort();
growthMediaUploadRequestController.abort();
growthRecordSaveRequestController.abort();
personOptionsController.abort();
growthTypeRequestController.abort();
growthRecordDeleteController.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.record-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,
.record-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;
}
.person-option-note {
display: block;
margin: 8rpx 8rpx 0;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
}
.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(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;
}
.record-list {
display: flex;
flex-direction: column;
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;
font-size: clamp(14px, 22rpx, 17px);
}
.record-filters { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12rpx; }
.record-filters picker > view {
@include adaptive-records-field;
display: flex;
box-sizing: border-box;
min-height: 80rpx;
align-items: center;
justify-content: space-between;
padding: 14rpx 18rpx;
gap: 10rpx;
}
.record-filters text { color: $ink-muted; font-size: clamp(13px, 21rpx, 16px); overflow-wrap: anywhere; }
.record-filters text:last-child { color: $brand-red; text-align: right; }
.filter-empty { display: block; padding: 34rpx 20rpx; color: $ink-muted; font-size: clamp(14px, 23rpx, 17px); text-align: center; }
.record-card {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18rpx;
min-height: 128rpx;
padding: 28rpx 32rpx;
}
.record-card > view {
min-width: 0;
flex: 1;
}
.record-card text {
display: block;
}
.record-card text:first-child {
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 30rpx, 22px);
font-weight: 700;
}
.record-card text:not(:first-child) {
margin-top: 7rpx;
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
line-height: 1.45;
overflow-wrap: anywhere;
}
</style>