2f5dd6eefc
后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页
小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整
后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
479 lines
16 KiB
JavaScript
479 lines
16 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() + '日';
|
|
}
|
|
|
|
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 天', '幼年期', '成年期', '老年期'];
|
|
|
|
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: '' },
|
|
postId: { type: Number, value: 0 },
|
|
},
|
|
data: {
|
|
innerType: '',
|
|
pet: {},
|
|
segSel: {},
|
|
optSel: {},
|
|
dateVals: { vaccine: '2026-07-21', deworm: '2026-07-15' },
|
|
wInput: '',
|
|
wNote: '',
|
|
costAmount: '',
|
|
postContent: '',
|
|
postImages: [],
|
|
addName: '',
|
|
addBirthday: '',
|
|
addWeight: '',
|
|
addColor: '',
|
|
addBreed: '',
|
|
commentText: '',
|
|
aiInput: '',
|
|
aiMessages: [],
|
|
riskData: null,
|
|
comments: [],
|
|
reminders: [],
|
|
poster: null,
|
|
reportDetail: null,
|
|
proInfo: null,
|
|
saving: 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.optSel = {};
|
|
patch.costAmount = '';
|
|
patch.postContent = '';
|
|
patch.postImages = [];
|
|
patch.addName = '';
|
|
patch.addBirthday = '';
|
|
patch.addWeight = '';
|
|
patch.addColor = '';
|
|
patch.addBreed = '';
|
|
patch.commentText = '';
|
|
patch.riskData = null;
|
|
patch.saving = false;
|
|
}
|
|
if (type === 'weight') {
|
|
patch.wInput = (pet.weight || '').replace('kg', '');
|
|
patch.wNote = '';
|
|
}
|
|
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)) };
|
|
}
|
|
if (type === 'ai') {
|
|
patch.aiMessages = [
|
|
{
|
|
role: 'ai',
|
|
text: `我是 ${pet.name || '毛孩子'} 的养宠助手。我会优先基于档案、计划和历史记录回答,而不是泛泛聊天。`,
|
|
},
|
|
];
|
|
patch.aiInput = '';
|
|
}
|
|
this.setData(patch);
|
|
this.loadSheetData(type);
|
|
},
|
|
|
|
// 按弹层类型拉取真实数据
|
|
loadSheetData(type) {
|
|
const id = store.currentPetId();
|
|
if (type === 'comments' && this.data.postId) {
|
|
api.listComments(this.data.postId).then((page) => this.setData({ comments: page.list || [] })).catch(() => {});
|
|
} else if (type === 'reminders' && id) {
|
|
api
|
|
.getReminders(id)
|
|
.then((list) =>
|
|
this.setData({
|
|
reminders: (list || []).map((r) => ({
|
|
title: r.title,
|
|
when: r.next_due_date ? fmtDate(r.next_due_date) : r.frequency || '',
|
|
})),
|
|
}),
|
|
)
|
|
.catch(() => {});
|
|
} 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 });
|
|
}
|
|
},
|
|
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 });
|
|
},
|
|
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 }); },
|
|
onCostInput(e) { this.setData({ costAmount: e.detail.value }); },
|
|
onPostInput(e) { this.setData({ postContent: e.detail.value }); },
|
|
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;
|
|
},
|
|
|
|
// 异常观察 → 生成风险评估(真调后端 AI,失败回退)
|
|
onGenRisk() {
|
|
const id = store.currentPetId();
|
|
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;
|
|
switch (t) {
|
|
case 'poop': {
|
|
const s = POOP[this.segIdx('poopState')];
|
|
return { type: 'poop', icon: '💩', title: '排便记录:' + s, category: s };
|
|
}
|
|
case 'food': {
|
|
const a = FOOD[this.segIdx('food')];
|
|
return { type: 'food', icon: '🍽️', title: '饮食记录:食欲' + a };
|
|
}
|
|
case 'medicine':
|
|
return { type: 'medicine', icon: '💊', title: '用药记录' };
|
|
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 };
|
|
}
|
|
case 'vaccine':
|
|
return { type: 'vaccine', icon: '💉', title: '疫苗提醒:' + this.data.dateVals.vaccine };
|
|
case 'deworm':
|
|
return { type: 'deworm', icon: '🛡️', title: '驱虫提醒:' + this.data.dateVals.deworm };
|
|
case 'risk':
|
|
return { type: 'symptom', icon: '🤒', title: '异常观察:风险中', category: '中' };
|
|
case 'vetSummary':
|
|
return { type: 'note', icon: '📄', title: '生成就医前摘要' };
|
|
default:
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async createAndClose(rec) {
|
|
const id = store.currentPetId();
|
|
if (!rec || !id) {
|
|
this.close();
|
|
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());
|
|
},
|
|
onSaveWeight() {
|
|
let w = (this.data.wInput || '').toString().replace(/\s+/g, '');
|
|
if (!w) w = (this.data.pet.weight || '').replace('kg', '');
|
|
const num = parseFloat(w) || 0;
|
|
const weight = w.includes('kg') ? w : 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) => {
|
|
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 || !this.data.postId) return this.close();
|
|
try {
|
|
await api.createComment(this.data.postId, text);
|
|
this.triggerEvent('commented');
|
|
} catch (e) {
|
|
wx.showToast({ title: e.message || '评论失败', icon: 'none' });
|
|
}
|
|
this.close();
|
|
},
|
|
|
|
// AI 聊天
|
|
onAiInput(e) { this.setData({ aiInput: e.detail.value }); },
|
|
sendAI(e) {
|
|
const preset = e.currentTarget.dataset.text;
|
|
const text = (preset || this.data.aiInput || '').trim();
|
|
if (!text) return;
|
|
const msgs = this.data.aiMessages.concat([{ role: 'user', text }]);
|
|
this.setData({ aiMessages: msgs, aiInput: '' });
|
|
api
|
|
.aiChat({ pet_id: store.currentPetId() || null, session: 'sheet', text })
|
|
.then((res) => {
|
|
this.setData({ aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: res.reply }]) });
|
|
})
|
|
.catch(() => {
|
|
this.setData({
|
|
aiMessages: this.data.aiMessages.concat([{ role: 'ai', text: '网络异常,请稍后再试。' }]),
|
|
});
|
|
});
|
|
},
|
|
},
|
|
});
|