Files
jiapuapp/components/family/FeedCommentSection.vue
T

719 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="comment-section">
<view class="comment-section__heading">
<text class="section-title">评论</text>
<button class="comment-entry" @click="focusCommentEditor">写评论</button>
</view>
<AppLoading v-if="commentState === 'loading'" text="正在读取评论" />
<view v-else-if="commentState === 'list'" class="comment-list">
<view v-for="comment in comments" :key="comment.id" class="comment-card">
<view class="comment-card__heading">
<text>{{ comment.author }}</text>
<text>{{ formatMinuteTimestamp(comment.time) || "刚刚" }}</text>
</view>
<text class="comment-card__content">{{ comment.content }}</text>
<view class="comment-card__actions">
<AppButton
compact
type="secondary"
label="回复"
@click="startReply(comment)"
/>
<AppButton
v-if="comment.canDelete && !comment.userDeleted"
compact
type="secondary"
label="删除"
@click="requestDeleteComment(comment)"
/>
<AppButton
v-if="comment.replyCount"
compact
type="secondary"
:label="replyState(comment.id) === 'ready' ? '收起回复' : `查看 ${comment.replyCount} 条回复`"
@click="toggleReplies(comment)"
/>
</view>
<view
v-if="replyState(comment.id) !== 'closed'"
class="comment-replies"
>
<text v-if="replyState(comment.id) === 'loading'" class="reply-state">
正在读取回复
</text>
<view v-else-if="replyState(comment.id) === 'ready'">
<text v-if="!replyRows(comment.id).length" class="reply-state">
暂未读取到回复
</text>
<view
v-for="reply in replyRows(comment.id)"
:key="reply.id"
class="reply-card"
>
<view class="reply-card__heading">
<text>{{ reply.author }}</text>
<text>{{ formatMinuteTimestamp(reply.time) || "刚刚" }}</text>
</view>
<text v-if="reply.parentAuthor" class="reply-card__target">
回复 {{ reply.parentAuthor }}
</text>
<text class="reply-card__content">{{ reply.content }}</text>
<view class="reply-card__actions">
<AppButton
compact
type="secondary"
label="回复"
@click="startReply(reply, comment)"
/>
<AppButton
v-if="reply.canDelete && !reply.userDeleted"
compact
type="secondary"
label="删除回复"
@click="requestDeleteComment(reply, comment)"
/>
</view>
</view>
<AppButton
v-if="shouldShowReplyMore(comment.id)"
compact
type="secondary"
:disabled="replyMoreState(comment.id) === 'loading'"
:label="replyMoreLabel(comment.id)"
@click="loadMoreReplies(comment)"
/>
</view>
<text v-else class="reply-state reply-state--error">
回复暂时无法读取
<text role="button" @click="loadReplies(comment)">重新读取</text>
</text>
</view>
</view>
<AppButton
v-if="shouldShowCommentMore"
compact
type="secondary"
:disabled="commentMoreState === 'loading'"
:label="commentMoreLabel"
@click="loadMoreComments"
/>
</view>
<view v-else class="comment-state-copy">
<text>{{ commentStateCopy }}</text>
<AppButton
v-if="commentState === 'error'"
compact
type="secondary"
label="重新加载评论"
@click="reload"
/>
</view>
<view class="comment-editor">
<view v-if="replyTarget" class="reply-target">
<text>正在回复 {{ replyTarget.author }}</text>
<text role="button" @click="clearReplyTarget">取消回复</text>
</view>
<textarea
v-model="commentDraft"
auto-height
maxlength="1000"
:placeholder="replyTarget ? `回复 ${replyTarget.author}` : '写下你的评论'"
placeholder-class="comment-editor__placeholder"
:focus="commentFocused"
@input="commentError = ''"
@blur="commentFocused = false"
/>
<text v-if="commentError" class="comment-error">{{ commentError }}</text>
<AppButton
block
:disabled="isSubmittingComment"
:label="isSubmittingComment ? '正在提交' : replyTarget ? '发表回复' : '发表评论'"
@click="submitComment"
/>
</view>
</view>
<AppDialog
:visible="commentDeleteVisible"
:close-on-mask="false"
eyebrow="删除评论"
title="确认删除这条评论?"
message="删除后评论内容将不再显示。"
:confirm-text="deletingComment ? '正在删除' : '确认删除'"
cancel-text="取消"
show-cancel
@confirm="confirmDeleteComment"
@cancel="closeDeleteConfirmation"
/>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref } from "vue";
import AppButton from "@/components/AppButton.vue";
import AppDialog from "@/components/AppDialog.vue";
import AppLoading from "@/components/AppLoading.vue";
import {
createRequestController,
isRequestCancelled
} from "@/services/api/request-controller.js";
import { familyFeedApi } from "@/services/api/family-feed-service.js";
import { getRequestErrorMessage } from "@/services/api/request-error-message.js";
import { formatMinuteTimestamp } from "@/utils/display-time.js";
import { createNonIdempotentWriteGuard } from "@/utils/non-idempotent-write-guard.js";
const props = defineProps({
genealogyId: {
type: String,
required: true,
},
feedId: {
type: String,
required: true,
},
refreshFeedSummary: {
type: Function,
required: true,
},
});
const COMMENT_PAGE_SIZE = 20;
const commentState = ref("loading");
const comments = ref([]);
const currentCommentPage = ref(1);
const totalCommentCount = ref(0);
const commentMoreState = ref("idle");
const replyThreadsByCommentId = ref({});
const replyTarget = ref(null);
const commentDraft = ref("");
const commentFocused = ref(false);
const commentError = ref("");
const isSubmittingComment = ref(false);
const commentDeleteVisible = ref(false);
const deletingComment = ref(false);
const commentDeleteTarget = ref(null);
const commentReadRequestController = createRequestController();
const commentSubmissionRequestController = createRequestController();
const commentDeletionRequestController = createRequestController();
const commentCreateGuard = createNonIdempotentWriteGuard();
const replyRequestControllers = new Map();
let componentActive = true;
const hasValidContext = computed(() =>
/^[1-9]\d*$/.test(props.genealogyId) && /^[1-9]\d*$/.test(props.feedId),
);
const hasMoreComments = computed(() =>
comments.value.length < totalCommentCount.value,
);
const shouldShowCommentMore = computed(() =>
hasMoreComments.value || ["loading", "error"].includes(commentMoreState.value),
);
const commentMoreLabel = computed(() => {
if (commentMoreState.value === "loading") return "正在加载评论";
if (commentMoreState.value === "error") return "加载失败,重新加载";
return "继续加载评论";
});
const commentStateCopy = computed(() =>
commentState.value === "empty"
? "还没有评论,欢迎留下第一句话。"
: "评论暂时无法读取,稍后可重新进入本页查看。",
);
const replyRequestController = (commentId) => {
const controllerKey = String(commentId);
if (!replyRequestControllers.has(controllerKey)) {
replyRequestControllers.set(controllerKey, createRequestController());
}
return replyRequestControllers.get(controllerKey);
};
const emptyReplyThread = () => ({
state: "closed",
rows: [],
page: 1,
total: 0,
moreState: "idle",
});
const replyThread = (commentId) =>
replyThreadsByCommentId.value[String(commentId)] || emptyReplyThread();
const updateReplyThread = (commentId, changes) => {
const threadId = String(commentId);
replyThreadsByCommentId.value = {
...replyThreadsByCommentId.value,
[threadId]: { ...replyThread(threadId), ...changes },
};
};
const replyState = (commentId) => replyThread(commentId).state;
const replyRows = (commentId) => replyThread(commentId).rows;
const replyHasMore = (commentId) => {
const thread = replyThread(commentId);
return thread.rows.length < thread.total;
};
const replyMoreState = (commentId) => replyThread(commentId).moreState;
const shouldShowReplyMore = (commentId) =>
replyHasMore(commentId) || ["loading", "error"].includes(replyMoreState(commentId));
const replyMoreLabel = (commentId) => {
const state = replyMoreState(commentId);
if (state === "loading") return "正在加载回复";
if (state === "error") return "加载失败,重新加载";
return "继续加载回复";
};
const reload = async () => {
if (!hasValidContext.value) return;
commentReadRequestController.abort();
commentState.value = "loading";
currentCommentPage.value = 1;
commentMoreState.value = "idle";
try {
const commentPage = await familyFeedApi.getFeedCommentPage(
props.genealogyId,
props.feedId,
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
{ requestController: commentReadRequestController },
);
if (!componentActive) return;
comments.value = commentPage.rows;
totalCommentCount.value = commentPage.total;
commentMoreState.value =
comments.value.length < totalCommentCount.value ? "idle" : "done";
commentState.value = commentPage.rows.length ? "list" : "empty";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
commentState.value = "error";
}
};
const loadMoreComments = async () => {
if (!hasMoreComments.value || commentMoreState.value === "loading") return;
commentMoreState.value = "loading";
try {
const nextPage = currentCommentPage.value + 1;
const commentPage = await familyFeedApi.getFeedCommentPage(
props.genealogyId,
props.feedId,
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
{ requestController: commentReadRequestController },
);
if (!componentActive) return;
const knownCommentIds = new Set(
comments.value.map((comment) => String(comment.id)),
);
comments.value = comments.value.concat(
commentPage.rows.filter(
(comment) => !knownCommentIds.has(String(comment.id)),
),
);
currentCommentPage.value = nextPage;
totalCommentCount.value = commentPage.total;
commentMoreState.value =
comments.value.length < totalCommentCount.value ? "idle" : "done";
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
commentMoreState.value = "error";
}
};
const loadReplies = async (comment) => {
const commentId = String(comment?.id || "");
if (!hasValidContext.value || !/^[1-9]\d*$/.test(commentId)) return;
updateReplyThread(commentId, { state: "loading" });
try {
const replyPage = await familyFeedApi.getCommentReplyPage(
props.genealogyId,
props.feedId,
commentId,
{ pageNum: 1, pageSize: COMMENT_PAGE_SIZE },
{ requestController: replyRequestController(commentId) },
);
if (!componentActive) return;
updateReplyThread(commentId, {
state: "ready",
rows: replyPage.rows,
page: 1,
total: replyPage.total,
moreState: replyPage.rows.length < replyPage.total ? "idle" : "done",
});
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
updateReplyThread(commentId, { state: "error" });
}
};
const loadMoreReplies = async (comment) => {
const commentId = String(comment?.id || "");
const thread = replyThread(commentId);
if (!replyHasMore(commentId) || thread.moreState === "loading") return;
updateReplyThread(commentId, { moreState: "loading" });
try {
const nextPage = thread.page + 1;
const replyPage = await familyFeedApi.getCommentReplyPage(
props.genealogyId,
props.feedId,
commentId,
{ pageNum: nextPage, pageSize: COMMENT_PAGE_SIZE },
{ requestController: replyRequestController(commentId) },
);
if (!componentActive) return;
const currentReplies = replyRows(commentId);
const knownReplyIds = new Set(
currentReplies.map((reply) => String(reply.id)),
);
const replies = currentReplies.concat(
replyPage.rows.filter((reply) => !knownReplyIds.has(String(reply.id))),
);
updateReplyThread(commentId, {
rows: replies,
page: nextPage,
total: replyPage.total,
moreState: replies.length < replyPage.total ? "idle" : "done",
});
} catch (error) {
if (!componentActive || isRequestCancelled(error)) return;
updateReplyThread(commentId, { moreState: "error" });
}
};
const toggleReplies = (comment) => {
const commentId = String(comment?.id || "");
if (replyState(commentId) === "ready") {
updateReplyThread(commentId, { state: "closed" });
return;
}
void loadReplies(comment);
};
const startReply = (comment, rootComment = comment) => {
// 写入使用实际父评论,刷新则使用所属一级评论;两者不能合并成同一个 ID。
replyTarget.value = {
id: comment.id,
author: comment.author,
rootComment,
};
commentError.value = "";
};
const clearReplyTarget = () => {
replyTarget.value = null;
commentError.value = "";
};
const focusCommentEditor = () => {
if (typeof uni?.pageScrollTo !== "function") return;
uni.pageScrollTo({
selector: ".comment-editor",
duration: 240,
complete: () => {
commentFocused.value = false;
nextTick(() => {
if (componentActive) commentFocused.value = true;
});
},
});
};
const requestDeleteComment = (comment, parent = null) => {
if (!comment?.canDelete || comment.userDeleted || deletingComment.value) return;
commentDeleteTarget.value = { comment, parent };
commentDeleteVisible.value = true;
commentError.value = "";
};
const closeDeleteConfirmation = () => {
if (deletingComment.value) return;
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
};
const confirmDeleteComment = async () => {
const deletion = commentDeleteTarget.value;
if (!deletion?.comment?.canDelete || deletingComment.value) return;
deletingComment.value = true;
let deletionCommitted = false;
try {
await familyFeedApi.deleteFeedComment(
props.genealogyId,
props.feedId,
deletion.comment.id,
{ requestController: commentDeletionRequestController },
);
deletionCommitted = true;
if (!componentActive) return;
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
await reload();
if (deletion.parent) await loadReplies(deletion.parent);
const feedRefreshed = await props.refreshFeedSummary();
if (componentActive && feedRefreshed === false) {
commentError.value = "评论已删除,动态统计暂时未更新。";
}
} catch (error) {
if (!componentActive) return;
if (deletionCommitted) {
commentDeleteVisible.value = false;
commentDeleteTarget.value = null;
commentError.value = "评论已删除,动态统计暂时未更新。";
return;
}
if (isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(
error,
"这条评论删除失败,请稍后重试。",
);
commentDeleteVisible.value = false;
} finally {
if (componentActive) deletingComment.value = false;
}
};
const submitComment = async () => {
if (isSubmittingComment.value || !hasValidContext.value) return;
const commentContent = commentDraft.value.trim();
if (!commentContent) {
commentError.value = "请填写评论内容";
return;
}
const target = replyTarget.value;
const payload = {
commentContent,
...(target ? { parentCommentId: target.id } : {}),
};
const createAttempt = commentCreateGuard.begin(payload);
if (createAttempt === null) {
commentError.value =
"上次评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
return;
}
isSubmittingComment.value = true;
commentError.value = "";
let commentCommitted = false;
try {
await familyFeedApi.createFeedComment(
props.genealogyId,
props.feedId,
payload,
{ requestController: commentSubmissionRequestController },
);
commentCommitted = true;
if (!componentActive) return;
commentDraft.value = "";
replyTarget.value = null;
await reload();
if (target) {
await loadReplies(target.rootComment);
}
const feedRefreshed = await props.refreshFeedSummary();
if (componentActive && feedRefreshed === false) {
commentError.value = "评论已发布,动态统计暂时未更新。";
}
} catch (error) {
if (!componentActive) return;
if (commentCommitted) {
commentError.value = "评论已发布,但动态统计暂时未更新。";
return;
}
if (commentCreateGuard.recordFailure(createAttempt, error)) {
commentError.value =
"评论结果暂时无法确认,请先刷新评论列表,避免重复发表。";
return;
}
if (isRequestCancelled(error)) return;
commentError.value = getRequestErrorMessage(
error,
"评论提交失败,请稍后重试",
);
} finally {
if (componentActive) isSubmittingComment.value = false;
}
};
defineExpose({ reload });
onMounted(() => {
void reload();
});
onUnmounted(() => {
componentActive = false;
commentReadRequestController.abort();
commentSubmissionRequestController.abort();
commentDeletionRequestController.abort();
replyRequestControllers.forEach((requestController) => requestController.abort());
replyRequestControllers.clear();
});
</script>
<style scoped lang="scss">
@import "../../styles/adaptive-frame-profiles.scss";
.comment-section {
@include adaptive-family-content;
box-sizing: border-box;
margin-top: 18rpx;
padding: 28rpx;
}
.comment-section__heading,
.comment-card__heading,
.reply-card__heading,
.reply-target {
display: flex;
justify-content: space-between;
gap: 18rpx;
}
.comment-section__heading {
align-items: center;
}
.section-title {
display: block;
color: $ink;
font-family: STKaiti, KaiTi, serif;
font-size: clamp(17px, 32rpx, 22px);
font-weight: 700;
}
.comment-entry {
min-width: 144rpx;
min-height: 72rpx;
margin: 0;
padding: 0 18rpx;
border: 0;
background: transparent;
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
line-height: 72rpx;
}
.comment-entry::after {
border: 0;
}
.comment-list,
.comment-state-copy {
margin-top: 18rpx;
}
.comment-card {
padding: 18rpx 0;
border-bottom: 1rpx solid rgba(128, 89, 49, 0.16);
}
.comment-card__heading text:first-child {
color: $brand-red;
font-size: clamp(14px, 23rpx, 17px);
font-weight: 700;
}
.reply-card__heading text:first-child,
.reply-target text:first-child {
color: $brand-red;
font-size: clamp(14px, 22rpx, 17px);
font-weight: 700;
}
.comment-card__heading text:last-child,
.reply-card__heading text:last-child,
.reply-card__target,
.reply-state {
color: $ink-muted;
font-size: clamp(13px, 21rpx, 16px);
}
.comment-card__content,
.reply-card__content {
display: block;
color: $ink;
line-height: 1.55;
white-space: pre-wrap;
}
.comment-card__content {
margin-top: 10rpx;
font-size: clamp(15px, 25rpx, 18px);
}
.comment-card__actions,
.reply-card__actions {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.comment-card__actions {
margin-top: 14rpx;
}
.reply-card__actions {
margin-top: 10rpx;
}
.comment-card__actions .app-button,
.reply-card__actions .app-button {
width: auto;
min-width: 132rpx;
}
.comment-card__actions .app-button {
min-height: 58rpx;
padding: 0 16rpx;
}
.comment-replies {
margin-top: 14rpx;
padding: 14rpx 18rpx;
border-left: 4rpx solid rgba(159, 23, 15, 0.3);
background: rgba(135, 94, 52, 0.045);
}
.reply-card + .reply-card {
margin-top: 14rpx;
padding-top: 14rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.13);
}
.reply-card__target,
.reply-card__content,
.reply-state {
display: block;
margin-top: 6rpx;
}
.reply-card__content {
font-size: clamp(14px, 23rpx, 17px);
}
.reply-state--error {
color: $brand-red;
}
.reply-state--error text {
text-decoration: underline;
}
.comment-state-copy {
display: block;
color: $ink-muted;
font-size: clamp(15px, 24rpx, 18px);
line-height: 1.5;
}
.comment-state-copy .app-button {
width: 260rpx;
max-width: 100%;
margin-top: 16rpx;
}
.comment-editor {
margin-top: 24rpx;
padding-top: 22rpx;
border-top: 1rpx solid rgba(128, 89, 49, 0.18);
}
.reply-target {
margin-bottom: 12rpx;
padding: 12rpx 16rpx;
border: 1rpx solid rgba(159, 23, 15, 0.22);
border-radius: 10rpx;
background: rgba(159, 23, 15, 0.05);
}
.reply-target text:last-child {
color: $brand-red;
font-size: clamp(13px, 21rpx, 16px);
}
.comment-editor textarea {
display: block;
box-sizing: border-box;
width: 100%;
min-height: 130rpx;
padding: 18rpx;
border: 1rpx solid rgba(128, 89, 49, 0.3);
border-radius: 12rpx;
color: $ink;
font-size: clamp(15px, 25rpx, 18px);
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: clamp(14px, 22rpx, 17px);
}
</style>