This commit is contained in:
rain
2026-07-24 18:03:02 +08:00
parent c7f278fe79
commit ec8486b035
86 changed files with 7943 additions and 1841 deletions
+164 -170
View File
@@ -1,232 +1,226 @@
<!-- 页面编号F-03用途动态详情与一级评论动态正文 DTO 未声明时不展示伪字段 -->
<!-- 页面编号F-03用途家族动态详情与一级评论 -->
<template>
<view class="feed-detail-page" :class="{ 'feed-state--expired': feedState === 'expired', 'feed-state--error': feedState === 'error', 'feed-state--ready': feedState === 'ready' }">
<view class="feed-detail-page" :class="`feed-state--${feedState}`">
<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="动态正文的展示字段尚未由后端合同声明。" />
</view>
<view class="feed-detail-content">
<AppLoading v-if="feedState === 'loading'" text="正在读取动态" description="请稍候,正在同步家族动态与评论。" />
<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__title">动态正文待后端字段合同</text>
<text class="feed-article-card__body">已根据当前动态 ID 读取评论正文内容会在详情响应提供可消费 DTO 后再接入</text>
</view>
<view class="feed-comments-panel">
<view class="feed-comments-heading">
<text>家人评论</text><text>{{ feedComments.length }} </text>
<view v-else-if="feedState === 'ready'" class="feed-detail-body">
<view class="feed-card">
<view class="feed-card__heading">
<text>{{ feedTypeLabel(feed.type) }}</text>
<text>{{ feed.time || '未标注时间' }}</text>
</view>
<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 class="feed-card__content">{{ feed.content }}</text>
<view class="feed-card__meta">
<text>发布{{ feed.publisher }}</text>
<text>{{ feed.likeCount }} 次点赞 · {{ feed.commentCount }} 条评论</text>
</view>
</view>
<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>
<text v-else-if="commentNotice" class="feed-comment-notice">{{ commentNotice }}</text>
<AppButton block :disabled="commentState === 'submitting'" :label="commentState === 'submitting' ? '正在提交' : '提交评论'" @click="submitComment" />
<view class="comment-section">
<text class="section-title">评论</text>
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
<view v-else-if="commentState === 'list'" class="comment-list">
<view v-for="item in comments" :key="item.id" class="comment-card">
<view class="comment-card__heading">
<text>{{ item.author }}</text>
<text>{{ item.time || '刚刚' }}</text>
</view>
<text class="comment-card__content">{{ item.content }}</text>
</view>
</view>
<text v-else class="comment-state-copy">{{ commentStateCopy }}</text>
<view class="comment-editor">
<textarea
v-model="commentDraft"
auto-height
maxlength="1000"
placeholder="写下你的评论"
placeholder-class="comment-editor__placeholder"
@input="commentError = ''"
/>
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
<AppButton
block
:disabled="isSubmittingComment"
:label="isSubmittingComment ? '正在提交' : '发表评论'"
@click="submitComment"
/>
</view>
</view>
</template>
</view>
<view v-else class="feed-state-card">
<text>{{ stateCopy.title }}</text>
<text>{{ stateCopy.copy }}</text>
<AppButton :type="feedState === 'error' ? 'secondary' : 'primary'" block :label="stateCopy.action" @click="handleStateAction" />
<AppButton type="secondary" block :label="stateCopy.action" @click="handleStateAction" />
</view>
</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, onUnmounted, ref } from "vue";
import { onBackPress, onLoad } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { onBackPress, onLoad, onShow, 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 { createDiscardConfirmation } from "@/utils/discard-confirmation.js";
import { appApi, createRequestController, isRequestCancelled } from "@/utils/api.js";
import {
goBack,
handleBackPress,
returnTo,
runBackGuard,
} from "@/utils/navigation.js";
import { goBack, handleBackPress, returnTo } from "@/utils/navigation.js";
const genealogyId = ref("");
const feedId = ref("");
const feedState = ref("loading");
const commentState = ref("idle");
const commentState = ref("loading");
const feed = ref(null);
const comments = ref([]);
const commentDraft = ref("");
const commentError = ref("");
const commentNotice = ref("");
const discardVisible = ref(false);
const feedComments = ref([]);
const feedRequestController = createRequestController();
let loadSequence = 0;
const isDirty = computed(() => Boolean(commentDraft.value.trim()));
const isSubmittingComment = ref(false);
const controller = createRequestController();
let pageActive = true;
const feedTypeLabel = (type) => String(type || "").trim().toLowerCase() === "text" ? "文字动态" : "家族动态";
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(genealogyId.value) && /^[1-9]\d*$/.test(feedId.value),
);
const stateCopy = computed(() => {
if (feedState.value === "error") {
if (!hasValidContext.value) {
return {
title: "评论暂不可用",
copy: "评论读取没有得到可消费的服务端响应;页面不会用本地数据替代。",
action: "重新读取",
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会展示其他动态。",
action: "返回上一页",
};
}
if (feedState.value === "missing") {
return {
title: "该动态已不存在",
copy: "它可能已被发布者删除,或当前账号已不再拥有查看权限。",
action: "返回家族动态",
};
}
return {
title: "动态入口无效",
copy: "没有取得当前家谱和动态标识,页面不会回退到其他动态。",
action: genealogyId.value ? "返回家族动态" : "返回上一页",
title: "动态暂时无法读取",
copy: "请检查网络后重新读取;本页不会替换为其他动态。",
action: "重新读取",
};
});
const discardConfirmation = createDiscardConfirmation((visible) => {
discardVisible.value = visible;
const commentStateCopy = computed(() => {
if (commentState.value === "empty") return "还没有评论,欢迎留下第一句话。";
return "评论暂时无法读取,稍后可重新进入本页查看。";
});
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 loadFeed = async () => {
if (!hasValidContext.value) {
feedState.value = "invalid";
return;
}
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;
const current = await appApi.getFeedDetail(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
feed.value = current;
feedState.value = "ready";
return true;
} catch (error) {
if (isRequestCancelled(error) || sequence !== loadSequence) return false;
feedState.value = "error";
return false;
if (!pageActive || isRequestCancelled(error)) return;
feedState.value = error?.code === "HTTP_ERROR" && error?.httpStatus === 404 ? "missing" : "error";
}
};
const loadComments = async () => {
if (!hasValidContext.value || feedState.value !== "ready") return;
commentState.value = "loading";
try {
const rows = await appApi.getFeedComments(genealogyId.value, feedId.value, { requestController: controller });
if (!pageActive) return;
comments.value = rows;
commentState.value = rows.length ? "list" : "empty";
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentState.value = "error";
}
};
const refresh = async () => {
await loadFeed();
if (feedState.value === "ready") await loadComments();
};
const submitComment = async () => {
if (isSubmittingComment.value || !hasValidContext.value) return;
const commentContent = commentDraft.value.trim();
if (!commentContent) {
commentError.value = "请填写评论内容";
return;
}
isSubmittingComment.value = true;
commentError.value = "";
try {
await appApi.createFeedComment(genealogyId.value, feedId.value, { commentContent }, { requestController: controller });
if (!pageActive) return;
commentDraft.value = "";
await refresh();
} catch (error) {
if (!pageActive || isRequestCancelled(error)) return;
commentError.value = error?.message || "评论提交失败,请稍后重试";
} finally {
if (pageActive) isSubmittingComment.value = false;
}
};
onLoad((query) => {
genealogyId.value = String(query.genealogyId || "");
feedId.value = String(query.feedId || "");
loadComments();
genealogyId.value = String(query?.genealogyId || "");
feedId.value = String(query?.feedId || "");
void refresh();
});
onShow(() => {
if (hasValidContext.value) void refresh();
});
onUnload(() => {
pageActive = false;
controller.abort();
});
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 = "";
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 requestBack = () =>
runBackGuard({
transientOpen: discardVisible.value,
dirty: isDirty.value,
submitting: commentState.value === "submitting",
"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") return loadComments();
return backToFamily();
};
const backToFamily = () => hasValidContext.value
? returnTo("F01", { genealogyId: genealogyId.value })
: goBack();
const requestBack = () => backToFamily();
const handleStateAction = () => feedState.value === "error" ? refresh() : backToFamily();
onBackPress((event) => handleBackPress(event, requestBack));
onUnmounted(() => {
++loadSequence;
feedRequestController.abort();
discardConfirmation.dispose();
});
</script>
<style scoped lang="scss">
@use "../../styles/adaptive-frame-profiles.scss" as adaptive;
.feed-detail-page { display: flex; min-height: 100vh; flex-direction: column; background: $paper; }
.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: 42rpx 48rpx; }
.feed-article-card text, .feed-state-card > text { display: block; }
.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, .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-detail-header, .feed-detail-content { z-index: 1; }
.feed-detail-content { flex: 1; padding: 18rpx 24rpx 72rpx; }
.feed-detail-body { margin-top: 18rpx; }
.feed-card, .comment-section, .feed-state-card { @include adaptive.adaptive-family-content; box-sizing: border-box; }
.feed-card { padding: 30rpx; }
.feed-card__heading, .feed-card__meta, .comment-card__heading { display: flex; justify-content: space-between; gap: 18rpx; }
.feed-card__heading text:first-child, .comment-card__heading text:first-child { color: $brand-red; font-size: 23rpx; font-weight: 700; }
.feed-card__heading text:last-child, .comment-card__heading text:last-child { color: $ink-muted; font-size: 21rpx; text-align: right; }
.feed-card__content { display: block; margin-top: 20rpx; color: $ink; font-size: 29rpx; line-height: 1.7; white-space: pre-wrap; }
.feed-card__meta { margin-top: 22rpx; color: $ink-muted; font-size: 21rpx; }
.comment-section { margin-top: 18rpx; padding: 28rpx; }
.section-title { display: block; color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 32rpx; font-weight: 700; }
.comment-list { margin-top: 18rpx; }
.comment-card { padding: 18rpx 0; border-bottom: 1rpx solid rgba(128, 89, 49, .16); }
.comment-card__content { display: block; margin-top: 10rpx; color: $ink; font-size: 25rpx; line-height: 1.55; white-space: pre-wrap; }
.comment-state-copy { display: block; margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.5; }
.comment-editor { margin-top: 24rpx; padding-top: 22rpx; border-top: 1rpx solid rgba(128, 89, 49, .18); }
.comment-editor textarea { display: block; box-sizing: border-box; width: 100%; min-height: 130rpx; padding: 18rpx; border: 1rpx solid rgba(128, 89, 49, .3); border-radius: 12rpx; color: $ink; font-size: 25rpx; line-height: 1.55; }
.comment-editor__placeholder { color: #ab9a86; }
.comment-editor .app-button { margin-top: 18rpx; }
.comment-error { display: block; margin-top: 10rpx; color: $brand-red; font-size: 22rpx; }
.feed-state-card { min-height: 340rpx; margin-top: 36rpx; padding: 78rpx 52rpx 50rpx; text-align: center; }
.feed-state-card > text { display: block; }
.feed-state-card > text:first-child { color: $ink; font-family: STKaiti, KaiTi, serif; font-size: 35rpx; font-weight: 700; }
.feed-state-card > text:nth-child(2) { margin-top: 18rpx; color: $ink-muted; font-size: 24rpx; line-height: 1.65; }
.feed-state-card .app-button { margin-top: 30rpx; }