feat(community): 评论改为独立二级页面 + 评论内容安全审核 + 评论数按实发实时纠正

前端:
- 新增 pages/comments 独立评论页(列表 + 底部输入条 + 回复),社区卡片点评论改为 navigateTo
- 输入条用普通流式布局 + 原生 adjust-position 做键盘避让,聚焦期间不 setData 碰输入框
- 评论只能配 1 张图;底部常驻「审核后公开」提示;发送按钮在途「审核中…」防连点
- 发布结果按状态区分:过审即时展示,其余提示「已提交,审核通过后展示」
- 评论页把已发布评论数回传社区列表,卡片数字实时更新

后端:
- CreateComment 文本判为违规时直接拒收(ErrContentRejected),不入库
- 社区列表/用户帖子列表用「已发布评论实时条数」覆盖 comment_count,
  修正异步过审/删待审/后台改状态导致的计数漂移
- IDCard 手机号返回完整号码(上一批遗留)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-08-03 16:04:21 +08:00
parent 51ead8f411
commit 7a55ee0f2e
12 changed files with 461 additions and 46 deletions
+39 -26
View File
@@ -82,6 +82,7 @@ Component({
replyTo: {},
cmFocus: false,
composing: false, // 评论输入面板是否展开(收起时只是一条细条)
sendingComment: false, // 发送+审核在途,防连点
kbHeight: 0,
total: 0,
},
@@ -120,6 +121,7 @@ Component({
patch.replyTo = {};
patch.cmFocus = false;
patch.composing = false;
patch.sendingComment = false;
patch.kbHeight = 0;
patch.saving = false;
}
@@ -655,32 +657,49 @@ Component({
// 评论
async onSendComment() {
if (this.data.sendingComment) return; // 防连点:审核请求在途时忽略后续点击
const text = (this.data.commentText || '').trim();
// 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close();
this.setData({ sendingComment: true });
wx.showLoading({ title: '审核中…', mask: true });
try {
await api.createComment(
const res = await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
this.setData({ commentText: '', commentImages: [], replyTo: {}, composing: false, cmFocus: false });
this.loadComments();
this.triggerEvent('commented');
wx.hideLoading();
this.setData({
commentText: '', commentImages: [], replyTo: {},
composing: false, cmFocus: false, kbHeight: 0, sendingComment: false,
});
// 过审即时展示;否则挂在人工/异步图片审核,通过后才出现
if (res && res.status === 'published') {
this.loadComments();
this.triggerEvent('commented');
wx.showToast({ title: '评论成功', icon: 'success' });
} else {
wx.showToast({ title: '已提交,审核通过后展示', icon: 'none' });
}
} catch (e) {
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
wx.hideLoading();
this.setData({ sendingComment: false });
// 违规内容后端直接拒收,这里把原因透出来
wx.showToast({ title: (e && e.message) || '评论失败', icon: 'none' });
}
},
// 评论配图,最多 3
// 评论配图:只允许 1
onPickCommentImages() {
const left = 3 - this.data.commentImages.length;
if (left <= 0) return wx.showToast({ title: '最多 3 张', icon: 'none' });
if (this.data.commentImages.length >= 1) {
return wx.showToast({ title: '评论只能配 1', icon: 'none' });
}
upload
.chooseAndUploadImages(left)
.then((files) => this.setData({ commentImages: this.data.commentImages.concat(files) }))
.chooseAndUploadImages(1)
.then((files) => this.setData({ commentImages: (files || []).slice(0, 1) }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
@@ -692,27 +711,21 @@ Component({
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
if (!id) return;
// 点任意评论直接展开面板并预填回复对象
this.setData({ replyTo: { id, name }, composing: true, cmFocus: true });
// 输入框常驻、属性不变,直接置 focus 拉键盘即可;只填「回复对象」这一个横幅
this.setData({ replyTo: { id, name }, cmFocus: true });
},
// 点细条:展开大输入面板并聚焦(拉起键盘)
onStartCompose() {
this.setData({ composing: true, cmFocus: true });
onCommentFocus() {
// 只复位一次性 focus 开关。键盘高度交给 onKbChange,用 transform 平移弹层来避让
this.setData({ cmFocus: false });
},
// 收起面板,草稿(文字/图片)留着,下次点开接着写
onCloseCompose() {
this.setData({ composing: false, cmFocus: false, kbHeight: 0 });
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
// 键盘高度变化(真机可靠来源)。把高度写进 data,
// wxml 里只用它算 transform 平移量 —— 纯合成层操作,不 reflow、不抖焦点
onKbChange(e) {
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
},
onCommentBlur() {
// 失焦先放下键盘。有草稿就保持展开(否则点「发送」会因面板收起而丢掉这一下点击);
// 空内容才收回细条
const empty = !(this.data.commentText || '').trim() && !this.data.commentImages.length;
this.setData({ kbHeight: 0, cmFocus: false, composing: empty ? false : this.data.composing });
// 失焦不主动清 kbHeight:键盘真正收起时 onKbChange 会回调 0,避免和收键盘动画抢
this.setData({ cmFocus: false });
},
onCancelReply() {
this.setData({ replyTo: {} });
@@ -7,7 +7,11 @@ module.exports.sel = function (map, key, index, def) {
</wxs>
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap">
<view class="sheet {{innerType === 'comments' ? 'sheet-cmp' : ''}}" catchtap="noop">
<!-- 键盘避让:只用 transform 平移整张弹层(合成层操作,不触发 reflow,
所以不会把聚焦中的 textarea 抖失焦 → 不再「弹一下就收」的死循环)。
show/hide 的滑入滑出也并进这一个 transform,避免和 CSS 里的 transform 打架。 -->
<view class="sheet {{innerType === 'comments' ? 'sheet-cmp' : ''}}" catchtap="noop"
style="transform:translateY({{show ? (innerType === 'comments' && kbHeight ? ('-' + kbHeight + 'px') : '0px') : '106%'}})">
<view class="sheetbar"></view>
<!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区,
@@ -68,23 +72,20 @@ module.exports.sel = function (map, key, index, def) {
</view>
</scroll-view>
<!-- 收起态:一条细条,点了才展开成大写评论面板(小红书那套) -->
<view wx:if="{{!composing}}" class="cm-collapsed" catchtap="onStartCompose">
<view class="cm-collapsed-pill {{commentText ? 'has' : ''}}">{{commentText || '说点什么…'}}</view>
<view class="cm-collapsed-ic"><pt-icon name="photo" size="{{40}}"></pt-icon></view>
</view>
<!-- 展开态:大输入面板,浮在键盘上方 -->
<view wx:else class="cmp-dock" style="padding-bottom:{{kbHeight ? kbHeight + 'px' : 'calc(20rpx + env(safe-area-inset-bottom))'}}">
<view class="cm-dock-head">
<text class="cm-dock-title">{{replyTo.name ? '回复 ' + replyTo.name : '写评论'}}</text>
<view class="cm-dock-close" catchtap="onCloseCompose"><pt-icon name="close" size="{{30}}"></pt-icon></view>
<!-- 常驻评论条:textarea 的 class / auto-height / 尺寸全程不变,回复时只置 focus。
之前真机「闪 + 不聚焦」是因为聚焦同帧还改了 textarea 自身属性(mini→full、
auto-height 切换),被微信当成 reflow 把焦点抖掉了。现在什么都不切。 -->
<view class="cmp-dock">
<view wx:if="{{replyTo.name}}" class="cm-replying">
回复 {{replyTo.name}}
<view class="cm-cancel" catchtap="onCancelReply"><pt-icon name="close" size="{{24}}"></pt-icon></view>
</view>
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '有爱评论,说点好听的~'}}"
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '说点什么…'}}"
placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur"
bindkeyboardheightchange="onKbChange"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
adjust-position="{{false}}" cursor-spacing="12"></textarea>
adjust-position="{{false}}" cursor-spacing="16"></textarea>
<view wx:if="{{commentImages.length}}" class="img-picker" style="margin-top:12rpx">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image>
@@ -92,10 +93,11 @@ module.exports.sel = function (map, key, index, def) {
</view>
</view>
<view class="cm-toolbar">
<view class="cm-pic" catchtap="onPickCommentImages"><pt-icon name="photo" size="{{44}}"></pt-icon></view>
<view class="cm-pic {{commentImages.length ? 'disabled' : ''}}" catchtap="onPickCommentImages"><pt-icon name="photo" size="{{44}}"></pt-icon></view>
<text class="cm-count">{{commentText.length}}/500</text>
<view class="cm-send {{commentText ? 'on' : ''}}" catchtap="onSendComment">发送</view>
<view class="cm-send {{commentText && !sendingComment ? 'on' : ''}}" catchtap="onSendComment">{{sendingComment ? '审核中…' : '发送'}}</view>
</view>
<view class="cm-tip">评论通过安全审核后才会公开显示,配图最多 1 张</view>
</view>
</block>
@@ -107,6 +107,8 @@
padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom));
transition:padding-bottom .2s ease;
}
/* 展开态:给顶部一点投影,和列表分层 */
.cmp-dock.open{box-shadow:0 -8rpx 24rpx rgba(0,0,0,.05)}
.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0}
.cm-av{
@@ -155,9 +157,9 @@
.cm-dock-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)}
.cm-dock-close{display:flex;align-items:center;color:var(--muted);padding:var(--sp-1)}
.cm-ta{
width:100%;box-sizing:border-box;min-height:200rpx;max-height:520rpx;height:auto;
padding:var(--sp-4);background:var(--surface-2);border-radius:var(--r-md);
font-size:var(--fs-lg);line-height:1.5;
width:100%;box-sizing:border-box;min-height:96rpx;max-height:360rpx;height:auto;
padding:var(--sp-3) var(--sp-4);background:var(--surface-2);border-radius:var(--r-md);
font-size:var(--fs-md);line-height:1.5;
}
.cm-toolbar{display:flex;align-items:center;gap:var(--sp-3);margin-top:var(--sp-3)}
.cm-pic{flex:none;display:flex;align-items:center;color:var(--muted)}
@@ -168,3 +170,5 @@
background:var(--line);color:#fff;font-size:var(--fs-md);font-weight:var(--fw-b);transition:.16s ease;
}
.cm-send.on{background:var(--cta)}
.cm-pic.disabled{opacity:.35}
.cm-tip{margin-top:var(--sp-2);color:var(--muted2);font-size:var(--fs-cap);line-height:1.5}