完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+100 -54
View File
@@ -9,9 +9,9 @@
}"
>
<ModulePageBackground module="family" />
<view class="family-page__header"
><PageHeader title="家族动态" action="发布" @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'"
@@ -19,9 +19,9 @@
description="请稍候,正在读取家宴、通知与共同记忆。"
/>
<view v-if="feedState !== 'loading'" class="feed-heading"
><text>汤氏家族圈</text><text>家宴通知与共同记忆</text></view
><text>{{ familyTitle }}</text><text>家宴通知与共同记忆</text></view
>
<view v-if="feedState !== 'loading'" class="feed-shortcuts">
<view v-if="feedState !== 'loading' && hasValidContext" class="feed-shortcuts">
<view
v-for="item in shortcuts"
:key="item.key"
@@ -49,56 +49,67 @@
</template>
<view v-else-if="feedState !== 'loading'" class="feed-state-card">
<view class="feed-state-card__copy"
><text>{{
feedState === "empty" ? "还没有家族动态" : "家族动态暂不可用"
}}</text
><text>{{
feedState === "empty"
? "发布第一条通知、家宴记录或家族故事。"
: "请稍后重新进入,已有内容不会受到影响。"
}}</text></view
><text>{{ stateCopy.title }}</text
><text>{{ stateCopy.copy }}</text></view
>
</view>
<view
v-if="feedState !== 'loading'"
class="feed-action"
@click="feedState === 'error' ? (feedState = 'list') : toPublish()"
><text>{{
feedState === "error" ? "重新查看" : "发布家族动态"
}}</text></view
@click="handlePrimaryAction"
><text>{{ stateCopy.action }}</text></view
>
</view>
<AppTabbar active="family" />
</view>
</template>
<script setup>
import { ref } from "vue";
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 { 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 = [
{
id: 1,
tag: "团圆记忆",
time: "今天 10:24",
title: "端午家宴",
content: "今年端午全家相聚,留下了许多温暖照片。",
author: "汤正国",
},
{
id: 2,
tag: "家族通知",
time: "昨天 18:02",
title: "修谱资料征集",
content: "请家人补充老照片中的人物姓名与拍摄时间。",
author: "谱主",
},
];
const feeds = ref([]);
const familyTitle = computed(() =>
genealogy.value ? `${genealogy.value.name}家族圈` : "家族圈",
);
const stateCopy = computed(() => {
if (!hasValidContext.value) {
return {
title: "请先选择可访问的家谱",
copy: "家族动态必须归属明确家谱,页面不会展示其他家谱的内容。",
action: "返回我的家谱",
};
}
if (feedState.value === "empty") {
return {
title: "还没有家族动态",
copy: "可先查看其他家族内容;发布接口接入后才能新增动态。",
action: "填写动态预览",
};
}
if (feedState.value === "error") {
return {
title: "家族动态暂不可用",
copy: "请稍后重新查看,已有内容不会受到影响。",
action: "重新查看",
};
}
return { title: "", copy: "", action: "发布家族动态" };
});
const shortcuts = [
{ key: "articles", label: "谱文" },
{ key: "albums", label: "相册" },
@@ -110,8 +121,30 @@ const shortcuts = [
{ key: "videos", label: "家族视频" },
];
onLoad((query) => {
genealogyId.value =
query.genealogyId || genealogyContext.getCurrentGenealogyId() || "";
const hasRouteIdentity = Object.prototype.hasOwnProperty.call(query, "genealogyId");
const resolvedGenealogyId = hasRouteIdentity
? 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) {
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"
@@ -119,28 +152,41 @@ onLoad((query) => {
? "empty"
: query.state === "error"
? "error"
: "list";
: feeds.value.length
? "list"
: "empty";
});
const toPublish = () =>
uni.navigateTo({
url: `/pages/family/f02-publish-feed?genealogyId=${genealogyId.value}`,
});
hasValidContext.value
? openPage("F02", { genealogyId: genealogyId.value }, "F01")
: goRoot("G01");
const openDetail = (item) =>
uni.navigateTo({ url: `/pages/family/f03-feed-detail?feedId=${item.id}` });
openPage(
"F03",
{ genealogyId: genealogyId.value, feedId: String(item.id) },
"F01",
);
const openSection = (key) => {
const routes = {
articles: "/pages/family/f04-article-list",
albums: "/pages/family/f07-album-list",
rituals: "/pages/records/r05-ritual-list",
memos: "/pages/records/r10-memo-list",
people: "/pages/records/r01-people-list",
gifts: "/pages/records/r03-gift-list",
merits: "/pages/records/r11-merit-records",
videos: "/pages/family/f10-video-list",
articles: "F04",
albums: "F07",
rituals: "R05",
memos: "R10",
people: "R01",
gifts: "R03",
merits: "R11",
videos: "F10",
};
uni.navigateTo({
url: `${routes[key]}?genealogyId=${genealogyId.value}`,
});
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();
};
</script>
<style scoped lang="scss">
+115 -23
View File
@@ -4,11 +4,12 @@
class="publish-page"
:class="{
'publish-state--form': publishState === 'form',
'publish-state--success': publishState === 'success',
'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="发布动态" /></view
><PageHeader title="发布动态" custom-back @back="requestBack" /></view
><view class="publish-panel"
><view
v-if="publishState === 'form'"
@@ -23,42 +24,99 @@
placeholder="写下想对家人说的话"
placeholder-class="publish-placeholder"
/></view
><AppButton block label="发布动态" @click="submit" /></view
><view v-else class="publish-result"
><text>{{
publishState === "success" ? "动态已发布" : "动态未发布"
}}</text
><text>{{
publishState === "success"
? "家人现在可以在家族圈看到这条记录。"
: "请检查内容后重新发布,当前文字仍保留在页面中。"
}}</text
><AppButton
block
:label="publishState === 'success' ? '继续发布' : '重新填写'"
@click="publishState = 'form'" /></view></view
: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="当前内容尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
><AppToast :visible="toastVisible" :message="toastMessage"
/></view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 { createDiscardConfirmation } from "@/utils/discard-confirmation.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 discardVisible = ref(false);
const toastVisible = ref(false);
const toastMessage = ref("");
let toastTimer = null;
let submitTimer = null;
const isDirty = computed(() => Boolean(content.value.trim()));
const hasValidContext = computed(() =>
["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
),
);
const resultCopy = computed(() => ({
preview: {
title: "动态内容已完成本地预览",
copy: "当前尚未提交服务器,返回家族圈后不会出现这条动态。",
action: "返回家族圈(不发布)",
},
error: {
title: "动态未提交",
copy: "当前文字仍保留在页面中,可返回表单继续核对。",
action: "返回填写",
},
invalid: {
title: "动态入口无效",
copy: "没有找到可发布内容的成员家谱,页面不会创建无归属动态。",
action: "返回上一页",
},
}[publishState.value]));
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
publishState.value =
query.state === "success"
? "success"
: query.state === "error"
? "error"
: "form";
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;
}
});
const showToast = (message) => {
toastMessage.value = message;
@@ -70,14 +128,48 @@ const showToast = (message) => {
}, 1800);
};
const submit = () => {
if (isSubmitting.value || !hasValidContext.value) return;
if (!content.value.trim()) {
showToast("请先写下动态内容");
return;
}
publishState.value = "success";
isSubmitting.value = true;
const submittedContent = content.value.trim();
const timer = setTimeout(() => {
if (submitTimer !== timer) return;
submitTimer = null;
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 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 === "error") {
publishState.value = "form";
return;
}
return goBack();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (toastTimer) clearTimeout(toastTimer);
if (submitTimer) clearTimeout(submitTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
+91 -38
View File
@@ -2,7 +2,7 @@
<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="动态详情" /></view>
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
<view v-if="feedState === 'loading'" class="feed-detail-loading">
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
@@ -30,93 +30,146 @@
</view>
</view>
<view class="feed-comment-form" :class="{ 'comment-state--saving': commentState === 'saving', 'comment-state--error': commentState === 'error' }">
<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="对家人说点什么" />
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
<AppButton block :disabled="commentState === 'saving'" :label="commentState === 'saving' ? '正在发送' : '发送评论'" @click="submitComment" />
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
</view>
</template>
<view v-else class="feed-state-card">
<text>{{ feedState === 'expired' ? '动态已失效' : '动态暂不可用' }}</text>
<text>{{ feedState === 'expired' ? '这条动态可能已被发布人删除,请返回家族圈查看其他内容。' : '请稍后重新查看,已有家族记录不会受到影响。' }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="feedState === 'error' ? '重新查看' : '返回家族圈'" @click="feedState === 'error' ? restoreFeed() : backToFamily()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppToast :visible="toastVisible" message="评论已发送" />
<AppDialog
:visible="discardVisible"
title="放弃评论草稿?"
message="当前评论尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续填写"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
</view>
</template>
<script setup>
import { onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 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 {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const feedRecords = [
{ id: "1", tag: "团圆记忆", time: "今天 10:24", title: "端午家宴", content: "今年端午全家相聚,长辈讲起祖居旧事,孩子们也为大家拍下了新的全家福。饭后我们把照片和口述片段整理进家族档案,让这份热闹成为往后仍能翻看的共同记忆。", author: "汤正国" },
{ id: "2", tag: "家族通知", time: "昨天 18:02", title: "修谱资料征集", content: "请家人补充老照片中的人物姓名、拍摄时间和地点。无法确认的信息也可以先写下线索,由熟悉往事的长辈共同核对。", author: "谱主" },
];
const feedId = ref("1");
const currentFeed = ref(feedRecords[0]);
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 forceCommentFailure = ref(false);
const feedComments = ref([
{ id: 1, author: "汤淑华", time: "今天 10:42", content: "一家人能常常相聚,就是最珍贵的福气。" },
{ id: 2, author: "汤文清", time: "今天 11:08", content: "照片已经整理好了,晚些时候放进春节团圆相册。" },
]);
const discardVisible = ref(false);
const feedComments = ref([]);
let submitTimer = null;
let toastTimer = null;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const stateCopy = computed(() => {
if (feedState.value === "error" && currentFeed.value) {
return {
title: "动态暂不可用",
copy: "请稍后重新查看,已有家族记录不会受到影响。",
action: "重新查看",
};
}
return {
title: "动态已失效或入口无效",
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
action: genealogyId.value ? "返回家族圈" : "返回上一页",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
onLoad((query) => {
feedId.value = String(query.feedId || "1");
const selected = feedRecords.find((item) => item.id === feedId.value);
currentFeed.value = selected || feedRecords[0];
forceCommentFailure.value = query.commentResult === "error";
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)
? query.state
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const submitComment = () => {
if (commentState.value === "saving") return;
if (commentState.value === "validating" || !currentFeed.value) return;
const content = commentDraft.value.trim();
if (!content) {
commentError.value = "请先写下评论内容";
return;
}
commentError.value = "";
commentState.value = "saving";
submitTimer = setTimeout(() => {
if (forceCommentFailure.value) {
commentState.value = "error";
commentError.value = "评论发送失败,请保留文字后重试";
forceCommentFailure.value = false;
return;
}
feedComments.value.push({ id: Date.now(), author: "我", time: "刚刚", content });
commentDraft.value = "";
commentState.value = "idle";
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;
};
const restoreFeed = () => { feedState.value = "ready"; };
const backToFamily = () => uni.redirectTo({ url: "/pages/family/f01-family-feed" });
const requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "validating",
"close-transient": cancelDiscard,
"block-submitting": () => true,
"confirm-discard": requestDiscardConfirmation,
});
const backToFamily = async () => {
if (!genealogyId.value) return goBack();
const confirmed = isDirty.value ? await requestDiscardConfirmation() : true;
if (!confirmed) return false;
return returnTo("F01", { genealogyId: genealogyId.value });
};
const handleStateAction = () => {
if (feedState.value === "error" && currentFeed.value) return restoreFeed();
return backToFamily();
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
if (submitTimer) clearTimeout(submitTimer);
if (toastTimer) clearTimeout(toastTimer);
discardConfirmation.dispose();
});
</script>
+66 -20
View File
@@ -2,7 +2,7 @@
<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' }">
<ModulePageBackground module="family" />
<view class="article-list-header"><PageHeader title="谱文" action="新建" @action="createArticle" /></view>
<view class="article-list-header"><PageHeader title="谱文" :action="hasValidContext ? '新建' : ''" @action="createArticle" /></view>
<view v-if="listState === 'loading'" class="article-list-loading">
<AppLoading text="正在整理家族谱文" description="请稍候,正在读取家训、往事与序言。" />
@@ -37,9 +37,9 @@
</template>
<view v-else class="article-list-state-card">
<text>{{ listState === 'empty' ? '还没有谱文' : '谱文列表暂不可用' }}</text>
<text>{{ listState === 'empty' ? '记录第一篇家风家训或家族往事。' : '请稍后重新查看,已有谱文不会受到影响。' }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="listState === 'error' ? '重新查看' : '新建谱文'" @click="listState === 'error' ? restoreArticles() : createArticle()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="listState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
</view>
@@ -52,14 +52,16 @@ 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 baseArticles = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", summary: "从敬老、睦亲与守信的小事里,看见家风如何代代相传。", author: "汤文正", updatedAt: "今天更新" },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", summary: "长辈口述的旧居记忆,以及每年中秋一家人相聚的故事。", author: "汤淑华", updatedAt: "昨天更新" },
{ id: "103", category: "族谱序言", title: "续修族谱序", summary: "说明本次续修的缘起、资料来源与共同参与的家人。", author: "谱主", updatedAt: "5 月 12 日" },
];
const articles = ref([...baseArticles]);
const genealogyId = ref("");
const hasValidContext = ref(false);
const articles = ref([]);
const activeCategory = ref("全部");
const keyword = ref("");
const listState = ref("loading");
@@ -71,20 +73,64 @@ const filteredArticles = computed(() => {
return categoryMatched && keywordMatched;
});
});
const stateCopy = computed(() => ({
empty: {
title: "还没有谱文",
copy: "当前家谱尚无可读取谱文,真实写接口接入后才能新增。",
action: "填写谱文预览",
},
error: {
title: "谱文列表暂不可用",
copy: "请稍后重新查看,已有谱文不会受到影响。",
action: "重新查看",
},
invalid: {
title: "谱文入口无效",
copy: "没有找到可访问的成员家谱,页面不会展示其他家谱内容。",
action: "返回上一页",
},
}[listState.value]));
onLoad((query) => {
const count = Math.max(1, Math.min(Number(query.count) || baseArticles.length, 50));
articles.value = Array.from({ length: count }, (_, index) => ({
...baseArticles[index % baseArticles.length],
id: String(101 + index),
title: count > baseArticles.length ? `${baseArticles[index % baseArticles.length].title}(第 ${index + 1} 篇)` : baseArticles[index].title,
}));
listState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
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";
});
const openArticle = (article) => uni.navigateTo({ url: `/pages/family/f05-article-detail?articleId=${article.id}` });
const createArticle = () => uni.navigateTo({ url: "/pages/family/f06-article-editor?mode=create" });
const restoreArticles = () => { listState.value = "ready"; };
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 = "全部"; };
</script>
+36 -29
View File
@@ -19,7 +19,7 @@
</view>
</view>
<view class="article-actions">
<AppButton block :label="favorite ? '已收藏谱文' : '收藏谱文'" @click="toggleFavorite" />
<AppButton block disabled label="收藏暂未开放" />
<AppButton type="secondary" block label="编辑谱文" @click="editArticle" />
</view>
</template>
@@ -30,31 +30,24 @@
<AppButton :type="articleState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppToast :visible="toastVisible" :message="favorite ? '已收藏谱文' : '已取消收藏'" />
</view>
</template>
<script setup>
import { computed, onUnmounted, reactive, ref } from "vue";
import { computed, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import AppButton from "@/components/AppButton.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 { findFamilyArticleFixture } from "@/data/mock.js";
import { goBack, openPage, returnTo } from "@/utils/navigation.js";
const articleRecords = [
{ id: "101", category: "家风家训", title: "孝友传家的日常", author: "汤文正", updatedAt: "2024 年 5 月 12 日", paragraphs: ["孝友传家,不只在族谱序言里,也在一家人每日的言行中。长辈以宽厚待晚辈,晚辈以耐心照料长辈,亲友之间守信互助,便是最朴素也最长久的家风。", "勤俭并非一味节省,而是珍惜所得、量入为出,也愿意在家人需要时伸出援手。家中每一代人都可以用自己的方式,把这份分寸与担当继续传下去。", "敬祖睦宗,最终是为了让今天的家人彼此认识、彼此关心。记录姓名与世代之外,也应留下真实的生活、共同经历和温暖记忆。"] },
{ id: "102", category: "家族往事", title: "祖居门前的那棵桂花树", author: "汤淑华", updatedAt: "2024 年 5 月 10 日", paragraphs: ["祖居门前曾有一棵桂花树。每到中秋,院里都是清甜的香气,远道回来的家人也总能循着那股味道找到家门。", "后来房屋几经修缮,桂花树仍被大家小心保留下来。它见过孩子长大,也见过长辈把往事一遍遍讲给后来人。"] },
{ id: "103", category: "族谱序言", title: "续修族谱序", author: "谱主", updatedAt: "2024 年 5 月 8 日", paragraphs: ["本次续修以旧谱、碑记、户籍资料和长辈口述为基础,由家人共同核对补充。凡暂不能确认之处,均保留来源和疑问,留待后续查证。", "愿这份记录不仅理清世系,也能保存家风、人物与共同记忆。"] },
];
const articleId = ref("101");
const article = reactive({ ...articleRecords[0] });
const genealogyId = ref("");
const articleId = ref("");
const article = ref(null);
const articleState = ref("loading");
const favorite = ref(false);
const toastVisible = ref(false);
let toastTimer = null;
const articleParagraphs = computed(() => article.paragraphs || []);
const articleParagraphs = computed(() => article.value?.paragraphs || []);
const articleStateClasses = computed(() => ({
[`article-state--${articleState.value}`]: true,
"article-state--expired": articleState.value === "expired",
@@ -62,32 +55,46 @@ const articleStateClasses = computed(() => ({
"article-state--error": articleState.value === "error",
}));
const stateCopy = computed(() => ({
expired: { title: "这篇谱文已无法查看", copy: "内容可能已被作者删除或取消公开,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
expired: { title: "这篇谱文已无法查看", copy: "当前家谱中不存在这篇谱文,页面不会回退到其他文章。", action: genealogyId.value ? "返回谱文列表" : "返回上一页" },
privacy: { title: "这篇谱文暂未公开", copy: "作者仅向有权限的家人开放正文,请返回谱文列表查看其他内容。", action: "返回谱文列表" },
error: { title: "谱文暂不可用", copy: "请稍后重新查看,已有谱文不会受到影响。", action: "重新查看" },
}[articleState.value] || {}));
onLoad((query) => {
articleId.value = String(query.articleId || "101");
const selected = articleRecords.find((item) => item.id === articleId.value);
if (selected) Object.assign(article, selected);
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)
? query.state
? selected
? query.state
: "expired"
: selected
? "ready"
: "expired";
});
const toggleFavorite = () => {
favorite.value = !favorite.value;
toastVisible.value = true;
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
const editArticle = () =>
openPage(
"F06",
{
genealogyId: genealogyId.value,
mode: "edit",
articleId: articleId.value,
},
"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();
};
const editArticle = () => uni.navigateTo({ url: `/pages/family/f06-article-editor?mode=edit&articleId=${articleId.value}` });
const backToArticles = () => uni.redirectTo({ url: "/pages/family/f04-article-list" });
const handleStateAction = () => { if (articleState.value === "error") articleState.value = "ready"; else backToArticles(); };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
</script>
<style scoped lang="scss">
+135 -36
View File
@@ -6,7 +6,7 @@
>
<ModulePageBackground module="family" />
<view class="article-editor-page__header">
<PageHeader title="编辑谱文" />
<PageHeader title="编辑谱文" custom-back @back="requestBack" />
</view>
<view v-if="editorState === 'loading'" class="article-editor-loading">
@@ -17,17 +17,15 @@
</view>
<view
v-else-if="editorState === 'success'"
v-else-if="editorState === 'preview' || editorState === 'invalid'"
class="article-editor-content"
>
<view class="editor-result-card">
<view class="editor-result-card__body">
<text class="editor-eyebrow">保存结果</text>
<text class="editor-result-card__title">谱文已保存</text>
<text class="editor-result-card__copy"
>这篇谱文已收录可返回谱文列表继续查看</text
>
<AppButton block label="返回谱文列表" @click="returnToList" />
<text class="editor-eyebrow">{{ resultCopy.eyebrow }}</text>
<text class="editor-result-card__title">{{ resultCopy.title }}</text>
<text class="editor-result-card__copy">{{ resultCopy.copy }}</text>
<AppButton block :label="resultCopy.action" @click="handleResultAction" />
</view>
</view>
</view>
@@ -38,7 +36,7 @@
<text class="editor-eyebrow">草稿 · 未发布</text>
<text class="editor-title">把值得传承的故事写下来</text>
<text class="editor-intro"
>补充标题分类和正文保存后可返回谱文列表继续查看</text
>补充标题分类和正文当前只校验并生成本地预览</text
>
<view class="editor-field">
@@ -108,22 +106,45 @@
<AppButton
block
:label="actionLabel"
:disabled="editorState === 'saving'"
:disabled="isSubmitting"
@click="submit"
/>
</view>
</view>
</view>
</view>
<AppDialog
:visible="discardVisible"
title="放弃谱文草稿?"
message="当前内容尚未提交服务器,确认返回后不会保留。"
confirm-text="放弃并返回"
cancel-text="继续编辑"
show-cancel
:close-on-mask="false"
@confirm="confirmDiscard"
@cancel="cancelDiscard"
/>
</template>
<script setup>
import { computed, reactive, ref } from "vue";
import { onLoad, onUnload } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } 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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
const draftFixture = {
title: "汤氏家训辑录",
@@ -132,28 +153,68 @@ const draftFixture = {
"孝友传家,勤俭立业;敬祖睦宗,诚实待人。愿后人常怀感恩,彼此扶持。",
};
const editorState = ref("form");
const genealogyId = ref("");
const articleId = ref("");
const editorMode = ref("create");
const simulateSaveFailure = ref(false);
const isSubmitting = ref(false);
const discardVisible = ref(false);
const allowedStates = new Set([
"draft",
"loading",
"validation",
"saving",
"error",
"success",
"preview",
]);
const form = reactive({ title: "", category: "", content: "" });
const fieldErrors = reactive({ title: "", category: "", content: "" });
const baseline = ref("");
let saveTimer = null;
const actionLabel = computed(() =>
editorState.value === "saving"
? "正在保存…"
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 discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
});
const requestDiscardConfirmation = discardConfirmation.request;
const confirmDiscard = discardConfirmation.confirm;
const cancelDiscard = discardConfirmation.cancel;
const fillDraft = () => {
Object.assign(form, draftFixture);
@@ -165,16 +226,34 @@ const showAllFieldErrors = () => {
};
onLoad((query) => {
articleId.value = query.articleId || "";
editorMode.value = articleId.value || query.mode === "edit" ? "edit" : "create";
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";
simulateSaveFailure.value = requestedState === "error";
if (
editorMode.value === "edit" ||
["draft", "saving", "error"].includes(requestedState)
) {
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;
});
@@ -195,28 +274,48 @@ const validate = () => {
return !fieldErrors.title && !fieldErrors.category && !fieldErrors.content;
};
const submit = () => {
if (editorState.value === "saving") return;
if (isSubmitting.value || !hasValidContext.value) return;
if (!validate()) {
editorState.value = "validation";
return;
}
editorState.value = "saving";
saveTimer = setTimeout(() => {
editorState.value = simulateSaveFailure.value ? "error" : "success";
simulateSaveFailure.value = false;
isSubmitting.value = true;
const submitSnapshot = formSnapshot.value;
const timer = setTimeout(() => {
if (saveTimer !== timer) return;
editorState.value = submitSnapshot ? "preview" : "error";
isSubmitting.value = false;
saveTimer = null;
}, 320);
saveTimer = timer;
};
const returnToList = () =>
uni.redirectTo({
url:
editorMode.value === "edit" && articleId.value
? `/pages/family/f05-article-detail?articleId=${articleId.value}`
: "/pages/family/f04-article-list",
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();
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (saveTimer) clearTimeout(saveTimer);
discardConfirmation.dispose();
});
</script>
+131 -30
View File
@@ -2,7 +2,7 @@
<template>
<view class="album-list-page" :class="albumStateClasses">
<ModulePageBackground module="family" />
<view class="album-list-header"><PageHeader title="家族相册" action="新建" @action="createAlbum" /></view>
<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="请稍候,正在读取照片与更新时间。" />
@@ -11,6 +11,11 @@
<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>
@@ -29,76 +34,168 @@
</template>
<view v-else class="album-state-card">
<text>{{ albumState === 'empty' ? '还没有相册' : '相册暂不可用' }}</text>
<text>{{ albumState === 'empty' ? '从第一本团圆相册开始收集家族影像。' : '请稍后重新查看,已有照片不会受到影响。' }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="albumState === 'error' ? '重新查看' : '新建相册'" @click="albumState === 'error' ? restoreAlbums() : createAlbum()" />
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="albumState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</view>
<AppDialog :visible="dialogVisible" eyebrow="新建相册" title="为家人整理一段影像" message="相册创建后可继续添加照片和说明。" confirm-text="创建相册" cancel-text="取消" show-cancel @confirm="confirmCreateAlbum" @cancel="closeCreateDialog">
<AppDialog :visible="dialogVisible" eyebrow="新建相册预览" title="为家人整理一段影像" message="当前只生成本地预览,不会创建服务器相册。" confirm-text="生成本地预览" cancel-text="取消" show-cancel :close-on-mask="false" @confirm="confirmCreateAlbum" @cancel="requestCloseCreateDialog">
<view class="album-dialog-field">
<text>相册名称</text>
<input v-model="albumNameDraft" maxlength="30" placeholder="例如:春节团圆" />
<text v-if="albumNameError">{{ albumNameError }}</text>
</view>
</AppDialog>
<AppToast :visible="toastVisible" message="相册已创建" />
<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, onUnmounted, ref } from "vue";
import { onLoad } from "@dcloudio/uni-app";
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 {
getGenealogyFixtureAccess,
listFamilyAlbumFixtures,
} from "@/data/mock.js";
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import {
goBack,
handleBackPress,
openPage,
runBackGuard,
} from "@/utils/navigation.js";
const baseAlbums = [
{ id: "201", name: "2024 春节团圆", photoCount: 18, updatedAt: "今天更新", description: "三代家人的团圆饭与院前合影", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" },
{ id: "202", name: "祖居旧影", photoCount: 32, updatedAt: "5 月 10 日更新", description: "祖居、旧物与长辈珍藏的老照片", cover: "/static/assets/modules/family/f08/f08-ancestral-home.png" },
{ id: "203", name: "儿童成长", photoCount: 46, updatedAt: "持续更新", description: "记录孩子们每一个值得珍藏的瞬间", cover: "/static/assets/modules/family/f08/f08-family-portrait.png" },
];
const albums = ref([...baseAlbums]);
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 toastVisible = ref(false);
let toastTimer = null;
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 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) => {
const count = Math.max(1, Math.min(Number(query.count) || baseAlbums.length, 30));
albums.value = Array.from({ length: count }, (_, index) => ({
...baseAlbums[index % baseAlbums.length],
id: String(201 + index),
name: count > baseAlbums.length ? `${baseAlbums[index % baseAlbums.length].name}${index + 1}` : baseAlbums[index].name,
}));
albumState.value = ["loading", "empty", "error"].includes(query.state) ? query.state : "ready";
genealogyId.value = String(query.genealogyId || "");
hasValidContext.value = ["owner", "member"].includes(
getGenealogyFixtureAccess(genealogyId.value).accessRole,
);
if (!genealogyId.value || !hasValidContext.value) {
albumState.value = "invalid";
return;
}
albums.value = listFamilyAlbumFixtures(genealogyId.value);
albumState.value = ["loading", "empty", "error"].includes(query.state)
? query.state
: albums.value.length
? "ready"
: "empty";
});
const openAlbum = (album) => uni.navigateTo({ url: `/pages/family/f08-album-detail?albumId=${album.id}` });
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; }
albums.value.unshift({ id: String(Date.now()), name, photoCount: 0, updatedAt: "刚刚创建", description: "等待添加第一张照片", cover: "/static/assets/modules/family/f08/f08-reunion-hero.png" });
albumState.value = "ready";
localAlbumPreview.value = Object.freeze({ name });
albumNameDraft.value = "";
dialogVisible.value = false;
toastVisible.value = true;
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
};
const restoreAlbums = () => { albumState.value = "ready"; };
onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
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,
});
};
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => { discardConfirmation.dispose(); });
</script>
<style scoped lang="scss">
@@ -110,6 +207,10 @@ onUnmounted(() => { if (toastTimer) clearTimeout(toastTimer); });
.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; }
+44 -30
View File
@@ -2,27 +2,25 @@
<template>
<view class="album-detail-page" :class="`album-state--${albumState}`">
<ModulePageBackground module="family" />
<view class="album-detail-header"><PageHeader title="相册详情" /></view>
<view class="album-detail-header"><PageHeader title="相册详情" custom-back @back="requestBack" /></view>
<view class="album-detail-content">
<view v-if="albumState === 'expired'" class="album-expired-state">
<view class="album-state-card__body">
<text class="album-state-card__eyebrow">相册状态</text>
<text class="album-state-card__title">相册已失效</text>
<text class="album-state-card__copy">这个相册已无法查看</text>
<AppButton block label="返回相册列表" @click="returnToAlbums" />
<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">家族影像 · 2024</text>
<text class="album-heading__title">2024 春节团圆</text>
<text class="album-heading__copy"
>一家人围坐团圆让今朝欢聚与祖居旧影留在同一本相册里</text
>
<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 张照片" : "5 张照片" }}</text
>{{ albumState === "empty" ? "0 张照片" : `${photos.length} 张照片` }}</text
>
</view>
@@ -102,20 +100,23 @@ import { onBackPress, 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";
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 photos = [
{ src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", caption: "除夕团圆 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", caption: "院前合影 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", caption: "围桌守岁 · 2024" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", caption: "祖居旧影 · 1968" },
{ src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", caption: "门前合影 · 1972" },
];
const openPreview = (index) => {
previewIndex.value = index;
previewVisible.value = true;
@@ -128,16 +129,27 @@ const closePreview = () => {
};
const toUpload = () =>
uni.navigateTo({
url: `/pages/family/f09-media-upload?albumId=${albumId.value}`,
});
openPage(
"F09",
{ genealogyId: genealogyId.value, albumId: albumId.value },
"F08",
);
const returnToAlbums = () => {
uni.redirectTo({ url: "/pages/family/f07-album-list" });
};
const returnToAlbums = () =>
genealogyId.value
? returnTo("F07", { genealogyId: genealogyId.value })
: goBack();
onLoad((query) => {
albumId.value = query.albumId || "reunion";
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"
@@ -149,11 +161,13 @@ onLoad((query) => {
previewVisible.value = albumState.value === "preview";
});
onBackPress(() => {
if (!previewVisible.value) return false;
closePreview();
return true;
});
const requestBack = () =>
runBackGuard({
transientOpen: previewVisible.value,
"close-transient": closePreview,
});
onBackPress((event) => handleBackPress(event, requestBack));
</script>
+131 -158
View File
@@ -5,46 +5,55 @@
:class="`media-upload-state--${uploadState}`"
>
<ModulePageBackground module="family" />
<view class="media-upload-header"><PageHeader title="上传照片" /></view>
<view class="media-upload-header"><PageHeader title="添加照片" custom-back @back="requestBack" /></view>
<view class="media-upload-content">
<view class="media-album-card">
<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>
<view v-else class="media-album-card">
<image
class="media-album-card__cover"
:src="mockLibrary[0].src"
:alt="mockLibrary[0].alt"
: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">2024 春节团圆</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'" class="media-permission-card">
<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"
>授权后才能选择要加入当前相册的照片当前仅展示 H5 审核状态</text
>授权后才能选择照片当前只验证选择与说明流程不会上传</text
>
<view class="media-permission-action" @click="selectMockPhotos">
<AppButton block label="重新授权" />
</view>
</view>
<view v-else-if="uploadState === 'success'" class="media-success-card">
<text class="media-state-card__eyebrow">上传结果</text>
<text class="media-success-title">4 张照片已上传</text>
<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"
>照片已加入2024 春节团圆可以返回相册继续查看</text
>照片尚未上传也没有加入{{ album.name }}返回后不会保存</text
>
<view class="media-success-action" @click="returnToAlbum">
<AppButton block label="返回当前相册" />
<view class="media-preview-action" @click="returnToAlbum">
<AppButton block label="返回当前相册(不上传)" />
</view>
</view>
<template v-else>
<template v-else-if="album">
<view class="media-section-heading">
<text>选择照片</text>
<text>{{ selectedPhotos.length }} / {{ MAX_PHOTOS }}</text>
@@ -57,7 +66,6 @@
class="media-photo-tile"
:class="{
'media-photo-tile--active': index === activePhotoIndex,
'media-photo-tile--failed': photo.status === 'error',
}"
@click="selectPhoto(index)"
>
@@ -80,21 +88,6 @@
>
<text>删除</text>
</view>
<text
v-if="photo.status === 'error'"
class="media-photo-status media-photo-status--error"
>上传失败</text
>
<text
v-else-if="photo.status === 'uploading'"
class="media-photo-status media-photo-status--uploading"
>{{ photo.progress }}%</text
>
<text
v-else-if="photo.status === 'uploaded'"
class="media-photo-status media-photo-status--uploaded"
>已上传</text
>
</view>
<view
@@ -162,104 +155,95 @@
</view>
</view>
<template v-if="uploadState === 'uploading'">
<text class="media-progress-copy"
>正在上传 2/{{ selectedPhotos.length }}</text
>
<view class="media-progress-action">
<AppButton block label="正在上传" />
</view>
</template>
<view
v-else-if="uploadState === 'error'"
class="media-retry-action"
@click="retryFailed"
>
<AppButton block label="重试 2 张失败照片" />
</view>
<view v-else class="media-primary-action" @click="startUpload">
<view class="media-primary-action" @click="generatePreview">
<AppButton
block
:disabled="isSubmitting"
:label="
selectedPhotos.length
? `上传 ${selectedPhotos.length} 张照片`
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 { onLoad } from "@dcloudio/uni-app";
import { onBackPress, onLoad, onUnload } 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";
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: "",
},
{ id: "reunion", src: "/static/assets/modules/family/f08/f08-reunion-hero.png", alt: "春节团圆时三代家人的合影", note: "" },
{ id: "portrait", src: "/static/assets/modules/family/f08/f08-family-portrait.png", alt: "家人在院落前的春节合影", note: "" },
{ id: "table", src: "/static/assets/modules/family/f08/f08-reunion-table.png", alt: "家人围坐吃年夜饭", note: "" },
{ id: "home", src: "/static/assets/modules/family/f08/f08-ancestral-home.png", alt: "祖居院落的复古旧照", note: "" },
{ id: "ancestor", src: "/static/assets/modules/family/f08/f08-ancestral-portrait.png", alt: "老一辈家人在祖居门前的合影", note: "" },
];
const selectedPhotos = ref([]);
const albumId = ref("");
const activePhoto = computed(
() => selectedPhotos.value[activePhotoIndex.value] || null,
);
const isLocked = computed(() => uploadState.value === "uploading");
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 = (state) =>
mockLibrary.slice(0, 4).map((photo, index) => ({
...photo,
note: "",
progress: state === "uploading" ? [100, 62, 35, 0][index] : 0,
status:
state === "error"
? index < 2
? "error"
: "uploaded"
: state,
}));
const makeSelectedPhotos = () =>
mockLibrary.slice(0, 4).map((photo) => ({ ...photo, note: "" }));
const selectMockPhotos = () => {
selectedPhotos.value = makeSelectedPhotos("selected");
selectedPhotos.value = makeSelectedPhotos();
activePhotoIndex.value = 0;
validationMessage.value = "";
uploadState.value = "selected";
@@ -286,8 +270,6 @@ const addMockPhoto = () => {
...next,
id: `${next.id}-${nextIndex + 1}`,
note: "",
progress: 0,
status: "selected",
});
};
@@ -297,7 +279,8 @@ const updateActiveNote = (event) => {
}
};
const startUpload = () => {
const generatePreview = () => {
if (isSubmitting.value || !album.value) return;
if (!selectedPhotos.value.length) {
selectMockPhotos();
return;
@@ -307,40 +290,59 @@ const startUpload = () => {
return;
}
validationMessage.value = "";
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
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 retryFailed = () => {
selectedPhotos.value = selectedPhotos.value.map((photo, index) => ({
...photo,
progress: [100, 62, 35, 0][index] || 0,
status: "uploading",
}));
uploadState.value = "uploading";
};
const returnToAlbum = () => {
uni.redirectTo({
url: `/pages/family/f08-album-detail?albumId=${albumId.value}`,
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,
});
};
onLoad((query) => {
albumId.value = query.albumId || "reunion";
uploadState.value = ["permission", "selected", "uploading", "error", "success"].includes(query.state)
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 (!["initial", "permission"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos(uploadState.value);
if (["selected", "preview"].includes(uploadState.value)) {
selectedPhotos.value = makeSelectedPhotos();
batchDescription.value = "春节团圆照片整理";
}
});
onBackPress((event) => handleBackPress(event, requestBack));
onUnload(() => {
if (previewTimer) clearTimeout(previewTimer);
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@@ -379,9 +381,7 @@ onLoad((query) => {
.media-state-card__eyebrow,
.media-state-card__title,
.media-state-card__copy,
.media-success-title,
.media-photo-status,
.media-progress-copy {
.media-preview-title {
display: block;
}
.media-album-card__eyebrow {
@@ -437,17 +437,12 @@ onLoad((query) => {
outline: 4rpx solid rgba(159, 44, 35, 0.78);
outline-offset: -4rpx;
}
.media-photo-tile--failed {
border-style: dashed;
border-color: #9f2c23;
}
.media-photo-tile__image {
width: 100%;
height: 100%;
}
.media-photo-order,
.media-photo-current,
.media-photo-status {
.media-photo-current {
position: absolute;
z-index: 2;
color: #fffaf0;
@@ -485,21 +480,6 @@ onLoad((query) => {
padding: 7rpx 8rpx;
background: rgba(145, 36, 29, 0.88);
}
.media-photo-status {
right: 0;
bottom: 0;
left: 0;
padding: 7rpx 8rpx;
background: rgba(58, 43, 32, 0.82);
text-align: center;
}
.media-photo-status--error {
background: rgba(132, 35, 29, 0.92);
font-weight: 700;
}
.media-photo-status--uploaded {
background: rgba(55, 82, 51, 0.88);
}
.media-add-tile {
display: flex;
min-height: 44px;
@@ -591,19 +571,12 @@ onLoad((query) => {
font-size: 21rpx;
line-height: 1.45;
}
.media-primary-action,
.media-retry-action,
.media-progress-action {
.media-primary-action {
margin-top: 24rpx;
}
.media-progress-copy {
margin-top: 22rpx;
color: $ink;
font-size: 23rpx;
text-align: center;
}
.media-permission-card,
.media-success-card {
.media-preview-card,
.media-invalid-card {
@include adaptive.adaptive-family-panel;
min-height: 410rpx;
margin-top: 24rpx;
@@ -617,7 +590,7 @@ onLoad((query) => {
letter-spacing: 2rpx;
}
.media-state-card__title,
.media-success-title {
.media-preview-title {
margin-top: 12rpx;
color: $ink;
font-family: STKaiti, KaiTi, serif;
@@ -631,7 +604,7 @@ onLoad((query) => {
line-height: 1.65;
}
.media-permission-action,
.media-success-action {
.media-preview-action {
margin-top: 30rpx;
}
@media (max-width: 340px) {
+27 -10
View File
@@ -1,6 +1,6 @@
<!-- 页面编号F-10用途家族视频待开放状态 -->
<template>
<view class="video-status-page">
<view class="video-status-page" :class="{ 'video-status-state--invalid': !hasValidContext }">
<ModulePageBackground module="family" />
<view class="video-status-header"><PageHeader title="家族视频" /></view>
@@ -15,13 +15,11 @@
<view class="video-status-card">
<view class="video-status-card__body">
<text class="video-status-card__eyebrow">视频 · 服务说明</text>
<text class="video-status-card__title">视频服务暂未开放</text>
<text class="video-status-card__copy"
>开放后可在这里浏览家族影像与纪念视频</text
>
<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="返回家族首页" />
<AppButton block :label="hasValidContext ? '返回家族首页' : '返回上一页'" />
</view>
</view>
</view>
@@ -30,13 +28,32 @@
</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 returnToFamily = () => {
uni.reLaunch({ url: "/pages/family/f01-family-feed" });
};
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();
</script>
<style scoped lang="scss">