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:
@@ -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 映射为标签过滤(空表示不过滤)
|
||||
func tabTag(tab string) string {
|
||||
switch tab {
|
||||
@@ -81,6 +110,7 @@ func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachPostImages(posts)
|
||||
s.attachCommentCounts(posts)
|
||||
s.markFollowed(userID, posts)
|
||||
return posts, total, nil
|
||||
}
|
||||
@@ -299,6 +329,10 @@ func (s *Service) CreateComment(userID, postID string, content string, images []
|
||||
|
||||
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
|
||||
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
|
||||
// 文本被判违规:直接拒绝、不入库,让用户知道发不出去
|
||||
if status == model.PostRejected {
|
||||
return nil, ErrContentRejected
|
||||
}
|
||||
|
||||
comment := model.Comment{
|
||||
PostID: postID, UserID: userID, AuthorName: name, Content: content,
|
||||
|
||||
@@ -24,6 +24,9 @@ var ErrForbidden = errors.New("无权操作")
|
||||
// ErrInvalidParam 参数不合法(handler 据此返回 40000)
|
||||
var ErrInvalidParam = errors.New("参数不合法")
|
||||
|
||||
// ErrContentRejected 内容安全判定为违规,直接拒绝发送(不入库)
|
||||
var ErrContentRejected = errors.New("内容包含违规信息,无法发布")
|
||||
|
||||
// Service 业务逻辑聚合,方法按领域分散在各文件
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
|
||||
@@ -215,6 +215,7 @@ func (s *Service) ListUserPosts(viewerID, userID string, offset, limit int) ([]m
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachPostImages(posts)
|
||||
s.attachCommentCounts(posts)
|
||||
s.markFollowed(viewerID, posts)
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"pages/idcard/idcard",
|
||||
"pages/report/report",
|
||||
"pages/community/community",
|
||||
"pages/comments/comments",
|
||||
"pages/learn/learn",
|
||||
"pages/article/article",
|
||||
"pages/mine/mine",
|
||||
|
||||
@@ -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 });
|
||||
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>
|
||||
<!-- 常驻评论条: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>
|
||||
|
||||
<!-- 展开态:大输入面板,浮在键盘上方 -->
|
||||
<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>
|
||||
<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}
|
||||
|
||||
@@ -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' }));
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"nav-bar": "/components/nav-bar/nav-bar",
|
||||
"pt-icon": "/components/pt-icon/index"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -105,7 +105,21 @@ Page({
|
||||
.catch((e) => toastErr(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) {
|
||||
this.setData({ sheetType: e.currentTarget.dataset.type, sheetPostId: '', sheetShow: true });
|
||||
|
||||
Reference in New Issue
Block a user