Files
jiapuapp/pages/family/f09-media-upload.vue
T
2026-07-27 06:50:23 +08:00

199 lines
11 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.
<!-- 页面编号F-09用途上传真实图片并创建相册照片记录 -->
<template>
<view class="media-upload-page" :class="`media-state--${pageState}`">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-content">
<view v-if="pageState === 'form'" class="media-panel">
<text class="media-panel__title">添加一张家族照片</text>
<text class="media-panel__note">请先选择图片保存时只会提交真实上传回执中的文件标识</text>
<view class="media-field media-field--upload">
<text class="media-field__label"><text class="required-mark">*</text>照片</text>
<button class="upload-button" :disabled="uploading || submitting" @click="selectPhoto">{{ uploading ? '上传中…' : (receipt ? '重新选择图片' : '选择图片') }}</button>
<text v-if="receipt" class="upload-receipt">已上传{{ receipt.fileName || '图片' }}</text>
</view>
<text v-if="uploadError" class="field-error">{{ uploadError }}</text>
<view class="media-field">
<text class="media-field__label">照片标题</text>
<input v-model="form.photoTitle" maxlength="60" placeholder="例如:祖宅合影" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field media-field--textarea">
<text class="media-field__label">照片说明</text>
<textarea v-model="form.photoDesc" maxlength="500" auto-height placeholder="补充照片中的人物、场景或故事" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field">
<text class="media-field__label">拍摄人</text>
<input v-model="form.photographer" maxlength="30" placeholder="请输入拍摄人" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<view class="media-field media-field--picker">
<text class="media-field__label">拍摄日期</text>
<picker mode="date" :value="form.shootDate" @change="selectShootDate">
<view :class="{ placeholder: !form.shootDate }">{{ form.shootDate || '请选择' }}</view>
</picker>
</view>
<view class="media-field media-field--picker">
<text class="media-field__label">拍摄时间</text>
<picker mode="time" :value="form.shootClock" @change="selectShootClock">
<view :class="{ placeholder: !form.shootClock }">{{ form.shootClock || '请选择' }}</view>
</picker>
</view>
<view class="media-field">
<text class="media-field__label">排序值</text>
<input v-model="form.sortOrder" type="number" placeholder="数值越小越靠前" placeholder-class="placeholder" @input="submitError = ''" />
</view>
<text v-if="submitError" class="field-error">{{ submitError }}</text>
<AppButton block :disabled="uploading || submitting" :label="submitting ? '正在保存…' : '保存照片'" @click="submitPhoto" />
</view>
<view v-else class="media-state-card">
<text class="media-state-card__title">{{ stateCopy.title }}</text>
<text class="media-state-card__copy">{{ stateCopy.copy }}</text>
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃照片草稿?"
message="当前内容尚未保存到服务端,返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmation.confirm"
@cancel="confirmation.cancel"
/>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import { isImagePickCancelled, pickAndUploadImage, toConsumerOssId } from "@/utils/resumable-image-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
const genealogyId = ref("");
const albumId = ref("");
const pageState = ref("form");
const receipt = ref(null);
const uploadError = ref("");
const submitError = ref("");
const uploading = ref(false);
const submitting = ref(false);
const form = reactive({ photoTitle: "", photoDesc: "", photographer: "", shootDate: "", shootClock: "", sortOrder: "" });
const controller = createRequestController();
const discardVisible = ref(false);
const confirmation = createDiscardConfirmation((visible) => { discardVisible.value = visible; });
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
const isDirty = computed(() => Boolean(receipt.value) || Object.values(form).some((value) => value.trim()));
const shootTime = computed(() => form.shootDate ? `${form.shootDate} ${form.shootClock || "00:00"}:00` : "");
const stateCopy = computed(() => pageState.value === "success"
? { title: "照片已提交服务端", copy: "服务端已返回成功结果。", action: "返回相册" }
: { title: "照片入口无效", copy: "没有取得有效家谱或相册标识。", action: "返回上一页" });
onLoad((query) => {
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
if (!hasValidContext.value) pageState.value = "invalid";
});
const selectPhoto = async () => {
if (uploading.value || submitting.value) return;
uploading.value = true;
uploadError.value = "";
try {
receipt.value = await pickAndUploadImage({ requestController: controller });
} catch (error) {
if (!isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = error?.message || "图片上传失败,请稍后重试";
}
} finally {
uploading.value = false;
}
};
const selectShootDate = (event) => { form.shootDate = event.detail.value || ""; submitError.value = ""; };
const selectShootClock = (event) => { form.shootClock = event.detail.value || ""; submitError.value = ""; };
const submitPhoto = async () => {
if (uploading.value || submitting.value || !hasValidContext.value) return;
if (!receipt.value) {
submitError.value = "请先选择并上传照片";
return;
}
submitting.value = true;
submitError.value = "";
try {
const { shootDate, shootClock, ...photoForm } = form;
await appApi.createAlbumPhoto(genealogyId.value, albumId.value, {
...photoForm,
shootTime: shootTime.value,
ossId: toConsumerOssId(receipt.value.ossId),
}, { requestController: controller });
pageState.value = "success";
} catch (error) {
if (!isRequestCancelled(error)) submitError.value = error?.message || "照片保存失败,请稍后重试";
} finally {
submitting.value = false;
}
};
const returnToAlbum = () => hasValidContext.value
? returnTo("F08", { genealogyId: genealogyId.value, albumId: albumId.value })
: goBack();
const requestBack = () => runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: uploading.value || submitting.value,
"close-transient": confirmation.cancel,
"block-submitting": () => true,
"confirm-discard": confirmation.request,
});
const handleStateAction = () => pageState.value === "success" ? returnToAlbum() : goBack();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
controller.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.media-upload-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.media-upload-header, .media-upload-content { z-index: 1; }
.media-upload-content { flex: 1; padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-panel, .media-state-card { @include adaptive.adaptive-family-panel; box-sizing: border-box; }
.media-panel { padding: 38rpx 32rpx 42rpx; }
.media-panel__title, .media-panel__note, .media-field__label, .field-error { display: block; }
.media-panel__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
.media-panel__note { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
.media-field { margin-top: 20rpx; }
.media-field__label { margin: 0 6rpx 8rpx; color: $ink; font-size: 25rpx; font-weight: 700; }
.required-mark { display: inline-block; margin-right: 4rpx; color: $brand-red; font-size: 26rpx; line-height: 1; transform: translateY(-3rpx); }
.media-field input, .media-field textarea { @include adaptive.adaptive-family-field; box-sizing: border-box; display: block; width: 100%; color: $ink; font-size: 24rpx; }
.media-field input { min-height: 78rpx; padding: 0 22rpx; }
.media-field--picker picker { display: block; min-height: 78rpx; }
.media-field--picker picker > view { display: flex; min-height: 78rpx; align-items: center; padding: 0 22rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); color: $ink; font-size: 24rpx; }
.media-field textarea { min-height: 116rpx; padding: 18rpx 22rpx; line-height: 1.55; }
.placeholder { color: #9e8e79; }
.media-field--upload { display: grid; gap: 12rpx; padding: 18rpx 20rpx; border: 1rpx solid rgba(128, 89, 49, .34); border-radius: 12rpx; background: rgba(255, 252, 245, .7); }
.media-field--upload .media-field__label { margin: 0; }
.upload-button { justify-self: start; min-height: 88rpx; margin: 0; padding: 0 20rpx; border: 1rpx solid rgba(159, 23, 15, .42); border-radius: 8rpx; background: transparent; color: $brand-red; font-size: 22rpx; }
.upload-receipt { color: $ink-muted; font-size: 21rpx; overflow-wrap: anywhere; }
.field-error { margin-top: 10rpx; color: $brand-red; font-size: 22rpx; line-height: 1.45; }
.media-panel .app-button { margin-top: 28rpx; }
.media-state-card { min-height: 410rpx; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-state-card text { display: block; }
.media-state-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.media-state-card__copy { margin-top: 16rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.65; }
.media-state-card .app-button { margin-top: 30rpx; }
</style>