feat: migrate app routes and business modules

This commit is contained in:
2026-08-12 18:22:59 +08:00
parent 555aa00043
commit cc706378c2
247 changed files with 28623 additions and 14988 deletions
+718
View File
@@ -0,0 +1,718 @@
<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>
+68
View File
@@ -0,0 +1,68 @@
<template>
<view
v-if="visibleFiles.length"
class="feed-media"
:class="`feed-media--${layout}`"
>
<image
v-for="file in visibleFiles"
:key="file.fileId || file.ossId || file.accessUrl"
class="feed-media__image"
:src="file.accessUrl"
mode="aspectFill"
role="button"
aria-label="查看动态图片"
@click.stop="preview(file)"
/>
</view>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
files: { type: Array, default: () => [] },
});
const visibleFiles = computed(() =>
props.files.filter((file) => typeof file?.accessUrl === "string" && file.accessUrl),
);
const layout = computed(() => {
if (visibleFiles.value.length === 1) return "single";
if (visibleFiles.value.length === 2) return "double";
return "grid";
});
const preview = (file) => {
const urls = visibleFiles.value.map((item) => item.accessUrl);
if (!file?.accessUrl || !urls.length || typeof uni?.previewImage !== "function") return;
uni.previewImage({ current: file.accessUrl, urls });
};
</script>
<style scoped lang="scss">
.feed-media {
display: grid;
gap: 8rpx;
margin-top: 16rpx;
overflow: hidden;
border-radius: 10rpx;
}
.feed-media--single {
grid-template-columns: minmax(0, 1fr);
}
.feed-media--double {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.feed-media--grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.feed-media__image {
display: block;
width: 100%;
height: 190rpx;
background: rgba(120, 84, 48, 0.08);
}
.feed-media--single .feed-media__image {
height: 360rpx;
}
</style>