完成70%

This commit is contained in:
2026-07-24 07:56:22 +08:00
parent bb6431b319
commit c7f278fe79
92 changed files with 4741 additions and 14440 deletions
+25 -604
View File
@@ -1,627 +1,48 @@
<!-- 页面编号F-09用途照片选择说明上传进度与失败重试视觉候选 -->
<!-- 页面编号F-09用途相册照片写入没有文件上传与 OSS 回执 owner 时关闭 -->
<template>
<view
class="media-upload-page"
:class="`media-upload-state--${uploadState}`"
>
<view class="media-upload-page">
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="returnToAlbum" /></view>
<view class="media-upload-content">
<view v-if="uploadState === 'invalid'" class="media-invalid-card">
<text class="media-state-card__eyebrow">相册入口无效</text>
<text class="media-state-card__title">没有找到当前相册</text>
<text class="media-state-card__copy">页面不会把照片归入其他家谱或其他相册</text>
<view class="media-preview-action" @click="returnFromInvalid">
<AppButton block :label="genealogyId ? '返回相册列表' : '返回上一页'" />
</view>
<view class="media-state-card">
<text class="media-state-card__eyebrow">文件服务待接入</text>
<text class="media-state-card__title">照片上传暂不可用</text>
<text class="media-state-card__copy">照片写入接口要求已有 `ossId`当前没有经 Apifox 核实的文件上传 owner真实 OSS 回执或访问 URL页面不再从本地图片库选择图片也不会生成上传预览</text>
<AppButton block :label="hasValidContext ? '返回相册列表' : '返回上一页'" @click="returnToAlbum" />
</view>
<view v-else class="media-album-card">
<image
class="media-album-card__cover"
:src="album.cover"
:alt="album.name"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">{{ album.name }}</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission' && album" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择照片当前只验证选择与说明流程不会上传</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'preview'" class="media-preview-card">
<text class="media-state-card__eyebrow">本地流程预览</text>
<text class="media-preview-title">{{ selectedPhotos.length }} 张照片已完成本地校验</text>
<text class="media-state-card__copy"
>照片尚未上传也没有加入{{ album.name }}返回后不会保存</text
>
<view class="media-preview-action" @click="returnToAlbum">
<AppButton block label="返回当前相册(不上传)" />
</view>
</view>
<template v-else-if="album">
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
</view>
<view class="media-photo-grid">
<view
v-for="(photo, index) in selectedPhotos"
:key="photo.id"
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
}"
@click="selectPhoto(index)"
>
<image
class="media-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="media-photo-order">{{ index + 1 }}</text>
<text
v-if="index === activePhotoIndex"
class="media-photo-current"
>当前</text
>
<view
v-if="!isLocked"
class="media-photo-remove"
@click.stop="removePhoto(index)"
>
<text>删除</text>
</view>
</view>
<view
v-if="selectedPhotos.length < MAX_PHOTOS && !isLocked"
class="media-add-tile"
@click="selectedPhotos.length ? addMockPhoto() : selectMockPhotos()"
>
<text class="media-add-tile__title">{{
selectedPhotos.length ? "继续添加" : "选择照片"
}}</text>
<text class="media-add-tile__copy">{{
selectedPhotos.length ? `还可选择 ${MAX_PHOTOS - selectedPhotos.length}` : "最多 9 张"
}}</text>
</view>
</view>
<view v-if="selectedPhotos.length" class="media-description-panel">
<view class="media-batch-field">
<view class="media-field-heading">
<text>整批说明</text><text>必填</text>
</view>
<textarea
v-model="batchDescription"
auto-height
:disabled="isLocked"
maxlength="120"
placeholder="例如:2024 年春节全家团圆留影"
placeholder-class="media-field-placeholder"
/>
</view>
<text v-if="validationMessage" class="media-field-error">{{
validationMessage
}}</text>
<view v-if="activePhoto" class="media-photo-field">
<view class="media-photo-editor">
<image
class="media-photo-editor__thumb"
:src="activePhoto.src"
:alt="activePhoto.alt"
mode="aspectFill"
/>
<view class="media-photo-editor__copy">
<text class="media-photo-editor__position"
> {{ activePhotoIndex + 1 }} /
{{ selectedPhotos.length }} </text
>
<text class="media-photo-editor__hint"
>正在编辑此照片的说明</text
>
</view>
</view>
<view class="media-field-heading">
<text>当前照片说明</text><text>选填</text>
</view>
<textarea
:value="activePhoto.note"
auto-height
:disabled="isLocked"
maxlength="80"
placeholder="补充人物、时间或拍摄地点"
placeholder-class="media-field-placeholder"
@input="updateActiveNote"
/>
</view>
</view>
<view class="media-primary-action" @click="generatePreview">
<AppButton
block
:disabled="isSubmitting"
:label="
isSubmitting
? '正在校验'
: selectedPhotos.length
? `生成 ${selectedPhotos.length} 张照片预览`
: '选择照片'
"
/>
</view>
</template>
</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, ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import { 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 { findFamilyAlbumFixture } from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, returnTo } from "@/utils/navigation.js";
const MAX_PHOTOS = 9;
const genealogyId = ref("");
const albumId = ref("");
const album = ref(null);
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
const selectedPhotos = ref([]);
const isSubmitting = ref(false);
const discardVisible = ref(false);
let previewTimer = null;
const mockLibrary = [
{ id: "reunion", src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", note: "" },
{ id: "portrait", src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", note: "" },
{ id: "table", src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", note: "" },
{ id: "home", src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", note: "" },
{ id: "ancestor", src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", note: "" },
];
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => isSubmitting.value);
const isDirty = computed(() =>
Boolean(
selectedPhotos.value.length ||
batchDescription.value.trim() ||
selectedPhotos.value.some((photo) => photo.note.trim()),
),
);
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const makeSelectedPhotos = () =>
mockLibrary.slice(0, 4).map((photo) => ({ ...photo, note: "" }));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos();
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
};
const selectPhoto = (index) => {
if (!isLocked.value) activePhotoIndex.value = index;
};
const removePhoto = (index) => {
if (isLocked.value) return;
selectedPhotos.value.splice(index, 1);
activePhotoIndex.value = Math.max(
0,
Math.min(activePhotoIndex.value, selectedPhotos.value.length - 1),
);
};
const addMockPhoto = () => {
if (isLocked.value || selectedPhotos.value.length >= MAX_PHOTOS) return;
const nextIndex = selectedPhotos.value.length;
const next = mockLibrary[nextIndex % mockLibrary.length];
selectedPhotos.value.push({
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
});
};
const updateActiveNote = (event) => {
if (!isLocked.value && activePhoto.value) {
activePhoto.value.note = event.detail.value;
}
};
const generatePreview = () => {
if (isSubmitting.value || !album.value) return;
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
}
if (!batchDescription.value.trim()) {
validationMessage.value = "请填写整批照片说明";
return;
}
validationMessage.value = "";
isSubmitting.value = true;
const selectedCount = selectedPhotos.value.length;
const timer = setTimeout(() => {
if (previewTimer !== timer) return;
previewTimer = null;
isSubmitting.value = false;
uploadState.value = selectedCount > 0 ? "preview" : "selected";
}, 280);
previewTimer = timer;
};
const returnToAlbum = () =>
returnTo("F08", {
genealogyId: genealogyId.value,
albumId: albumId.value,
});
const returnFromInvalid = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: isSubmitting.value,
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
albumId.value = String(query.albumId || "");
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
if (!album.value) {
uploadState.value = "invalid";
return;
}
uploadState.value = ["permission", "selected", "preview"].includes(query.state)
? query.state
: "initial";
if (["selected", "preview"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos();
batchDescription.value = "春节团圆照片整理";
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (previewTimer) clearTimeout(previewTimer);
discardConfirmation.dispose();
genealogyId.value = String(query?.genealogyId || "");
albumId.value = String(query?.albumId || "");
});
const returnToAlbum = () => hasValidContext.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
</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 {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
@include adaptive.adaptive-family-panel;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
grid-template-columns: 132rpx minmax(0, 1fr);
gap: 20rpx;
align-items: center;
padding: 14rpx 20rpx;
}
.media-album-card__cover {
width: 132rpx;
height: 132rpx;
border: 2rpx solid rgba(179, 133, 63, 0.62);
box-shadow: 0 6rpx 14rpx rgba(91, 60, 32, 0.12);
}
.media-album-card__copy text,
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-preview-title {
display: block;
}
.media-album-card__eyebrow {
color: $brand-red;
font-size: 21rpx;
letter-spacing: 2rpx;
}
.media-album-card__title {
margin-top: 7rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 31rpx;
font-weight: 700;
}
.media-album-card__limit {
margin-top: 7rpx;
color: $ink-muted;
font-size: 21rpx;
}
.media-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin: 24rpx 8rpx 12rpx;
color: $brand-red;
font-size: 25rpx;
font-weight: 700;
}
.media-section-heading text:last-child {
color: $ink-muted;
font-size: 21rpx;
font-weight: 400;
}
.media-photo-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12rpx;
}
.media-photo-tile,
.media-add-tile {
min-width: 0;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
aspect-ratio: 1;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(247, 241, 231, 0.88);
}
.media-photo-tile {
position: relative;
}
.media-photo-tile--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current {
position: absolute;
z-index: 2;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-order {
top: 7rpx;
left: 7rpx;
min-width: 34rpx;
padding: 3rpx 7rpx;
border-radius: 999px;
background: rgba(43, 31, 23, 0.78);
text-align: center;
}
.media-photo-current {
bottom: 7rpx;
left: 7rpx;
padding: 4rpx 8rpx;
background: rgba(74, 45, 27, 0.84);
}
.media-photo-remove {
position: absolute;
z-index: 3;
top: 0;
right: 0;
display: flex;
min-width: 44px;
min-height: 44px;
align-items: flex-start;
justify-content: flex-end;
color: #fffaf0;
font-size: 20rpx;
}
.media-photo-remove text {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
flex-direction: column;
align-items: center;
justify-content: center;
border-style: dashed;
color: $brand-red;
text-align: center;
}
.media-add-tile__title {
font-size: 24rpx;
font-weight: 700;
}
.media-add-tile__copy {
margin-top: 6rpx;
color: $ink-muted;
font-size: 19rpx;
}
.media-description-panel {
margin-top: 22rpx;
}
.media-batch-field,
.media-photo-field {
padding: 18rpx 20rpx 20rpx;
border: 2rpx solid rgba(179, 133, 63, 0.55);
background: rgba(250, 246, 237, 0.76);
}
.media-photo-field {
margin-top: 16rpx;
}
.media-photo-editor {
display: grid;
grid-template-columns: 78rpx minmax(0, 1fr);
gap: 14rpx;
align-items: center;
margin-bottom: 16rpx;
padding-bottom: 14rpx;
border-bottom: 1px solid rgba(179, 133, 63, 0.32);
}
.media-photo-editor__thumb {
width: 78rpx;
height: 78rpx;
border: 2rpx solid rgba(159, 44, 35, 0.62);
}
.media-photo-editor__copy text {
display: block;
}
.media-photo-editor__position {
color: $ink;
font-size: 23rpx;
font-weight: 700;
}
.media-photo-editor__hint {
margin-top: 6rpx;
color: $ink-muted;
font-size: 20rpx;
}
.media-field-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: $ink;
font-size: 24rpx;
font-weight: 700;
}
.media-field-heading text:last-child {
color: $brand-red;
font-size: 20rpx;
font-weight: 500;
}
.media-batch-field textarea,
.media-photo-field textarea {
width: 100%;
min-height: 92rpx;
margin-top: 12rpx;
box-sizing: border-box;
color: $ink;
font-size: 23rpx;
line-height: 1.55;
}
.media-field-placeholder {
color: #9e8e79;
}
.media-field-error {
display: block;
margin: 9rpx 6rpx 0;
color: $brand-red;
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action {
margin-top: 24rpx;
}
.media-permission-card,
.media-preview-card,
.media-invalid-card {
@include adaptive.adaptive-family-panel;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
padding: 98rpx 46rpx 58rpx;
text-align: center;
}
.media-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-preview-title {
margin-top: 12rpx;
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-permission-action,
.media-preview-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.media-upload-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.media-photo-grid {
gap: 8rpx;
}
.media-album-card {
grid-template-columns: 116rpx minmax(0, 1fr);
gap: 14rpx;
}
.media-album-card__cover {
width: 116rpx;
height: 116rpx;
}
}
.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 { padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
.media-state-card { @include adaptive.adaptive-family-panel; min-height: 410rpx; box-sizing: border-box; padding: 98rpx 46rpx 58rpx; text-align: center; }
.media-state-card text { display: block; }
.media-state-card__eyebrow { color: $brand-red; font-size: 22rpx; letter-spacing: 2rpx; }
.media-state-card__title { margin-top: 12rpx; 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>