保存视觉审核与文档流迁移进度

This commit is contained in:
rain
2026-07-20 17:23:09 +08:00
parent 5a31a75da0
commit 01d246c47b
59 changed files with 3615 additions and 378 deletions
+11 -4
View File
@@ -192,6 +192,7 @@ const openSection = (key) => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.feed-shortcut {
position: relative;
@@ -212,10 +213,14 @@ const openSection = (key) => {
.feed-card,
.feed-state-card {
position: relative;
display: flex;
width: 100%;
height: 230rpx;
min-height: 98px;
flex-direction: column;
justify-content: center;
margin-top: 17rpx;
box-sizing: border-box;
}
.feed-card__skin,
.feed-state-card > image,
@@ -224,11 +229,12 @@ const openSection = (key) => {
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.feed-card__copy {
position: absolute;
inset: 15% 9%;
position: relative;
z-index: 1;
padding: 15% 9%;
}
.feed-card text {
display: block;
@@ -260,9 +266,10 @@ const openSection = (key) => {
margin-top: 70rpx;
}
.feed-state-card > view {
position: absolute;
inset: 23% 10%;
position: relative;
z-index: 1;
padding: 23% 10%;
box-sizing: border-box;
text-align: center;
}
.feed-state-card text {
+14 -8
View File
@@ -99,19 +99,22 @@ onUnmounted(() => {
}
.publish-panel {
width: calc(100% - 32rpx);
height: min(640px, calc((100vw - 16px) * 1.48));
min-height: min(640px, calc((100vw - 16px) * 1.48));
margin: 18rpx auto 0;
padding: 9%;
box-sizing: border-box;
}
.publish-panel__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-form,
.publish-result {
position: absolute;
inset: 9% 9%;
position: relative;
z-index: 1;
}
.publish-form > text,
.publish-result > text {
@@ -133,20 +136,23 @@ onUnmounted(() => {
}
.publish-field {
position: relative;
height: 250rpx;
display: flex;
min-height: 250rpx;
margin-top: 24rpx;
padding: 25rpx;
box-sizing: border-box;
}
.publish-field image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.publish-field textarea {
position: absolute;
inset: 25rpx;
position: relative;
z-index: 1;
width: auto;
width: 100%;
height: 200rpx;
color: $ink;
font-size: 24rpx;
@@ -159,7 +165,7 @@ onUnmounted(() => {
margin-top: 24rpx;
}
.publish-result {
top: 29%;
margin-top: 20%;
text-align: center;
}
.publish-result > .app-button {
+383 -2
View File
@@ -1,5 +1,386 @@
<!-- 页面编号F-06用途新建与编辑谱文 -->
<template><ModulePage page-id="f06" /></template>
<template>
<view
class="article-editor-page"
:class="`article-editor-state--${editorState}`"
>
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
<AppLoading
text="正在打开谱文编辑器"
description="请稍候,草稿正在展开。"
/>
</view>
<view
v-else-if="editorState === 'success'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">保存结果</text>
<text class="editor-result-card__title">谱文已保存</text>
<text class="editor-result-card__copy"
>这篇谱文已收录可返回谱文列表继续查看</text
>
<AppButton block label="返回谱文列表" @click="returnToList" />
</view>
</view>
</view>
<view v-else class="article-editor-content">
<view class="editor-panel">
<view class="editor-panel__body">
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文保存后可返回谱文列表继续查看</text
>
<view class="editor-field">
<text class="editor-field__label">文章标题</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.title }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.title"
maxlength="40"
placeholder="请输入文章标题"
placeholder-class="editor-placeholder"
@input="clearFieldError('title')"
/>
</view>
<text v-if="fieldErrors.title" class="editor-field__error">{{
fieldErrors.title
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">文章分类</text>
<view
class="editor-control"
:class="{ 'editor-control--error': fieldErrors.category }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<input
v-model="form.category"
maxlength="20"
placeholder="如:家风家训"
placeholder-class="editor-placeholder"
@input="clearFieldError('category')"
/>
</view>
<text v-if="fieldErrors.category" class="editor-field__error">{{
fieldErrors.category
}}</text>
</view>
<view class="editor-field">
<text class="editor-field__label">正文内容</text>
<view
class="editor-control editor-control--textarea"
:class="{ 'editor-control--error': fieldErrors.content }"
>
<image :src="fieldFrame" mode="scaleToFill" />
<textarea
v-model="form.content"
maxlength="1200"
placeholder="记录家训、往事或想留给后人的话"
placeholder-class="editor-placeholder"
@input="clearFieldError('content')"
/>
</view>
<text v-if="fieldErrors.content" class="editor-field__error">{{
fieldErrors.content
}}</text>
</view>
<text class="editor-draft-note"
>当前为未发布草稿真实草稿保存将在接口阶段接入</text
>
<text v-if="editorState === 'error'" class="editor-save-error"
>保存失败已填写内容仍保留请稍后重试</text
>
<AppButton
block
:label="actionLabel"
:disabled="editorState === 'saving'"
@click="submit"
/>
</view>
</view>
</view>
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppLoading from "@/components/AppLoading.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const fieldFrame =
"/static/assets/modules/family/transparent/module-field-frame.png";
const draftFixture = {
title: "汤氏家训辑录",
category: "家风家训",
content:
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const allowedStates = new Set([
"draft",
"loading",
"validation",
"saving",
"error",
"success",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
let saveTimer = null;
const actionLabel = computed(() =>
editorState.value === "saving"
? "正在保存…"
: editorState.value === "error"
? "重新保存"
: "保存谱文",
);
const fillDraft = (failed = false) => {
Object.assign(form, draftFixture, failed ? { title: "保存失败" } : {});
};
const showAllFieldErrors = () => {
fieldErrors.title = "请填写文章标题";
fieldErrors.category = "请填写文章分类";
fieldErrors.content = "请填写正文内容";
};
onLoad((query) => {
const requestedState = allowedStates.has(query.state) ? query.state : "form";
if (["draft", "saving", "error"].includes(requestedState)) {
fillDraft(requestedState === "error");
}
if (requestedState === "validation") showAllFieldErrors();
editorState.value = requestedState;
});
const clearFieldError = (field) => {
fieldErrors[field] = "";
if (
editorState.value === "error" ||
editorState.value === "validation"
) {
editorState.value = "form";
}
};
const validate = () => {
fieldErrors.title = form.title.trim() ? "" : "请填写文章标题";
fieldErrors.category = form.category.trim() ? "" : "请填写文章分类";
fieldErrors.content = form.content.trim() ? "" : "请填写正文内容";
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
if (editorState.value === "saving") return;
if (!validate()) {
editorState.value = "validation";
return;
}
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value =
form.title.trim() === "保存失败" ? "error" : "success";
saveTimer = null;
}, 320);
};
const returnToList = () => uni.navigateBack();
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
});
</script>
<style scoped lang="scss">
.article-editor-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.article-editor-page__header,
.article-editor-loading,
.article-editor-content {
position: relative;
z-index: 2;
}
.article-editor-loading {
padding-top: 150rpx;
}
.article-editor-content {
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
}
.editor-panel,
.editor-result-card {
position: relative;
width: 100%;
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;
box-sizing: border-box;
}
.editor-panel__body {
position: relative;
z-index: 1;
padding: 38rpx 34rpx 42rpx;
}
.editor-eyebrow {
display: block;
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
text-align: center;
}
.editor-title {
display: block;
margin-top: 10rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 34rpx;
font-weight: 700;
text-align: center;
}
.editor-intro {
display: block;
margin: 10rpx 8rpx 22rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.55;
text-align: center;
}
.editor-field {
margin-top: 18rpx;
}
.editor-field__label {
display: block;
margin: 0 8rpx 8rpx;
color: $ink;
font-size: 25rpx;
font-weight: 700;
}
.editor-control {
position: relative;
display: flex;
height: 82rpx;
align-items: center;
}
.editor-control > image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.editor-control input,
.editor-control textarea {
position: relative;
z-index: 1;
box-sizing: border-box;
width: 100%;
color: $ink;
font-size: 24rpx;
}
.editor-control input {
width: 100%;
height: 82rpx;
padding: 0 28rpx;
}
.editor-control--textarea {
height: 220rpx;
padding: 20rpx 24rpx;
box-sizing: border-box;
}
.editor-control textarea {
width: 100%;
height: 180rpx;
line-height: 1.65;
}
.editor-control--error {
filter: sepia(0.18) saturate(1.2);
}
.editor-placeholder {
color: #9e8e79;
}
.editor-field__error,
.editor-save-error {
display: block;
color: $brand-red;
font-size: 24rpx;
line-height: 34rpx;
}
.editor-field__error {
margin: 5rpx 8rpx 0;
}
.editor-draft-note {
display: block;
margin: 20rpx 10rpx 0;
color: $ink-muted;
font-size: 22rpx;
line-height: 1.55;
text-align: center;
}
.editor-save-error {
margin: 12rpx 8rpx 0;
text-align: center;
}
.editor-panel__body > .app-button {
margin-top: 22rpx;
}
.editor-result-card {
min-height: 420rpx;
}
.editor-result-card__body {
position: relative;
z-index: 1;
padding: 112rpx 52rpx 68rpx;
text-align: center;
}
.editor-result-card__title {
display: block;
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.editor-result-card__copy {
display: block;
margin-top: 16rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.65;
}
.editor-result-card__body > .app-button {
margin-top: 30rpx;
}
@media (max-width: 340px) {
.article-editor-content {
padding-right: 14rpx;
padding-left: 14rpx;
}
.editor-panel__body {
padding-right: 26rpx;
padding-left: 26rpx;
}
}
</style>
+339 -2
View File
@@ -1,5 +1,342 @@
<!-- 页面编号F-08用途相册详情与照片墙 -->
<template><ModulePage page-id="f08" /></template>
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效</text>
<text class="album-state-card__copy">这个相册已无法查看</text>
<AppButton block label="返回相册列表" @click="returnToAlbums" />
</view>
</view>
<template v-else>
<view class="album-heading">
<text class="album-heading__eyebrow">家族影像 · 2024</text>
<text class="album-heading__title">2024 春节团圆</text>
<text class="album-heading__copy"
>一家人围坐团圆让今朝欢聚与祖居旧影留在同一本相册里</text
>
<text class="album-heading__count"
>{{ albumState === "empty" ? "0 张照片" : "5 张照片" }}</text
>
</view>
<view v-if="albumState === 'empty'" class="album-empty-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">照片墙</text>
<text class="album-state-card__title">相册里还没有照片</text>
<text class="album-state-card__copy"
>添加第一张团圆影像把值得珍藏的时刻留在这里</text
>
</view>
</view>
<view v-else class="album-wall">
<view
class="album-photo-tile album-photo-tile--hero"
@click="openPreview(0)"
>
<image
class="album-hero-photo"
:src="photos[0].src"
:alt="photos[0].alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photos[0].caption }}</text>
</view>
<view class="album-photo-grid">
<view
v-for="(photo, index) in photos.slice(1)"
:key="photo.src"
class="album-photo-tile album-photo-tile--grid"
@click="openPreview(index + 1)"
>
<image
class="album-photo-tile__image"
:src="photo.src"
:alt="photo.alt"
mode="aspectFill"
/>
<text class="album-photo-tile__caption">{{ photo.caption }}</text>
</view>
</view>
</view>
<view class="album-upload-action" @click="showUploadNotice">
<AppButton block label="添加照片" />
</view>
</template>
</view>
<view v-if="previewVisible" class="album-preview">
<view class="album-preview__toolbar">
<text class="album-preview__title">照片预览</text>
<view class="album-preview__close" @click="closePreview"><text>关闭</text></view>
</view>
<image
class="album-preview__image"
:src="photos[previewIndex].src"
:alt="photos[previewIndex].alt"
mode="aspectFit"
/>
<view class="album-preview__footer">
<text class="album-preview__position"
>{{ previewIndex + 1 }} / {{ photos.length }}</text
>
<text class="album-preview__caption">{{ photos[previewIndex].caption }}</text>
</view>
</view>
<AppToast :visible="uploadNoticeVisible" :message="uploadNoticeMessage" />
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import { ref } from "vue";
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.vue";
import AppToast from "@/components/AppToast.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const albumState = ref("normal");
const previewVisible = ref(false);
const previewIndex = ref(0);
const uploadNoticeVisible = ref(false);
const uploadNoticeMessage = "上传照片将在入口接通后开放";
let uploadNoticeTimer;
const photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", caption: "院前合影 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", caption: "围桌守岁 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", caption: "祖居旧影 · 1968" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", caption: "门前合影 · 1972" },
];
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
albumState.value = "preview";
};
const closePreview = () => {
previewVisible.value = false;
albumState.value = "normal";
};
const showUploadNotice = () => {
uploadNoticeVisible.value = true;
clearTimeout(uploadNoticeTimer);
uploadNoticeTimer = setTimeout(() => {
uploadNoticeVisible.value = false;
}, 1800);
};
const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" });
};
onLoad((query) => {
albumState.value =
query.state === "empty"
? "empty"
: query.state === "preview"
? "preview"
: query.state === "expired"
? "expired"
: "normal";
previewVisible.value = albumState.value === "preview";
});
onBackPress(() => {
if (!previewVisible.value) return false;
closePreview();
return true;
});
onUnload(() => {
clearTimeout(uploadNoticeTimer);
});
</script>
<style scoped lang="scss">
.album-detail-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.album-detail-header,
.album-detail-content {
position: relative;
z-index: 2;
}
.album-detail-content {
padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
}
.album-heading {
padding: 18rpx 12rpx 20rpx;
text-align: center;
}
.album-heading text,
.album-state-card__body text,
.album-preview__footer text {
display: block;
}
.album-heading__eyebrow,
.album-state-card__eyebrow {
color: $brand-red;
font-size: 22rpx;
letter-spacing: 2rpx;
}
.album-heading__title,
.album-state-card__title {
margin-top: 8rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 36rpx;
font-weight: 700;
}
.album-heading__copy,
.album-state-card__copy {
margin-top: 10rpx;
color: $ink-muted;
font-size: 23rpx;
line-height: 1.6;
}
.album-heading__count {
margin-top: 12rpx;
color: #806a51;
font-size: 22rpx;
}
.album-wall {
margin-top: 8rpx;
}
.album-photo-tile {
position: relative;
min-height: 44px;
overflow: hidden;
box-sizing: border-box;
border: 2rpx solid rgba(179, 133, 63, 0.62);
background: rgba(245, 238, 226, 0.86);
box-shadow: 0 8rpx 18rpx rgba(91, 60, 32, 0.12);
}
.album-photo-tile--hero {
width: 100%;
aspect-ratio: 16 / 9;
}
.album-photo-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 14rpx;
}
.album-photo-tile--grid {
aspect-ratio: 4 / 3;
}
.album-hero-photo,
.album-photo-tile__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.album-photo-tile__caption {
position: absolute;
z-index: 1;
right: 0;
bottom: 0;
left: 0;
padding: 10rpx 12rpx;
background: rgba(48, 35, 25, 0.72);
color: #fffaf0;
font-size: 21rpx;
line-height: 1.35;
}
.album-upload-action {
margin-top: 24rpx;
}
.album-empty-state,
.album-expired-state {
position: relative;
width: 100%;
min-height: 430rpx;
box-sizing: border-box;
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;
}
.album-state-card__body {
padding: 104rpx 40rpx 62rpx;
text-align: center;
}
.album-state-card__body > .app-button {
margin-top: 34rpx;
}
.album-preview {
position: fixed;
z-index: 80;
inset: 0;
display: flex;
flex-direction: column;
background: rgba(24, 17, 13, 0.96);
}
.album-preview__toolbar {
display: flex;
min-height: 96rpx;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top) + 12rpx) 24rpx 12rpx;
}
.album-preview__title {
color: #fffaf0;
font-size: 29rpx;
font-weight: 700;
}
.album-preview__close {
display: flex;
min-width: 44px;
min-height: 44px;
align-items: center;
justify-content: center;
color: #fffaf0;
font-size: 25rpx;
}
.album-preview__image {
width: 100%;
min-height: 0;
flex: 1;
}
.album-preview__footer {
padding: 20rpx 28rpx calc(30rpx + env(safe-area-inset-bottom));
color: #fffaf0;
text-align: center;
}
.album-preview__position {
font-size: 23rpx;
letter-spacing: 2rpx;
}
.album-preview__caption {
margin-top: 8rpx;
color: rgba(255, 250, 240, 0.78);
font-size: 22rpx;
}
@media (max-width: 340px) {
.album-detail-content {
padding-right: 16rpx;
padding-left: 16rpx;
}
.album-photo-grid {
gap: 10rpx;
}
}
</style>
+654 -3
View File
@@ -1,5 +1,656 @@
<!-- 页面编号F-09用途图片预览上传与失败状态 -->
<template><ModulePage page-id="f09" /></template>
<!-- 页面编号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 ModulePage from "@/components/ModulePage.vue";
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>
+141 -3
View File
@@ -1,5 +1,143 @@
<!-- 页面编号F-10用途家族视频列表详情与待开放状态 -->
<template><ModulePage page-id="f10" /></template>
<!-- 页面编号F-10用途家族视频待开放状态 -->
<template>
<view class="video-status-page">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
<view class="video-status-content">
<view class="video-status-lead">
<image
src="/static/assets/modules/genealogy/transparent/section-divider.png"
mode="scaleToFill"
/>
<text>记录会动的家族记忆</text>
</view>
<view class="video-status-card">
<image
class="video-status-card__skin"
src="/static/assets/modules/family/transparent/module-content-frame.png"
mode="scaleToFill"
/>
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">视频 · 服务说明</text>
<text class="video-status-card__title">视频服务暂未开放</text>
<text class="video-status-card__copy"
>开放后可在这里浏览家族影像与纪念视频</text
>
<view class="video-return-action" @click="returnToFamily">
<AppButton block label="返回家族首页" />
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import ModulePage from "@/components/ModulePage.vue";
import AppButton from "@/components/AppButton.vue";
import ModulePageBackground from "@/components/ModulePageBackground.vue";
import PageHeader from "@/components/PageHeader.vue";
const returnToFamily = () => {
uni.reLaunch({ url: "/pages/family/f01-family-feed" });
};
</script>
<style scoped lang="scss">
.video-status-page {
position: relative;
min-height: 100vh;
overflow-x: hidden;
background: $paper;
}
.video-status-header,
.video-status-content {
position: relative;
z-index: 2;
}
.video-status-content {
padding: 16rpx 26rpx 56rpx;
}
.video-status-lead {
position: relative;
height: 56rpx;
margin: 0 12rpx 18rpx;
color: $ink-muted;
font-size: 23rpx;
text-align: center;
}
.video-status-lead image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.video-status-lead text {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.video-status-card {
position: relative;
width: 100%;
min-height: 330rpx;
text-align: center;
}
.video-status-card__skin {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.video-status-card__body {
position: relative;
z-index: 1;
padding: 70rpx 60rpx 48rpx;
}
.video-status-card__eyebrow,
.video-status-card__title,
.video-status-card__copy {
display: block;
}
.video-status-card__eyebrow {
color: $brand-red;
font-size: 23rpx;
letter-spacing: 3rpx;
}
.video-status-card__title {
margin-top: 14rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: 35rpx;
font-weight: 700;
}
.video-status-card__copy {
margin-top: 17rpx;
color: $ink-muted;
font-size: 24rpx;
line-height: 1.6;
}
.video-return-action {
margin: 28rpx auto 0;
}
@media (min-width: 400px) {
.video-status-content {
padding-right: 34rpx;
padding-left: 34rpx;
}
}
@media (max-width: 340px) {
.video-status-content {
padding-right: 20rpx;
padding-left: 20rpx;
}
.video-status-card__body {
padding-right: 44rpx;
padding-left: 44rpx;
}
}
</style>