完成50%
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user