Files
jiapuapp/pages/family/add-photo.vue
T

446 lines
12 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="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>
<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, reactive, ref } from "vue";
import { onBackPress, onLoad, onUnload } 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 {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { familyMediaApi } from "@/services/api/family-media-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import {
isImagePickCancelled,
pickAndUploadImage,
} from "@/utils/media-upload.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation/gateway.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: "",
});
const photoUploadController = createRequestController();
const photoSaveController = createRequestController();
const photoCreateGuard = createNonIdempotentWriteGuard();
let pageActive = true;
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 {
const uploadedPhoto = await pickAndUploadImage({
requestController: photoUploadController,
});
if (!pageActive) return;
receipt.value = uploadedPhoto;
} catch (error) {
if (pageActive && !isImagePickCancelled(error) && !isRequestCancelled(error)) {
uploadError.value = getRequestErrorMessage(error, "图片上传失败,请稍后重试");
}
} finally {
if (pageActive) 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;
}
const { shootDate, shootClock, ...photoForm } = form;
const payload = {
...photoForm,
shootTime: shootTime.value,
ossId: receipt.value.ossId,
};
const createAttempt = photoCreateGuard.begin(payload);
if (createAttempt === null) {
submitError.value =
"上次保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
return;
}
submitting.value = true;
submitError.value = "";
try {
await familyMediaApi.createAlbumPhoto(
genealogyId.value,
albumId.value,
payload,
{ requestController: photoSaveController },
);
if (!pageActive) return;
pageState.value = "success";
} catch (error) {
if (!pageActive) return;
if (photoCreateGuard.recordFailure(createAttempt, error)) {
submitError.value =
"保存结果暂时无法确认,请先返回相册检查,避免重复添加。";
return;
}
if (!isRequestCancelled(error))
submitError.value = getRequestErrorMessage(error, "照片保存失败,请稍后重试");
} finally {
if (pageActive) 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));
onUnload(() => {
pageActive = false;
photoUploadController.abort();
photoSaveController.abort();
confirmation.dispose();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.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-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: clamp(19px, 36rpx, 24px);
font-weight: 700;
}
.media-panel__note {
margin-top: 10rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.55;
}
.media-field {
margin-top: 20rpx;
}
.media-field__label {
margin: 0 6rpx 8rpx;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
font-weight: 700;
}
.required-mark {
display: inline-block;
margin-right: 4rpx;
color: $brand-red;
font-size: clamp(16px, 26rpx, 20px);
line-height: 1;
transform: translateY(-3rpx);
}
.media-field input,
.media-field textarea {
@include adaptive-family-field;
box-sizing: border-box;
display: block;
width: 100%;
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.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, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.7);
color: $ink;
font-size: clamp(15px, 24rpx, 18px);
}
.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, 0.34);
border-radius: 12rpx;
background: rgba(255, 252, 245, 0.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, 0.42);
border-radius: 8rpx;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
}
.upload-receipt {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
overflow-wrap: anywhere;
}
.field-error {
margin-top: 10rpx;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
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: clamp(19px, 35rpx, 24px);
font-weight: 700;
}
.media-state-card__copy {
margin-top: 16rpx;
color: $ink-muted;
font-size: clamp(14px, 23rpx, 17px);
line-height: 1.65;
}
.media-state-card .app-button {
margin-top: 30rpx;
}
</style>