feat(fe): 评论改成小红书那套交互——点评论直接唤起输入

上一版做成了居中弹窗,参考图给的是另一种范式:输入栏常驻底部,点任意
一条评论就把它激活并填上「回复 @xxx」。按参考图重做,居中那套一并删掉,
不留死代码。

结构从「一个滚动容器装所有东西」改成三段式:
  固定头(共 N 条评论)+ 滚动列表 + 常驻输入栏
输入栏必须在滚动区外——放进去的话列表一滚它就跟着走,点完评论还得再
把输入框找回来,正是这次要解决的问题。

交互:
- 点评论或回复的任意位置 → 输入框获得焦点,placeholder 变「回复 @xxx」,
  上方出现可取消的橙色提示条。不用再单独点一个「回复」按钮
- 删除从长按改成显式的「删除」文字,只在自己的评论上出现——长按是
  隐藏交互,没人会发现
- 回复带首字头像并缩进一层,「甲 回复 乙」的指向关系显示在昵称行

键盘处理用 adjust-position=false + bindfocus 拿键盘高度自己顶:弹层是
fixed 定位,交给系统调整会把整块推出屏幕。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-29 16:39:36 +08:00
parent 9fc61e878e
commit fe420ba47c
3 changed files with 150 additions and 115 deletions
@@ -117,9 +117,11 @@ Component({
proInfo: null,
saving: false,
posterSaving: false,
centered: false,
commentImages: [],
replyTo: {},
cmFocus: false,
kbHeight: 0,
total: 0,
},
observers: {
show: function (show) {
@@ -139,8 +141,7 @@ Component({
methods: {
setType(type, fresh) {
const pet = store.getPet();
// 评论以输入为主,贴底弹层会被键盘顶掉大半屏,改成居中
const patch = { innerType: type, pet, centered: type === 'comments' };
const patch = { innerType: type, pet };
if (fresh) {
patch.segSel = {};
patch.optSel = {};
@@ -167,6 +168,8 @@ Component({
patch.commentText = '';
patch.commentImages = [];
patch.replyTo = {};
patch.cmFocus = false;
patch.kbHeight = 0;
patch.riskData = null;
patch.saving = false;
}
@@ -413,13 +416,18 @@ Component({
api.listComments(this.data.postId)
.then((page) =>
this.setData({
total: page.total || 0,
comments: (page.list || []).map((c) => ({
...c,
timeText: fmtAgo(c.created_at),
// 没有头像字段,用昵称首字做个色块,比一律显示同一个 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) })),
replies: (c.replies || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
})),
}),
)
@@ -965,9 +973,20 @@ Component({
});
},
// 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id
// 小红书那套:点任意一条评论/回复,直接把输入框激活并填上「回复 @xxx」,
// 不用再去底部找输入框、也不用单独点一个「回复」按钮
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
this.setData({ replyTo: { id, name } });
if (!id) return;
this.setData({ replyTo: { id, name }, cmFocus: true });
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
},
onCommentBlur() {
this.setData({ kbHeight: 0, cmFocus: false });
},
onCancelReply() {
this.setData({ replyTo: {} });
@@ -979,7 +998,11 @@ Component({
.listReplies(id)
.then((list) =>
this.setData({
[`comments[${index}].replies`]: (list || []).map((r) => ({ ...r, timeText: fmtAgo(r.created_at) })),
[`comments[${index}].replies`]: (list || []).map((r) => ({
...r,
timeText: fmtAgo(r.created_at),
initial: (r.author_name || '?').slice(0, 1),
})),
}),
)
.catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' }));
@@ -7,10 +7,91 @@ module.exports.sel = function (map, key, index, def) {
</wxs>
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
<view class="sheet {{centered ? 'sheet-center' : ''}}" catchtap="noop">
<view class="sheet {{innerType === 'comments' ? 'sheet-cmp' : ''}}" catchtap="noop">
<view class="sheetbar"></view>
<scroll-view class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区,
否则列表一滚它就跟着走,点评论回复时还得再找回来 -->
<block wx:if="{{innerType === 'comments'}}">
<view class="cmp-head">共 {{total}} 条评论</view>
<scroll-view class="cmp-list" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view wx:for="{{comments}}" wx:key="id" class="cm"
bindtap="onReplyTo" data-id="{{item.id}}" data-name="{{item.author_name}}">
<view class="cm-av">{{item.initial}}</view>
<view class="cm-body">
<view class="cm-name">{{item.author_name}}<text wx:if="{{item.is_self}}" class="mine-tag">我</text></view>
<view class="cm-text">{{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 class="cm-meta">
<text>{{item.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{item.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{item.id}}" data-self="{{true}}">删除</text>
</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"
catchtap="onReplyTo" data-id="{{r.id}}" data-name="{{r.author_name}}">
<view class="cm-av cm-av-sm">{{r.initial}}</view>
<view class="cm-body">
<view class="cm-name">
{{r.author_name}}<text wx:if="{{r.is_self}}" class="mine-tag">我</text>
<text wx:if="{{r.reply_to_name}}" class="cm-at"> 回复 {{r.reply_to_name}}</text>
</view>
<view class="cm-text">{{r.content}}</view>
<view class="cm-meta">
<text>{{r.timeText}}</text>
<text class="cm-reply-btn">回复</text>
<text wx:if="{{r.is_self}}" class="cm-del" catchtap="onDeleteComment"
data-id="{{r.id}}" data-self="{{true}}">删除</text>
</view>
</view>
</view>
<view wx:if="{{item.reply_n > item.replies.length}}" class="cm-more"
catchtap="onExpandReplies" data-id="{{item.id}}" data-index="{{index}}">
— 展开全部 {{item.reply_n}} 条回复
</view>
</view>
</view>
</view>
<view wx:if="{{!comments.length}}" class="empty lg">还没有评论,来抢个沙发</view>
</scroll-view>
<view class="cmp-dock" style="padding-bottom:{{kbHeight ? kbHeight + 'px' : 'calc(20rpx + env(safe-area-inset-bottom))'}}">
<view wx:if="{{replyTo.name}}" class="cm-replying">
<text>回复 {{replyTo.name}}</text>
<view class="cm-cancel" catchtap="onCancelReply"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-bottom:12rpx">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemoveCommentImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
</view>
<textarea class="textarea cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name : '有话要说,快来评论'}}"
placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
adjust-position="{{false}}" cursor-spacing="12"></textarea>
<view class="cm-bar">
<view class="cm-pic" catchtap="onPickCommentImages">
<pt-icon name="photo" size="{{36}}"></pt-icon>
</view>
<text class="cm-count">{{commentText.length}}/500</text>
<view class="cm-send {{commentText ? 'on' : ''}}" catchtap="onSendComment">发送</view>
</view>
</view>
</block>
<scroll-view wx:if="{{innerType !== 'comments'}}" class="sheet-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<!-- 记录体重 -->
<block wx:if="{{innerType === 'weight'}}">
<view class="sheet-h3">记录体重</view>
@@ -318,69 +399,6 @@ module.exports.sel = function (map, key, index, def) {
</block>
<!-- 评论 -->
<block wx:elif="{{innerType === 'comments'}}">
<view class="sheet-h3">评论 {{comments.length ? comments.length : ''}}</view>
<view wx:for="{{comments}}" wx:key="id" class="cm">
<view class="cm-av">{{item.initial}}</view>
<view class="cm-body">
<view class="cm-head">
<text class="cm-name">{{item.author_name}}</text>
<text wx:if="{{item.is_self}}" class="mine-tag">我</text>
<text class="cm-time">{{item.timeText}}</text>
</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>
<view wx:if="{{!comments.length}}" class="empty">还没有评论,来抢个沙发</view>
<view wx:if="{{comments.length}}" class="cm-tip">长按自己的评论可删除</view>
<view class="cm-editor">
<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">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
<view class="img-del" catchtap="onRemoveCommentImage" data-index="{{index}}"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
</view>
<view class="cm-bar">
<view class="cm-pic" bindtap="onPickCommentImages">
<pt-icon name="photo" size="{{34}}"></pt-icon>
<text>配图 {{commentImages.length}}/3</text>
</view>
<text class="cm-count">{{commentText.length}}/500</text>
<view class="cm-send {{commentText ? 'on' : ''}}" bindtap="onSendComment">发送</view>
</view>
</view>
</block>
<!-- 多宠物管理 -->
<block wx:elif="{{innerType === 'managePets'}}">
@@ -83,60 +83,54 @@
/* 离屏画布:挪到可视区外而不是 display:none,否则 selectorQuery 取不到节点 */
.poster-canvas{position:fixed;left:-9999rpx;top:0;width:600rpx;height:840rpx}
/* 评论。原来拿 .record-row(左右两端对齐)当评论用,昵称在左内容在右,
内容一长就被挤扁。改成头像 + 昵称时间 + 内容的正常结构。 */
.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)}
.cm:last-of-type{border-bottom:0}
/* ── 评论:头 + 滚动列表 + 常驻输入栏 ── */
.sheet.sheet-cmp{display:flex;flex-direction:column;height:82vh;padding-bottom:0}
.cmp-head{
flex:none;text-align:center;padding:0 0 var(--sp-3);
font-size:var(--fs-sm);color:var(--muted);border-bottom:1rpx solid var(--line);
}
.cmp-list{flex:1;min-height:0;padding:var(--sp-3) var(--sp-5) 0}
.cmp-dock{
flex:none;background:#fff;border-top:1rpx solid var(--line);
padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom));
transition:padding-bottom .2s ease;
}
.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0}
.cm-av{
width:64rpx;height:64rpx;border-radius:var(--r-sm);flex:none;
width:64rpx;height:64rpx;border-radius:50%;flex:none;
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary-ink);
font-size:var(--fs-md);font-weight:var(--fw-b);
}
.cm-av-sm{width:48rpx;height:48rpx;font-size:var(--fs-sm)}
.cm-body{flex:1;min-width:0}
.cm-head{display:flex;align-items:center;gap:var(--sp-1)}
.cm-name{font-size:var(--fs-sm);font-weight:var(--fw-b);color:var(--text-2)}
.cm-time{margin-left:auto;font-size:var(--fs-cap);color:var(--muted2);flex:none}
.cm-name{font-size:var(--fs-sm);color:var(--muted)}
.cm-at{color:var(--muted2)}
.cm-text{margin-top:6rpx;font-size:var(--fs-md);line-height:1.55;word-break:break-word}
.cm-tip{margin-top:var(--sp-3);text-align:center;color:var(--muted2);font-size:var(--fs-cap)}
.cm-input{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-4)}
.cm-input .input{flex:1}
.cm-send{
flex:none;height:88rpx;padding:0 var(--sp-5);border-radius:var(--r-md);
display:flex;align-items:center;justify-content:center;
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
}
.cm-send.on{background:var(--cta)}
/* 居中弹层:评论这类以输入为主的,贴底会被键盘顶掉大半屏 */
.sheet.sheet-center{
left:var(--sp-5);right:var(--sp-5);bottom:auto;top:50%;
border-radius:var(--r-lg);
transform:translateY(-50%) scale(.94);opacity:0;
}
.overlay.show .sheet.sheet-center{transform:translateY(-50%) scale(1);opacity:1}
.sheet-center .sheet-scroll{max-height:62vh;padding:0 var(--sp-5) var(--sp-5)}
.sheet-center .sheetbar{display:none}
/* 评论编辑区 */
.cm-editor{margin-top:var(--sp-4);border-top:1rpx solid var(--line);padding-top:var(--sp-4)}
.cm-ta{min-height:160rpx;height:auto}
.cm-bar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)}
.cm-pic{display:flex;align-items:center;gap:var(--sp-1);color:var(--muted);font-size:var(--fs-sm)}
.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-meta{display:flex;align-items:center;gap:var(--sp-4);margin-top:var(--sp-2);font-size:var(--fs-cap);color:var(--muted2)}
.cm-reply-btn{color:var(--muted)}
.cm-del{color:var(--red-ink)}
.cm-replies{margin-top:var(--sp-2)}
.cm-reply{display:flex;gap:var(--sp-2);padding:var(--sp-2) 0}
.cm-more{margin-top:4rpx;font-size:var(--fs-cap);color:var(--muted)}
/* 回复:只缩进一层,再深手机上就没法读了 */
.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)}
/* 回复 @xxx」的提示条 */
.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;
display:inline-flex;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2);
padding:4rpx var(--sp-3);border-radius:var(--r-full);
background:var(--primary-soft);color:var(--primary-ink);font-size:var(--fs-cap);
}
.cm-cancel{display:flex;align-items:center}
.cm-ta{min-height:120rpx;height:auto;background:var(--surface-2)}
.cm-bar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-2)}
.cm-pic{display:flex;align-items:center;color:var(--muted)}
.cm-count{margin-left:auto;color:var(--muted2);font-size:var(--fs-cap)}
.cm-send{
flex:none;height:72rpx;padding:0 var(--sp-5);border-radius:var(--r-full);
display:flex;align-items:center;justify-content:center;
background:var(--line);color:#fff;font-size:var(--fs-sm);font-weight:var(--fw-b);transition:.16s ease;
}
.cm-send.on{background:var(--cta)}