diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.js b/pets-fe/components/bottom-sheet/bottom-sheet.js index bded3c9..0c5a82a 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.js +++ b/pets-fe/components/bottom-sheet/bottom-sheet.js @@ -1,4 +1,5 @@ const store = require('../../utils/store.js'); +const recordTypes = require('../../utils/recordTypes.js'); const api = require('../../utils/api.js'); const upload = require('../../utils/upload.js'); @@ -15,6 +16,21 @@ function daysLater(n) { 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 ''; @@ -43,16 +59,15 @@ const GENDERS = ['男孩', '女孩', '不确定']; const STAGES = ['刚到家 0-30 天', '幼年期', '成年期', '老年期']; // 任务可关联的记录弹层类型 -const TASK_SHEETS = [ - { key: '', label: '不关联' }, - { key: 'weight', label: '体重' }, - { key: 'poop', label: '便便' }, - { key: 'food', label: '饮食' }, - { key: 'symptom', label: '异常' }, - { key: 'vaccine', label: '疫苗' }, - { key: 'medicine', label: '用药' }, -]; -const TASK_SHEET_LABELS = TASK_SHEETS.map((s) => s.label); +// 任务能关联的记录类型。原来这里也是写死的 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 = [ @@ -63,6 +78,26 @@ const REMINDER_TYPES = [ ]; 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 = ['食品', '医疗', '用品']; @@ -83,6 +118,13 @@ Component({ segSel: {}, optSel: {}, dateVals: { vaccine: '', deworm: '' }, + // 通用简易记录(洗护/清洁那 15 种共用) + isSimple: false, + simpleLabel: '', + simpleDate: '', + simpleNote: '', + simpleImages: [], + simplePlaceholder: '', wInput: '', wNote: '', poopNote: '', @@ -95,7 +137,7 @@ Component({ postImages: [], manageTasks: [], taskForm: { id: '', title: '', description: '', priority: '', sheetIdx: 0 }, - taskSheetLabels: TASK_SHEET_LABELS, + taskSheetLabels: [], remForm: { id: '', typeIdx: 0, title: '', date: '', freq: '' }, remTypeLabels: REMINDER_LABELS, myPets: [], @@ -155,6 +197,7 @@ Component({ 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 = ''; @@ -172,6 +215,18 @@ Component({ 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', ''); @@ -399,6 +454,24 @@ Component({ 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 }); }, @@ -608,7 +681,8 @@ Component({ onTaskSheet(e) { this.setData({ 'taskForm.sheetIdx': Number(e.detail.value) }); }, onEditTaskItem(e) { const t = this.data.manageTasks[e.currentTarget.dataset.index]; - let sheetIdx = TASK_SHEETS.findIndex((s) => s.key === (t.sheet_type || '')); + 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 }, @@ -618,7 +692,8 @@ Component({ const f = this.data.taskForm; const title = (f.title || '').trim(); if (!title) return wx.showToast({ title: '填个任务名', icon: 'none' }); - const body = { title, description: f.description, priority: f.priority, sheet_type: TASK_SHEETS[f.sheetIdx].key }; + 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(() => { @@ -713,6 +788,20 @@ Component({ // 依据当前弹层类型组装一条健康记录 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')]; diff --git a/pets-fe/components/bottom-sheet/bottom-sheet.wxml b/pets-fe/components/bottom-sheet/bottom-sheet.wxml index adb50f4..3064cf2 100644 --- a/pets-fe/components/bottom-sheet/bottom-sheet.wxml +++ b/pets-fe/components/bottom-sheet/bottom-sheet.wxml @@ -232,6 +232,32 @@ module.exports.sel = function (map, key, index, def) { + + + 记录{{simpleLabel}} + 选个时间、写句备注就行,需要的话可以附张照片。 + + + {{simpleDate}} + + + + + + + + + + + + + + + 提醒中心 diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index ef9a255..36853e3 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -2,6 +2,7 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const { toastErr } = require('../../utils/ui.js'); const { syncTabBar } = require('../../utils/tabbar.js'); +const recordTypes = require('../../utils/recordTypes.js'); function todayStr() { const d = new Date(); @@ -52,18 +53,14 @@ function mapTask(t) { }; } -// 快速记录入口。颜色按类别分组(体重/疫苗=橙、便便/饮食=绿、异常/用药=蓝、消费/照片=紫), -// 靠颜色和图标区分,不再靠 emoji。 -const QUICK_ITEMS = [ - { type: 'weight', icon: 'weight', label: '体重', tone: 'tone-1' }, - { type: 'poop', icon: 'poop', label: '便便', tone: 'tone-2' }, - { type: 'food', icon: 'food', label: '饮食', tone: 'tone-2' }, - { type: 'symptom', icon: 'symptom', label: '异常', tone: 'tone-3' }, - { type: 'cost', icon: 'cost', label: '消费', tone: 'tone-4' }, - { type: 'medicine', icon: 'medicine', label: '用药', tone: 'tone-3' }, - { type: 'photo', icon: 'photo', label: '照片', tone: 'tone-4' }, - { type: 'vaccine', icon: 'vaccine', label: '疫苗', tone: 'tone-1' }, -]; +// 快速记录入口取后端类型表的前 8 个(按分组顺序再按 sort)。 +// 原来这里是 8 项写死的,后台加一种类型首页看不到;现在调 sort 就能换首屏露出哪几个。 +// 首页只放 8 个是版式限制(4 列 2 行),全部 24 种在记录页 +function quickFrom(groups) { + const flat = []; + (groups || []).forEach((g) => g.items.forEach((t) => flat.push(t))); + return flat.slice(0, 8); +} // 门面上的状态 chip:健康状态 + 最多两条洞察 function heroChips(summary) { @@ -82,7 +79,7 @@ Page({ pet: {}, summary: { greeting: '', insights: [], week: [], advice: '', health_pct: 0, health_status: '正常' }, tasks: [], - quickItems: QUICK_ITEMS, + quickItems: [], heroChips: [], undoneText: '', firstArticle: null, @@ -113,6 +110,7 @@ Page({ }, onShow() { syncTabBar(this); + this.loadTypes(); store .ready() .then(() => { @@ -122,6 +120,13 @@ Page({ }) .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); }, + loadTypes() { + if (this.data.quickItems.length) return; + recordTypes + .load() + .then((groups) => this.setData({ quickItems: quickFrom(groups) })) + .catch(() => {}); + }, loadAll() { this.loadSummary(); this.loadTasks(); diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index 9a86a2d..3432c19 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -111,8 +111,8 @@ 更多 - - {{item.label}} + + {{item.label}} diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js index 6302213..edfa693 100644 --- a/pets-fe/pages/record/record.js +++ b/pets-fe/pages/record/record.js @@ -2,6 +2,7 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const { toastErr } = require('../../utils/ui.js'); const { syncTabBar } = require('../../utils/tabbar.js'); +const recordTypes = require('../../utils/recordTypes.js'); function pad(n) { return n < 10 ? '0' + n : '' + n; @@ -18,32 +19,22 @@ function fmtTime(iso) { } // 记录类型 → 配色。和首页快速记录入口用同一套分组: // 体重/疫苗=橙、便便/饮食=绿、异常/用药=蓝、消费/照片=紫 -const TYPE_TONE = { - weight: 'tone-1', vaccine: 'tone-1', deworm: 'tone-1', - poop: 'tone-2', food: 'tone-2', - symptom: 'tone-3', medicine: 'tone-3', - cost: 'tone-4', photo: 'tone-4', -}; +// 时间轴每条记录的色调跟着它所属的分组走,和「记一笔」的宫格是同一套色。 +// 原来这里是 9 种写死的映射,新类型会全掉到 tone-1,一片橙色分不出类别 +function toneOf(type) { + const t = recordTypes.get(type); + return (t && t.tone) || 'tone-1'; +} -// 记一笔的九个入口 -const RECORD_ITEMS = [ - { type: 'weight', icon: 'weight', label: '体重', tone: 'tone-1' }, - { type: 'food', icon: 'food', label: '饮食', tone: 'tone-2' }, - { type: 'poop', icon: 'poop', label: '便便', tone: 'tone-2' }, - { type: 'symptom', icon: 'symptom', label: '异常', tone: 'tone-3' }, - { type: 'medicine', icon: 'medicine', label: '用药', tone: 'tone-3' }, - { type: 'cost', icon: 'cost', label: '消费', tone: 'tone-4' }, - { type: 'vaccine', icon: 'vaccine', label: '疫苗', tone: 'tone-1' }, - { type: 'deworm', icon: 'deworm', label: '驱虫', tone: 'tone-1' }, - { type: 'photo', icon: 'photo', label: '照片', tone: 'tone-4' }, -]; +// 「记一笔」的入口不再写死在这里:类型和分组由后端 record_types 表提供 +// (后台可配),前端只负责渲染。utils/recordTypes.js 是两处共用的缓存。 function mapRecord(r) { return { id: r.id, // 图标按 type 取(9 值枚举,可靠);老数据 icon 字段里的 emoji 只作兜底 type: r.type || r.icon || '', - tone: TYPE_TONE[r.type] || 'tone-1', + tone: toneOf(r.type), title: r.title, desc: fmtTime(r.occurred_at) + (r.description ? '|' + r.description : ''), image: r.image_url || '', @@ -111,7 +102,7 @@ Page({ recHasMore: false, recFilters: REC_FILTERS, recFilterIdx: 0, - recordItems: RECORD_ITEMS, + typeGroups: [], insights: [], trendSvg: '', trendStats: null, @@ -133,6 +124,7 @@ Page({ }, onShow() { syncTabBar(this); + this.loadTypes(); store .ready() .then(() => { @@ -184,6 +176,15 @@ Page({ const it = this.data.insights[e.currentTarget.dataset.index]; if (it && it.action) this.openSheetType(it.action); }, + // 类型只在缓存没热时真正请求;弹层也读这份缓存判断走哪种表单, + // 所以这一步必须在用户能点开弹层之前完成 + loadTypes() { + if (this.data.typeGroups.length) return; + recordTypes + .load() + .then((groups) => this.setData({ typeGroups: groups })) + .catch(() => {}); + }, loadRecords() { const id = store.currentPetId(); if (!id) return; diff --git a/pets-fe/pages/record/record.wxml b/pets-fe/pages/record/record.wxml index 7165bdb..133fc76 100644 --- a/pets-fe/pages/record/record.wxml +++ b/pets-fe/pages/record/record.wxml @@ -11,13 +11,20 @@ 建一份档案 + 记一笔 - - - {{item.label}} + + {{item.label}}{{item.items.length}} + + + {{t.label}} + + 还没有配置可记事项 diff --git a/pets-fe/pages/record/record.wxss b/pets-fe/pages/record/record.wxss index 25c7996..4c33bec 100644 --- a/pets-fe/pages/record/record.wxss +++ b/pets-fe/pages/record/record.wxss @@ -57,3 +57,19 @@ .ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4} .ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)} .ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)} + +/* ===== 记一笔的分组 ===== + 四组各一个色系,色值全走 token 的 soft/ink 二件套,没有新色。 + .quick 的配色由 .tone-N 决定(app.wxss 里已有),这里只管组标题和留白 */ +.tg{margin-bottom:var(--sp-4)} +.tg:last-of-type{margin-bottom:0} +.tg-head{ + display:flex;align-items:center;gap:var(--sp-2); + font-size:var(--fs-md);font-weight:var(--fw-b);margin-bottom:var(--sp-3); +} +.tg-bar{width:6rpx;height:26rpx;border-radius:3rpx;background:currentColor} +.tg-n{margin-left:auto;font-size:var(--fs-cap);font-weight:var(--fw);color:var(--muted2)} +.tg.tone-1 .tg-head{color:var(--primary-ink)} +.tg.tone-2 .tg-head{color:var(--green-ink)} +.tg.tone-3 .tg-head{color:var(--blue-ink)} +.tg.tone-4 .tg-head{color:var(--purple-ink)} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index ce01589..8c654fb 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -46,6 +46,8 @@ const api = { // 用户 userSummary: () => request({ url: '/api/user/summary' }), + // 记录类型(后台可配)。分组好的,前端直接渲染 + recordTypes: () => request({ url: '/api/record-types' }), updateDecoration: (body) => request({ url: '/api/user/decoration', method: 'PUT', data: body }), // 宠物 diff --git a/pets-fe/utils/recordTypes.js b/pets-fe/utils/recordTypes.js new file mode 100644 index 0000000..e0c16a9 --- /dev/null +++ b/pets-fe/utils/recordTypes.js @@ -0,0 +1,78 @@ +// 记录类型缓存。类型从后端来(后台可配),记录页要拿它渲染分组宫格, +// 弹层要拿它判断走专用表单还是通用简易表单。两边必须是同一份数据—— +// 各自请求一次的话,后台改完配置两边会短暂不一致。 +const api = require('./api.js'); + +// 四个分组对应的色系。这是纯表现层的东西,不进后端配置: +// 后端存 group key,色值留在前端,改配色不用动数据 +const GROUP_TONE = { + daily: 'tone-1', + health: 'tone-2', + care: 'tone-4', + clean: 'tone-3', +}; + +let groups = null; // [{ key, label, items: [...] }] +let byCode = {}; +let loading = null; + +function index(gs) { + groups = (gs || []).map((g) => ({ + ...g, + tone: GROUP_TONE[g.key] || 'tone-1', + items: (g.items || []).map((t) => ({ ...t, tone: GROUP_TONE[g.key] || 'tone-1' })), + })); + byCode = {}; + groups.forEach((g) => g.items.forEach((t) => (byCode[t.code] = t))); + return groups; +} + +// load 只真正请求一次;并发调用共用同一个 promise +function load() { + if (groups) return Promise.resolve(groups); + if (loading) return loading; + loading = api + .recordTypes() + .then((gs) => { + loading = null; + return index(gs); + }) + .catch((e) => { + loading = null; + throw e; + }); + return loading; +} + +// get 是同步的,给弹层用——弹层打开时不该再等一次网络请求。 +// 记录页/首页先 load 过,缓存已经热了;万一没热,返回 undefined, +// 调用方按「专用表单」处理(那 9 种的分支是写死在 wxml 里的,不依赖这份缓存) +function get(code) { + return byCode[code]; +} + +// isSimple 走通用简易表单的类型。缓存没热时返回 false, +// 宁可打不开也不要用错的表单存下一条字段不全的记录 +function isSimple(code) { + const t = byCode[code]; + return !!t && t.form === 'simple'; +} + +function label(code) { + const t = byCode[code]; + return (t && t.label) || ''; +} + +// 后台改了配置之后强制重取 +function reset() { + groups = null; + byCode = {}; + loading = null; +} + +// groups 同步读缓存,给「任务关联记录类型」那个下拉用 +function all() { + return groups; +} + +module.exports = { load, get, isSimple, label, reset, groups: all, GROUP_TONE };