const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const upload = require('../../utils/upload.js'); function fmtDate(iso) { if (!iso) return ''; const d = new Date(iso); return d.getMonth() + 1 + '月' + d.getDate() + '日'; } // n 天后的 YYYY-MM-DD,给提醒类弹层做默认日期 function daysLater(n) { const d = new Date(Date.now() + n * 86400000); const p = (x) => (x < 10 ? '0' + x : '' + x); return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); } // "2026-07-30" → "2026-07-30T12:00:00+08:00" // // 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义 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()); } // 提醒类型(与后端 model.Reminder* 常量对应) const REMINDER_TYPES = [ { key: 'vaccine', label: '疫苗' }, { key: 'deworm', label: '驱虫' }, { key: 'weight', label: '体重' }, { key: 'monthlyReport', label: '月度报告' }, ]; const REMINDER_LABELS = REMINDER_TYPES.map((t) => t.label); // 标签和社区顶部筛选保持一致(晒宠 / 新手求助 / 经验) const PTAG = ['晒宠', '新手求助', '经验']; Component({ options: { addGlobalClass: true }, properties: { show: { type: Boolean, value: false }, type: { type: String, value: '' }, // 雪花 ID 是 18 位字符串,声明成 Number 会超出 JS 安全整数范围而丢精度 postId: { type: String, value: '' }, }, data: { innerType: '', pet: {}, segSel: {}, optSel: {}, // 通用简易记录(洗护/清洁那 15 种共用) postContent: '', pfFocus: false, // 发帖内容框聚焦标记(点整块 field 聚焦用) postImages: [], remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' }, remTypeLabels: REMINDER_LABELS, myPets: [], exportText: '', fbContent: '', fbContact: '', fbDone: false, commentText: '', comments: [], reminders: [], poster: null, reportDetail: null, proInfo: null, saving: false, posterSaving: false, commentImages: [], replyTo: {}, cmFocus: false, composing: false, // 评论输入面板是否展开(收起时只是一条细条) sendingComment: false, // 发送+审核在途,防连点 kbHeight: 0, total: 0, }, observers: { show: function (show) { // 自定义 tabBar 会盖住弹层底部按钮;打开弹层时把它藏起来,关闭时恢复 // 用 getTabBar().hidden 开关,不用 wx.showTabBar(真机上会额外冒出原生 tabBar) this.toggleTabBar(!!show); }, 'show, type': function (show, type) { if (show && type) this.setType(type, true); }, }, lifetimes: { detached() { this.toggleTabBar(false); }, }, methods: { setType(type, fresh) { const pet = store.getPet(); const patch = { innerType: type, pet }; if (fresh) { patch.segSel = {}; patch.optSel = {}; patch.postContent = ''; patch.postImages = []; patch.medDose = ''; patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' }; patch.exportText = ''; patch.fbContent = ''; patch.fbContact = ''; patch.fbDone = false; patch.commentText = ''; patch.commentImages = []; patch.replyTo = {}; patch.cmFocus = false; patch.composing = false; patch.sendingComment = false; patch.kbHeight = 0; patch.saving = false; } this.setData(patch); this.loadSheetData(type); }, // 按弹层类型拉取真实数据 loadSheetData(type) { const id = store.currentPetId(); if (type === 'manageTasks') { this.loadManageTasks(); } else if (type === 'managePets') { this.loadMyPets(); } else if (type === 'exportData') { this.buildExport(); } else if (type === 'comments' && this.data.postId) { this.loadComments(); } else if (type === 'reminders' && id) { this.loadReminders(); } else if (type === 'poster' && id) { api.getPoster(id).then((p) => this.setData({ poster: p })).catch(() => {}); } else if (type === 'reportDetail' && id) { api.weeklyReport(id).then((r) => this.setData({ reportDetail: r })).catch(() => {}); } else if (type === 'pro') { api.getPro().then((p) => this.setData({ proInfo: p })).catch(() => {}); } }, goSheet(e) { this.setType(e.currentTarget.dataset.type, true); }, close() { this.triggerEvent('close'); }, onMaskTap() { this.close(); }, noop() {}, // 藏/显当前 tab 页的自定义 tabBar(非 tab 页无 getTabBar,安全跳过) toggleTabBar(hidden) { const pages = getCurrentPages(); const page = pages[pages.length - 1]; if (page && typeof page.getTabBar === 'function' && page.getTabBar()) { page.getTabBar().setData({ hidden }); } }, // ---- 成长海报:画进 canvas 才能存进相册 ---- onSavePoster() { if (this.data.posterSaving) return; this.setData({ posterSaving: true }); this.drawPoster() .then((tempPath) => this.saveToAlbum(tempPath)) .then(() => wx.showToast({ title: '已保存到相册', icon: 'success' })) .catch((e) => { if (e && e.canceled) return; wx.showToast({ title: (e && e.message) || '保存失败', icon: 'none' }); }) .then(() => this.setData({ posterSaving: false })); }, // 把海报画到离屏 canvas 上,返回临时图片路径 drawPoster() { const W = 600; const H = 840; return new Promise((resolve, reject) => { wx.createSelectorQuery() .in(this) .select('#posterCanvas') .fields({ node: true, size: true }) .exec((res) => { const node = res && res[0] && res[0].node; if (!node) return reject(new Error('画布还没准备好,稍后再试')); // 按设备像素比放大,否则在高分屏上导出的图是糊的 let dpr = 2; try { dpr = (wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync()).pixelRatio || 2; } catch (err) { dpr = 2; } node.width = W * dpr; node.height = H * dpr; const ctx = node.getContext('2d'); ctx.scale(dpr, dpr); const pet = this.data.pet || {}; const p = this.data.poster || {}; // 优先用后端海报数据里的头像(和身份卡同一路径,可靠),store 兜底 const avatarUrl = p.pet_avatar_url || pet.avatar_url || ''; // 头像是远程图,异步加载。加载完(或没有头像)再往下画, // 否则头像会画不上。加载失败当没有头像处理 const loadAvatar = () => new Promise((res) => { if (!avatarUrl) return res(null); const img = node.createImage(); img.onload = () => res(img); img.onerror = () => res(null); img.src = avatarUrl; }); loadAvatar().then((avatar) => { const bg = ctx.createLinearGradient(0, 0, W, H); bg.addColorStop(0, '#FFF7E8'); bg.addColorStop(1, '#FFFFFF'); ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); ctx.fillStyle = '#F2DFC3'; ctx.fillRect(0, 0, W, 8); ctx.textAlign = 'center'; ctx.fillStyle = '#2D2925'; ctx.font = '600 40px sans-serif'; ctx.fillText((pet.name || '毛孩子') + ' 的成长报告', W / 2, 96); // 上传过照片就画圆形头像,没有才退回 emoji(默认头像太丑) const avD = 132, avX = (W - avD) / 2, avY = 132; if (avatar) { ctx.save(); ctx.beginPath(); ctx.arc(W / 2, avY + avD / 2, avD / 2, 0, Math.PI * 2); ctx.closePath(); ctx.clip(); // 居中裁剪成正方形再画,竖图/横图都不压扁 const iw = avatar.width || avD; const ih = avatar.height || avD; const side = Math.min(iw, ih); const sx = (iw - side) / 2; const sy = (ih - side) / 2; ctx.drawImage(avatar, sx, sy, side, side, avX, avY, avD, avD); ctx.restore(); } else { ctx.font = '96px sans-serif'; ctx.fillText(pet.emoji || '🐾', W / 2, 216); } ctx.textAlign = 'center'; ctx.fillStyle = '#8D8277'; ctx.font = '24px sans-serif'; ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 262); // 周报告:显示统计周期,让人知道这四个数是近 7 天的 if (p.period) { ctx.fillStyle = '#C7A36A'; ctx.font = '22px sans-serif'; ctx.fillText('近 7 天 · ' + p.period, W / 2, 298); } ctx.fillStyle = 'rgba(255,255,255,.8)'; this.roundRect(ctx, 60, 316, W - 120, 232, 24); ctx.fill(); const lines = [ '完成任务 ' + (p.tasks_completed || 0) + ' 项', '体重记录 ' + (p.weight_records || 0) + ' 次', '疫苗记录 ' + (p.vaccine_records || 0) + ' 次', '高风险异常 ' + (p.high_risk_count || 0) + ' 次', ]; ctx.textAlign = 'left'; ctx.font = '28px sans-serif'; lines.forEach((t, i) => { const y = 360 + i * 52; ctx.fillStyle = '#73BE9D'; ctx.fillText('✓', 92, y); ctx.fillStyle = '#443D37'; ctx.fillText(t, 132, y); }); ctx.textAlign = 'center'; ctx.fillStyle = '#2D2925'; ctx.font = '600 30px sans-serif'; ctx.fillText('健康状态:' + (p.headline || '稳定成长'), W / 2, 620); ctx.fillStyle = '#B5A99D'; ctx.font = '22px sans-serif'; ctx.fillText('生成自 · 肉垫计划', W / 2, 780); // 头像加载完(或没有)才走到这里,此刻画布已完整 wx.canvasToTempFilePath( { canvas: node, fileType: 'png', success: (r) => resolve(r.tempFilePath), fail: () => reject(new Error('生成图片失败')), }, this, ); }); // loadAvatar().then }); }); }, roundRect(ctx, x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }, // 存相册要相册写权限。用户拒过一次之后 wx.authorize 不会再弹, // 必须引导他去设置页打开,否则这里会永远静默失败。 saveToAlbum(filePath) { return new Promise((resolve, reject) => { wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: (err) => { const msg = (err && err.errMsg) || ''; if (msg.indexOf('cancel') >= 0) return reject({ canceled: true }); if (msg.indexOf('auth deny') >= 0 || msg.indexOf('authorize') >= 0) { wx.showModal({ title: '需要相册权限', content: '保存卡片需要允许访问相册,去设置里打开一下?', confirmText: '去设置', success: (r) => { if (r.confirm) wx.openSetting({}); }, }); return reject({ canceled: true }); } reject(new Error('保存失败')); }, }); }); }, onSeg(e) { const { group, index } = e.currentTarget.dataset; const segSel = Object.assign({}, this.data.segSel, { [group]: Number(index) }); this.setData({ segSel }); }, onOpt(e) { const { group, index } = e.currentTarget.dataset; const optSel = Object.assign({}, this.data.optSel, { [group]: Number(index) }); this.setData({ optSel }); }, onPostInput(e) { this.setData({ postContent: e.detail.value }); }, // 点内容 field 任意处都聚焦(原生 textarea 在 scroll-view 里命中区域会偏) onPostFieldTap() { this.setData({ pfFocus: true }); }, onPostBlur() { this.setData({ pfFocus: false }); }, 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), // 没有头像字段,用昵称首字做个色块,比一律显示同一个 emoji 强 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), })), })), }), ) .catch(() => {}); }, // 长按删除自己的评论 onDeleteComment(e) { const { id, self } = e.currentTarget.dataset; if (!id || !self) return; const c = { id }; wx.showModal({ title: '删除评论', content: '确定删除这条评论?', confirmColor: '#D9534F', success: (res) => { if (!res.confirm) return; api.deleteComment(c.id) .then(() => { this.loadComments(); this.triggerEvent('commented'); }) .catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); }, }); }, // ---- 多宠物管理 ---- loadMyPets() { const cur = store.currentPetId(); this.setData({ myPets: (store.getPets() || []).map((p) => ({ id: p.id, name: p.name, emoji: p.emoji, meta: [p.type, p.stage, p.weight].filter(Boolean).join(' · '), current: p.id === cur, })), }); }, onSwitchPet(e) { const id = e.currentTarget.dataset.id; store.switchPet(id); this.loadMyPets(); wx.showToast({ title: '已切换', icon: 'success' }); setTimeout(() => this.close(), 500); }, // 多宠管理里点某只去编辑。editPet 那支已经搬到 pages/petform, // 这里必须关掉弹层再跳页 —— 留着 setType('editPet') 会开一个空白弹层 onEditPetFromList(e) { const id = e.currentTarget.dataset.id; store.switchPet(id); this.close(); wx.navigateTo({ url: '/pages/petform/petform?id=' + id }); }, // ---- 导出健康档案 ---- buildExport() { const id = store.currentPetId(); const pet = store.getPet() || {}; if (!id) { this.setData({ exportText: '还没有建档,先添加一只毛孩子吧。' }); return; } Promise.all([ api.getRecords(id, { page: 1, pageSize: 50 }).catch(() => ({ list: [], total: 0 })), api.getReminders(id).catch(() => []), ]).then(([recPage, rems]) => { const lines = []; lines.push('【肉垫计划 · 健康档案】'); lines.push(`宠物:${pet.name || ''}(${pet.type || ''}|${pet.gender || ''})`); lines.push(`阶段:${pet.stage || '-'} 年龄:${pet.age || '-'} 体重:${pet.weight || '-'}`); if (pet.breed || pet.color) lines.push(`品种/毛色:${pet.breed || '-'} / ${pet.color || '-'}`); lines.push(''); lines.push(`— 健康记录(共 ${recPage.total || 0} 条,导出最近 ${(recPage.list || []).length} 条)—`); (recPage.list || []).forEach((r) => { lines.push(`· ${fmtDate(r.occurred_at)} ${r.title}${r.description ? '|' + r.description : ''}`); }); lines.push(''); lines.push('— 提醒 —'); (rems || []).forEach((r) => { lines.push(`· ${r.title}:${r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置'}`); }); lines.push(''); lines.push('(本档案仅供日常照护与就医参考,不构成诊断意见)'); this.setData({ exportText: lines.join('\n') }); }); }, onCopyExport() { const t = this.data.exportText; if (!t) return; wx.setClipboardData({ data: t, success: () => wx.showToast({ title: '已复制到剪贴板', icon: 'success' }), }); }, // ---- 意见反馈 ---- onFbContent(e) { this.setData({ fbContent: e.detail.value }); }, onFbContact(e) { this.setData({ fbContact: e.detail.value }); }, onSubmitFeedback() { const content = (this.data.fbContent || '').trim(); if (!content) return wx.showToast({ title: '写点什么再提交', icon: 'none' }); if (this.data.saving) return; this.setData({ saving: true }); api .submitFeedback({ content, contact: (this.data.fbContact || '').trim() }) .then(() => this.setData({ fbDone: true, saving: false })) .catch((e) => { wx.showToast({ title: e.message || '提交失败', icon: 'none' }); this.setData({ saving: false }); }); }, // ---- 提醒增删改 ---- loadReminders() { const id = store.currentPetId(); if (!id) return; api .getReminders(id) .then((list) => this.setData({ reminders: (list || []).map((r) => ({ id: r.id, type: r.type, title: r.title, date: r.next_due_date ? String(r.next_due_date).slice(0, 10) : '', freq: r.frequency || '', when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '未设置', })), }), ) .catch(() => {}); }, onRemTitle(e) { this.setData({ 'remForm.title': e.detail.value }); }, onRemFreq(e) { this.setData({ 'remForm.freq': e.detail.value }); }, onRemType(e) { this.setData({ 'remForm.typeIdx': Number(e.detail.value) }); }, onRemDate(e) { this.setData({ 'remForm.date': e.detail.value }); }, onEditReminder(e) { const r = this.data.reminders[e.currentTarget.dataset.index]; let idx = REMINDER_TYPES.findIndex((t) => t.key === r.type); if (idx < 0) idx = 0; this.setData({ remForm: { id: r.id, typeIdx: idx, title: r.title, date: r.date, freq: r.freq } }); }, onSaveReminder() { const f = this.data.remForm; const title = (f.title || '').trim(); if (!title) return wx.showToast({ title: '填个提醒名', icon: 'none' }); const body = { type: REMINDER_TYPES[f.typeIdx].key, title, next_due_date: f.date || '', frequency: (f.freq || '').trim(), }; const petId = store.currentPetId(); const p = f.id ? api.updateReminder(f.id, body) : api.createReminder(petId, body); p.then(() => { this.setData({ remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' } }); this.loadReminders(); this.triggerEvent('saved'); }).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' })); }, onDeleteReminder(e) { const r = this.data.reminders[e.currentTarget.dataset.index]; wx.showModal({ title: '删除提醒', content: '确定删除「' + r.title + '」?', success: (res) => { if (!res.confirm) return; api.deleteReminder(r.id).then(() => { this.loadReminders(); this.triggerEvent('saved'); }).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' })); }, }); }, // ---- 管理任务 ---- loadManageTasks() { const id = store.currentPetId(); if (!id) return; api.getTasks(id).then((tasks) => this.setData({ manageTasks: tasks || [] })).catch(() => {}); }, onPickPostImages() { const left = 2 - this.data.postImages.length; if (left <= 0) return wx.showToast({ title: '最多 2 张', icon: 'none' }); upload .chooseAndUploadImages(left) .then((list) => this.setData({ postImages: this.data.postImages.concat(list) })) .catch((e) => { if (e && e.canceled) return; wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' }); }); }, onRemovePostImage(e) { const i = e.currentTarget.dataset.index; const arr = this.data.postImages.slice(); arr.splice(i, 1); this.setData({ postImages: arr }); }, onCommentInput(e) { this.setData({ commentText: e.detail.value }); }, segIdx(group) { const v = this.data.segSel[group]; return v === undefined ? 0 : v; }, optIdx(group) { const v = this.data.optSel[group]; return v === undefined ? 0 : v; }, // 记录表单整体搬去 pages/addrecord 之后,这里只剩「生成就医前摘要」 // 会往 health_records 写一条 note —— 它是个动作留痕,不是用户填的表单 buildRecord() { if (this.data.innerType === 'vetSummary') { return { type: 'note', icon: '📄', title: '生成就医前摘要' }; } return null; }, async createAndClose(rec) { const id = store.currentPetId(); if (!rec) { this.close(); return; } // 没建档时点「保存」原来是静默关掉弹层,用户以为存上了,其实什么都没发生 if (!id) { wx.showToast({ title: '先建一份宠物档案再记录', icon: 'none' }); return; } if (this.data.saving) return; this.setData({ saving: true }); try { const saved = await api.createRecord(id, rec); if (rec.type === 'weight') store.patchCurrent({ weight: rec.title.replace('体重 ', '') }); this.triggerEvent('saved', saved); this.close(); } catch (e) { wx.showToast({ title: e.message || '保存失败', icon: 'none' }); this.setData({ saving: false }); } }, onSave() { this.createAndClose(this.buildRecord()); }, async onCreatePost() { const content = (this.data.postContent || '').trim(); if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' }); const tag = PTAG[this.data.optSel.ptag === undefined ? 0 : this.data.optSel.ptag]; if (this.data.saving) return; this.setData({ saving: true }); try { // 去掉发布身份,统一用发布者昵称(后端按 user 取昵称) const post = await api.createPost({ identity: 'user', content, tags: [tag], image_file_ids: this.data.postImages.map((i) => i.id), }); this.triggerEvent('posted'); this.close(); // 内容安全审核:没直接过审的,明确告诉用户在审核,别以为发失败/是 bug if (post && post.status && post.status !== 'published') { wx.showModal({ title: '发布成功,审核中', content: '内容需要通过安全审核后才会出现在社区。可在「我的 → 我的帖子」查看审核状态。', showCancel: false, confirmText: '知道了', }); } else { wx.showToast({ title: '已发布', icon: 'success' }); } } catch (e) { wx.showToast({ title: e.message || '发布失败', icon: 'none' }); this.setData({ saving: false }); } }, // 评论 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 { 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: {}, 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.hideLoading(); this.setData({ sendingComment: false }); // 违规内容后端直接拒收,这里把原因透出来 wx.showToast({ title: (e && e.message) || '评论失败', icon: 'none' }); } }, // 评论配图:只允许 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' }); }); }, // 回复某条评论:输入区顶部显示「回复 xxx」,发送时带上 parent_id // 小红书那套:点任意一条评论/回复,直接把输入框激活并填上「回复 @xxx」, // 不用再去底部找输入框、也不用单独点一个「回复」按钮 onReplyTo(e) { const { id, name } = e.currentTarget.dataset; if (!id) return; // 输入框常驻、属性不变,直接置 focus 拉键盘即可;只填「回复对象」这一个横幅 this.setData({ replyTo: { id, name }, cmFocus: true }); }, onCommentFocus() { // 只复位一次性 focus 开关。键盘高度交给 onKbChange,用 transform 平移弹层来避让 this.setData({ cmFocus: false }); }, // 键盘高度变化(真机可靠来源)。把高度写进 data, // wxml 里只用它算 transform 平移量 —— 纯合成层操作,不 reflow、不抖焦点 onKbChange(e) { this.setData({ kbHeight: (e.detail && e.detail.height) || 0 }); }, onCommentBlur() { // 失焦不主动清 kbHeight:键盘真正收起时 onKbChange 会回调 0,避免和收键盘动画抢 this.setData({ cmFocus: false }); }, onCancelReply() { this.setData({ replyTo: {} }); }, // 一级评论默认只带 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' })); }, onPreviewCommentImage(e) { const { urls, cur } = e.currentTarget.dataset; if (urls && urls.length) wx.previewImage({ urls, current: cur }); }, onRemoveCommentImage(e) { const list = this.data.commentImages.slice(); list.splice(e.currentTarget.dataset.index, 1); this.setData({ commentImages: list }); }, }, });