完成70%
This commit is contained in:
@@ -1,114 +1,50 @@
|
||||
<!-- 页面编号:F-01;用途:家族动态首页、列表、空态与失败状态。 -->
|
||||
<!-- 页面编号:F-01;用途:家族动态入口。列表 DTO 缺失时不展示 fixture 条目。 -->
|
||||
<template>
|
||||
<view
|
||||
class="family-page"
|
||||
:class="{
|
||||
'feed-state--list': feedState === 'list',
|
||||
'feed-state--empty': feedState === 'empty',
|
||||
'feed-state--error': feedState === 'error',
|
||||
}"
|
||||
>
|
||||
<view class="family-page" :class="`feed-state--${feedState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="family-page__header">
|
||||
<PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" />
|
||||
</view>
|
||||
<view class="family-page__header"><PageHeader root title="家族动态" :action="hasValidContext ? '发布' : ''" @action="toPublish" /></view>
|
||||
<view class="feed-content">
|
||||
<AppLoading
|
||||
v-if="feedState === 'loading'"
|
||||
text="正在整理家族动态"
|
||||
description="请稍候,正在读取家宴、通知与共同记忆。"
|
||||
/>
|
||||
<view v-if="feedState !== 'loading'" class="feed-heading"
|
||||
><text>{{ familyTitle }}</text><text>家宴、通知与共同记忆</text></view
|
||||
>
|
||||
<view v-if="feedState !== 'loading' && hasValidContext" class="feed-shortcuts">
|
||||
<view
|
||||
v-for="item in shortcuts"
|
||||
:key="item.key"
|
||||
class="feed-shortcut"
|
||||
@click="openSection(item.key)"
|
||||
><text>{{ item.label }}</text></view
|
||||
>
|
||||
</view>
|
||||
<template v-if="feedState === 'list'">
|
||||
<view
|
||||
v-for="item in feeds"
|
||||
:key="item.id"
|
||||
class="feed-card"
|
||||
@click="openDetail(item)"
|
||||
>
|
||||
<view class="feed-card__copy">
|
||||
<text class="feed-card__meta"
|
||||
>{{ item.tag }} · {{ item.time }}</text
|
||||
>
|
||||
<text class="feed-card__title">{{ item.title }}</text>
|
||||
<text class="feed-card__summary">{{ item.content }}</text>
|
||||
<text class="feed-card__author">发布人:{{ item.author }}</text>
|
||||
</view>
|
||||
<view class="feed-heading"><text>家族圈</text><text>家宴、通知与共同记忆</text></view>
|
||||
<template v-if="hasValidContext">
|
||||
<view class="feed-source-note"><text>动态列表接口当前没有声明条目 DTO;页面不猜测标题、正文、发布人或时间,已停止展示本地动态。</text></view>
|
||||
<view class="feed-shortcuts">
|
||||
<view v-for="item in shortcuts" :key="item.key" class="feed-shortcut" @click="openSection(item.key)"><text>{{ item.label }}</text></view>
|
||||
</view>
|
||||
</template>
|
||||
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
|
||||
<view class="feed-state-card__copy"
|
||||
><text>{{ stateCopy.title }}</text
|
||||
><text>{{ stateCopy.copy }}</text></view
|
||||
>
|
||||
<view class="feed-state-card">
|
||||
<view class="feed-state-card__copy"><text>{{ stateCopy.title }}</text><text>{{ stateCopy.copy }}</text></view>
|
||||
</view>
|
||||
<view
|
||||
v-if="feedState !== 'loading'"
|
||||
class="feed-action"
|
||||
@click="handlePrimaryAction"
|
||||
><text>{{ stateCopy.action }}</text></view
|
||||
>
|
||||
<view class="feed-action" @click="handlePrimaryAction"><text>{{ stateCopy.action }}</text></view>
|
||||
</view>
|
||||
<AppTabbar active="family" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import AppTabbar from "@/components/AppTabbar.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import {
|
||||
findGenealogyFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
listFamilyFeedFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { genealogyContext } from "@/utils/genealogy-context.js";
|
||||
import { goRoot, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const genealogy = ref(null);
|
||||
const hasValidContext = ref(false);
|
||||
const feedState = ref("loading");
|
||||
const feeds = ref([]);
|
||||
const familyTitle = computed(() =>
|
||||
genealogy.value ? `${genealogy.value.name}家族圈` : "家族圈",
|
||||
);
|
||||
const feedState = ref("unavailable");
|
||||
const stateCopy = computed(() => {
|
||||
if (!hasValidContext.value) {
|
||||
return {
|
||||
title: "请先选择可访问的家谱",
|
||||
title: "请先选择有效家谱",
|
||||
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
|
||||
action: "返回我的家谱",
|
||||
};
|
||||
}
|
||||
if (feedState.value === "empty") {
|
||||
return {
|
||||
title: "还没有家族动态",
|
||||
copy: "可先查看其他家族内容;发布接口接入后才能新增动态。",
|
||||
action: "填写动态预览",
|
||||
};
|
||||
}
|
||||
if (feedState.value === "error") {
|
||||
return {
|
||||
title: "家族动态暂不可用",
|
||||
copy: "请稍后重新查看,已有内容不会受到影响。",
|
||||
action: "重新查看",
|
||||
};
|
||||
}
|
||||
return { title: "", copy: "", action: "发布家族动态" };
|
||||
return {
|
||||
title: "动态列表待后端字段合同",
|
||||
copy: "已找到动态列表 operation,但响应只声明通用对象数组;待后端提供动态 ID、标题、正文、发布人和时间字段后再恢复列表与详情入口。",
|
||||
action: "发布家族动态",
|
||||
};
|
||||
});
|
||||
const shortcuts = [
|
||||
{ key: "articles", label: "谱文" },
|
||||
@@ -120,52 +56,25 @@ const shortcuts = [
|
||||
{ key: "merits", label: "功德录" },
|
||||
{ key: "videos", label: "家族视频" },
|
||||
];
|
||||
|
||||
onLoad((query) => {
|
||||
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query, "genealogyId");
|
||||
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query || {}, "genealogyId");
|
||||
const resolvedGenealogyId = hasRouteIdentity
|
||||
? String(query.genealogyId || "")
|
||||
? String(query?.genealogyId || "")
|
||||
: String(genealogyContext.getCurrentGenealogyId() || "");
|
||||
genealogyId.value = resolvedGenealogyId;
|
||||
genealogy.value = findGenealogyFixture(resolvedGenealogyId);
|
||||
const access = getGenealogyFixtureAccess(resolvedGenealogyId);
|
||||
const isAccessible = Boolean(
|
||||
genealogy.value && ["owner", "member"].includes(access.accessRole),
|
||||
);
|
||||
if (!isAccessible) {
|
||||
feeds.value = [];
|
||||
feedState.value = "error";
|
||||
return;
|
||||
}
|
||||
if (!hasRouteIdentity) {
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(resolvedGenealogyId);
|
||||
feedState.value = hasValidContext.value ? "unavailable" : "error";
|
||||
if (!hasRouteIdentity && hasValidContext.value) {
|
||||
goRoot("F01", { genealogyId: resolvedGenealogyId }).catch(() => {
|
||||
hasValidContext.value = false;
|
||||
feedState.value = "error";
|
||||
});
|
||||
return;
|
||||
}
|
||||
hasValidContext.value = isAccessible;
|
||||
feeds.value = listFamilyFeedFixtures(resolvedGenealogyId);
|
||||
feedState.value =
|
||||
query.state === "loading"
|
||||
? "loading"
|
||||
: query.state === "empty"
|
||||
? "empty"
|
||||
: query.state === "error"
|
||||
? "error"
|
||||
: feeds.value.length
|
||||
? "list"
|
||||
: "empty";
|
||||
});
|
||||
const toPublish = () =>
|
||||
hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
|
||||
: goRoot("G01");
|
||||
const openDetail = (item) =>
|
||||
openPage(
|
||||
"F03",
|
||||
{ genealogyId: genealogyId.value, feedId: String(item.id) },
|
||||
"F01",
|
||||
);
|
||||
const toPublish = () => hasValidContext.value
|
||||
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
|
||||
: goRoot("G01");
|
||||
const openSection = (key) => {
|
||||
const routes = {
|
||||
articles: "F04",
|
||||
@@ -179,147 +88,27 @@ const openSection = (key) => {
|
||||
};
|
||||
return openPage(routes[key], { genealogyId: genealogyId.value }, "F01");
|
||||
};
|
||||
const handlePrimaryAction = () => {
|
||||
if (!hasValidContext.value) return goRoot("G01");
|
||||
if (feedState.value === "error") {
|
||||
feeds.value = listFamilyFeedFixtures(genealogyId.value);
|
||||
feedState.value = feeds.value.length ? "list" : "empty";
|
||||
return;
|
||||
}
|
||||
return toPublish();
|
||||
};
|
||||
const handlePrimaryAction = () => hasValidContext.value ? toPublish() : goRoot("G01");
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.family-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.family-page__header,
|
||||
.feed-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.feed-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 190rpx;
|
||||
}
|
||||
.feed-heading text {
|
||||
display: block;
|
||||
}
|
||||
.feed-heading text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-heading text:last-child {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
.feed-shortcuts {
|
||||
@include adaptive.adaptive-scroll-button(secondary);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
margin-top: 15rpx;
|
||||
}
|
||||
.feed-shortcut {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.feed-shortcut text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: $ink;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-card,
|
||||
.feed-state-card {
|
||||
@include adaptive.adaptive-family-letter;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 230rpx;
|
||||
min-height: 98px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
margin-top: 17rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.feed-card__copy {
|
||||
padding: 15% 9%;
|
||||
}
|
||||
.feed-card text {
|
||||
display: block;
|
||||
}
|
||||
.feed-card__meta {
|
||||
color: #806a51;
|
||||
font-size: 21rpx;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
.feed-card__title {
|
||||
margin-top: 5rpx;
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-card__summary {
|
||||
margin-top: 6rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.feed-card__author {
|
||||
margin-top: 6rpx;
|
||||
color: $brand-red;
|
||||
font-size: 21rpx;
|
||||
}
|
||||
.feed-state-card {
|
||||
margin-top: 70rpx;
|
||||
}
|
||||
.feed-state-card__copy {
|
||||
padding: 23% 10%;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
.feed-state-card text {
|
||||
display: block;
|
||||
}
|
||||
.feed-state-card text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 31rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.feed-state-card text:last-child {
|
||||
margin-top: 13rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.feed-action {
|
||||
@include adaptive.adaptive-scroll-button(primary);
|
||||
width: 514rpx;
|
||||
max-width: 100%;
|
||||
min-height: 76rpx;
|
||||
margin: 19rpx auto 0;
|
||||
}
|
||||
.feed-action text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: #fff9ed;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.family-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.family-page__header, .feed-content { z-index: 1; }
|
||||
.feed-content { flex: 1; padding: 24rpx 24rpx 190rpx; }
|
||||
.feed-heading text { display: block; }
|
||||
.feed-heading text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.feed-heading text:last-child { margin-top: 6rpx; color: $ink-muted; font-size: 23rpx; }
|
||||
.feed-source-note { margin-top: 10rpx; padding: 12rpx 16rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255, 255, 255, .56); }
|
||||
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||
.feed-shortcuts { @include adaptive.adaptive-scroll-button(secondary); display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); width: 100%; min-height: 48px; margin-top: 15rpx; }
|
||||
.feed-shortcut { width: 100%; height: 48px; min-height: 44px; }
|
||||
.feed-shortcut text { display: flex; align-items: center; justify-content: center; height: 100%; color: $ink; font-size: 22rpx; font-weight: 700; }
|
||||
.feed-state-card { @include adaptive.adaptive-family-letter; display: flex; width: 100%; min-height: 230rpx; flex-direction: column; justify-content: center; margin-top: 70rpx; box-sizing: border-box; }
|
||||
.feed-state-card__copy { padding: 23% 10%; box-sizing: border-box; text-align: center; }
|
||||
.feed-state-card text { display: block; }
|
||||
.feed-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.feed-state-card text:last-child { margin-top: 13rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.45; }
|
||||
.feed-action { @include adaptive.adaptive-scroll-button(primary); width: 514rpx; max-width: 100%; min-height: 76rpx; margin: 19rpx auto 0; }
|
||||
.feed-action text { display: flex; align-items: center; justify-content: center; height: 100%; color: #fff9ed; font-size: 24rpx; font-weight: 700; }
|
||||
</style>
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
<!-- 页面编号:F-02;用途:发布家族动态与提交结果。 -->
|
||||
<!-- 页面编号:F-02;用途:按 Apifox 已声明的 feedContent 发布家族动态。 -->
|
||||
<template>
|
||||
<view
|
||||
class="publish-page"
|
||||
:class="{
|
||||
'publish-state--form': publishState === 'form',
|
||||
'publish-state--preview': publishState === 'preview',
|
||||
'publish-state--error': publishState === 'error',
|
||||
'publish-state--invalid': publishState === 'invalid',
|
||||
}"
|
||||
><ModulePageBackground module="family" /><view class="publish-page__header"
|
||||
><PageHeader title="发布动态" custom-back @back="requestBack" /></view
|
||||
><view class="publish-panel"
|
||||
><view
|
||||
v-if="publishState === 'form'"
|
||||
class="publish-form"
|
||||
><text>记录此刻</text
|
||||
><text>分享通知、活动、家族故事或一段共同记忆。</text
|
||||
><view class="publish-field"
|
||||
><textarea
|
||||
v-model="content"
|
||||
auto-height
|
||||
maxlength="300"
|
||||
placeholder="写下想对家人说的话"
|
||||
placeholder-class="publish-placeholder"
|
||||
/></view
|
||||
><AppButton
|
||||
block
|
||||
:label="isSubmitting ? '正在校验' : '生成本地预览'"
|
||||
:disabled="isSubmitting"
|
||||
@click="submit"
|
||||
/></view
|
||||
><view v-else class="publish-result"
|
||||
><text>{{ resultCopy.title }}</text
|
||||
><text>{{ resultCopy.copy }}</text
|
||||
><AppButton
|
||||
block
|
||||
:label="resultCopy.action"
|
||||
@click="handleResultAction" /></view></view
|
||||
><AppDialog
|
||||
<view class="publish-page" :class="`publish-state--${publishState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="publish-page__header"><PageHeader title="发布动态" custom-back @back="requestBack" /></view>
|
||||
<view class="publish-panel">
|
||||
<view v-if="publishState === 'form'" class="publish-form">
|
||||
<text>记录此刻</text>
|
||||
<text>当前接口只明确文本动态的 `feedContent` 请求字段;媒体、排序和状态均不由本页猜测或提交。</text>
|
||||
<view class="publish-field"><textarea v-model="content" auto-height maxlength="300" placeholder="写下想对家人说的话" placeholder-class="publish-placeholder" @input="submitError = ''" /></view>
|
||||
<text v-if="submitError" class="publish-error">{{ submitError }}</text>
|
||||
<AppButton block :label="isSubmitting ? '正在提交' : '提交动态'" :disabled="isSubmitting" @click="submit" />
|
||||
</view>
|
||||
<view v-else class="publish-result">
|
||||
<text>{{ resultCopy.title }}</text>
|
||||
<text>{{ resultCopy.copy }}</text>
|
||||
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃动态草稿?"
|
||||
message="当前内容尚未提交服务器,确认返回后不会保留。"
|
||||
@@ -48,46 +28,34 @@
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
><AppToast :visible="toastVisible" :message="toastMessage"
|
||||
/></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { getGenealogyFixtureAccess } from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const content = ref("");
|
||||
const publishState = ref("form");
|
||||
const isSubmitting = ref(false);
|
||||
const submitError = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const toastVisible = ref(false);
|
||||
const toastMessage = ref("");
|
||||
let toastTimer = null;
|
||||
let submitTimer = null;
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Boolean(content.value.trim()));
|
||||
const hasValidContext = computed(() =>
|
||||
["owner", "member"].includes(
|
||||
getGenealogyFixtureAccess(genealogyId.value).accessRole,
|
||||
),
|
||||
);
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() => ({
|
||||
preview: {
|
||||
title: "动态内容已完成本地预览",
|
||||
copy: "当前尚未提交服务器,返回家族圈后不会出现这条动态。",
|
||||
action: "返回家族圈(不发布)",
|
||||
success: {
|
||||
title: "动态已提交服务端",
|
||||
copy: "服务端已返回成功信封。动态列表仍缺条目 DTO,返回后不会把本地内容伪装成列表项。",
|
||||
action: "返回家族动态",
|
||||
},
|
||||
error: {
|
||||
title: "动态未提交",
|
||||
@@ -96,7 +64,7 @@ const resultCopy = computed(() => ({
|
||||
},
|
||||
invalid: {
|
||||
title: "动态入口无效",
|
||||
copy: "没有找到可发布内容的成员家谱,页面不会创建无归属动态。",
|
||||
copy: "没有取得有效家谱标识,页面不会创建无归属动态。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
}[publishState.value]));
|
||||
@@ -108,57 +76,45 @@ const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
if (!genealogyId.value || !hasValidContext.value) {
|
||||
publishState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
if (["preview", "error"].includes(query.state)) {
|
||||
content.value = "这是一段尚未提交服务器的家族动态预览。";
|
||||
publishState.value = query.state;
|
||||
}
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value) publishState.value = "invalid";
|
||||
});
|
||||
const showToast = (message) => {
|
||||
toastMessage.value = message;
|
||||
toastVisible.value = true;
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => {
|
||||
toastVisible.value = false;
|
||||
toastTimer = null;
|
||||
}, 1800);
|
||||
};
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !hasValidContext.value) return;
|
||||
if (!content.value.trim()) {
|
||||
showToast("请先写下动态内容");
|
||||
const feedContent = content.value.trim();
|
||||
if (!feedContent) {
|
||||
submitError.value = "请先写下动态内容";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
const submittedContent = content.value.trim();
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
submitTimer = null;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createFeed(genealogyId.value, { feedContent }, { requestController });
|
||||
content.value = "";
|
||||
publishState.value = "success";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
publishState.value = "error";
|
||||
submitError.value = error?.message || "动态提交失败,请稍后重试。";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
publishState.value = submittedContent ? "preview" : "error";
|
||||
}, 280);
|
||||
submitTimer = timer;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const returnToFamily = async () => {
|
||||
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () => {
|
||||
if (publishState.value === "preview") return returnToFamily();
|
||||
if (publishState.value === "success") return returnToFamily();
|
||||
if (publishState.value === "error") {
|
||||
publishState.value = "form";
|
||||
return;
|
||||
@@ -167,75 +123,24 @@ const handleResultAction = () => {
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => {
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
if (submitTimer) clearTimeout(submitTimer);
|
||||
requestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.publish-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.publish-page__header,
|
||||
.publish-panel {
|
||||
@include adaptive.adaptive-family-content;
|
||||
z-index: 1;
|
||||
}
|
||||
.publish-panel {
|
||||
width: calc(100% - 32rpx);
|
||||
margin: 18rpx auto 0;
|
||||
padding: 9%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.publish-form > text,
|
||||
.publish-result > text {
|
||||
display: block;
|
||||
}
|
||||
.publish-form > text:first-child,
|
||||
.publish-result > text:first-child {
|
||||
color: $ink;
|
||||
font-family: STKaiti, KaiTi, serif;
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
.publish-form > text:nth-child(2),
|
||||
.publish-result > text:nth-child(2) {
|
||||
margin-top: 12rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.publish-field {
|
||||
@include adaptive.adaptive-family-field;
|
||||
display: flex;
|
||||
min-height: 250rpx;
|
||||
margin-top: 24rpx;
|
||||
padding: 25rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.publish-field textarea {
|
||||
width: 100%;
|
||||
min-height: 200rpx;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.publish-placeholder {
|
||||
color: #8e806e;
|
||||
}
|
||||
.publish-form > .app-button {
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
.publish-result {
|
||||
margin-top: 20%;
|
||||
text-align: center;
|
||||
}
|
||||
.publish-result > .app-button {
|
||||
margin: 30rpx auto 0;
|
||||
}
|
||||
.publish-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.publish-page__header, .publish-panel { @include adaptive.adaptive-family-content; z-index: 1; }
|
||||
.publish-panel { width: calc(100% - 32rpx); margin: 18rpx auto 0; padding: 9%; box-sizing: border-box; }
|
||||
.publish-form > text, .publish-result > text { display: block; }
|
||||
.publish-form > text:first-child, .publish-result > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.publish-form > text:nth-child(2), .publish-result > text:nth-child(2) { margin-top: 12rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.publish-field { @include adaptive.adaptive-family-field; display: flex; min-height: 250rpx; margin-top: 24rpx; padding: 25rpx; box-sizing: border-box; }
|
||||
.publish-field textarea { width: 100%; min-height: 200rpx; color: $ink; font-size: 24rpx; line-height: 1.7; }
|
||||
.publish-placeholder { color: #8e806e; }
|
||||
.publish-error { margin-top: 12rpx; color: $brand-red; font-size: 22rpx; line-height: 1.5; }
|
||||
.publish-form > .app-button { margin-top: 24rpx; }
|
||||
.publish-result { margin-top: 20%; text-align: center; }
|
||||
.publish-result > .app-button { margin: 30rpx auto 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
<!-- 页面编号:F-03;用途:动态详情、评论与内容失效状态。 -->
|
||||
<!-- 页面编号:F-03;用途:动态详情与一级评论。动态正文 DTO 未声明时不展示伪字段。 -->
|
||||
<template>
|
||||
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
|
||||
|
||||
<view v-if="feedState === 'loading'" class="feed-detail-loading">
|
||||
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
|
||||
<AppLoading text="正在读取家人评论" description="动态正文的展示字段尚未由后端合同声明。" />
|
||||
</view>
|
||||
|
||||
<view v-else class="feed-detail-content">
|
||||
<template v-if="feedState === 'ready'">
|
||||
<view class="feed-source-note"><text>动态详情接口尚未声明标题、正文、发布人和发布时间字段;本页不猜测这些字段,只展示该动态的真实一级评论。</text></view>
|
||||
<view class="feed-article-card">
|
||||
<text class="feed-article-card__meta">{{ currentFeed.tag }} · {{ currentFeed.time }}</text>
|
||||
<text class="feed-article-card__title">{{ currentFeed.title }}</text>
|
||||
<text class="feed-article-card__body">{{ currentFeed.content }}</text>
|
||||
<text class="feed-article-card__author">发布人:{{ currentFeed.author }}</text>
|
||||
<text class="feed-article-card__title">动态正文待后端字段合同</text>
|
||||
<text class="feed-article-card__body">已根据当前动态 ID 读取评论。正文内容会在详情响应提供可消费 DTO 后再接入。</text>
|
||||
</view>
|
||||
|
||||
<view class="feed-comments-panel">
|
||||
@@ -24,17 +23,19 @@
|
||||
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
|
||||
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
|
||||
<text>{{ comment.content }}</text>
|
||||
<text v-if="comment.replyCount > 0" class="feed-comment-card__replies">直属回复 {{ comment.replyCount }} 条</text>
|
||||
</view>
|
||||
<view v-if="!feedComments.length" class="feed-comments-empty">
|
||||
<text>还没有评论</text><text>写下第一句祝福或共同记忆。</text>
|
||||
<text>还没有一级评论</text><text>服务端返回评论后会显示在这里。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="feed-comment-form" :class="{ 'comment-state--validating': commentState === 'validating', 'comment-state--preview': commentState === 'preview', 'comment-state--error': commentState === 'error' }">
|
||||
<text>写下评论</text>
|
||||
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
|
||||
<view class="feed-comment-form" :class="{ 'comment-state--submitting': commentState === 'submitting', 'comment-state--error': commentState === 'error' }">
|
||||
<text>提交一级评论</text>
|
||||
<textarea v-model="commentDraft" auto-height maxlength="1000" placeholder="对家人说点什么" />
|
||||
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
|
||||
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
|
||||
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
|
||||
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +48,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃评论草稿?"
|
||||
message="当前评论尚未提交服务器,确认返回后不会保留。"
|
||||
message="当前评论尚未提交服务端,确认返回后不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -55,7 +56,6 @@
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -65,11 +65,10 @@ import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findFamilyFeedFixture } from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
@@ -79,29 +78,28 @@ import {
|
||||
|
||||
const genealogyId = ref("");
|
||||
const feedId = ref("");
|
||||
const currentFeed = ref(null);
|
||||
const feedState = ref("loading");
|
||||
const commentState = ref("idle");
|
||||
const commentDraft = ref("");
|
||||
const commentError = ref("");
|
||||
const toastVisible = ref(false);
|
||||
const commentNotice = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const feedComments = ref([]);
|
||||
let submitTimer = null;
|
||||
let toastTimer = null;
|
||||
const feedRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
|
||||
const stateCopy = computed(() => {
|
||||
if (feedState.value === "error" && currentFeed.value) {
|
||||
if (feedState.value === "error") {
|
||||
return {
|
||||
title: "动态暂不可用",
|
||||
copy: "请稍后重新查看,已有家族记录不会受到影响。",
|
||||
action: "重新查看",
|
||||
title: "评论暂不可用",
|
||||
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
|
||||
action: "重新读取",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态已失效或入口无效",
|
||||
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
|
||||
action: genealogyId.value ? "返回家族圈" : "返回上一页",
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
|
||||
action: genealogyId.value ? "返回家族动态" : "返回上一页",
|
||||
};
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -111,45 +109,69 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!genealogyId.value || !feedId.value) {
|
||||
feedState.value = "expired";
|
||||
return false;
|
||||
}
|
||||
const sequence = ++loadSequence;
|
||||
feedState.value = "loading";
|
||||
try {
|
||||
const comments = await appApi.getFeedComments(genealogyId.value, feedId.value, {
|
||||
requestController: feedRequestController,
|
||||
});
|
||||
if (sequence !== loadSequence) return false;
|
||||
feedComments.value = comments;
|
||||
feedState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
|
||||
feedState.value = "error";
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
feedId.value = String(query.feedId || "");
|
||||
const selected = findFamilyFeedFixture(genealogyId.value, feedId.value);
|
||||
currentFeed.value = selected;
|
||||
feedComments.value = selected?.comments || [];
|
||||
feedState.value = ["loading", "error", "expired"].includes(query.state)
|
||||
? selected
|
||||
? query.state
|
||||
: "expired"
|
||||
: selected
|
||||
? "ready"
|
||||
: "expired";
|
||||
loadComments();
|
||||
});
|
||||
|
||||
const submitComment = () => {
|
||||
if (commentState.value === "validating" || !currentFeed.value) return;
|
||||
const submitComment = async () => {
|
||||
if (commentState.value === "submitting" || feedState.value !== "ready") return;
|
||||
const content = commentDraft.value.trim();
|
||||
if (!content) {
|
||||
commentError.value = "请先写下评论内容";
|
||||
commentNotice.value = "";
|
||||
return;
|
||||
}
|
||||
commentError.value = "";
|
||||
commentState.value = "validating";
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
submitTimer = null;
|
||||
commentState.value = "preview";
|
||||
toastVisible.value = true;
|
||||
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
|
||||
}, 320);
|
||||
submitTimer = timer;
|
||||
commentNotice.value = "";
|
||||
commentState.value = "submitting";
|
||||
try {
|
||||
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent: content }, {
|
||||
requestController: feedRequestController,
|
||||
});
|
||||
commentDraft.value = "";
|
||||
const refreshed = await loadComments();
|
||||
if (!refreshed) {
|
||||
commentState.value = "error";
|
||||
commentError.value = "评论已提交,但列表刷新失败;请稍后重新查看。";
|
||||
return;
|
||||
}
|
||||
commentState.value = "idle";
|
||||
commentNotice.value = "评论已提交,并已从服务端刷新。";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
commentError.value = error?.message || "评论提交失败,请稍后重试。";
|
||||
}
|
||||
};
|
||||
const restoreFeed = () => { feedState.value = "ready"; };
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: commentState.value === "validating",
|
||||
submitting: commentState.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -161,14 +183,14 @@ const backToFamily = async () => {
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (feedState.value === "error" && currentFeed.value) return restoreFeed();
|
||||
if (feedState.value === "error") return loadComments();
|
||||
return backToFamily();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnmounted(() => {
|
||||
if (submitTimer) clearTimeout(submitTimer);
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
++loadSequence;
|
||||
feedRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -180,26 +202,29 @@ onUnmounted(() => {
|
||||
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
|
||||
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
|
||||
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.feed-source-note { margin-top: 12rpx; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255,255,255,.56); }
|
||||
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { @include adaptive.adaptive-family-content; width: 100%; }
|
||||
.feed-article-card { padding: 46rpx 48rpx 42rpx; }
|
||||
.feed-article-card { padding: 42rpx 48rpx; }
|
||||
.feed-article-card text, .feed-state-card > text { display: block; }
|
||||
.feed-article-card__meta, .feed-article-card__author { color: $ink-muted; font-size: 22rpx; }
|
||||
.feed-article-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.feed-article-card__body { margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.75; }
|
||||
.feed-article-card__author { margin-top: 22rpx; }
|
||||
.feed-article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||
.feed-article-card__body { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.7; }
|
||||
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
|
||||
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
|
||||
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
|
||||
.feed-comment-card { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 20rpx 22rpx; }
|
||||
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
||||
.feed-comment-card > .feed-comment-card__replies { color: $ink-muted; font-size: 20rpx; }
|
||||
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
|
||||
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
|
||||
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
|
||||
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.feed-comment-form textarea { @include adaptive.adaptive-family-field; width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
||||
.feed-comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
|
||||
.feed-comment-error, .feed-comment-notice { display: block; margin-top: 10rpx; font-size: 22rpx; }
|
||||
.feed-comment-error { color: $brand-red; }
|
||||
.feed-comment-notice { color: $ink-muted; }
|
||||
.feed-comment-form .app-button { margin-top: 22rpx; }
|
||||
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
|
||||
@@ -1,45 +1,13 @@
|
||||
<!-- 页面编号:F-04;用途:谱文分类、搜索、列表与新建入口。 -->
|
||||
<!-- 页面编号:F-04;用途:谱文入口。列表 item DTO 未声明时不展示本地文章。 -->
|
||||
<template>
|
||||
<view class="article-list-page" :class="{ 'article-list-state--loading': listState === 'loading', 'article-list-state--empty': listState === 'empty', 'article-list-state--error': listState === 'error' }">
|
||||
<view class="article-list-page" :class="`article-list-state--${listState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-list-header"><PageHeader title="谱文" :action="hasValidContext ? '新建' : ''" @action="createArticle" /></view>
|
||||
|
||||
<view v-if="listState === 'loading'" class="article-list-loading">
|
||||
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
|
||||
</view>
|
||||
|
||||
<view v-else class="article-list-content">
|
||||
<template v-if="listState === 'ready'">
|
||||
<view class="article-search">
|
||||
<input v-model="keyword" placeholder="搜索标题、作者或正文" confirm-type="search" />
|
||||
</view>
|
||||
<view class="article-categories">
|
||||
<view class="article-categories__row">
|
||||
<view v-for="category in articleCategories" :key="category" class="article-category" :class="{ 'article-category--active': activeCategory === category }" @click="activeCategory = category"><text>{{ category }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="filteredArticles.length" class="article-list">
|
||||
<view v-for="article in filteredArticles" :key="article.id" class="article-card" role="button" :aria-label="`查看谱文${article.title}`" @click="openArticle(article)">
|
||||
<text class="article-card__category">{{ article.category }}</text>
|
||||
<text class="article-card__title">{{ article.title }}</text>
|
||||
<text class="article-card__summary">{{ article.summary }}</text>
|
||||
<view class="article-card__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
|
||||
</view>
|
||||
<AppButton block label="新建谱文" @click="createArticle" />
|
||||
</view>
|
||||
|
||||
<view v-else class="article-list-state-card">
|
||||
<text>{{ keyword || activeCategory !== '全部' ? '没有找到相关谱文' : '还没有谱文' }}</text>
|
||||
<text>{{ keyword || activeCategory !== '全部' ? '换一个关键词或分类继续查找。' : '从第一篇家训、序言或家族往事开始记录。' }}</text>
|
||||
<AppButton block :label="keyword || activeCategory !== '全部' ? '清空筛选' : '新建谱文'" @click="keyword || activeCategory !== '全部' ? resetFilters() : createArticle()" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="article-list-state-card">
|
||||
<view class="article-list-content">
|
||||
<view class="article-list-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -49,115 +17,42 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } 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";
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listFamilyArticleFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { goBack, openPage } from "@/utils/navigation.js";
|
||||
|
||||
const articleCategories = ["全部", "家风家训", "家族往事", "族谱序言"];
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const articles = ref([]);
|
||||
const activeCategory = ref("全部");
|
||||
const keyword = ref("");
|
||||
const listState = ref("loading");
|
||||
const filteredArticles = computed(() => {
|
||||
const term = keyword.value.trim().toLowerCase();
|
||||
return articles.value.filter((article) => {
|
||||
const categoryMatched = activeCategory.value === "全部" || article.category === activeCategory.value;
|
||||
const keywordMatched = !term || `${article.title} ${article.summary} ${article.author}`.toLowerCase().includes(term);
|
||||
return categoryMatched && keywordMatched;
|
||||
});
|
||||
});
|
||||
const stateCopy = computed(() => ({
|
||||
empty: {
|
||||
title: "还没有谱文",
|
||||
copy: "当前家谱尚无可读取谱文,真实写接口接入后才能新增。",
|
||||
action: "填写谱文预览",
|
||||
},
|
||||
error: {
|
||||
title: "谱文列表暂不可用",
|
||||
copy: "请稍后重新查看,已有谱文不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
invalid: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱内容。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
}[listState.value]));
|
||||
|
||||
const listState = ref("unavailable");
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? {
|
||||
title: "谱文列表待后端字段合同",
|
||||
copy: "列表接口只声明通用对象数组,没有文章 ID、分类、标题、摘要、作者或更新时间字段;页面已停止展示本地文章和本地筛选。",
|
||||
action: "新建谱文",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱标识,页面不会展示其他家谱内容。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
hasValidContext.value = ["owner", "member"].includes(
|
||||
getGenealogyFixtureAccess(genealogyId.value).accessRole,
|
||||
);
|
||||
if (!genealogyId.value || !hasValidContext.value) {
|
||||
listState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
articles.value = listFamilyArticleFixtures(genealogyId.value);
|
||||
listState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: articles.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||
listState.value = hasValidContext.value ? "unavailable" : "invalid";
|
||||
});
|
||||
|
||||
const openArticle = (article) =>
|
||||
openPage(
|
||||
"F05",
|
||||
{ genealogyId: genealogyId.value, articleId: String(article.id) },
|
||||
"F04",
|
||||
);
|
||||
const createArticle = () =>
|
||||
hasValidContext.value
|
||||
? openPage(
|
||||
"F06",
|
||||
{ genealogyId: genealogyId.value, mode: "create" },
|
||||
"F04",
|
||||
)
|
||||
: Promise.resolve(false);
|
||||
const restoreArticles = () => {
|
||||
articles.value = listFamilyArticleFixtures(genealogyId.value);
|
||||
listState.value = articles.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (listState.value === "invalid") return goBack();
|
||||
if (listState.value === "error") return restoreArticles();
|
||||
return createArticle();
|
||||
};
|
||||
const resetFilters = () => { keyword.value = ""; activeCategory.value = "全部"; };
|
||||
const createArticle = () => hasValidContext.value
|
||||
? openPage("F06", { genealogyId: genealogyId.value, mode: "create" }, "F04")
|
||||
: Promise.resolve(false);
|
||||
const handleStateAction = () => hasValidContext.value ? createArticle() : goBack();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.article-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.article-list-header, .article-list-loading, .article-list-content { z-index: 1; }
|
||||
.article-list-loading { min-height: calc(100vh - 100rpx); }
|
||||
.article-list-header, .article-list-content { z-index: 1; }
|
||||
.article-list-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.article-search { @include adaptive.adaptive-family-field; min-height: 82rpx; padding: 12rpx 26rpx; }
|
||||
.article-search input { width: 100%; min-height: 58rpx; color: $ink; font-size: 24rpx; }
|
||||
.article-categories { width: 100%; margin-top: 16rpx; }
|
||||
.article-categories__row { display: flex; flex-wrap: wrap; gap: 12rpx; padding: 2rpx 4rpx 8rpx; }
|
||||
.article-category { @include adaptive.adaptive-family-field; min-height: 58rpx; padding: 0 28rpx; color: $ink-muted; font-size: 23rpx; }
|
||||
.article-category text { display: flex; min-height: 58rpx; align-items: center; }
|
||||
.article-category--active { color: $brand-red; font-weight: 700; }
|
||||
.article-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 10rpx; }
|
||||
.article-card, .article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; }
|
||||
.article-card { min-height: 196rpx; padding: 34rpx 46rpx 30rpx; }
|
||||
.article-card > text { display: block; }
|
||||
.article-card__category { color: $brand-red; font-size: 21rpx; font-weight: 700; letter-spacing: 2rpx; }
|
||||
.article-card__title { margin-top: 7rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 31rpx; font-weight: 700; }
|
||||
.article-card__summary { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.55; }
|
||||
.article-card__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 12rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.article-list > .app-button { margin-top: 8rpx; }
|
||||
.article-list-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.article-list-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.article-list-state-card > text { display: block; }
|
||||
.article-list-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
.article-list-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
<!-- 页面编号:F-05;用途:谱文正文、收藏、编辑与受控状态。 -->
|
||||
<!-- 页面编号:F-05;用途:谱文详情。详情 DTO 未声明时不展示本地正文。 -->
|
||||
<template>
|
||||
<view class="article-detail-page" :class="articleStateClasses">
|
||||
<view class="article-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-detail-header"><PageHeader title="谱文详情" /></view>
|
||||
|
||||
<view v-if="articleState === 'loading'" class="article-detail-loading">
|
||||
<AppLoading text="正在读取谱文" description="请稍候,正在整理正文与收录信息。" />
|
||||
</view>
|
||||
|
||||
<view v-else class="article-detail-content">
|
||||
<template v-if="articleState === 'ready'">
|
||||
<view class="article-paper">
|
||||
<text class="article-paper__category">{{ article.category }}</text>
|
||||
<text class="article-paper__title">{{ article.title }}</text>
|
||||
<view class="article-paper__meta"><text>{{ article.author }}</text><text>{{ article.updatedAt }}</text></view>
|
||||
<view class="article-paper__body">
|
||||
<text v-for="(paragraph, index) in articleParagraphs" :key="index">{{ paragraph }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="article-actions">
|
||||
<AppButton block disabled label="收藏暂未开放" />
|
||||
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="article-state-card">
|
||||
<view class="article-detail-header"><PageHeader title="谱文详情" custom-back @back="backToArticles" /></view>
|
||||
<view class="article-detail-content">
|
||||
<view class="article-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<AppButton block :label="stateCopy.action" @click="backToArticles" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -37,86 +17,42 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { onLoad } 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";
|
||||
import { findFamilyArticleFixture } from "@/data/mock.js";
|
||||
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const article = ref(null);
|
||||
const articleState = ref("loading");
|
||||
const articleParagraphs = computed(() => article.value?.paragraphs || []);
|
||||
const articleStateClasses = computed(() => ({
|
||||
[`article-state--${articleState.value}`]: true,
|
||||
"article-state--expired": articleState.value === "expired",
|
||||
"article-state--privacy": articleState.value === "privacy",
|
||||
"article-state--error": articleState.value === "error",
|
||||
}));
|
||||
const stateCopy = computed(() => ({
|
||||
expired: { title: "这篇谱文已无法查看", copy: "当前家谱中不存在这篇谱文,页面不会回退到其他文章。", action: genealogyId.value ? "返回谱文列表" : "返回上一页" },
|
||||
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
|
||||
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
|
||||
}[articleState.value] || {}));
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
articleId.value = String(query.articleId || "");
|
||||
const selected = findFamilyArticleFixture(genealogyId.value, articleId.value);
|
||||
article.value = selected;
|
||||
articleState.value = ["loading", "error", "expired", "privacy"].includes(query.state)
|
||||
? selected
|
||||
? query.state
|
||||
: "expired"
|
||||
: selected
|
||||
? "ready"
|
||||
: "expired";
|
||||
});
|
||||
|
||||
const editArticle = () =>
|
||||
openPage(
|
||||
"F06",
|
||||
{
|
||||
genealogyId: genealogyId.value,
|
||||
mode: "edit",
|
||||
articleId: articleId.value,
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(articleId.value));
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? {
|
||||
title: "谱文正文待后端字段合同",
|
||||
copy: "详情接口没有声明文章标题、分类、作者、更新时间或正文段落字段;页面已停止读取本地文章,不能猜测这些字段。",
|
||||
action: "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
title: "谱文入口无效",
|
||||
copy: "没有取得有效家谱或文章标识,页面不会回退到其他文章。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
"F05",
|
||||
);
|
||||
const backToArticles = () =>
|
||||
genealogyId.value
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
const handleStateAction = () => {
|
||||
if (articleState.value === "error" && article.value) {
|
||||
articleState.value = "ready";
|
||||
return;
|
||||
}
|
||||
return backToArticles();
|
||||
};
|
||||
);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
articleId.value = String(query?.articleId || "");
|
||||
});
|
||||
const backToArticles = () => /^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.article-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.article-detail-header, .article-detail-loading, .article-detail-content { z-index: 1; }
|
||||
.article-detail-loading { min-height: calc(100vh - 100rpx); }
|
||||
.article-detail-header, .article-detail-content { z-index: 1; }
|
||||
.article-detail-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.article-paper, .article-state-card { @include adaptive.adaptive-family-content; width: 100%; }
|
||||
.article-paper { padding: 50rpx 48rpx 54rpx; }
|
||||
.article-paper > text { display: block; }
|
||||
.article-paper__category { color: $brand-red; font-size: 22rpx; font-weight: 700; letter-spacing: 3rpx; }
|
||||
.article-paper__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 39rpx; font-weight: 700; line-height: 1.35; }
|
||||
.article-paper__meta { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6rpx 18rpx; margin-top: 16rpx; color: $ink-muted; font-size: 21rpx; }
|
||||
.article-paper__body { margin-top: 28rpx; }
|
||||
.article-paper__body text { display: block; margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.85; text-align: justify; }
|
||||
.article-actions { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14rpx; margin-top: 18rpx; }
|
||||
.article-state-card { min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.article-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 350rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.article-state-card > text { display: block; }
|
||||
.article-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
.article-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
.article-state-card .app-button { margin-top: 30rpx; }
|
||||
@media (max-width: 340px) { .article-actions { grid-template-columns: minmax(0, 1fr); } }
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
<!-- 页面编号:F-06;用途:新建与编辑谱文。 -->
|
||||
<!-- 页面编号:F-06;用途:按已声明 articleTitle/articleContent 创建谱文。 -->
|
||||
<template>
|
||||
<view
|
||||
class="article-editor-page"
|
||||
:class="`article-editor-state--${editorState}`"
|
||||
>
|
||||
<view class="article-editor-page" :class="`article-editor-state--${editorState}`">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="article-editor-page__header">
|
||||
<PageHeader title="编辑谱文" custom-back @back="requestBack" />
|
||||
</view>
|
||||
|
||||
<view v-if="editorState === 'loading'" class="article-editor-loading">
|
||||
<AppLoading
|
||||
text="正在打开谱文编辑器"
|
||||
description="请稍候,草稿正在展开。"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-else-if="editorState === 'preview' || editorState === 'invalid'"
|
||||
class="article-editor-content"
|
||||
>
|
||||
<view class="editor-result-card">
|
||||
<view class="article-editor-page__header"><PageHeader title="新建谱文" custom-back @back="requestBack" /></view>
|
||||
<view class="article-editor-content">
|
||||
<view v-if="editorState === 'form'" class="editor-panel">
|
||||
<view class="editor-panel__body">
|
||||
<text class="editor-eyebrow">服务端创建</text>
|
||||
<text class="editor-title">把值得传承的故事写下来</text>
|
||||
<text class="editor-intro">本页只发送 Apifox 已声明且可映射的文章标题与正文。分类需要 `categoryId`,当前没有可用分类 owner,故不展示或猜测分类。</text>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">文章标题</text>
|
||||
<view class="editor-control"><input v-model="form.title" maxlength="40" placeholder="请输入文章标题" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
|
||||
</view>
|
||||
<view class="editor-field">
|
||||
<text class="editor-field__label">正文内容</text>
|
||||
<view class="editor-control editor-control--textarea"><textarea v-model="form.content" auto-height maxlength="1200" placeholder="记录家训、往事或想留给后人的话" placeholder-class="editor-placeholder" @input="submitError = ''" /></view>
|
||||
</view>
|
||||
<text v-if="submitError" class="editor-save-error">{{ submitError }}</text>
|
||||
<AppButton block :label="isSubmitting ? '正在提交' : '提交谱文'" :disabled="isSubmitting" @click="submit" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="editor-result-card">
|
||||
<view class="editor-result-card__body">
|
||||
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
|
||||
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
|
||||
@@ -29,89 +30,6 @@
|
||||
</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 }"
|
||||
>
|
||||
<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 }"
|
||||
>
|
||||
<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 }"
|
||||
>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
auto-height
|
||||
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="isSubmitting"
|
||||
@click="submit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
@@ -127,87 +45,38 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad, onUnload } from "@dcloudio/uni-app";
|
||||
import { computed, onUnmounted, reactive, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
findFamilyArticleFixture,
|
||||
getGenealogyFixtureAccess,
|
||||
} from "@/data/mock.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { goBack, handleBackPress, returnTo, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const draftFixture = {
|
||||
title: "汤氏家训辑录",
|
||||
category: "家风家训",
|
||||
content:
|
||||
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
|
||||
};
|
||||
const editorState = ref("form");
|
||||
const genealogyId = ref("");
|
||||
const articleId = ref("");
|
||||
const editorMode = ref("create");
|
||||
const isSubmitting = ref(false);
|
||||
const discardVisible = ref(false);
|
||||
const allowedStates = new Set([
|
||||
"draft",
|
||||
"loading",
|
||||
"validation",
|
||||
"error",
|
||||
"preview",
|
||||
]);
|
||||
const form = reactive({ title: "", category: "", content: "" });
|
||||
const fieldErrors = reactive({ title: "", category: "", content: "" });
|
||||
const baseline = ref("");
|
||||
let saveTimer = null;
|
||||
|
||||
const actionLabel = computed(() =>
|
||||
isSubmitting.value
|
||||
? "正在校验…"
|
||||
: editorState.value === "error"
|
||||
? "重新校验"
|
||||
: "生成本地预览",
|
||||
);
|
||||
const formSnapshot = computed(() => JSON.stringify(form));
|
||||
const isDirty = computed(() =>
|
||||
Boolean(baseline.value) && formSnapshot.value !== baseline.value,
|
||||
);
|
||||
const hasValidContext = computed(() =>
|
||||
Boolean(
|
||||
genealogyId.value &&
|
||||
["owner", "member"].includes(
|
||||
getGenealogyFixtureAccess(genealogyId.value).accessRole,
|
||||
) &&
|
||||
(editorMode.value === "create" ||
|
||||
(editorMode.value === "edit" && articleId.value)),
|
||||
),
|
||||
);
|
||||
const resultCopy = computed(() =>
|
||||
editorState.value === "preview"
|
||||
? {
|
||||
eyebrow: "本地流程预览",
|
||||
title: "谱文内容已通过本地校验",
|
||||
copy: "当前尚未提交服务器,返回后不会新增或修改谱文。",
|
||||
action:
|
||||
editorMode.value === "edit"
|
||||
? "返回原谱文(不保存)"
|
||||
: "返回谱文列表(不保存)",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "没有找到要编辑的谱文上下文",
|
||||
copy: "请从当前家谱的谱文列表重新进入,页面不会创建无归属内容。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
const submitError = ref("");
|
||||
const form = reactive({ title: "", content: "" });
|
||||
const requestController = createRequestController();
|
||||
const isDirty = computed(() => Boolean(form.title.trim() || form.content.trim()));
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value));
|
||||
const resultCopy = computed(() => editorState.value === "success"
|
||||
? {
|
||||
eyebrow: "服务端已接受",
|
||||
title: "谱文已提交",
|
||||
copy: "服务端已返回成功信封。谱文列表与详情仍缺展示 DTO,返回后不会生成本地文章卡片。",
|
||||
action: "返回谱文列表",
|
||||
}
|
||||
: {
|
||||
eyebrow: "谱文入口无效",
|
||||
title: "无法创建谱文",
|
||||
copy: "当前只有新建操作可映射;编辑需要可靠的文章详情和文章 ID,暂不从 fixture 进入。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
);
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
@@ -216,263 +85,72 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const fillDraft = () => {
|
||||
Object.assign(form, draftFixture);
|
||||
};
|
||||
const showAllFieldErrors = () => {
|
||||
fieldErrors.title = "请填写文章标题";
|
||||
fieldErrors.category = "请填写文章分类";
|
||||
fieldErrors.content = "请填写正文内容";
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
articleId.value = String(query.articleId || "");
|
||||
editorMode.value = String(query.mode || "");
|
||||
const article =
|
||||
editorMode.value === "edit"
|
||||
? findFamilyArticleFixture(genealogyId.value, articleId.value)
|
||||
: null;
|
||||
const modeIsValid =
|
||||
(editorMode.value === "create" && !articleId.value) ||
|
||||
(editorMode.value === "edit" && Boolean(article));
|
||||
if (!modeIsValid || !hasValidContext.value) {
|
||||
editorState.value = "invalid";
|
||||
return;
|
||||
}
|
||||
const requestedState = allowedStates.has(query.state) ? query.state : "form";
|
||||
if (article) {
|
||||
Object.assign(form, {
|
||||
title: article.title,
|
||||
category: article.category,
|
||||
content: article.paragraphs.join("\n\n"),
|
||||
});
|
||||
} else if (["draft", "error", "preview"].includes(requestedState)) {
|
||||
fillDraft();
|
||||
}
|
||||
baseline.value = formSnapshot.value;
|
||||
if (editorMode.value === "create" && requestedState === "draft") {
|
||||
baseline.value = JSON.stringify({ title: "", category: "", content: "" });
|
||||
}
|
||||
if (requestedState === "validation") showAllFieldErrors();
|
||||
editorState.value = requestedState;
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
if (!hasValidContext.value || query?.mode !== "create") editorState.value = "invalid";
|
||||
});
|
||||
|
||||
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 = () => {
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !hasValidContext.value) return;
|
||||
if (!validate()) {
|
||||
editorState.value = "validation";
|
||||
const articleTitle = form.title.trim();
|
||||
const articleContent = form.content.trim();
|
||||
if (!articleTitle || !articleContent) {
|
||||
submitError.value = !articleTitle ? "请填写文章标题" : "请填写正文内容";
|
||||
return;
|
||||
}
|
||||
isSubmitting.value = true;
|
||||
const submitSnapshot = formSnapshot.value;
|
||||
const timer = setTimeout(() => {
|
||||
if (saveTimer !== timer) return;
|
||||
editorState.value = submitSnapshot ? "preview" : "error";
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createArticle(genealogyId.value, { articleTitle, articleContent }, { requestController });
|
||||
form.title = "";
|
||||
form.content = "";
|
||||
editorState.value = "success";
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "谱文提交失败,请稍后重试。";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
saveTimer = null;
|
||||
}, 320);
|
||||
saveTimer = timer;
|
||||
}
|
||||
};
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const returnToTarget = async () => {
|
||||
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
|
||||
if (!confirmed) return false;
|
||||
return editorMode.value === "edit"
|
||||
? returnTo("F05", {
|
||||
genealogyId: genealogyId.value,
|
||||
articleId: articleId.value,
|
||||
})
|
||||
: returnTo("F04", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleResultAction = () =>
|
||||
editorState.value === "preview" ? returnToTarget() : goBack();
|
||||
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
const handleResultAction = () => editorState.value === "success"
|
||||
? returnTo("F04", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnload(() => {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
onUnmounted(() => {
|
||||
requestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.article-editor-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.article-editor-page__header,
|
||||
.article-editor-loading,
|
||||
.article-editor-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.article-editor-loading {
|
||||
padding-top: 150rpx;
|
||||
}
|
||||
.article-editor-content {
|
||||
padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
.editor-panel,
|
||||
.editor-result-card {
|
||||
@include adaptive.adaptive-family-panel;
|
||||
width: 100%;
|
||||
}
|
||||
.editor-panel__body {
|
||||
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 {
|
||||
@include adaptive.adaptive-family-field;
|
||||
display: flex;
|
||||
min-height: 82rpx;
|
||||
align-items: center;
|
||||
}
|
||||
.editor-control input,
|
||||
.editor-control textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
color: $ink;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
.editor-control input {
|
||||
width: 100%;
|
||||
min-height: 82rpx;
|
||||
padding: 0 28rpx;
|
||||
}
|
||||
.editor-control--textarea {
|
||||
min-height: 220rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.editor-control textarea {
|
||||
width: 100%;
|
||||
min-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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.article-editor-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.article-editor-page__header, .article-editor-content { z-index: 1; }
|
||||
.article-editor-content { padding: 18rpx 20rpx calc(38rpx + env(safe-area-inset-bottom)); }
|
||||
.editor-panel, .editor-result-card { @include adaptive.adaptive-family-panel; width: 100%; }
|
||||
.editor-panel__body { 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 { @include adaptive.adaptive-family-field; display: flex; min-height: 82rpx; align-items: center; }
|
||||
.editor-control input, .editor-control textarea { box-sizing: border-box; width: 100%; color: $ink; font-size: 24rpx; }
|
||||
.editor-control input { min-height: 82rpx; padding: 0 28rpx; }
|
||||
.editor-control--textarea { min-height: 220rpx; padding: 20rpx 24rpx; }
|
||||
.editor-control textarea { min-height: 180rpx; line-height: 1.65; }
|
||||
.editor-placeholder { color: #9e8e79; }
|
||||
.editor-save-error { display: block; margin: 12rpx 8rpx 0; color: $brand-red; font-size: 24rpx; line-height: 34rpx; text-align: center; }
|
||||
.editor-panel__body > .app-button { margin-top: 22rpx; }
|
||||
.editor-result-card { min-height: 420rpx; }
|
||||
.editor-result-card__body { 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; }
|
||||
</style>
|
||||
|
||||
+58
-188
@@ -1,63 +1,22 @@
|
||||
<!-- 页面编号:F-07;用途:家族相册列表、创建与受控状态。 -->
|
||||
<!-- 页面编号:F-07;用途:相册入口。列表 DTO 缺失时不展示本地相册。 -->
|
||||
<template>
|
||||
<view class="album-list-page" :class="albumStateClasses">
|
||||
<view class="album-list-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="createAlbum" /></view>
|
||||
|
||||
<view v-if="albumState === 'loading'" class="album-list-loading">
|
||||
<AppLoading text="正在整理家族相册" description="请稍候,正在读取照片与更新时间。" />
|
||||
</view>
|
||||
|
||||
<view v-else class="album-list-content">
|
||||
<template v-if="albumState === 'ready'">
|
||||
<view class="album-list-lead"><text>让每一张照片都回到家人身边</text><text>共 {{ albums.length }} 本相册</text></view>
|
||||
<view v-if="localAlbumPreview" class="album-local-preview">
|
||||
<text>本地预览 · 尚未提交服务器</text>
|
||||
<text>{{ localAlbumPreview.name }}</text>
|
||||
<text>这本相册不会加入正式列表,离开页面后不保存。</text>
|
||||
</view>
|
||||
<view v-if="albums.length" class="album-list">
|
||||
<view v-for="album in albums" :key="album.id" class="album-card" role="button" :aria-label="`打开相册${album.name}`" @click="openAlbum(album)">
|
||||
<view class="album-card__media"><image class="album-card__cover" :src="album.cover" mode="aspectFill" :alt="album.name" /></view>
|
||||
<view class="album-card__copy">
|
||||
<text>{{ album.name }}</text>
|
||||
<text>{{ album.photoCount }} 张照片 · {{ album.updatedAt }}</text>
|
||||
<text>{{ album.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<AppButton block label="新建相册" @click="createAlbum" />
|
||||
</view>
|
||||
<view v-else class="album-state-card">
|
||||
<text>还没有相册</text><text>创建一本相册,把团圆、成长与祖居旧影整理在一起。</text>
|
||||
<AppButton block label="新建相册" @click="createAlbum" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view v-else class="album-state-card">
|
||||
<view class="album-list-header"><PageHeader title="家族相册" :action="hasValidContext ? '新建' : ''" custom-back @back="requestBack" @action="openCreateDialog" /></view>
|
||||
<view class="album-list-content">
|
||||
<view class="album-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
|
||||
<AppButton block :label="stateCopy.action" @click="handleStateAction" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<AppDialog :visible="dialogVisible" eyebrow="新建相册预览" title="为家人整理一段影像" message="当前只生成本地预览,不会创建服务器相册。" confirm-text="生成本地预览" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmCreateAlbum" @cancel="requestCloseCreateDialog">
|
||||
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="仅提交已声明的相册名称;封面、排序和状态没有可用 owner,不会猜测提交。" :confirm-text="isSubmitting ? '正在提交' : '提交相册'" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="submitAlbum" @cancel="closeCreateDialog">
|
||||
<view class="album-dialog-field">
|
||||
<text>相册名称</text>
|
||||
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
|
||||
<text v-if="albumNameError">{{ albumNameError }}</text>
|
||||
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" @input="submitError = ''" />
|
||||
<text v-if="submitError">{{ submitError }}</text>
|
||||
</view>
|
||||
</AppDialog>
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃相册草稿?"
|
||||
message="当前相册尚未提交服务器,确认后不会保留。"
|
||||
confirm-text="放弃"
|
||||
cancel-text="继续整理"
|
||||
show-cancel
|
||||
:close-on-mask="false"
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -66,162 +25,74 @@ import { computed, onUnmounted, ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import {
|
||||
getGenealogyFixtureAccess,
|
||||
listFamilyAlbumFixtures,
|
||||
} from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import { goBack, handleBackPress, runBackGuard } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = ref(false);
|
||||
const albums = ref([]);
|
||||
const albumState = ref("loading");
|
||||
const dialogVisible = ref(false);
|
||||
const albumNameDraft = ref("");
|
||||
const albumNameError = ref("");
|
||||
const localAlbumPreview = ref(null);
|
||||
const discardVisible = ref(false);
|
||||
const albumStateClasses = computed(() => ({
|
||||
[`album-list-state--${albumState.value}`]: true,
|
||||
"album-state--empty": albumState.value === "empty",
|
||||
"album-list-state--loading": albumState.value === "loading",
|
||||
"album-list-state--error": albumState.value === "error",
|
||||
}));
|
||||
const isDirty = computed(() =>
|
||||
Boolean(albumNameDraft.value.trim() || localAlbumPreview.value),
|
||||
const submitError = ref("");
|
||||
const isSubmitting = ref(false);
|
||||
const created = ref(false);
|
||||
const requestController = createRequestController();
|
||||
const stateCopy = computed(() => !hasValidContext.value
|
||||
? { title: "相册入口无效", copy: "没有取得有效家谱标识,页面不会展示其他家谱相册。", action: "返回上一页" }
|
||||
: created.value
|
||||
? { title: "相册已提交服务端", copy: "服务端已返回成功信封;相册列表仍没有条目 DTO,页面不会生成本地相册卡片。", action: "新建相册" }
|
||||
: { title: "相册列表待后端字段合同", copy: "列表响应没有声明相册 ID、封面、名称、照片数、描述或更新时间字段;页面已停止展示本地相册。", action: "新建相册" },
|
||||
);
|
||||
const stateCopy = computed(() => ({
|
||||
empty: {
|
||||
title: "还没有相册",
|
||||
copy: "当前家谱尚无可读取相册,可先生成不会保存的本地预览。",
|
||||
action: "填写相册预览",
|
||||
},
|
||||
error: {
|
||||
title: "相册暂不可用",
|
||||
copy: "请稍后重新查看,已有照片不会受到影响。",
|
||||
action: "重新查看",
|
||||
},
|
||||
invalid: {
|
||||
title: "相册入口无效",
|
||||
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱相册。",
|
||||
action: "返回上一页",
|
||||
},
|
||||
}[albumState.value]));
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
discardVisible.value = visible;
|
||||
});
|
||||
const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
hasValidContext.value = ["owner", "member"].includes(
|
||||
getGenealogyFixtureAccess(genealogyId.value).accessRole,
|
||||
);
|
||||
if (!genealogyId.value || !hasValidContext.value) {
|
||||
albumState.value = "invalid";
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
hasValidContext.value = /^[1-9]\d*$/.test(genealogyId.value);
|
||||
});
|
||||
const openCreateDialog = () => {
|
||||
if (!hasValidContext.value || isSubmitting.value) return;
|
||||
albumNameDraft.value = "";
|
||||
submitError.value = "";
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
const closeCreateDialog = () => {
|
||||
if (!isSubmitting.value) dialogVisible.value = false;
|
||||
};
|
||||
const submitAlbum = async () => {
|
||||
if (isSubmitting.value) return;
|
||||
const albumName = albumNameDraft.value.trim();
|
||||
if (!albumName) {
|
||||
submitError.value = "请填写相册名称";
|
||||
return;
|
||||
}
|
||||
albums.value = listFamilyAlbumFixtures(genealogyId.value);
|
||||
albumState.value = ["loading", "empty", "error"].includes(query.state)
|
||||
? query.state
|
||||
: albums.value.length
|
||||
? "ready"
|
||||
: "empty";
|
||||
isSubmitting.value = true;
|
||||
submitError.value = "";
|
||||
try {
|
||||
await appApi.createAlbum(genealogyId.value, { albumName }, { requestController });
|
||||
dialogVisible.value = false;
|
||||
created.value = true;
|
||||
} catch (error) {
|
||||
if (!isRequestCancelled(error)) submitError.value = error?.message || "相册提交失败,请稍后重试。";
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
const requestBack = () => runBackGuard({
|
||||
transientOpen: dialogVisible.value,
|
||||
submitting: isSubmitting.value,
|
||||
"close-transient": closeCreateDialog,
|
||||
"block-submitting": () => true,
|
||||
});
|
||||
|
||||
const openAlbum = (album) =>
|
||||
openPage(
|
||||
"F08",
|
||||
{ genealogyId: genealogyId.value, albumId: String(album.id) },
|
||||
"F07",
|
||||
);
|
||||
const createAlbum = () => { albumNameDraft.value = ""; albumNameError.value = ""; dialogVisible.value = true; };
|
||||
const closeCreateDialog = () => { dialogVisible.value = false; };
|
||||
const requestCloseCreateDialog = async () => {
|
||||
if (!albumNameDraft.value.trim()) {
|
||||
closeCreateDialog();
|
||||
return true;
|
||||
}
|
||||
const confirmed = await requestDiscardConfirmation();
|
||||
if (!confirmed) return false;
|
||||
albumNameDraft.value = "";
|
||||
albumNameError.value = "";
|
||||
closeCreateDialog();
|
||||
return true;
|
||||
};
|
||||
const confirmCreateAlbum = () => {
|
||||
const name = albumNameDraft.value.trim();
|
||||
if (!name) { albumNameError.value = "请填写相册名称"; return; }
|
||||
localAlbumPreview.value = Object.freeze({ name });
|
||||
albumNameDraft.value = "";
|
||||
dialogVisible.value = false;
|
||||
};
|
||||
const restoreAlbums = () => {
|
||||
albums.value = listFamilyAlbumFixtures(genealogyId.value);
|
||||
albumState.value = albums.value.length ? "ready" : "empty";
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (albumState.value === "invalid") return goBack();
|
||||
if (albumState.value === "error") return restoreAlbums();
|
||||
return createAlbum();
|
||||
};
|
||||
const requestBack = () => {
|
||||
if (discardVisible.value) {
|
||||
return runBackGuard({
|
||||
transientOpen: true,
|
||||
"close-transient": cancelDiscard,
|
||||
});
|
||||
}
|
||||
if (dialogVisible.value) {
|
||||
return runBackGuard({
|
||||
transientOpen: true,
|
||||
"close-transient": requestCloseCreateDialog,
|
||||
});
|
||||
}
|
||||
return runBackGuard({
|
||||
dirty: isDirty.value,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
});
|
||||
};
|
||||
const handleStateAction = () => hasValidContext.value ? openCreateDialog() : goBack();
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
onUnmounted(() => { discardConfirmation.dispose(); });
|
||||
onUnmounted(() => requestController.abort());
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.album-list-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.album-list-header, .album-list-loading, .album-list-content { z-index: 1; }
|
||||
.album-list-loading { min-height: calc(100vh - 100rpx); }
|
||||
.album-list-header, .album-list-content { z-index: 1; }
|
||||
.album-list-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.album-list-lead { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8rpx 18rpx; min-height: 62rpx; padding: 0 20rpx; color: $ink-muted; font-size: 22rpx; background: url("/static/assets/modules/genealogy/transparent/section-divider.png") center / 100% auto no-repeat; }
|
||||
.album-list { display: flex; flex-direction: column; gap: 16rpx; margin-top: 16rpx; }
|
||||
.album-local-preview { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 22rpx 24rpx; }
|
||||
.album-local-preview text { display: block; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
|
||||
.album-local-preview text:first-child { color: $brand-red; font-weight: 700; }
|
||||
.album-local-preview text:nth-child(2) { margin-top: 6rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 29rpx; font-weight: 700; }
|
||||
.album-card, .album-state-card { @include adaptive.adaptive-family-content; width: 100%; }
|
||||
.album-card { display: grid; grid-template-columns: minmax(150rpx, 0.7fr) minmax(0, 1.3fr); min-height: 210rpx; gap: 22rpx; padding: 30rpx 38rpx; }
|
||||
.album-card__media { width: 100%; aspect-ratio: 4 / 3; align-self: center; }
|
||||
.album-card__cover { display: block; width: 100%; height: 100%; }
|
||||
.album-card__copy { align-self: center; min-width: 0; }
|
||||
.album-card__copy text { display: block; overflow-wrap: anywhere; }
|
||||
.album-card__copy text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.album-card__copy text:nth-child(2) { margin-top: 9rpx; color: $brand-red; font-size: 21rpx; }
|
||||
.album-card__copy text:last-child { margin-top: 8rpx; color: $ink-muted; font-size: 22rpx; line-height: 1.5; }
|
||||
.album-list > .app-button { margin-top: 8rpx; }
|
||||
.album-state-card { min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.album-state-card { @include adaptive.adaptive-family-content; width: 100%; min-height: 340rpx; margin-top: 30rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.album-state-card > text { display: block; }
|
||||
.album-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
.album-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
|
||||
@@ -230,5 +101,4 @@ onUnmounted(() => { discardConfirmation.dispose(); });
|
||||
.album-dialog-field > text:first-child { display: block; color: $ink; font-size: 23rpx; font-weight: 700; }
|
||||
.album-dialog-field input { @include adaptive.adaptive-family-field; width: 100%; min-height: 76rpx; margin-top: 10rpx; padding: 0 22rpx; color: $ink; font-size: 24rpx; }
|
||||
.album-dialog-field > text:last-child { display: block; margin-top: 8rpx; color: $brand-red; font-size: 21rpx; }
|
||||
@media (max-width: 340px) { .album-card { grid-template-columns: 130rpx minmax(0, 1fr); gap: 16rpx; padding-right: 30rpx; padding-left: 30rpx; } }
|
||||
</style>
|
||||
|
||||
@@ -1,342 +1,50 @@
|
||||
<!-- 页面编号:F-08;用途:相册详情与照片墙。 -->
|
||||
<!-- 页面编号:F-08;用途:相册照片墙。响应未声明照片展示 DTO 时保持关闭。 -->
|
||||
<template>
|
||||
<view class="album-detail-page" :class="`album-state--${albumState}`">
|
||||
<view class="album-detail-page">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="requestBack" /></view>
|
||||
|
||||
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="returnToAlbums" /></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="genealogyId ? '返回相册列表' : '返回上一页'" @click="returnToAlbums" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<view class="album-heading">
|
||||
<text class="album-heading__eyebrow">家族影像</text>
|
||||
<text class="album-heading__title">{{ album.name }}</text>
|
||||
<text class="album-heading__copy">{{ album.description }}</text>
|
||||
<text class="album-heading__count"
|
||||
>{{ albumState === "empty" ? "0 张照片" : `${photos.length} 张照片` }}</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="toUpload">
|
||||
<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 class="album-state-card">
|
||||
<text>{{ stateCopy.title }}</text>
|
||||
<text>{{ stateCopy.copy }}</text>
|
||||
<AppButton block :label="stateCopy.action" @click="returnToAlbums" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
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";
|
||||
import { findFamilyAlbumFixture } from "@/data/mock.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
openPage,
|
||||
returnTo,
|
||||
runBackGuard,
|
||||
} from "@/utils/navigation.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const albumState = ref("normal");
|
||||
const genealogyId = ref("");
|
||||
const albumId = ref("");
|
||||
const album = ref(null);
|
||||
const photos = ref([]);
|
||||
const previewVisible = ref(false);
|
||||
const previewIndex = ref(0);
|
||||
|
||||
const openPreview = (index) => {
|
||||
previewIndex.value = index;
|
||||
previewVisible.value = true;
|
||||
albumState.value = "preview";
|
||||
};
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false;
|
||||
albumState.value = "normal";
|
||||
};
|
||||
|
||||
const toUpload = () =>
|
||||
openPage(
|
||||
"F09",
|
||||
{ genealogyId: genealogyId.value, albumId: albumId.value },
|
||||
"F08",
|
||||
);
|
||||
|
||||
const returnToAlbums = () =>
|
||||
genealogyId.value
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
|
||||
const hasValidContext = computed(() => /^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(albumId.value));
|
||||
const stateCopy = computed(() => hasValidContext.value
|
||||
? { title: "照片墙待后端字段合同", copy: "照片列表没有声明照片 ID、OSS 访问地址、标题、说明或相册展示字段;页面已停止展示本地图片和本地预览。", action: "返回相册列表" }
|
||||
: { title: "相册入口无效", copy: "没有取得有效家谱或相册标识,页面不会回退到其他相册。", action: "返回上一页" },
|
||||
);
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
albumId.value = String(query.albumId || "");
|
||||
album.value = findFamilyAlbumFixture(genealogyId.value, albumId.value);
|
||||
photos.value = album.value?.photos || [];
|
||||
if (!album.value) {
|
||||
albumState.value = "expired";
|
||||
previewVisible.value = false;
|
||||
return;
|
||||
}
|
||||
albumState.value =
|
||||
query.state === "empty"
|
||||
? "empty"
|
||||
: query.state === "preview"
|
||||
? "preview"
|
||||
: query.state === "expired"
|
||||
? "expired"
|
||||
: "normal";
|
||||
previewVisible.value = albumState.value === "preview";
|
||||
genealogyId.value = String(query?.genealogyId || "");
|
||||
albumId.value = String(query?.albumId || "");
|
||||
});
|
||||
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: previewVisible.value,
|
||||
"close-transient": closePreview,
|
||||
});
|
||||
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
const returnToAlbums = () => /^[1-9]\d*$/.test(genealogyId.value)
|
||||
? returnTo("F07", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.album-detail-page {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
background: $paper;
|
||||
}
|
||||
.album-detail-header,
|
||||
.album-detail-content {
|
||||
z-index: 1;
|
||||
}
|
||||
.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 {
|
||||
@include adaptive.adaptive-family-panel;
|
||||
width: 100%;
|
||||
min-height: 430rpx;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
.album-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
|
||||
.album-detail-header, .album-detail-content { z-index: 1; }
|
||||
.album-detail-content { padding: 22rpx 24rpx calc(48rpx + env(safe-area-inset-bottom)); }
|
||||
.album-state-card { @include adaptive.adaptive-family-panel; width: 100%; min-height: 430rpx; padding: 104rpx 40rpx 62rpx; box-sizing: border-box; text-align: center; }
|
||||
.album-state-card text { display: block; }
|
||||
.album-state-card text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.album-state-card text:nth-child(2) { margin-top: 10rpx; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.album-state-card .app-button { margin-top: 34rpx; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,142 +1,11 @@
|
||||
<!-- 页面编号:F-10;用途:家族视频待开放状态。 -->
|
||||
<!-- 页面编号:F-10;用途:家族视频。当前缺读取、发布和播放业务 owner。 -->
|
||||
<template>
|
||||
<view class="video-status-page" :class="{ 'video-status-state--invalid': !hasValidContext }">
|
||||
<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">
|
||||
<view class="video-status-card__body">
|
||||
<text class="video-status-card__eyebrow">{{ hasValidContext ? '视频 · 服务说明' : '页面入口' }}</text>
|
||||
<text class="video-status-card__title">{{ hasValidContext ? '视频服务暂未开放' : '家谱身份无效' }}</text>
|
||||
<text class="video-status-card__copy">{{ hasValidContext ? '开放后可在这里浏览当前家谱的影像与纪念视频。' : '请从一个可访问的成员家谱重新进入,页面不会展示其他家谱内容。' }}</text>
|
||||
<view class="video-return-action" @click="returnToFamily">
|
||||
<AppButton block :label="hasValidContext ? '返回家族首页' : '返回上一页'" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="video-page"><ModulePageBackground module="family" /><view class="page-header"><PageHeader title="家族视频" custom-back @back="returnToFamily" /></view><view class="page-content"><view class="state-card"><text>视频服务暂未开放</text><text>Apifox 当前只有删除视频 operation,未声明视频列表、详情、播放地址、发布、编辑、评论、点赞或分享 owner。页面不以相册、参考项目或本地数据补造视频能力。</text><AppButton block :label="hasValidContext ? '返回家族动态' : '返回上一页'" @click="returnToFamily" /></view></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";
|
||||
import { getGenealogyFixtureAccess } from "@/data/mock.js";
|
||||
import { goBack, returnTo } from "@/utils/navigation.js";
|
||||
|
||||
const genealogyId = ref("");
|
||||
const hasValidContext = computed(() =>
|
||||
Boolean(
|
||||
genealogyId.value &&
|
||||
["owner", "member"].includes(
|
||||
getGenealogyFixtureAccess(genealogyId.value).accessRole,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
});
|
||||
|
||||
const returnToFamily = () =>
|
||||
hasValidContext.value
|
||||
? returnTo("F01", { genealogyId: genealogyId.value })
|
||||
: goBack();
|
||||
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"; import { goBack,returnTo } from "@/utils/navigation.js"; const genealogyId=ref("");const hasValidContext=computed(()=>/^[1-9]\d*$/.test(genealogyId.value));onLoad((query)=>{genealogyId.value=String(query?.genealogyId||"");});const returnToFamily=()=>hasValidContext.value?returnTo("F01",{genealogyId:genealogyId.value}):goBack();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
|
||||
|
||||
.video-status-page {
|
||||
min-height: 100vh;
|
||||
background: $paper;
|
||||
}
|
||||
.video-status-content {
|
||||
padding: 16rpx 26rpx 56rpx;
|
||||
}
|
||||
.video-status-lead {
|
||||
display: grid;
|
||||
min-height: 56rpx;
|
||||
margin: 0 12rpx 18rpx;
|
||||
color: $ink-muted;
|
||||
font-size: 23rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.video-status-lead image {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.video-status-lead text {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
grid-area: 1 / 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.video-status-card {
|
||||
@include adaptive.adaptive-family-panel;
|
||||
width: 100%;
|
||||
min-height: 330rpx;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
}
|
||||
.video-status-card__body {
|
||||
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;
|
||||
}
|
||||
}
|
||||
.video-page{display:flex;min-height:100vh;flex-direction:column;background:$paper}.page-header,.page-content{z-index:1}.page-content{padding:18rpx 24rpx 72rpx}.state-card{box-sizing:border-box;min-height:340rpx;padding:78rpx 52rpx 50rpx;text-align:center;@include adaptive.adaptive-family-content}.state-card text{display:block}.state-card text:first-child{color:$ink;font-family:STKaiti,KaiTi,serif;font-size:35rpx;font-weight:700}.state-card text:nth-child(2){margin-top:18rpx;color:$ink-muted;font-size:24rpx;line-height:1.65}.state-card .app-button{margin-top:28rpx}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user