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
+34
View File
@@ -44,6 +44,35 @@ func (s *Service) attachPostImages(posts []model.Post) {
} }
} }
// attachCommentCounts 用「已发布评论」的实时条数覆盖 comment_count。
// 维护型计数器在这些路径上会漂移:图片评论异步过审后没补计数、删除待审评论时误减、
// 后台审核改状态没同步。直接按实际已发布条数算,永远和评论弹层「共 X 条」一致。
func (s *Service) attachCommentCounts(posts []model.Post) {
if len(posts) == 0 {
return
}
ids := make([]string, len(posts))
for i := range posts {
ids[i] = posts[i].ID
}
type row struct {
PostID string
N int
}
var rows []row
s.db.Model(&model.Comment{}).
Select("post_id, count(*) as n").
Where("post_id IN ? AND status = ?", ids, model.PostPublished).
Group("post_id").Scan(&rows)
cnt := make(map[string]int, len(rows))
for _, r := range rows {
cnt[r.PostID] = r.N
}
for i := range posts {
posts[i].CommentCount = cnt[posts[i].ID]
}
}
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤) // tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
func tabTag(tab string) string { func tabTag(tab string) string {
switch tab { switch tab {
@@ -81,6 +110,7 @@ func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post
return nil, 0, err return nil, 0, err
} }
s.attachPostImages(posts) s.attachPostImages(posts)
s.attachCommentCounts(posts)
s.markFollowed(userID, posts) s.markFollowed(userID, posts)
return posts, total, nil return posts, total, nil
} }
@@ -299,6 +329,10 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL // 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0) status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
// 文本被判违规:直接拒绝、不入库,让用户知道发不出去
if status == model.PostRejected {
return nil, ErrContentRejected
}
comment := model.Comment{ comment := model.Comment{
PostID: postID, UserID: userID, AuthorName: name, Content: content, PostID: postID, UserID: userID, AuthorName: name, Content: content,
+3
View File
@@ -24,6 +24,9 @@ var ErrForbidden = errors.New("无权操作")
// ErrInvalidParam 参数不合法(handler 据此返回 40000 // ErrInvalidParam 参数不合法(handler 据此返回 40000
var ErrInvalidParam = errors.New("参数不合法") var ErrInvalidParam = errors.New("参数不合法")
// ErrContentRejected 内容安全判定为违规,直接拒绝发送(不入库)
var ErrContentRejected = errors.New("内容包含违规信息,无法发布")
// Service 业务逻辑聚合,方法按领域分散在各文件 // Service 业务逻辑聚合,方法按领域分散在各文件
type Service struct { type Service struct {
db *gorm.DB db *gorm.DB
+1
View File
@@ -215,6 +215,7 @@ func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]m
return nil, 0, err return nil, 0, err
} }
s.attachPostImages(posts) s.attachPostImages(posts)
s.attachCommentCounts(posts)
s.markFollowed(viewerID, posts) s.markFollowed(viewerID, posts)
return posts, total, nil return posts, total, nil
} }
+1
View File
@@ -11,6 +11,7 @@
"pages/idcard/idcard", "pages/idcard/idcard",
"pages/report/report", "pages/report/report",
"pages/community/community", "pages/community/community",
"pages/comments/comments",
"pages/learn/learn", "pages/learn/learn",
"pages/article/article", "pages/article/article",
"pages/mine/mine", "pages/mine/mine",
+39 -26
View File
@@ -82,6 +82,7 @@ Component({
replyTo: {}, replyTo: {},
cmFocus: false, cmFocus: false,
composing: false, // 评论输入面板是否展开(收起时只是一条细条) composing: false, // 评论输入面板是否展开(收起时只是一条细条)
sendingComment: false, // 发送+审核在途,防连点
kbHeight: 0, kbHeight: 0,
total: 0, total: 0,
}, },
@@ -120,6 +121,7 @@ Component({
patch.replyTo = {}; patch.replyTo = {};
patch.cmFocus = false; patch.cmFocus = false;
patch.composing = false; patch.composing = false;
patch.sendingComment = false;
patch.kbHeight = 0; patch.kbHeight = 0;
patch.saving = false; patch.saving = false;
} }
@@ -655,32 +657,49 @@ Component({
// 评论 // 评论
async onSendComment() { async onSendComment() {
if (this.data.sendingComment) return; // 防连点:审核请求在途时忽略后续点击
const text = (this.data.commentText || '').trim(); const text = (this.data.commentText || '').trim();
// 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示 // 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' }); if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close(); if (!this.data.postId) return this.close();
this.setData({ sendingComment: true });
wx.showLoading({ title: '审核中…', mask: true });
try { try {
await api.createComment( const res = await api.createComment(
this.data.postId, this.data.postId,
text, text,
this.data.commentImages.map((i) => i.url), this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '', this.data.replyTo.id || '',
); );
this.setData({ commentText: '', commentImages: [], replyTo: {}, composing: false, cmFocus: false }); wx.hideLoading();
this.loadComments(); this.setData({
this.triggerEvent('commented'); 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) { } 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() { onPickCommentImages() {
const left = 3 - this.data.commentImages.length; if (this.data.commentImages.length >= 1) {
if (left <= 0) return wx.showToast({ title: '最多 3 张', icon: 'none' }); return wx.showToast({ title: '评论只能配 1', icon: 'none' });
}
upload upload
.chooseAndUploadImages(left) .chooseAndUploadImages(1)
.then((files) => this.setData({ commentImages: this.data.commentImages.concat(files) })) .then((files) => this.setData({ commentImages: (files || []).slice(0, 1) }))
.catch((e) => { .catch((e) => {
if (e && e.canceled) return; if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' }); wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
@@ -692,27 +711,21 @@ Component({
onReplyTo(e) { onReplyTo(e) {
const { id, name } = e.currentTarget.dataset; const { id, name } = e.currentTarget.dataset;
if (!id) return; if (!id) return;
// 点任意评论直接展开面板并预填回复对象 // 输入框常驻、属性不变,直接置 focus 拉键盘即可;只填「回复对象」这一个横幅
this.setData({ replyTo: { id, name }, composing: true, cmFocus: true }); this.setData({ replyTo: { id, name }, cmFocus: true });
}, },
// 点细条:展开大输入面板并聚焦(拉起键盘) onCommentFocus() {
onStartCompose() { // 只复位一次性 focus 开关。键盘高度交给 onKbChange,用 transform 平移弹层来避让
this.setData({ composing: true, cmFocus: true }); this.setData({ cmFocus: false });
}, },
// 收起面板,草稿(文字/图片)留着,下次点开接着写 // 键盘高度变化(真机可靠来源)。把高度写进 data,
onCloseCompose() { // wxml 里只用它算 transform 平移量 —— 纯合成层操作,不 reflow、不抖焦点
this.setData({ composing: false, cmFocus: false, kbHeight: 0 }); onKbChange(e) {
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 }); this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
}, },
onCommentBlur() { onCommentBlur() {
// 失焦先放下键盘。有草稿就保持展开(否则点「发送」会因面板收起而丢掉这一下点击); // 失焦不主动清 kbHeight:键盘真正收起时 onKbChange 会回调 0,避免和收键盘动画抢
// 空内容才收回细条 this.setData({ cmFocus: false });
const empty = !(this.data.commentText || '').trim() && !this.data.commentImages.length;
this.setData({ kbHeight: 0, cmFocus: false, composing: empty ? false : this.data.composing });
}, },
onCancelReply() { onCancelReply() {
this.setData({ replyTo: {} }); this.setData({ replyTo: {} });
@@ -7,7 +7,11 @@ module.exports.sel = function (map, key, index, def) {
</wxs> </wxs>
<view class="overlay {{show ? 'show' : ''}}" bindtap="onMaskTap"> <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> <view class="sheetbar"></view>
<!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区, <!-- 评论:头 + 滚动列表 + 常驻输入栏。输入栏不能放进滚动区,
@@ -68,23 +72,20 @@ module.exports.sel = function (map, key, index, def) {
</view> </view>
</scroll-view> </scroll-view>
<!-- 收起态:一条细条,点了才展开成大写评论面板(小红书那套) --> <!-- 常驻评论条:textarea 的 class / auto-height / 尺寸全程不变,回复时只置 focus。
<view wx:if="{{!composing}}" class="cm-collapsed" catchtap="onStartCompose"> 之前真机「闪 + 不聚焦」是因为聚焦同帧还改了 textarea 自身属性(mini→full、
<view class="cm-collapsed-pill {{commentText ? 'has' : ''}}">{{commentText || '说点什么…'}}</view> auto-height 切换),被微信当成 reflow 把焦点抖掉了。现在什么都不切。 -->
<view class="cm-collapsed-ic"><pt-icon name="photo" size="{{40}}"></pt-icon></view> <view class="cmp-dock">
</view> <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 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>
</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" placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur" focus="{{cmFocus}}" bindfocus="onCommentFocus" bindblur="onCommentBlur"
bindkeyboardheightchange="onKbChange"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}" 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:if="{{commentImages.length}}" class="img-picker" style="margin-top:12rpx">
<view wx:for="{{commentImages}}" wx:key="id" class="img-thumb"> <view wx:for="{{commentImages}}" wx:key="id" class="img-thumb">
<image src="{{item.url}}" mode="aspectFill"></image> <image src="{{item.url}}" mode="aspectFill"></image>
@@ -92,10 +93,11 @@ module.exports.sel = function (map, key, index, def) {
</view> </view>
</view> </view>
<view class="cm-toolbar"> <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> <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>
<view class="cm-tip">评论通过安全审核后才会公开显示,配图最多 1 张</view>
</view> </view>
</block> </block>
@@ -107,6 +107,8 @@
padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom)); padding:var(--sp-3) var(--sp-5) calc(20rpx + env(safe-area-inset-bottom));
transition:padding-bottom .2s ease; 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{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0}
.cm-av{ .cm-av{
@@ -155,9 +157,9 @@
.cm-dock-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} .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-dock-close{display:flex;align-items:center;color:var(--muted);padding:var(--sp-1)}
.cm-ta{ .cm-ta{
width:100%;box-sizing:border-box;min-height:200rpx;max-height:520rpx;height:auto; width:100%;box-sizing:border-box;min-height:96rpx;max-height:360rpx;height:auto;
padding:var(--sp-4);background:var(--surface-2);border-radius:var(--r-md); padding:var(--sp-3) var(--sp-4);background:var(--surface-2);border-radius:var(--r-md);
font-size:var(--fs-lg);line-height:1.5; 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-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)} .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; 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-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}
+173
View File
@@ -0,0 +1,173 @@
const api = require('../../utils/api.js');
const upload = require('../../utils/upload.js');
// 相对时间:社区里关心「多久以前」,精确到秒没意义
function fmtAgo(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const min = Math.floor((Date.now() - d.getTime()) / 60000);
if (min < 1) return '刚刚';
if (min < 60) return min + ' 分钟前';
if (min < 60 * 24) return Math.floor(min / 60) + ' 小时前';
if (min < 60 * 24 * 7) return Math.floor(min / 1440) + ' 天前';
const p = (n) => (n < 10 ? '0' + n : '' + n);
const sameYear = d.getFullYear() === new Date().getFullYear();
return (sameYear ? '' : d.getFullYear() + '-') + p(d.getMonth() + 1) + '-' + p(d.getDate());
}
Page({
data: {
postId: '',
total: 0,
comments: [],
commentText: '',
commentImages: [],
replyTo: {},
cmFocus: false,
sendingComment: false,
},
onLoad(query) {
this.setData({ postId: query.postId || '' });
this.loadComments();
},
// 把当前已发布评论数回传给社区列表,卡片上的评论数实时跟着变
syncCountBack() {
const ch = this.getOpenerEventChannel && this.getOpenerEventChannel();
if (ch && ch.emit) ch.emit('commentChanged', { postId: this.data.postId, count: this.data.total });
},
loadComments() {
if (!this.data.postId) return;
api
.listComments(this.data.postId)
.then((page) => {
this.setData({
total: page.total || 0,
comments: (page.list || []).map((c) => ({
...c,
timeText: fmtAgo(c.created_at),
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),
initial: (r.author_name || '?').slice(0, 1),
})),
})),
});
this.syncCountBack();
})
.catch(() => {});
},
onCommentInput(e) {
this.setData({ commentText: e.detail.value });
},
// 点任意评论/回复:底部输入条切到「回复 xxx」并聚焦
onReplyTo(e) {
const { id, name } = e.currentTarget.dataset;
if (!id) return;
this.setData({ replyTo: { id, name }, cmFocus: true });
},
onCancelReply() {
this.setData({ replyTo: {} });
},
// 关键:聚焦期间 JS 绝不 setData 碰输入条(连 cmFocus:false 都不设),
// 否则原生 textarea 会被抖失焦、键盘弹一下就自己收。cmFocus 只在失焦时复位,
// 这样下次点回复 false→true 还能重新拉起键盘。键盘避让全交给原生 adjust-position。
onCommentBlur() {
this.setData({ cmFocus: false });
},
// 配图:评论只允许 1 张
onPickCommentImages() {
if (this.data.commentImages.length >= 1) {
return wx.showToast({ title: '评论只能配 1 张图', icon: 'none' });
}
upload
.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' });
});
},
onRemoveCommentImage(e) {
const list = this.data.commentImages.slice();
list.splice(e.currentTarget.dataset.index, 1);
this.setData({ commentImages: list });
},
onPreviewCommentImage(e) {
const { urls, cur } = e.currentTarget.dataset;
if (urls && urls.length) wx.previewImage({ urls, current: cur });
},
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.setData({ sendingComment: true });
wx.showLoading({ title: '审核中…', mask: true });
try {
const res = await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
wx.hideLoading();
this.setData({ commentText: '', commentImages: [], replyTo: {}, cmFocus: false, sendingComment: false });
if (res && res.status === 'published') {
this.loadComments();
wx.showToast({ title: '评论成功', icon: 'success' });
} else {
wx.showToast({ title: '已提交,审核通过后展示', icon: 'none' });
}
} catch (e) {
wx.hideLoading();
this.setData({ sendingComment: false });
wx.showToast({ title: (e && e.message) || '评论失败', icon: 'none' });
}
},
// 长按删除自己的评论
onDeleteComment(e) {
const { id, self } = e.currentTarget.dataset;
if (!id || !self) return;
wx.showModal({
title: '删除评论',
content: '确定删除这条评论?',
confirmColor: '#D9534F',
success: (r) => {
if (!r.confirm) return;
api
.deleteComment(id)
.then(() => this.loadComments())
.catch((err) => wx.showToast({ title: (err && err.message) || '删除失败', icon: 'none' }));
},
});
},
// 一级评论默认只带 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),
initial: (r.author_name || '?').slice(0, 1),
})),
}),
)
.catch((err) => wx.showToast({ title: (err && err.message) || '加载失败', icon: 'none' }));
},
});
+6
View File
@@ -0,0 +1,6 @@
{
"usingComponents": {
"nav-bar": "/components/nav-bar/nav-bar",
"pt-icon": "/components/pt-icon/index"
}
}
+82
View File
@@ -0,0 +1,82 @@
<nav-bar title="评论" show-back="{{true}}"></nav-bar>
<scroll-view class="cm-scroll" scroll-y="{{true}}" enhanced="{{true}}" show-scrollbar="{{false}}">
<view class="cm-count-head">共 {{total}} 条评论</view>
<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="cmp-empty">
<view class="cmp-empty-ic"><pt-icon name="comment" size="{{72}}"></pt-icon></view>
<view class="cmp-empty-t">还没有评论</view>
<view class="cmp-empty-p">来抢个沙发,说点什么吧~</view>
</view>
</scroll-view>
<!-- 底部输入条:普通流式排在 flex 列最底部(不 fixed)。页面整体 overflow:hidden 不能滚,
聚焦时原生 adjust-position 会把整屏均匀上移让输入条避开键盘 —— 不会像 fixed 那样把
页面过度上滚出大片空白,也不需要任何 JS 碰输入条(碰了就抖失焦)。 -->
<view class="cm-bar">
<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>
<view wx:if="{{commentImages.length}}" class="img-picker">
<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-row">
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '说点什么…'}}"
placeholder-class="placeholder" value="{{commentText}}" bindinput="onCommentInput"
focus="{{cmFocus}}" bindblur="onCommentBlur"
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
adjust-position="{{true}}" cursor-spacing="16"></textarea>
<view class="cm-pic {{commentImages.length ? 'disabled' : ''}}" catchtap="onPickCommentImages"><pt-icon name="photo" size="{{44}}"></pt-icon></view>
<view class="cm-send {{commentText && !sendingComment ? 'on' : ''}}" catchtap="onSendComment">{{sendingComment ? '审核中…' : '发送'}}</view>
</view>
<view class="cm-tip">评论通过安全审核后才会公开显示,配图最多 1 张</view>
</view>
+82
View File
@@ -0,0 +1,82 @@
/* flex 列 + 整页 overflow:hidden:页面本身不滚,列表在内层 scroll-view 里滚。
这样聚焦时 adjust-position 只能均匀上移整屏,不会过度上滚露出大空白 */
page{display:flex;flex-direction:column;height:100vh;overflow:hidden;background:var(--bg)}
.cm-scroll{flex:1;min-height:0;padding:0 var(--sp-5)}
.cm-count-head{
padding:var(--sp-3) 0;font-size:var(--fs-md);font-weight:var(--fw-b);
color:var(--text);border-bottom:1rpx solid var(--line);
}
/* 空态 */
.cmp-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;padding:160rpx 0;color:var(--muted)}
.cmp-empty-ic{
width:140rpx;height:140rpx;border-radius:50%;margin-bottom:var(--sp-4);
display:flex;align-items:center;justify-content:center;
background:var(--primary-soft);color:var(--primary);
}
.cmp-empty-t{font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text-2)}
.cmp-empty-p{margin-top:var(--sp-1);font-size:var(--fs-sm);color:var(--muted2)}
/* 评论行 */
.cm{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0}
.cm-av{
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-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-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)}
.mine-tag{
margin-left:var(--sp-1);font-size:var(--fs-cap);font-weight:var(--fw-b);
color:var(--primary-ink);background:var(--primary-soft);padding:2rpx var(--sp-1);border-radius:var(--r-full);
}
/* 底部输入条:flex 列里最后一行,不 fixed。整屏靠 adjust-position 均匀上移避让键盘 */
.cm-bar{
flex:none;background:#fff;border-top:1rpx solid var(--line);
padding:var(--sp-3) var(--sp-5) calc(16rpx + env(safe-area-inset-bottom));
}
.cm-replying{
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-bar-row{display:flex;align-items:flex-end;gap:var(--sp-3)}
.cm-ta{
flex:1;min-width:0;box-sizing:border-box;min-height:72rpx;max-height:300rpx;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-pic{flex:none;display:flex;align-items:center;color:var(--muted);height:72rpx}
.cm-pic.disabled{opacity:.35}
.cm-send{
flex:none;height:72rpx;padding:0 var(--sp-6);border-radius:var(--r-full);
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)}
.cm-tip{margin-top:var(--sp-2);color:var(--muted2);font-size:var(--fs-cap);line-height:1.5}
/* 配图预览 */
.img-picker{display:flex;flex-wrap:wrap;gap:var(--sp-3);margin-bottom:var(--sp-2)}
.img-thumb{position:relative;width:160rpx;height:160rpx;border-radius:var(--r-sm);overflow:hidden}
.img-thumb image{width:100%;height:100%}
.img-del{
position:absolute;top:0;right:0;width:44rpx;height:44rpx;
display:flex;align-items:center;justify-content:center;
background:rgba(0,0,0,.55);color:#fff;border-radius:0 var(--r-sm) 0 var(--r-sm);
}
+15 -1
View File
@@ -105,7 +105,21 @@ Page({
.catch((e) => toastErr(e, '点赞失败')); .catch((e) => toastErr(e, '点赞失败'));
}, },
openComments(e) { openComments(e) {
this.setData({ sheetPostId: e.currentTarget.dataset.id, sheetType: 'comments', sheetShow: true }); const id = e.currentTarget.dataset.id;
if (!id) return;
// 评论改成独立二级页面。原来套在 fixed 弹层里,输入框被键盘遮挡且无法可靠避让;
// 普通页面里的 fixed 输入条用 transform 平移就能稳稳浮在键盘上方
wx.navigateTo({
url: `/pages/comments/comments?postId=${id}`,
events: {
// 评论页把「已发布评论数」回传,卡片上的数字实时更新
commentChanged: (d) => {
if (!d || !d.postId) return;
const idx = this.data.posts.findIndex((p) => p.id === d.postId);
if (idx >= 0) this.setData({ [`posts[${idx}].comment_count`]: d.count });
},
},
});
}, },
openSheet(e) { openSheet(e) {
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true }); this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });