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 映射为标签过滤(空表示不过滤)
|
// 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.setData({
|
||||||
|
commentText: '', commentImages: [], replyTo: {},
|
||||||
|
composing: false, cmFocus: false, kbHeight: 0, sendingComment: false,
|
||||||
|
});
|
||||||
|
// 过审即时展示;否则挂在人工/异步图片审核,通过后才出现
|
||||||
|
if (res && res.status === 'published') {
|
||||||
this.loadComments();
|
this.loadComments();
|
||||||
this.triggerEvent('commented');
|
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 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>
|
||||||
|
<textarea class="cm-ta" placeholder="{{replyTo.name ? '回复 @' + replyTo.name + '…' : '说点什么…'}}"
|
||||||
<!-- 展开态:大输入面板,浮在键盘上方 -->
|
|
||||||
<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 + '…' : '有爱评论,说点好听的~'}}"
|
|
||||||
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}
|
||||||
|
|||||||
@@ -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, '点赞失败'));
|
.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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user