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

603 lines
16 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.
<!-- 页面编号R-08用途读取新建当前家谱的成长记录 -->
<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>新建成长记录</text>
<text class="form-copy">红色 * 为必填项其余内容可按需补充</text>
<view class="field field--context"
><text>关联人物</text
><view
><text>{{ personName }}</text
><text>{{ personId ? "已自动关联" : "未关联人物" }}</text></view
></view
>
<view class="field"
><text>记录类型</text
><input
v-model="form.recordType"
placeholder="请输入记录类型"
@input="error = ''"
/></view>
<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
>
<button
class="upload-button"
:disabled="uploading || submitting"
@click="uploadImage"
>
{{ uploading ? "上传中…" : "添加图片" }}
</button>
<text
v-for="(receipt, index) in mediaReceipts"
:key="`${receipt.ossId}-${index}`"
>已上传{{ receipt.fileName || "图片" }}</text
>
<text v-if="uploadError" class="error">{{ uploadError }}</text>
</view>
<view class="field"
><text>排序值</text
><input
v-model="form.sortOrder"
type="number"
placeholder="数值越小越靠前"
@input="error = ''"
/></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="submit"
/></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
>
<view v-for="item in records" :key="item.id" class="record-card">
<view
><text>{{ item.title }}</text
><text
>{{ item.type || item.personName
}}{{ item.date ? ` · ${item.date}` : "" }}</text
><text v-if="item.content">{{ item.content }}</text></view
>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃成长记录?"
message="尚未提交的内容将被清除。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad, onShow } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
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 {
appApi,
createRequestController,
isRequestCancelled,
} from "@/utils/api.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/resumable-image-upload.js";
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const personId = ref("");
const personName = 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 form = reactive({
lineagePersonId: "",
recordType: "",
recordTitle: "",
recordContent: "",
recordDate: "",
recordClock: "",
remindDate: "",
remindClock: "",
sortOrder: "",
});
const mediaReceipts = ref([]);
const controller = createRequestController();
let active = true;
const valid = computed(() => /^[1-9]\d*$/.test(genealogyId.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 dirty = computed(
() =>
Object.values(form).some((value) => String(value).trim()) ||
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: personId.value,
recordType: "",
recordTitle: "",
recordContent: "",
recordDate: "",
recordClock: "",
remindDate: "",
remindClock: "",
sortOrder: "",
});
mediaReceipts.value = [];
error.value = "";
uploadError.value = "";
};
const loadRecords = async () => {
if (!valid.value) return;
controller.abort();
listState.value = "loading";
try {
const rows = await appApi.getGrowthRecords(genealogyId.value, {
requestController: controller,
});
if (!active) return;
records.value = rows
.map((item) => ({
id: String(item.recordId || ""),
title: String(item.recordTitle || "未命名记录"),
type: String(item.recordType || ""),
personName: String(item.lineagePersonName || "未关联人物"),
date: String(item.recordDate || item.remindTime || ""),
content: String(item.recordContent || ""),
}))
.filter((item) => /^[1-9]\d*$/.test(item.id));
listState.value = records.value.length ? "ready" : "empty";
} catch (cause) {
if (!active || isRequestCancelled(cause)) return;
listState.value = "error";
}
};
const openCreate = () => {
if (!valid.value) return;
saveNotice.value = false;
resetForm();
view.value = "form";
};
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 uploadImage = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
mediaReceipts.value = [
...mediaReceipts.value,
await pickAndUploadImage({ requestController: controller }),
];
} catch (cause) {
if (!isImagePickCancelled(cause) && !isRequestCancelled(cause))
uploadError.value = cause?.message || "图片上传失败,请稍后重试。";
} finally {
uploading.value = false;
}
};
const submit = async () => {
if (submitting.value || uploading.value || !valid.value) return;
if (!form.recordTitle.trim()) {
error.value = "请填写记录标题";
return;
}
submitting.value = true;
error.value = "";
try {
const { recordClock, remindDate, remindClock, ...recordForm } = form;
await appApi.createGrowthRecord(
genealogyId.value,
{
...recordForm,
recordDate: recordTime.value,
remindTime: remindTime.value,
mediaOssIds: mediaOssIds.value,
},
{ requestController: controller },
);
resetForm();
view.value = "list";
saveNotice.value = true;
await loadRecords();
} catch (cause) {
if (!isRequestCancelled(cause))
error.value = cause?.message || "成长记录提交失败,请稍后重试。";
} finally {
submitting.value = false;
}
};
const requestBack = () =>
view.value !== "form"
? goBack()
: runBackGuard({
transientOpen: discardVisible.value,
dirty: dirty.value,
submitting: submitting.value || uploading.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)
: "";
personName.value = String(
query?.personName || (personId.value ? "当前查看的人物" : "当前家谱"),
);
resetForm();
if (valid.value) loadRecords();
else listState.value = "invalid";
});
onShow(() => {
if (valid.value && view.value === "list" && listState.value !== "loading")
loadRecords();
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
active = false;
controller.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 {
padding: 18rpx 24rpx 72rpx;
}
.form-card,
.state-card,
.record-card {
@include adaptive-records-content;
}
.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: 76rpx;
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;
}
.field--context > view {
@include adaptive-records-field;
display: flex;
min-height: 76rpx;
align-items: center;
justify-content: space-between;
padding: 16rpx 22rpx;
box-sizing: border-box;
}
.field--context > view > text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
}
.field--context > view > text:last-child {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.upload-field {
display: grid;
gap: 12rpx;
margin-top: 16rpx;
padding: 18rpx 22rpx;
@include adaptive-records-field;
}
.upload-field > view > text {
display: block;
}
.upload-field > view > text:first-child {
color: $ink;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.upload-field > view > 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: 16rpx;
}
.save-notice {
display: block;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.record-card {
min-height: 128rpx;
padding: 28rpx 32rpx;
}
.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>