Files
sundynix-pets/pets-fe/components/bottom-sheet/bottom-sheet.js
T
Blizzard 3fc3cbc7d2 feat(fe): 记录页 24 种 4 组,弹层加通用简易表单
## 通用简易表单
洗护/清洁那 15 种只需要「什么时候 + 备注 + 可选照片」。弹层现在是 28 个
wx:elif 手写分支(402 行),各写一个会推到 700 行,而且每加一种类型都要发版。

加一个 wx:elif="{{isSimple}}",isSimple 由 JS 按后端返回的 form 字段算,
和上面那 9 种永不重叠。插在 photo 之后、提醒中心之前,没动任何现有分支的顺序。

备注给了逐类型的具体占位提示(「用了什么沐浴露、有没有吹干」),
比「请输入备注」有用得多——用户看到提示才知道这栏该写什么。

## 一个不写就会静默出错的地方
dayToISO():后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期
("2026-07-30")它解不出来会**静默回退成 time.Now()**——用户选「昨天洗澡」
会存成今天,而且不报错。

时区必须带真实偏移:转成 UTC 的 Z 形式,落库再转回本地时会把边界日期挪一天
(首页任务重复生成那个 bug 就是这么来的)。取正午同样是为了远离日界。

## 四处写死的类型列表全删
  RECORD_ITEMS   record.js  记录页 9 宫格
  QUICK_ITEMS    home.js    首页 8 宫格
  TYPE_TONE      record.js  时间轴色调(9 种写死,新类型会全掉到橙色一片分不清)
  TASK_SHEETS    弹层       任务关联记录类型的下拉(只有 6 种,加了类型选不到)

全部改成读 utils/recordTypes.js 这份共用缓存。缓存 load() 只真正请求一次,
并发调用共用同一个 promise;isSimple/label/groups 是同步的,弹层打开时
不该再等一次网络。

首页 8 宫格改成取后端前 8 个(按分组顺序再按 sort),后台调 sort 就能换首屏
露出哪几个;全部 24 种在记录页。

分组色系(daily橙/health绿/care紫/clean蓝)留在前端,后端只存 group key——
改配色不用动数据。

## 联调(预生产库实跑)
  15 种新类型  全部落库,且选「昨天」没被存成今天(15/15)
  原有 9 种    逐一回归,写库 emoji 原样;weight 回写宠物档案 → 4.5kg ✓
  时间轴       24 条混排,色调分布 6/7/5/6,没有一条查不到类型
  体重趋势     仍只有 1 个点(没被 24 条记录污染)
  周报/首页汇总  正常

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:44:07 +08:00

1111 lines
41 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const store = require('../../utils/store.js');
const recordTypes = require('../../utils/recordTypes.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"
//
// 后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期它解不出来会静默
// 回退成 time.Now()——用户选了「昨天洗澡」会存成今天,而且不报错。
// 时区必须带真实偏移:转成 UTC 的 Z 形式,落库再转回本地时会把边界日期挪一天
// (首页任务那个 bug 就是这么来的)。取正午同样是为了远离日界。
function dayToISO(day) {
if (!day) return '';
const off = -new Date().getTimezoneOffset(); // 东八区是 +480
const sign = off >= 0 ? '+' : '-';
const a = Math.abs(off);
const p = (x) => (x < 10 ? '0' + x : '' + x);
return day + 'T12:00:00' + sign + p(Math.floor(a / 60)) + ':' + p(a % 60);
}
// 相对时间。社区里关心的是「多久以前发的」,精确到秒没意义
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());
}
function ageFromBirthday(bday) {
if (!bday) return '';
const b = new Date(bday);
const now = new Date();
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (months < 0) months = 0;
return months < 24 ? months + '个月' : Math.floor(months / 12) + '岁';
}
const GENDERS = ['男孩', '女孩', '不确定'];
const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期'];
// 任务可关联的记录弹层类型
// 任务能关联的记录类型。原来这里也是写死的 6 种,后台加了类型这个下拉里看不到,
// 于是「自定义任务关联新类型」这条路是断的。改成读同一份类型缓存
function taskSheets() {
const list = [{ key: '', label: '不关联' }];
(recordTypes.groups() || []).forEach((g) =>
g.items.forEach((t) => list.push({ key: t.code, label: t.label })),
);
return list;
}
// 提醒类型(与后端 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 SIMPLE_HINT = {
water: '大概喝了多少、换水了没有',
bath: '用了什么沐浴露、有没有吹干',
nail: '剪了几只爪、有没有出血',
ear: '耳道干不干净、有没有异味',
tooth: '用了什么牙膏、配合度怎么样',
brush: '掉毛多不多、有没有打结',
groom: '在哪家做的、剪了什么造型、花了多少',
litter: '换了多少、用的什么砂',
litterbox: '洗了几个、有没有消毒',
bowl: '有没有用洗碗液、有没有滑腻感',
waterbowl: '滤芯还好吗、有没有水垢',
clean: '消了哪些地方、用的什么消毒液',
checkup: '在哪家做的、结果怎么样、下次什么时候',
vet: '什么症状、医生怎么说、开了什么药',
supplement: '吃的什么、多大剂量、吃多久',
};
const POOP = ['正常', '软便', '拉稀'];
const FOOD = ['正常', '偏少', '不吃'];
const COST = ['食品', '医疗', '用品'];
const IDENTITY = ['petName', 'anonymous', 'official'];
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: {},
dateVals: { vaccine: '', deworm: '' },
// 通用简易记录(洗护/清洁那 15 种共用)
isSimple: false,
simpleLabel: '',
simpleDate: '',
simpleNote: '',
simpleImages: [],
simplePlaceholder: '',
wInput: '',
wNote: '',
poopNote: '',
foodText: '',
medName: '',
medDose: '',
costNote: '',
costAmount: '',
postContent: '',
postImages: [],
manageTasks: [],
taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 },
taskSheetLabels: [],
remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' },
remTypeLabels: REMINDER_LABELS,
myPets: [],
exportText: '',
fbContent: '',
fbContact: '',
fbDone: false,
addName: '',
addBirthday: '',
addWeight: '',
addColor: '',
addBreed: '',
commentText: '',
riskData: null,
comments: [],
reminders: [],
poster: null,
reportDetail: null,
proInfo: null,
saving: false,
posterSaving: false,
commentImages: [],
replyTo: {},
cmFocus: 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.costAmount = '';
patch.postContent = '';
patch.postImages = [];
patch.poopNote = '';
patch.foodText = '';
patch.medName = '';
patch.medDose = '';
patch.costNote = '';
patch.manageTasks = [];
patch.taskForm = { id: '', title: '', description: '', priority: '', sheetIdx: 0 };
patch.taskSheetLabels = taskSheets().map((x) => x.label);
patch.remForm = { id: '', typeIdx: 0, title: '', date: '', freq: '' };
patch.exportText = '';
patch.fbContent = '';
patch.fbContact = '';
patch.fbDone = false;
patch.addName = '';
patch.addBirthday = '';
patch.addWeight = '';
patch.addColor = '';
patch.addBreed = '';
patch.commentText = '';
patch.commentImages = [];
patch.replyTo = {};
patch.cmFocus = false;
patch.kbHeight = 0;
patch.riskData = null;
patch.saving = false;
patch.simpleNote = '';
patch.simpleImages = [];
}
// 是否走通用简易表单。缓存热的时候是同步的;万一没热就按「不是」处理,
// 那 9 种的分支写死在 wxml 里不依赖这份缓存,不会因此打不开
const simple = recordTypes.isSimple(type);
patch.isSimple = simple;
if (simple) {
const label = recordTypes.label(type);
patch.simpleLabel = label;
patch.simpleDate = daysLater(0);
patch.simplePlaceholder = SIMPLE_HINT[type] || ('这次' + label + '的情况,几个字就行');
}
if (type === 'weight') {
patch.wInput = (pet.weight || '').replace('kg', '');
patch.wNote = '';
}
// 提醒日期给个合理的默认值。原本写死成固定日期,时间一过就成了「提醒昨天」
if (type === 'vaccine') patch.dateVals = { ...this.data.dateVals, vaccine: daysLater(30) };
if (type === 'deworm') patch.dateVals = { ...this.data.dateVals, deworm: daysLater(90) };
if (type === 'editPet') {
// 预填当前宠物档案
patch.addName = pet.name || '';
patch.addColor = pet.color || '';
patch.addBreed = pet.breed || '';
patch.addWeight = (pet.weight || '').replace('kg', '');
patch.addBirthday = pet.birthday ? String(pet.birthday).slice(0, 10) : '';
patch.segSel = {
addType: pet.type === '狗狗' ? 1 : 0,
addGender: Math.max(0, GENDERS.indexOf(pet.gender)),
};
patch.optSel = { addStage: Math.max(0, STAGES.indexOf(pet.stage)) };
}
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(() => {});
}
},
onActivatePro() {
api
.activatePro()
.then((p) => {
this.setData({ proInfo: p });
wx.showToast({ title: '已开通 Pro', icon: 'success' });
setTimeout(() => this.close(), 800);
})
.catch((e) => wx.showToast({ title: e.message || '开通失败', icon: 'none' }));
},
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 || {};
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);
ctx.font = '96px sans-serif';
ctx.fillText(pet.emoji || '🐾', W / 2, 216);
ctx.fillStyle = '#8D8277';
ctx.font = '24px sans-serif';
ctx.fillText([pet.age, pet.weight, pet.stage].filter(Boolean).join(' | '), W / 2, 268);
ctx.fillStyle = 'rgba(255,255,255,.8)';
this.roundRect(ctx, 60, 310, 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);
// canvas 2d 是同步绘制,画完直接导出
wx.canvasToTempFilePath(
{
canvas: node,
fileType: 'png',
success: (r) => resolve(r.tempFilePath),
fail: () => reject(new Error('生成图片失败')),
},
this,
);
});
});
},
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 });
},
onSimpleDate(e) {
this.setData({ simpleDate: e.detail.value });
},
onSimpleNote(e) {
this.setData({ simpleNote: e.detail.value });
},
onPickSimpleImage() {
upload
.chooseAndUploadImage()
.then((f) => this.setData({ simpleImages: [f] }))
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
onRemoveSimpleImage() {
this.setData({ simpleImages: [] });
},
onDate(e) {
this.setData({ [`dateVals.${e.currentTarget.dataset.key}`]: e.detail.value });
},
onWInput(e) { this.setData({ wInput: e.detail.value }); },
onWNote(e) { this.setData({ wNote: e.detail.value }); },
onPoopNote(e) { this.setData({ poopNote: e.detail.value }); },
onFoodText(e) { this.setData({ foodText: e.detail.value }); },
onMedName(e) { this.setData({ medName: e.detail.value }); },
onMedDose(e) { this.setData({ medDose: e.detail.value }); },
onCostNote(e) { this.setData({ costNote: e.detail.value }); },
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
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);
},
onEditPetFromList(e) {
store.switchPet(e.currentTarget.dataset.id);
this.setType('editPet', true);
},
// ---- 导出健康档案 ----
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(() => {});
},
onTaskTitle(e) { this.setData({ 'taskForm.title': e.detail.value }); },
onTaskDesc(e) { this.setData({ 'taskForm.description': e.detail.value }); },
onTaskPriority() {
this.setData({ 'taskForm.priority': this.data.taskForm.priority === '重要' ? '' : '重要' });
},
onTaskSheet(e) { this.setData({ 'taskForm.sheetIdx': Number(e.detail.value) }); },
onEditTaskItem(e) {
const t = this.data.manageTasks[e.currentTarget.dataset.index];
const sheets = taskSheets();
let sheetIdx = sheets.findIndex((s) => s.key === (t.sheet_type || ''));
if (sheetIdx < 0) sheetIdx = 0;
this.setData({
taskForm: { id: t.id, title: t.title, description: t.description || '', priority: t.priority || '', sheetIdx },
});
},
onSaveTask() {
const f = this.data.taskForm;
const title = (f.title || '').trim();
if (!title) return wx.showToast({ title: '填个任务名', icon: 'none' });
const sheets = taskSheets();
const body = { title, description: f.description, priority: f.priority, sheet_type: (sheets[f.sheetIdx] || sheets[0]).key };
const id = store.currentPetId();
const p = f.id ? api.updateTask(f.id, body) : api.createTask(id, body);
p.then(() => {
this.setData({ taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 } });
this.loadManageTasks();
this.triggerEvent('saved');
}).catch((e) => wx.showToast({ title: e.message || '保存失败', icon: 'none' }));
},
onDeleteTaskItem(e) {
const t = this.data.manageTasks[e.currentTarget.dataset.index];
wx.showModal({
title: '删除任务',
content: '确定删除「' + t.title + '」?',
success: (r) => {
if (!r.confirm) return;
api.deleteTask(t.id).then(() => {
this.loadManageTasks();
this.triggerEvent('saved');
}).catch((err) => wx.showToast({ title: err.message || '删除失败', icon: 'none' }));
},
});
},
onPickPostImages() {
const left = 9 - this.data.postImages.length;
if (left <= 0) return wx.showToast({ title: '最多 9 张', 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 });
},
onAddName(e) { this.setData({ addName: e.detail.value }); },
onAddBirthday(e) { this.setData({ addBirthday: e.detail.value }); },
onAddWeight(e) { this.setData({ addWeight: e.detail.value }); },
onAddColor(e) { this.setData({ addColor: e.detail.value }); },
onAddBreed(e) { this.setData({ addBreed: e.detail.value }); },
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;
},
// 与 optIdx 的区别:没选就是没选,不当成选了第 0 项
optPicked(group) {
return this.data.optSel[group] !== undefined;
},
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
onGenRisk() {
const id = store.currentPetId();
if (!this.optPicked('symp')) {
wx.showToast({ title: '先选一下发生了什么', icon: 'none' });
return;
}
const symptoms = ['呕吐', '拉稀', '精神差', '皮肤红'];
const durations = ['刚刚', '半天', '1天以上'];
const spirits = ['正常', '一般', '明显变差'];
const body = {
symptoms: [symptoms[this.optIdx('symp')]],
duration: durations[this.segIdx('dur')],
spirit: spirits[this.segIdx('spirit')],
};
if (!id) {
this.setData({ innerType: 'risk', riskData: null });
return;
}
wx.showLoading({ title: '分析中...' });
api
.assessSymptom(id, body)
.then((res) => {
wx.hideLoading();
this.setData({ innerType: 'risk', riskData: res });
})
.catch(() => {
wx.hideLoading();
this.setData({ innerType: 'risk', riskData: null });
});
},
// 依据当前弹层类型组装一条健康记录
buildRecord() {
const t = this.data.innerType;
// 简易类型统一在这里组装。icon 故意留空:那 9 种老类型的 icon 里存的是
// emoji(写库值,不能动),新类型要是存 pt-icon 名,这一列就变成两套语义了。
// 渲染本来就按 type 取图标(type code 和图标名是同一个词),不需要这一列
if (this.data.isSimple) {
const img = (this.data.simpleImages || [])[0];
return {
type: t,
title: this.data.simpleLabel || t,
description: (this.data.simpleNote || '').trim(),
occurred_at: dayToISO(this.data.simpleDate),
image_file_id: img ? img.id : '',
image_url: img ? img.url : '',
};
}
switch (t) {
case 'poop': {
const s = POOP[this.segIdx('poopState')];
return {
type: 'poop', icon: '💩', title: '排便记录:' + s, category: s,
description: (this.data.poopNote || '').trim(),
};
}
case 'food': {
const a = FOOD[this.segIdx('food')];
const what = (this.data.foodText || '').trim();
return {
type: 'food', icon: '🍽️',
title: what ? '饮食记录:' + what : '饮食记录:食欲' + a,
category: a,
description: what ? '食欲' + a : '',
};
}
case 'medicine': {
const name = (this.data.medName || '').trim();
const dose = (this.data.medDose || '').trim();
return {
type: 'medicine', icon: '💊',
title: name ? '用药:' + name : '用药记录',
description: dose,
};
}
case 'cost': {
const c = COST[this.segIdx('cost')];
const amt = parseFloat(this.data.costAmount) || 0;
return {
type: 'cost', icon: '💰', title: '消费记录:¥' + amt, num_value: amt, category: c,
description: (this.data.costNote || '').trim(),
};
}
case 'vaccine':
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
case 'deworm':
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
case 'risk': {
// 用 AI 返回的真实风险等级,而不是写死「中」
const r = this.data.riskData || {};
const lv = r.risk_level || '中';
const desc = [r.causes, r.suggestion].filter(Boolean).join('');
return { type: 'symptom', icon: '🤒', title: '异常观察:风险' + lv, category: lv, description: desc };
}
case 'vetSummary':
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
default:
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() {
const t = this.data.innerType;
if (t === 'cost' && !(parseFloat(this.data.costAmount) > 0)) {
wx.showToast({ title: '先填个金额', icon: 'none' });
return;
}
if (t === 'medicine' && !(this.data.medName || '').trim()) {
wx.showToast({ title: '先填药品名称', icon: 'none' });
return;
}
this.createAndClose(this.buildRecord());
},
onSaveWeight() {
const w = (this.data.wInput || '').toString().replace(/\s+/g, '').replace('kg', '');
const num = parseFloat(w);
// 原来空值会静默存成「上次的体重」,等于凭空造一条假数据
if (!w || !(num > 0)) {
wx.showToast({ title: '填一个有效体重', icon: 'none' });
return;
}
const weight = w + 'kg';
this.createAndClose({ type: 'weight', icon: '⚖️', title: '体重 ' + weight, num_value: num, description: (this.data.wNote || '').trim() });
},
onSavePhoto() {
const id = store.currentPetId();
if (!id) return this.close();
upload
.chooseAndUploadImage()
.then((f) =>
api.createRecord(id, { type: 'photo', icon: '📷', title: '成长照片', image_file_id: f.id, image_url: f.url }),
)
.then((saved) => {
wx.showToast({ title: '照片已保存', icon: 'success' });
this.triggerEvent('saved', saved);
this.close();
})
.catch((e) => {
if (e && e.canceled) return;
wx.showToast({ title: (e && e.message) || '上传失败', icon: 'none' });
});
},
// 组装宠物档案表单
petFormBody() {
const isDog = this.segIdx('addType') === 1;
return {
name: (this.data.addName || '').trim(),
type: isDog ? '狗狗' : '猫猫',
emoji: isDog ? '🐶' : '🐱',
gender: GENDERS[this.segIdx('addGender')],
birthday: this.data.addBirthday,
weight: this.data.addWeight,
color: (this.data.addColor || '').trim(),
breed: (this.data.addBreed || '').trim(),
stage: STAGES[this.optIdx('addStage')],
age: ageFromBirthday(this.data.addBirthday),
};
},
// 添加/编辑 共用提交入口
onPetSubmit() {
if (this.data.innerType === 'editPet') return this.onEditPetSubmit();
return this.onAddPetSubmit();
},
async onAddPetSubmit() {
const body = this.petFormBody();
if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' });
if (this.data.saving) return;
this.setData({ saving: true });
try {
await api.createPet(body);
await store.loadPets();
this.triggerEvent('petadded');
this.close();
} catch (e) {
wx.showToast({ title: e.message || '添加失败', icon: 'none' });
this.setData({ saving: false });
}
},
async onEditPetSubmit() {
const body = this.petFormBody();
if (!body.name) return wx.showToast({ title: '请输入名字', icon: 'none' });
const id = store.currentPetId();
if (!id) return this.close();
if (this.data.saving) return;
this.setData({ saving: true });
try {
await api.updatePet(id, body);
await store.loadPets();
this.triggerEvent('petadded');
this.close();
} catch (e) {
wx.showToast({ title: e.message || '保存失败', icon: 'none' });
this.setData({ saving: false });
}
},
onDeletePet() {
const id = store.currentPetId();
const name = this.data.pet.name || '这只宠物';
if (!id) return;
wx.showModal({
title: '删除宠物',
content: `删除「${name}」后,它的记录、计划、提醒都会一并移除,且不可恢复。确定删除吗?`,
confirmText: '删除',
confirmColor: '#EE7D73',
success: (res) => {
if (!res.confirm) return;
api
.deletePet(id)
.then(async () => {
await store.loadPets();
this.triggerEvent('petadded');
this.close();
if (store.getPets().length === 0) {
wx.reLaunch({ url: '/pages/onboarding/onboarding' });
}
})
.catch((e) => wx.showToast({ title: e.message || '删除失败', icon: 'none' }));
},
});
},
// 发帖
async onCreatePost() {
const content = (this.data.postContent || '').trim();
if (!content) return wx.showToast({ title: '写点什么吧', icon: 'none' });
const identity = IDENTITY[this.segIdx('ident')];
const tag = PTAG[this.data.optSel.ptag === undefined ? 0 : this.data.optSel.ptag];
if (this.data.saving) return;
this.setData({ saving: true });
try {
await api.createPost({
pet_id: identity === 'petName' ? store.currentPetId() : null,
identity,
content,
tags: [tag],
image_file_ids: this.data.postImages.map((i) => i.id),
});
this.triggerEvent('posted');
this.close();
} catch (e) {
wx.showToast({ title: e.message || '发布失败', icon: 'none' });
this.setData({ saving: false });
}
},
// 评论
async onSendComment() {
const text = (this.data.commentText || '').trim();
// 原来空评论会直接把弹层关掉,看起来像发成功了。改成明确提示
if (!text) return wx.showToast({ title: '写点什么再发', icon: 'none' });
if (!this.data.postId) return this.close();
try {
await api.createComment(
this.data.postId,
text,
this.data.commentImages.map((i) => i.url),
this.data.replyTo.id || '',
);
this.setData({ commentText: '', commentImages: [], replyTo: {} });
this.loadComments();
this.triggerEvent('commented');
} catch (e) {
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
}
},
// 评论配图,最多 3 张
onPickCommentImages() {
const left = 3 - this.data.commentImages.length;
if (left <= 0) return wx.showToast({ title: '最多 3 张', icon: 'none' });
upload
.chooseAndUploadImages(left)
.then((files) => this.setData({ commentImages: this.data.commentImages.concat(files) }))
.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;
this.setData({ replyTo: { id, name }, cmFocus: true });
},
onCommentFocus(e) {
// adjust-position=false,键盘高度自己接管:弹层是 fixed 定位,
// 交给系统顶会把整块推出屏幕
this.setData({ kbHeight: (e.detail && e.detail.height) || 0 });
},
onCommentBlur() {
this.setData({ kbHeight: 0, 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 });
},
},
});