657 lines
17 KiB
Vue
657 lines
17 KiB
Vue
<!-- 页面编号: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>
|