feat: 评论支持回复(两级 + @某人)

不做无限层级——手机屏幕撑不住层层缩进,读到第四层就没法看了。
微信、小红书、B站清一色是两级:一级评论 + 其下的回复列表,回复里
用「回复 @某人」表达指向谁。

数据结构:comments 加 parent_id + reply_to_name。回复的回复会被
压平到同一条一级评论下(parent_id 取爷爷的),同时把被回复人的名字
记进 reply_to_name —— 这样既保住两级,又不丢「在跟谁说话」的信息。

几个容易做错的地方:
- 一次查完这一页所有一级评论的回复,不是每条一次查询(N+1)
- 一级评论默认只带 3 条回复,其余点「展开全部 N 条」再拉,避免热门
  评论一次返回几百条
- 删一级评论要连它下面的回复一起删,否则回复变成挂在空处的孤儿;
  帖子的 comment_count 也要按实际删除条数减,不是减 1
- total 统计含回复,和帖子上显示的数字对得上
- replies 为空时返回 [],不是 null

实测四条评论(含一条回复的回复):
  ▸ 甲:一级评论   (2 条回复)
      └ 乙:多久了?
      └ 甲 回复 乙:三天了
  ▸ 乙:另一条一级评论   (0 条回复)
删掉第一条一级评论后 total 4→1,comment_count 同步。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 16:33:50 +08:00
parent 535ef08da7
commit 9fc61e878e
8 changed files with 182 additions and 25 deletions
@@ -119,6 +119,7 @@ Component({
posterSaving: false,
centered: false,
commentImages: [],
replyTo: {},
},
observers: {
show: function (show) {
@@ -165,6 +166,7 @@ Component({
patch.addBreed = '';
patch.commentText = '';
patch.commentImages = [];
patch.replyTo = {};
patch.riskData = null;
patch.saving = false;
}
@@ -417,6 +419,7 @@ Component({
// 没有头像字段,用昵称首字做个色块,比一律显示同一个 emoji 强
initial: (c.author_name || '?').slice(0, 1),
imgs: Array.isArray(c.images) ? c.images : [],
replies: (c.replies || []).map((r) => ({ ...r, timeText: fmtAgo(r.created_at) })),
})),
}),
)
@@ -424,8 +427,9 @@ Component({
},
// 长按删除自己的评论
onDeleteComment(e) {
const c = this.data.comments[e.currentTarget.dataset.index];
if (!c || !c.is_self) return;
const { id, self } = e.currentTarget.dataset;
if (!id || !self) return;
const c = { id };
wx.showModal({
title: '删除评论',
content: '确定删除这条评论?',
@@ -934,8 +938,13 @@ Component({
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close();
try {
await api.createComment(this.data.postId, text, this.data.commentImages.map((i) => i.url));
this.setData({ commentText: '', commentImages: [] });
await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
this.setData({ commentText: '', commentImages: [], replyTo: {} });
this.loadComments();
this.triggerEvent('commented');
} catch (e) {
@@ -955,6 +964,26 @@ Component({
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
this.setData({ replyTo: { id, name } });
},
onCancelReply() {
this.setData({ replyTo: {} });
},
// 一级评论默认只带 3 条回复,点开拉全量
onExpandReplies(e) {
const { id, index } = e.currentTarget.dataset;
api
.listReplies(id)
.then((list) =>
this.setData({
[`comments[${index}].replies`]: (list || []).map((r) => ({ ...r, timeText: fmtAgo(r.created_at) })),
}),
)
.catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' }));
},
onPreviewCommentImage(e) {
const { urls, cur } = e.currentTarget.dataset;
if (urls && urls.length) wx.previewImage({ urls, current: cur });
@@ -321,7 +321,7 @@ module.exports.sel = function (map, key, index, def) {
<block wx:elif="{{innerType === 'comments'}}">
<view class="sheet-h3">评论 {{comments.length ? comments.length : ''}}</view>
<view wx:for="{{comments}}" wx:key="id" class="cm" bindlongpress="onDeleteComment" data-index="{{index}}">
<view wx:for="{{comments}}" wx:key="id" class="cm">
<view class="cm-av">{{item.initial}}</view>
<view class="cm-body">
<view class="cm-head">
@@ -329,12 +329,28 @@ module.exports.sel = function (map, key, index, def) {
<text wx:if="{{item.is_self}}" class="mine-tag">我</text>
<text class="cm-time">{{item.timeText}}</text>
</view>
<view class="cm-text">{{item.content}}</view>
<view class="cm-text" bindlongpress="onDeleteComment" data-id="{{item.id}}" data-self="{{item.is_self}}">{{item.content}}</view>
<view wx:if="{{item.imgs.length}}" class="cm-imgs">
<image wx:for="{{item.imgs}}" wx:for-item="u" wx:key="*this" class="cm-img"
src="{{u}}" mode="aspectFill" catchtap="onPreviewCommentImage"
data-urls="{{item.imgs}}" data-cur="{{u}}"></image>
</view>
<!-- 回复:只缩进一层。再深就没法在手机上读了 -->
<view wx:if="{{item.replies.length}}" class="cm-replies">
<view wx:for="{{item.replies}}" wx:for-item="r" wx:key="id" class="cm-reply"
bindlongpress="onDeleteComment" data-id="{{r.id}}" data-self="{{r.is_self}}">
<text class="cm-name">{{r.author_name}}</text>
<text wx:if="{{r.reply_to_name}}" class="cm-at"> 回复 {{r.reply_to_name}}</text>
<text class="cm-name"></text>{{r.content}}
</view>
<view wx:if="{{item.reply_n > item.replies.length}}" class="cm-more"
bindtap="onExpandReplies" data-id="{{item.id}}" data-index="{{index}}">
展开全部 {{item.reply_n}} 条回复
</view>
</view>
<view class="cm-act" bindtap="onReplyTo" data-id="{{item.id}}" data-name="{{item.author_name}}">回复</view>
</view>
</view>
@@ -342,7 +358,11 @@ module.exports.sel = function (map, key, index, def) {
<view wx:if="{{comments.length}}" class="cm-tip">长按自己的评论可删除</view>
<view class="cm-editor">
<textarea class="textarea cm-ta" placeholder="友善交流,分享你的经验…" placeholder-class="placeholder"
<view wx:if="{{replyTo.name}}" class="cm-replying">
<text>回复 {{replyTo.name}}</text>
<view class="cm-cancel" bindtap="onCancelReply"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<textarea class="textarea cm-ta" placeholder="{{replyTo.name ? '回复 ' + replyTo.name : '友善交流,分享你的经验…'}}" placeholder-class="placeholder"
value="{{commentText}}" bindinput="onCommentInput" maxlength="500"
auto-height="{{true}}" show-confirm-bar="{{false}}" cursor-spacing="24"></textarea>
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-top:16rpx">
@@ -127,3 +127,16 @@
.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)}
.cm-imgs{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)}
.cm-img{width:150rpx;height:150rpx;border-radius:var(--r-sm)}
/* 回复:只缩进一层,再深手机上就没法读了 */
.cm-replies{margin-top:var(--sp-2);padding:var(--sp-2) var(--sp-3);background:var(--surface-2);border-radius:var(--r-sm)}
.cm-reply{font-size:var(--fs-sm);line-height:1.6;color:var(--text);padding:4rpx 0}
.cm-at{color:var(--muted)}
.cm-more{margin-top:6rpx;font-size:var(--fs-cap);color:var(--primary-dark)}
.cm-act{margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted)}
.cm-replying{
display:flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);
padding:var(--sp-1) var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);align-self:flex-start;
}
.cm-cancel{display:flex;align-items:center}