feat(community): 评论改为独立二级页面 + 评论内容安全审核 + 评论数按实发实时纠正 #6
@@ -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 });
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
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,
|
||||
kbHeight: 0,
|
||||
},
|
||||
|
||||
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: {} });
|
||||
},
|
||||
|
||||
// 键盘高度变化:微信为「自定义输入条跟随键盘」提供的事件。只改输入条的 bottom(inline),
|
||||
// 评论列表完全不动。注意:聚焦期间除了这个 bottom,绝不 setData 碰输入框本身
|
||||
// (不设 cmFocus:false、不改它的布局),否则原生 textarea 会被抖失焦、键盘弹一下就收。
|
||||
onKbChange(e) {
|
||||
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
|
||||
},
|
||||
onCommentBlur() {
|
||||
// 失焦:收起键盘时把输入条放回底部;cmFocus 复位好让下次回复能重新拉键盘
|
||||
this.setData({ cmFocus: false, kbHeight: 0 });
|
||||
},
|
||||
|
||||
// 配图:评论只允许 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>
|
||||
|
||||
<!-- 底部输入条:固定定位,靠 bindkeyboardheightchange 拿到键盘高度后把自己的 bottom 抬到
|
||||
键盘上方。评论列表完全不动(adjust-position=false,页面不滚)。这是微信为「自定义输入条
|
||||
跟随键盘」专门提供的事件,移动的是 bottom 值、不 reflow 输入框内部,所以不会抖失焦。 -->
|
||||
<view class="cm-bar" style="bottom:{{kbHeight}}px">
|
||||
<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" bindkeyboardheightchange="onKbChange"
|
||||
maxlength="500" auto-height="{{true}}" show-confirm-bar="{{false}}"
|
||||
adjust-position="{{false}}" 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,88 @@
|
||||
/* flex 列 + 整页 overflow:hidden:页面本身不滚(adjust-position=false 时列表纹丝不动),
|
||||
列表在内层 scroll-view 里滚,底部输入条 fixed 覆在最下面 */
|
||||
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) 200rpx}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* 底部输入条:固定在底部,键盘弹起时 inline 的 bottom 会被抬到键盘上方(列表不动) */
|
||||
.cm-bar{
|
||||
position:fixed;left:0;right:0;bottom:0;z-index:50;
|
||||
background:#fff;border-top:1rpx solid var(--line);
|
||||
padding:var(--sp-3) var(--sp-5) calc(16rpx + env(safe-area-inset-bottom));
|
||||
transition:bottom .15s ease;
|
||||
}
|
||||
.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)}
|
||||
/* 起始就是单行高度(显式 height 盖掉全局 textarea 的 88rpx,避免「先高一下再缩成单行」的抖动),
|
||||
auto-height 只在文字换行时把它顶高;超过 max 就内部滚动 */
|
||||
.cm-ta{
|
||||
flex:1;min-width:0;box-sizing:border-box;
|
||||
height:72rpx;min-height:72rpx;max-height:280rpx;
|
||||
padding:16rpx var(--sp-4);background:var(--surface-2);border-radius:var(--r-md);
|
||||
font-size:var(--fs-md);line-height:1.4;
|
||||
}
|
||||
.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 });
|
||||
|
||||
@@ -45,6 +45,7 @@ function ageFromBirthday(bdayStr) {
|
||||
Page({
|
||||
data: {
|
||||
booting: true, // 冷启动先显示闪屏,静默登录 + 拉宠物期间不闪建档向导
|
||||
bootOut: false, // 闪屏淡出中:加一层渐隐动画再切走,避免硬切
|
||||
step: 0,
|
||||
petType: '猫猫',
|
||||
petEmoji: '🐱',
|
||||
@@ -68,20 +69,32 @@ Page({
|
||||
submitting: false,
|
||||
},
|
||||
onLoad() {
|
||||
this._bootStart = Date.now();
|
||||
this.setData({ today: today(), arrivedAt: today() });
|
||||
// 闪屏期间就把登录 + 宠物拉完,保证进首页时数据已经到位。
|
||||
// 有宠物直接进首页;没有才落到建档向导。登录失败也别卡在闪屏,
|
||||
// 放用户进向导(提交时会再 store.ready 重试)
|
||||
store
|
||||
.ready()
|
||||
.then(() => {
|
||||
if (store.getPets().length > 0) {
|
||||
.then(() => this._leaveBoot(store.getPets().length > 0 ? 'home' : 'wizard'))
|
||||
.catch(() => this._leaveBoot('wizard'));
|
||||
},
|
||||
// 冷启动闪屏收尾:先保证闪屏至少露够一小会(否则秒开会「闪一下」),
|
||||
// 再加一层渐隐动画,最后才真正切到首页 / 建档向导,避免硬切
|
||||
_leaveBoot(target) {
|
||||
const MIN_MS = 1900; // 至少显示这么久,让整条爪印走完 + logo 淡入,别一闪而过
|
||||
const FADE_MS = 340; // 与 .boot--out 的 CSS 过渡时长一致
|
||||
const wait = Math.max(0, MIN_MS - (Date.now() - (this._bootStart || 0)));
|
||||
setTimeout(() => {
|
||||
this.setData({ bootOut: true });
|
||||
setTimeout(() => {
|
||||
if (target === 'home') {
|
||||
wx.switchTab({ url: '/pages/home/home' });
|
||||
} else {
|
||||
this.setData({ booting: false });
|
||||
}
|
||||
})
|
||||
.catch(() => this.setData({ booting: false }));
|
||||
}, FADE_MS);
|
||||
}, wait);
|
||||
},
|
||||
nextStep() {
|
||||
this.setData({ step: Math.min(this.data.step + 1, 5) });
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<!-- 冷启动闪屏:登录 + 拉宠物期间显示,不再闪一下建档向导 -->
|
||||
<view wx:if="{{booting}}" class="boot">
|
||||
<view class="boot-badge">
|
||||
<view class="paw">
|
||||
<view class="bean bean-1"></view><view class="bean bean-2"></view>
|
||||
<view class="bean bean-3"></view><view class="bean bean-4"></view>
|
||||
<view class="pad"></view>
|
||||
<!-- 冷启动闪屏:登录 + 拉宠物期间显示。一串爪印一步步「踩」上屏幕,走到 logo 那里 -->
|
||||
<view wx:if="{{booting}}" class="boot {{bootOut ? 'boot--out' : ''}}">
|
||||
<view class="paw-trail">
|
||||
<view wx:for="{{[0,1,2,3,4,5]}}" wx:key="*this" class="pw pw-{{item}}">
|
||||
<view class="paw {{item % 2 === 0 ? 'foot-l' : 'foot-r'}}">
|
||||
<view class="bean bean-1"></view><view class="bean bean-2"></view>
|
||||
<view class="bean bean-3"></view><view class="bean bean-4"></view>
|
||||
<view class="pad"></view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="boot-name">肉垫计划</view>
|
||||
|
||||
@@ -16,18 +16,42 @@
|
||||
background:
|
||||
radial-gradient(circle at 50% 32%, rgba(253,179,92,.28), transparent 55%),
|
||||
var(--bg);
|
||||
transition:opacity .34s ease, transform .34s ease;
|
||||
}
|
||||
.boot-badge{
|
||||
width:184rpx;height:184rpx;border-radius:44rpx;
|
||||
background:linear-gradient(150deg,#FFB865,#F5943E);
|
||||
display:flex;align-items:center;justify-content:center;
|
||||
box-shadow:0 18rpx 42rpx rgba(214,138,52,.4);
|
||||
animation:boot-pop .5s ease both, boot-breathe 2.4s ease-in-out .5s infinite;
|
||||
/* 收尾渐隐:整屏淡出 + 轻微放大,像走进 App,而不是硬切 */
|
||||
.boot--out{opacity:0;transform:scale(1.06);pointer-events:none}
|
||||
|
||||
/* 爪印小径:一串爪印从下往上、左右交替地一步步「踩」出来,走向 logo。
|
||||
一次性踩完并留在屏上(不闪烁循环),走完 logo 再淡入 */
|
||||
.paw-trail{position:relative;width:340rpx;height:560rpx;margin:0 auto}
|
||||
.paw-trail .pw{position:absolute;animation:paw-stamp .56s cubic-bezier(.2,.85,.3,1) both}
|
||||
.paw-trail .paw{width:100rpx;height:92rpx}
|
||||
.paw-trail .foot-l{transform:rotate(-13deg) scale(.68)}
|
||||
.paw-trail .foot-r{transform:rotate(13deg) scale(-.68,.68)} /* 负 X 缩放 = 镜像成另一只脚 */
|
||||
/* 之字形落点(bottom 越大越靠上)+ 逐个延迟 .26s,节奏放慢一点,像一步一步走 */
|
||||
.paw-trail .pw-0{left:96rpx;bottom:0;animation-delay:0s}
|
||||
.paw-trail .pw-1{left:196rpx;bottom:104rpx;animation-delay:.26s}
|
||||
.paw-trail .pw-2{left:96rpx;bottom:208rpx;animation-delay:.52s}
|
||||
.paw-trail .pw-3{left:196rpx;bottom:312rpx;animation-delay:.78s}
|
||||
.paw-trail .pw-4{left:96rpx;bottom:416rpx;animation-delay:1.04s}
|
||||
.paw-trail .pw-5{left:196rpx;bottom:520rpx;animation-delay:1.3s}
|
||||
|
||||
/* logo 和文案等爪印快走到了再淡入,形成「走过来 → 到家」的收束感 */
|
||||
.boot-name{
|
||||
margin-top:var(--sp-4);font-size:var(--fs-2xl);font-weight:var(--fw-b);letter-spacing:-1rpx;
|
||||
animation:fade .5s ease both;animation-delay:1.5s;
|
||||
}
|
||||
.boot-tip{
|
||||
margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md);
|
||||
animation:fade .5s ease both;animation-delay:1.66s;
|
||||
}
|
||||
/* 每个爪印:从上方一点、缩着落下 → 踩实(轻微过冲、squash)→ 稳住并留在屏上 */
|
||||
@keyframes paw-stamp{
|
||||
0%{opacity:0;transform:translateY(-20rpx) scale(.5)}
|
||||
55%{opacity:1;transform:translateY(3rpx) scale(1.18)}
|
||||
74%{transform:translateY(0) scale(.95)}
|
||||
100%{opacity:1;transform:translateY(0) scale(1)}
|
||||
}
|
||||
.boot-name{margin-top:var(--sp-5);font-size:var(--fs-2xl);font-weight:var(--fw-b);letter-spacing:-1rpx}
|
||||
.boot-tip{margin-top:var(--sp-2);color:var(--muted);font-size:var(--fs-md)}
|
||||
@keyframes boot-pop{from{opacity:0;transform:scale(.8)}to{opacity:1;transform:scale(1)}}
|
||||
@keyframes boot-breathe{0%,100%{transform:translateY(0)}50%{transform:translateY(-10rpx)}}
|
||||
|
||||
.hero{text-align:center;padding:60rpx 0 var(--sp-5)}
|
||||
.pet-logo{
|
||||
|
||||
Reference in New Issue
Block a user