Files
jiapuapp/pages/family/f09-media-upload.vue
T
2026-07-20 17:23:13 +08:00

657 lines
17 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-upload-state--${uploadState}`"
>
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="上传照片" /></view>
<view class="media-upload-content">
<view class="media-album-card">
<image
class="media-album-card__cover"
:src="mockLibrary[0].src"
:alt="mockLibrary[0].alt"
mode="aspectFill"
/>
<view class="media-album-card__copy">
<text class="media-album-card__eyebrow">当前相册</text>
<text class="media-album-card__title">2024 春节团圆</text>
<text class="media-album-card__limit">最多可选择 9 </text>
</view>
</view>
<view v-if="uploadState === 'permission'" class="media-permission-card">
<text class="media-state-card__eyebrow">照片访问权限</text>
<text class="media-state-card__title">需要照片访问权限</text>
<text class="media-state-card__copy"
>授权后才能选择要加入当前相册的照片当前仅展示 H5 审核状态</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'success'" class="media-success-card">
<text class="media-state-card__eyebrow">上传结果</text>
<text class="media-success-title">4 张照片已上传</text>
<text class="media-state-card__copy"
>照片已加入2024 春节团圆”,可以返回相册继续查看</text
>
<view class="media-success-action" @click="returnToAlbum">
<AppButton block label="返回当前相册" />
</view>
</view>
<template v-else>
<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,
'media-photo-tile--failed': photo.status === 'error',
}"
@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>
<text
v-if="photo.status === 'error'"
class="media-photo-status media-photo-status--error"
>上传失败</text
>
<text
v-else-if="photo.status === 'uploading'"
class="media-photo-status media-photo-status--uploading"
>{{ photo.progress }}%</text
>
<text
v-else-if="photo.status === 'uploaded'"
class="media-photo-status media-photo-status--uploaded"
>已上传</text
>
</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"
: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"
:disabled="isLocked"
maxlength="80"
placeholder="补充人物、时间或拍摄地点"
placeholder-class="media-field-placeholder"
@input="updateActiveNote"
/>
</view>
</view>
<template v-if="uploadState === 'uploading'">
<text class="media-progress-copy"
>正在上传 2/{{ selectedPhotos.length }}</text
>
<view class="media-progress-action">
<AppButton block label="正在上传" />
</view>
</template>
<view
v-else-if="uploadState === 'error'"
class="media-retry-action"
@click="retryFailed"
>
<AppButton block label="重试 2 张失败照片" />
</view>
<view v-else class="media-primary-action" @click="startUpload">
<AppButton
block
:label="
selectedPhotos.length
? `上传 ${selectedPhotos.length} 张照片`
: '选择照片'
"
/>
</view>
</template>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const MAX_PHOTOS = 9;
const uploadState = ref("initial");
const activePhotoIndex = ref(0);
const batchDescription = ref("");
const validationMessage = ref("");
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 selectedPhotos = ref([]);
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => uploadState.value === "uploading");
const makeSelectedPhotos = (state) =>
mockLibrary.slice(0, 4).map((photo, index) => ({
...photo,
note: "",
progress: state === "uploading" ? [100, 62, 35, 0][index] : 0,
status:
state === "error"
? index < 2
? "error"
: "uploaded"
: state,
}));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos("selected");
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: "",
progress: 0,
status: "selected",
});
};
const updateActiveNote = (event) => {
if (!isLocked.value && activePhoto.value) {
activePhoto.value.note = event.detail.value;
}
};
const startUpload = () => {
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
}
if (!batchDescription.value.trim()) {
validationMessage.value = "请填写整批照片说明";
return;
}
validationMessage.value = "";
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const retryFailed = () => {
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const returnToAlbum = () => {
uni.redirectTo({ url: "/pages/family/f08-album-detail" });
};
onLoad((query) => {
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
? query.state
: "initial";
if (!["initial", "permission"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos(uploadState.value);
batchDescription.value = "春节团圆照片整理";
}
});
</script>
<style scoped lang="scss">
.media-upload-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.media-upload-header,
.media-upload-content {
position: relative;
z-index: 2;
}
.media-upload-content {
padding: 20rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.media-album-card {
position: relative;
display: grid;
min-height: 154rpx;
box-sizing: border-box;
grid-template-columns: 132rpx minmax(0, 1fr);
gap: 20rpx;
align-items: center;
padding: 14rpx 20rpx;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 58rpx;
border-image-repeat: stretch;
}
.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-success-title,
.media-photo-status,
.media-progress-copy {
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 {
position: relative;
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--active {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile--failed {
border-style: dashed;
border-color: #9f2c23;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current,
.media-photo-status {
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-photo-status {
right: 0;
bottom: 0;
left: 0;
padding: 7rpx 8rpx;
background: rgba(58, 43, 32, 0.82);
text-align: center;
}
.media-photo-status--error {
background: rgba(132, 35, 29, 0.92);
font-weight: 700;
}
.media-photo-status--uploaded {
background: rgba(55, 82, 51, 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%;
height: 92rpx;
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,
.media-retry-action,
.media-progress-action {
margin-top: 24rpx;
}
.media-progress-copy {
margin-top: 22rpx;
color: $ink;
font-size: 23rpx;
text-align: center;
}
.media-permission-card,
.media-success-card {
position: relative;
min-height: 410rpx;
margin-top: 24rpx;
box-sizing: border-box;
padding: 98rpx 46rpx 58rpx;
border: 20rpx solid transparent;
border-image-source: url("/static/assets/modules/family/transparent/module-content-frame.png");
border-image-slice: 72 fill;
border-image-width: 64rpx;
border-image-repeat: stretch;
text-align: center;
}
.media-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-success-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-success-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;
}
}
</style>