完成70%
This commit is contained in:
@@ -1,20 +1,19 @@
|
||||
<!-- 页面编号:F-03;用途:动态详情、评论与内容失效状态。 -->
|
||||
<!-- 页面编号:F-03;用途:动态详情与一级评论。动态正文 DTO 未声明时不展示伪字段。 -->
|
||||
<template>
|
||||
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
|
||||
<ModulePageBackground module="family" />
|
||||
<view class="feed-detail-header"><PageHeader title="动态详情" custom-back @back="requestBack" /></view>
|
||||
|
||||
<view v-if="feedState === 'loading'" class="feed-detail-loading">
|
||||
<AppLoading text="正在读取家族动态" description="请稍候,正在整理正文与家人评论。" />
|
||||
<AppLoading text="正在读取家人评论" description="动态正文的展示字段尚未由后端合同声明。" />
|
||||
</view>
|
||||
|
||||
<view v-else class="feed-detail-content">
|
||||
<template v-if="feedState === 'ready'">
|
||||
<view class="feed-source-note"><text>动态详情接口尚未声明标题、正文、发布人和发布时间字段;本页不猜测这些字段,只展示该动态的真实一级评论。</text></view>
|
||||
<view class="feed-article-card">
|
||||
<text class="feed-article-card__meta">{{ currentFeed.tag }} · {{ currentFeed.time }}</text>
|
||||
<text class="feed-article-card__title">{{ currentFeed.title }}</text>
|
||||
<text class="feed-article-card__body">{{ currentFeed.content }}</text>
|
||||
<text class="feed-article-card__author">发布人:{{ currentFeed.author }}</text>
|
||||
<text class="feed-article-card__title">动态正文待后端字段合同</text>
|
||||
<text class="feed-article-card__body">已根据当前动态 ID 读取评论。正文内容会在详情响应提供可消费 DTO 后再接入。</text>
|
||||
</view>
|
||||
|
||||
<view class="feed-comments-panel">
|
||||
@@ -24,17 +23,19 @@
|
||||
<view v-for="comment in feedComments" :key="comment.id" class="feed-comment-card">
|
||||
<view><text>{{ comment.author }}</text><text>{{ comment.time }}</text></view>
|
||||
<text>{{ comment.content }}</text>
|
||||
<text v-if="comment.replyCount > 0" class="feed-comment-card__replies">直属回复 {{ comment.replyCount }} 条</text>
|
||||
</view>
|
||||
<view v-if="!feedComments.length" class="feed-comments-empty">
|
||||
<text>还没有评论</text><text>写下第一句祝福或共同记忆。</text>
|
||||
<text>还没有一级评论</text><text>服务端返回评论后会显示在这里。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="feed-comment-form" :class="{ 'comment-state--validating': commentState === 'validating', 'comment-state--preview': commentState === 'preview', 'comment-state--error': commentState === 'error' }">
|
||||
<text>写下评论</text>
|
||||
<textarea v-model="commentDraft" auto-height maxlength="240" placeholder="对家人说点什么" />
|
||||
<view class="feed-comment-form" :class="{ 'comment-state--submitting': commentState === 'submitting', 'comment-state--error': commentState === 'error' }">
|
||||
<text>提交一级评论</text>
|
||||
<textarea v-model="commentDraft" auto-height maxlength="1000" placeholder="对家人说点什么" />
|
||||
<text v-if="commentError" class="feed-comment-error">{{ commentError }}</text>
|
||||
<AppButton block :disabled="commentState === 'validating'" :label="commentState === 'validating' ? '正在校验' : '生成评论预览'" @click="submitComment" />
|
||||
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
|
||||
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -47,7 +48,7 @@
|
||||
<AppDialog
|
||||
:visible="discardVisible"
|
||||
title="放弃评论草稿?"
|
||||
message="当前评论尚未提交服务器,确认返回后不会保留。"
|
||||
message="当前评论尚未提交服务端,确认返回后不会保留。"
|
||||
confirm-text="放弃并返回"
|
||||
cancel-text="继续填写"
|
||||
show-cancel
|
||||
@@ -55,7 +56,6 @@
|
||||
@confirm="confirmDiscard"
|
||||
@cancel="cancelDiscard"
|
||||
/>
|
||||
<AppToast :visible="toastVisible" message="评论尚未提交服务器,草稿已保留" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -65,11 +65,10 @@ import { onBackPress, onLoad } from "@dcloudio/uni-app";
|
||||
import AppButton from "@/components/AppButton.vue";
|
||||
import AppDialog from "@/components/AppDialog.vue";
|
||||
import AppLoading from "@/components/AppLoading.vue";
|
||||
import AppToast from "@/components/AppToast.vue";
|
||||
import ModulePageBackground from "@/components/ModulePageBackground.vue";
|
||||
import PageHeader from "@/components/PageHeader.vue";
|
||||
import { findFamilyFeedFixture } from "@/data/mock.js";
|
||||
import { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
|
||||
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
|
||||
import {
|
||||
goBack,
|
||||
handleBackPress,
|
||||
@@ -79,29 +78,28 @@ import {
|
||||
|
||||
const genealogyId = ref("");
|
||||
const feedId = ref("");
|
||||
const currentFeed = ref(null);
|
||||
const feedState = ref("loading");
|
||||
const commentState = ref("idle");
|
||||
const commentDraft = ref("");
|
||||
const commentError = ref("");
|
||||
const toastVisible = ref(false);
|
||||
const commentNotice = ref("");
|
||||
const discardVisible = ref(false);
|
||||
const feedComments = ref([]);
|
||||
let submitTimer = null;
|
||||
let toastTimer = null;
|
||||
const feedRequestController = createRequestController();
|
||||
let loadSequence = 0;
|
||||
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
|
||||
const stateCopy = computed(() => {
|
||||
if (feedState.value === "error" && currentFeed.value) {
|
||||
if (feedState.value === "error") {
|
||||
return {
|
||||
title: "动态暂不可用",
|
||||
copy: "请稍后重新查看,已有家族记录不会受到影响。",
|
||||
action: "重新查看",
|
||||
title: "评论暂不可用",
|
||||
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
|
||||
action: "重新读取",
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: "动态已失效或入口无效",
|
||||
copy: "没有找到当前家谱中的这条动态,页面不会回退到其他记录。",
|
||||
action: genealogyId.value ? "返回家族圈" : "返回上一页",
|
||||
title: "动态入口无效",
|
||||
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
|
||||
action: genealogyId.value ? "返回家族动态" : "返回上一页",
|
||||
};
|
||||
});
|
||||
const discardConfirmation = createDiscardConfirmation((visible) => {
|
||||
@@ -111,45 +109,69 @@ const requestDiscardConfirmation = discardConfirmation.request;
|
||||
const confirmDiscard = discardConfirmation.confirm;
|
||||
const cancelDiscard = discardConfirmation.cancel;
|
||||
|
||||
const loadComments = async () => {
|
||||
if (!genealogyId.value || !feedId.value) {
|
||||
feedState.value = "expired";
|
||||
return false;
|
||||
}
|
||||
const sequence = ++loadSequence;
|
||||
feedState.value = "loading";
|
||||
try {
|
||||
const comments = await appApi.getFeedComments(genealogyId.value, feedId.value, {
|
||||
requestController: feedRequestController,
|
||||
});
|
||||
if (sequence !== loadSequence) return false;
|
||||
feedComments.value = comments;
|
||||
feedState.value = "ready";
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
|
||||
feedState.value = "error";
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
onLoad((query) => {
|
||||
genealogyId.value = String(query.genealogyId || "");
|
||||
feedId.value = String(query.feedId || "");
|
||||
const selected = findFamilyFeedFixture(genealogyId.value, feedId.value);
|
||||
currentFeed.value = selected;
|
||||
feedComments.value = selected?.comments || [];
|
||||
feedState.value = ["loading", "error", "expired"].includes(query.state)
|
||||
? selected
|
||||
? query.state
|
||||
: "expired"
|
||||
: selected
|
||||
? "ready"
|
||||
: "expired";
|
||||
loadComments();
|
||||
});
|
||||
|
||||
const submitComment = () => {
|
||||
if (commentState.value === "validating" || !currentFeed.value) return;
|
||||
const submitComment = async () => {
|
||||
if (commentState.value === "submitting" || feedState.value !== "ready") return;
|
||||
const content = commentDraft.value.trim();
|
||||
if (!content) {
|
||||
commentError.value = "请先写下评论内容";
|
||||
commentNotice.value = "";
|
||||
return;
|
||||
}
|
||||
commentError.value = "";
|
||||
commentState.value = "validating";
|
||||
const timer = setTimeout(() => {
|
||||
if (submitTimer !== timer) return;
|
||||
submitTimer = null;
|
||||
commentState.value = "preview";
|
||||
toastVisible.value = true;
|
||||
toastTimer = setTimeout(() => { toastVisible.value = false; }, 1800);
|
||||
}, 320);
|
||||
submitTimer = timer;
|
||||
commentNotice.value = "";
|
||||
commentState.value = "submitting";
|
||||
try {
|
||||
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent: content }, {
|
||||
requestController: feedRequestController,
|
||||
});
|
||||
commentDraft.value = "";
|
||||
const refreshed = await loadComments();
|
||||
if (!refreshed) {
|
||||
commentState.value = "error";
|
||||
commentError.value = "评论已提交,但列表刷新失败;请稍后重新查看。";
|
||||
return;
|
||||
}
|
||||
commentState.value = "idle";
|
||||
commentNotice.value = "评论已提交,并已从服务端刷新。";
|
||||
} catch (error) {
|
||||
if (isRequestCancelled(error)) return;
|
||||
commentState.value = "error";
|
||||
commentError.value = error?.message || "评论提交失败,请稍后重试。";
|
||||
}
|
||||
};
|
||||
const restoreFeed = () => { feedState.value = "ready"; };
|
||||
const requestBack = () =>
|
||||
runBackGuard({
|
||||
transientOpen: discardVisible.value,
|
||||
dirty: isDirty.value,
|
||||
submitting: commentState.value === "validating",
|
||||
submitting: commentState.value === "submitting",
|
||||
"close-transient": cancelDiscard,
|
||||
"block-submitting": () => true,
|
||||
"confirm-discard": requestDiscardConfirmation,
|
||||
@@ -161,14 +183,14 @@ const backToFamily = async () => {
|
||||
return returnTo("F01", { genealogyId: genealogyId.value });
|
||||
};
|
||||
const handleStateAction = () => {
|
||||
if (feedState.value === "error" && currentFeed.value) return restoreFeed();
|
||||
if (feedState.value === "error") return loadComments();
|
||||
return backToFamily();
|
||||
};
|
||||
onBackPress((event) => handleBackPress(event, requestBack));
|
||||
|
||||
onUnmounted(() => {
|
||||
if (submitTimer) clearTimeout(submitTimer);
|
||||
if (toastTimer) clearTimeout(toastTimer);
|
||||
++loadSequence;
|
||||
feedRequestController.abort();
|
||||
discardConfirmation.dispose();
|
||||
});
|
||||
</script>
|
||||
@@ -180,26 +202,29 @@ onUnmounted(() => {
|
||||
.feed-detail-header, .feed-detail-loading, .feed-detail-content { z-index: 1; }
|
||||
.feed-detail-loading { min-height: calc(100vh - 100rpx); }
|
||||
.feed-detail-content { padding: 18rpx 24rpx 72rpx; }
|
||||
.feed-source-note { margin-top: 12rpx; padding: 14rpx 18rpx; border: 1rpx solid rgba(128, 106, 81, .22); border-radius: 10rpx; background: rgba(255,255,255,.56); }
|
||||
.feed-source-note text { display: block; color: $ink-muted; font-size: 20rpx; line-height: 1.5; }
|
||||
.feed-article-card, .feed-comments-panel, .feed-comment-form, .feed-state-card { @include adaptive.adaptive-family-content; width: 100%; }
|
||||
.feed-article-card { padding: 46rpx 48rpx 42rpx; }
|
||||
.feed-article-card { padding: 42rpx 48rpx; }
|
||||
.feed-article-card text, .feed-state-card > text { display: block; }
|
||||
.feed-article-card__meta, .feed-article-card__author { color: $ink-muted; font-size: 22rpx; }
|
||||
.feed-article-card__title { margin-top: 12rpx; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 36rpx; font-weight: 700; }
|
||||
.feed-article-card__body { margin-top: 18rpx; color: $ink; font-size: 25rpx; line-height: 1.75; }
|
||||
.feed-article-card__author { margin-top: 22rpx; }
|
||||
.feed-article-card__title { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
|
||||
.feed-article-card__body { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.7; }
|
||||
.feed-comments-panel { margin-top: 18rpx; padding: 38rpx 38rpx 34rpx; }
|
||||
.feed-comments-heading { display: flex; align-items: center; justify-content: space-between; color: $brand-red; font-size: 24rpx; font-weight: 700; }
|
||||
.feed-comments-heading text:last-child { color: $ink-muted; font-size: 21rpx; font-weight: 400; }
|
||||
.feed-comment-card { @include adaptive.adaptive-family-field; margin-top: 16rpx; padding: 20rpx 22rpx; }
|
||||
.feed-comment-card > view { display: flex; justify-content: space-between; gap: 18rpx; color: $ink-muted; font-size: 20rpx; }
|
||||
.feed-comment-card > text { display: block; margin-top: 9rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
||||
.feed-comment-card > .feed-comment-card__replies { color: $ink-muted; font-size: 20rpx; }
|
||||
.feed-comments-empty { padding: 34rpx 10rpx 20rpx; text-align: center; }
|
||||
.feed-comments-empty text { display: block; color: $ink-muted; font-size: 23rpx; line-height: 1.6; }
|
||||
.feed-comments-empty text:first-child { color: $ink; font-size: 28rpx; font-weight: 700; }
|
||||
.feed-comment-form { margin-top: 18rpx; padding: 38rpx 40rpx 42rpx; }
|
||||
.feed-comment-form > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 30rpx; font-weight: 700; }
|
||||
.feed-comment-form textarea { @include adaptive.adaptive-family-field; width: auto; min-width: 0; min-height: 126rpx; margin-top: 16rpx; padding: 20rpx 22rpx; color: $ink; font-size: 24rpx; line-height: 1.55; }
|
||||
.feed-comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
|
||||
.feed-comment-error, .feed-comment-notice { display: block; margin-top: 10rpx; font-size: 22rpx; }
|
||||
.feed-comment-error { color: $brand-red; }
|
||||
.feed-comment-notice { color: $ink-muted; }
|
||||
.feed-comment-form .app-button { margin-top: 22rpx; }
|
||||
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
|
||||
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
|
||||
|
||||
Reference in New Issue
Block a user