f417962453
- 删除页面:community(社区)、comments(评论)、user(他人主页)、relations(关注/粉丝) - app.json 去掉四个页面注册与社区 tab;tabBar 只剩 首页/报告/我的 - bottom-sheet 移除发帖(createPost)与评论(comments)整块 UI 及对应 JS/数据/常量 - profile-head 移除帖子/关注/粉丝社交数与关注按钮 - mine 去掉「我的帖子」「别人眼里的我」及关注/粉丝跳转 - api.js 移除 posts/comments/follow/relations 等社区接口(保留 userCard 供我的页头部) 说明:后端社区接口未动(微信审核只看小程序包,前端已无任何引用); 完整社区代码保留在 feat/dev 分支与 git 历史。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
513 lines
18 KiB
JavaScript
513 lines
18 KiB
JavaScript
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"
|
||
//
|
||
// 提醒类型(与后端 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);
|
||
|
||
|
||
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: {},
|
||
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
|
||
remTypeLabels: REMINDER_LABELS,
|
||
myPets: [],
|
||
exportText: '',
|
||
fbContent: '',
|
||
fbContact: '',
|
||
fbDone: false,
|
||
reminders: [],
|
||
poster: null,
|
||
reportDetail: null,
|
||
proInfo: null,
|
||
saving: false,
|
||
posterSaving: false,
|
||
},
|
||
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.medDose = '';
|
||
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
|
||
patch.exportText = '';
|
||
patch.fbContent = '';
|
||
patch.fbContact = '';
|
||
patch.fbDone = false;
|
||
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 === '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 });
|
||
},
|
||
// ---- 多宠物管理 ----
|
||
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(() => {});
|
||
},
|
||
|
||
segIdx(group) {
|
||
const v = this.data.segSel[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());
|
||
},
|
||
|
||
},
|
||
});
|