diff --git a/pets-be/internal/handler/plan.go b/pets-be/internal/handler/plan.go index a90988d..cd91000 100644 --- a/pets-be/internal/handler/plan.go +++ b/pets-be/internal/handler/plan.go @@ -181,3 +181,54 @@ func (h *Handler) TogglePlanTask(c *gin.Context) { } response.OK(c, gin.H{"ok": true}) } + +// planTaskReq 用户自己加/改的计划节点 +type planTaskReq struct { + Date string `json:"date"` + Title string `json:"title"` + Description string `json:"description"` + SheetType string `json:"sheet_type"` +} + +func (r planTaskReq) input() service.PlanTaskInput { + return service.PlanTaskInput{Date: r.Date, Title: r.Title, + Description: r.Description, SheetType: r.SheetType} +} + +// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条 +func (h *Handler) AddPlanTask(c *gin.Context) { + var req planTaskReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailParams(c, err.Error()) + return + } + t, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input()) + if err != nil { + respondErr(c, err) + return + } + response.OK(c, t) +} + +// UpdatePlanTask PUT /api/plan-tasks/:id +func (h *Handler) UpdatePlanTask(c *gin.Context) { + var req planTaskReq + if err := c.ShouldBindJSON(&req); err != nil { + response.FailParams(c, err.Error()) + return + } + if err := h.svc.UpdatePlanTask(middleware.UserID(c), idParam(c, "id"), req.input()); err != nil { + respondErr(c, err) + return + } + response.OK(c, nil) +} + +// DeletePlanTask DELETE /api/plan-tasks/:id +func (h *Handler) DeletePlanTask(c *gin.Context) { + if err := h.svc.DeletePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil { + respondErr(c, err) + return + } + response.OK(c, nil) +} diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index 0698cb2..f3dbcf5 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -79,6 +79,10 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.POST("/pets/:id/ai-plan", h.CreateAIPlan) g.POST("/ai-plan/:id/apply", h.ApplyAIPlan) g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask) + // 用户自己管理计划节点(原来只能勾选内置模板生成的那些) + g.POST("/pets/:id/plan-tasks", h.AddPlanTask) + g.PUT("/plan-tasks/:id", h.UpdatePlanTask) + g.DELETE("/plan-tasks/:id", h.DeletePlanTask) g.GET("/pets/:id/reminders", h.ListReminders) g.POST("/pets/:id/reminders", h.CreateReminder) diff --git a/pets-be/internal/service/plan.go b/pets-be/internal/service/plan.go index 4e5e75f..6d9da80 100644 --- a/pets-be/internal/service/plan.go +++ b/pets-be/internal/service/plan.go @@ -1,7 +1,9 @@ package service import ( + "errors" "math" + "strconv" "strings" "time" @@ -274,3 +276,135 @@ func extractAIInfo(input, stage string) map[string]string { "reminder": reminder, } } + +// PlanTaskInput 用户自己加/改的计划节点 +type PlanTaskInput struct { + Date string // "2026-08-05",落到 Day = 这天 - 计划开始日 + Title string + Description string + SheetType string // 关联的记录类型 code,可空 +} + +// planOf 取这只宠物当前的 30 天计划,顺带做归属校验 +func (s *Service) planOf(userID, petID string) (*model.Plan, error) { + pet, err := s.ownedPet(userID, petID) + if err != nil { + return nil, err + } + s.ensurePlan(pet) + var plan model.Plan + if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?", + petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil { + return nil, ErrNotFound + } + return &plan, nil +} + +// planSpanDays 计划覆盖到第几天。内置模板会生成 day=30 的节点(第 31 天), +// 所以窗口是 [0, 30] 而不是 [0, 29]——按 29 卡的话模板自己生成的最后一个节点 +// 就落在窗口外:日历上没有那一格,用户看得到却改不动也删不掉。 +const planSpanDays = 30 + +// dayOffset 把日期换算成计划里的 Day。只接受计划开始日起 planSpanDays 天内—— +// 「只做 30 天内的计划」是产品定的边界,超出的话日历上根本没有那一格 +func dayOffset(plan *model.Plan, dateStr string) (int, error) { + if plan.StartDate == nil { + return 0, errors.New("计划还没有开始日期") + } + d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local) + if err != nil { + return 0, ErrInvalidParam + } + start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(), + 0, 0, 0, 0, time.Local) + day := int(d.Sub(start).Hours() / 24) + if day < 0 || day > planSpanDays { + return 0, errors.New("只能安排计划开始后 30 天内的事,这天超出范围了") + } + return day, nil +} + +// AddPlanTask 用户自己往计划里加一条 +func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) (*model.PlanTask, error) { + if strings.TrimSpace(in.Title) == "" { + return nil, ErrInvalidParam + } + plan, err := s.planOf(userID, petID) + if err != nil { + return nil, err + } + day, err := dayOffset(plan, in.Date) + if err != nil { + return nil, err + } + t := model.PlanTask{ + PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day), + Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), + SheetType: in.SheetType, + } + if err := s.db.Create(&t).Error; err != nil { + return nil, err + } + t.Date = plan.StartDate.AddDate(0, 0, day).Format("2006-01-02") + return &t, nil +} + +// UpdatePlanTask 改标题/描述/日期 +func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error { + plan, t, err := s.ownedPlanTask(userID, taskID) + if err != nil { + return err + } + fields := map[string]any{} + if strings.TrimSpace(in.Title) != "" { + fields["title"] = strings.TrimSpace(in.Title) + } + fields["description"] = strings.TrimSpace(in.Description) + if in.Date != "" { + day, err := dayOffset(plan, in.Date) + if err != nil { + return err + } + fields["day"] = day + fields["day_label"] = dayLabelFor(day) + } + return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error +} + +// DeletePlanTask 删掉一条 +func (s *Service) DeletePlanTask(userID, taskID string) error { + _, t, err := s.ownedPlanTask(userID, taskID) + if err != nil { + return err + } + return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error +} + +// ownedPlanTask 校验这条节点属于当前用户的宠物。 +// 不校验的话,拿到别人的 task id 就能改删别人的计划 +func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) { + var t model.PlanTask + if err := s.db.First(&t, "id = ?", taskID).Error; err != nil { + return nil, nil, ErrNotFound + } + var plan model.Plan + if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil { + return nil, nil, ErrNotFound + } + if _, err := s.ownedPet(userID, plan.PetID); err != nil { + return nil, nil, ErrForbidden + } + return &plan, &t, nil +} + +// dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致 +func dayLabelFor(day int) string { + switch { + case day == 0: + return "今天" + case day == 1: + return "明天" + default: + return "第 " + strconv.Itoa(day+1) + " 天" + } +} diff --git a/pets-fe/app.json b/pets-fe/app.json index 6d92f94..6947644 100644 --- a/pets-fe/app.json +++ b/pets-fe/app.json @@ -6,6 +6,7 @@ "pages/record/record", "pages/addrecord/addrecord", "pages/history/history", + "pages/expense/expense", "pages/report/report", "pages/community/community", "pages/learn/learn", diff --git a/pets-fe/app.wxss b/pets-fe/app.wxss index 88d1da3..bd70994 100644 --- a/pets-fe/app.wxss +++ b/pets-fe/app.wxss @@ -444,3 +444,18 @@ page{scrollbar-width:none;-ms-overflow-style:none} .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)} + +/* ===== 日历网格 ===== + 首页(可展开日历)和计划页(30 天计划日历)都在用 */ +.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--sp-3);font-weight:var(--fw-b);font-size:var(--fs-lg)} +.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:var(--sp-2);text-align:center} +.cal-w{font-size:var(--fs-cap);color:var(--muted);padding:var(--sp-1) 0} +.cal-cell{ + height:76rpx;border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center; + font-weight:var(--fw-b);font-size:var(--fs-sm);background:var(--surface-2);position:relative; +} +.cal-cell.blank{background:transparent} +.cal-cell.today{background:var(--primary-soft);color:var(--primary-dark)} +.cal-cell.tasked::after{content:"";position:absolute;bottom:8rpx;width:8rpx;height:8rpx;border-radius:50%;background:var(--green)} +.cal-cell.sel{background:var(--primary);color:#fff} +.cal-cell.sel.tasked::after{background:#fff} diff --git a/pets-fe/pages/addrecord/addrecord.wxml b/pets-fe/pages/addrecord/addrecord.wxml index 361118f..0d3c982 100644 --- a/pets-fe/pages/addrecord/addrecord.wxml +++ b/pets-fe/pages/addrecord/addrecord.wxml @@ -25,14 +25,14 @@ {{item.label}} - {{item.unit}} {{item.label}} - @@ -49,7 +49,7 @@ 描述 - diff --git a/pets-fe/pages/addrecord/addrecord.wxss b/pets-fe/pages/addrecord/addrecord.wxss index a6e4cf7..9b4511f 100644 --- a/pets-fe/pages/addrecord/addrecord.wxss +++ b/pets-fe/pages/addrecord/addrecord.wxss @@ -10,7 +10,6 @@ .ar-k{flex:none;font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} .ar-v{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text-2)} .ar-in{flex:1;text-align:right;font-size:var(--fs-md);color:var(--text)} -.ar-ph{color:var(--muted2)} .ar-unit{flex:none;font-size:var(--fs-sm);color:var(--muted)} /* 单选独占一段:选项多的时候挤在一行右侧会换行成一团 */ diff --git a/pets-fe/pages/expense/expense.js b/pets-fe/pages/expense/expense.js new file mode 100644 index 0000000..a4a44ab --- /dev/null +++ b/pets-fe/pages/expense/expense.js @@ -0,0 +1,158 @@ +const store = require('../../utils/store.js'); +const api = require('../../utils/api.js'); +const recordTypes = require('../../utils/recordTypes.js'); +const { toastErr } = require('../../utils/ui.js'); + +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function today() { + const d = new Date(); + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} +// 同 addrecord:后端用 time.Parse(time.RFC3339) 解 occurred_at,只给日期解不出来会 +// 静默回退成 now;时区要带真实偏移,转 UTC 的 Z 形式落库会把边界日期挪一天 +function dayToISO(day) { + if (!day) return ''; + const off = -new Date().getTimezoneOffset(); + const sign = off >= 0 ? '+' : '-'; + const a = Math.abs(off); + return day + 'T12:00:00' + sign + pad2(Math.floor(a / 60)) + ':' + pad2(a % 60); +} +function fmtDay(iso) { + if (!iso) return ''; + const d = new Date(iso); + return d.getMonth() + 1 + '月' + d.getDate() + '日'; +} + +const PAGE = 50; + +Page({ + data: { + bill: {}, + cats: [], + cat: '', + customOn: false, + customCat: '', + amount: '', + date: '', + note: '', + list: [], + saving: false, + }, + onLoad() { + this.setData({ date: today() }); + store + .ready() + .then(() => recordTypes.load()) + .then(() => this.loadCats()) + .catch((e) => toastErr(e)); + }, + onShow() { + if (this._inited) this.reload(); + this._inited = true; + }, + onReady() { + this.reload(); + }, + // 类别预置读 record_types 里 cost 的字段配置——后台改一处, + // 这页和 addrecord 同时生效,不会出现两套类别把账单统计切开 + loadCats() { + const t = recordTypes.get('cost'); + const opt = ((t && t.fields) || []).find((f) => f.kind === 'options'); + this.setData({ cats: (opt && opt.options) || [] }); + }, + reload() { + const id = store.currentPetId(); + if (!id) return; + api + .getBill(id, 'month') + .then((bill) => this.setData({ bill: bill || {} })) + .catch(() => {}); + api + .getRecords(id, { type: 'cost', page: 1, pageSize: PAGE }) + .then((res) => { + const now = new Date(); + const list = (res.list || []) + // 只显示本月:接口按时间倒序返回,这里前端切一刀, + // 不为了「本月」再加一个后端参数 + .filter((r) => { + const d = new Date(r.occurred_at); + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); + }) + .map((r) => ({ ...r, timeText: fmtDay(r.occurred_at) })); + this.setData({ list }); + }) + .catch(() => {}); + }, + onAmount(e) { + this.setData({ amount: e.detail.value }); + }, + onPickCat(e) { + const v = e.currentTarget.dataset.val; + this.setData({ cat: this.data.cat === v ? '' : v, customOn: false, customCat: '' }); + }, + toggleCustom() { + const on = !this.data.customOn; + this.setData({ customOn: on, cat: on ? '' : this.data.cat }); + }, + onCustomCat(e) { + this.setData({ customCat: e.detail.value }); + }, + onDate(e) { + this.setData({ date: e.detail.value }); + }, + onNote(e) { + this.setData({ note: e.detail.value }); + }, + onSave() { + if (this.data.saving) return; + const id = store.currentPetId(); + if (!id) return wx.showToast({ title: '先建一份宠物档案', icon: 'none' }); + const amt = parseFloat(this.data.amount); + if (!(amt > 0)) return wx.showToast({ title: '填一个金额', icon: 'none' }); + const cat = (this.data.customOn ? this.data.customCat : this.data.cat).trim(); + if (!cat) return wx.showToast({ title: '选一个类别', icon: 'none' }); + + this.setData({ saving: true }); + // 落的是 type='cost' 的普通记录:金额进 num_value、类别进 category, + // 和 addrecord 走同一条路,报告页的账单聚合照样认 + api + .createRecord(id, { + type: 'cost', + title: '记账:' + amt + '元 ' + cat, + num_value: amt, + category: cat, + description: (this.data.note || '').trim(), + occurred_at: dayToISO(this.data.date), + }) + .then(() => { + this.setData({ saving: false, amount: '', note: '', customCat: '', customOn: false, cat: '' }); + wx.showToast({ title: '已记下', icon: 'success' }); + this.reload(); + }) + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); + }, + // 长按删除。流水里记错一笔很常见,不给删的话账单就一直是错的 + onDelete(e) { + const id = e.currentTarget.dataset.id; + wx.showModal({ + title: '删掉这笔?', + content: '删了账单统计会跟着变。', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deleteRecord(id) + .then(() => this.reload()) + .catch((err) => toastErr(err, '删除失败')); + }, + }); + }, + onShareAppMessage() { + return { title: '养宠一个月花了多少?', path: '/pages/expense/expense' }; + }, +}); diff --git a/pets-fe/pages/expense/expense.json b/pets-fe/pages/expense/expense.json new file mode 100644 index 0000000..6594cd8 --- /dev/null +++ b/pets-fe/pages/expense/expense.json @@ -0,0 +1,6 @@ +{ + "usingComponents": { + "nav-bar": "/components/nav-bar/nav-bar", + "pt-icon": "/components/pt-icon/index" + } +} diff --git a/pets-fe/pages/expense/expense.wxml b/pets-fe/pages/expense/expense.wxml new file mode 100644 index 0000000..61da736 --- /dev/null +++ b/pets-fe/pages/expense/expense.wxml @@ -0,0 +1,71 @@ + + + + + + + {{bill.period || '本月'}}花销 + ¥{{bill.total || 0}} + 单笔最高 ¥{{bill.max_single}} + + + {{item.category}}¥{{item.amount}} + + + + 这个月还没记过花销 + + + + + 记一笔 + + + ¥ + + + + 类别 + + {{item}} + + + 自定义 + + + + + 日期 + + {{date}} + + + 备注 + + + + {{saving ? '保存中…' : '记下这笔'}} + + + + + + + 本月流水 + {{list.length}} 笔 + + + + {{item.category || '未分类'}} + {{item.timeText}}{{item.description ? '|' + item.description : ''}} + + ¥{{item.num_value}} + + 还没有流水。长按某笔可以删除。 + + + diff --git a/pets-fe/pages/expense/expense.wxss b/pets-fe/pages/expense/expense.wxss new file mode 100644 index 0000000..13d59de --- /dev/null +++ b/pets-fe/pages/expense/expense.wxss @@ -0,0 +1,34 @@ +.ex-body{padding-bottom:var(--pad-b-plain)} + +/* 合计卡:数字要够大,这页第一眼就该看到花了多少 */ +.ex-sum{text-align:center} +.ex-sum-k{font-size:var(--fs-sm);color:var(--muted)} +.ex-sum-n{font-size:72rpx;font-weight:var(--fw-b);color:var(--text);line-height:1.2;margin:var(--sp-1) 0} +.ex-sum-s{font-size:var(--fs-cap);color:var(--muted2)} +.ex-cats{margin-top:var(--sp-5);text-align:left} +.ex-cat{margin-bottom:var(--sp-3)} +.ex-cat:last-child{margin-bottom:0} +.ex-cat-top{display:flex;justify-content:space-between;font-size:var(--fs-sm);color:var(--text-2);margin-bottom:6rpx} + +/* 金额输入做大,和键盘上的数字对得上 */ +.ex-amt{display:flex;align-items:baseline;gap:var(--sp-2);padding:var(--sp-4) 0 var(--sp-2);border-bottom:2rpx solid var(--primary-line)} +.ex-cur{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--primary-dark)} +.ex-in{flex:1;font-size:64rpx;font-weight:var(--fw-b);color:var(--text);height:84rpx} + +.ex-label{font-size:var(--fs-sm);color:var(--muted);margin:var(--sp-4) 0 var(--sp-2)} +.ex-chips{display:flex;flex-wrap:wrap;gap:var(--sp-2)} +.ex-chip{ + padding:var(--sp-2) var(--sp-4);border-radius:var(--r-full); + background:var(--surface-2);border:1rpx solid var(--line); + font-size:var(--fs-sm);color:var(--text-2); +} +.ex-chip.on{background:var(--primary-soft);border-color:var(--primary);color:var(--primary-ink);font-weight:var(--fw-b)} +.ex-chip-add{display:inline-flex;align-items:center;gap:4rpx} +.ex-custom{margin-top:var(--sp-3)} +.ex-save{margin-top:var(--sp-5)} +.ex-busy{opacity:.6} + +.ex-row{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-3) 0;border-bottom:1rpx solid var(--line)} +.ex-row:last-of-type{border-bottom:0} +.ex-row-main{flex:1;min-width:0} +.ex-row-amt{flex:none;font-size:var(--fs-lg);font-weight:var(--fw-b);color:var(--text)} diff --git a/pets-fe/pages/home/home.js b/pets-fe/pages/home/home.js index 88833b0..413f19e 100644 --- a/pets-fe/pages/home/home.js +++ b/pets-fe/pages/home/home.js @@ -3,104 +3,47 @@ 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(); - const p = (n) => (n < 10 ? '0' + n : '' + n); - return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()); -} - -function pad2(n) { - return n < 10 ? '0' + n : '' + n; -} - -// 整月网格。周一为一周起点,前面补空格子对齐星期。 -// 与计划页原来的实现同源——计划页的日历 tab 已并到这里,那边删掉了 -function buildCells(year, month, taskedDays, today) { - const first = new Date(year, month - 1, 1); - const lead = (first.getDay() + 6) % 7; - const daysInMonth = new Date(year, month, 0).getDate(); - const set = {}; - (taskedDays || []).forEach((d) => (set[d] = true)); - const cells = []; - for (let i = 0; i < lead; i++) cells.push({ day: 0 }); - for (let d = 1; d <= daysInMonth; d++) { - cells.push({ day: d, date: year + '-' + pad2(month) + '-' + pad2(d), tasked: !!set[d], today: d === today }); - } - return cells; -} - -// 「接下来」用的相对日期,比 7月31日 更好读 -function whenLabel(date, today) { - if (!date) return ''; - const diff = Math.round((new Date(date + 'T00:00:00') - new Date(today + 'T00:00:00')) / 86400000); - if (diff <= 0) return '今天'; - if (diff === 1) return '明天'; - if (diff === 2) return '后天'; - if (diff < 7) return diff + ' 天后'; - const p = date.split('-'); - return +p[1] + '月' + +p[2] + '日'; -} +const petmeta = require('../../utils/petmeta.js'); function mapTask(t) { - return { - id: t.id, - title: t.title, - sub: t.description, - priority: t.priority, - done: t.done, - sheet: t.sheet_type, - }; + return { id: t.id, title: t.title, sub: t.description, priority: t.priority, done: t.done, sheet: t.sheet_type }; } -// 快速记录入口取后端类型表的前 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); +// 时间轴上的相对时间。首页关心的是「什么时候记的」,精确到分只在今天有意义 +function fmtWhen(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (isNaN(d.getTime())) return ''; + const p = (n) => (n < 10 ? '0' + n : '' + n); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return '今天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); + const y = new Date(now.getTime() - 86400000); + if (d.toDateString() === y.toDateString()) return '昨天 ' + p(d.getHours()) + ':' + p(d.getMinutes()); + return d.getMonth() + 1 + '月' + d.getDate() + '日'; } -// 门面上的状态 chip:健康状态 + 最多两条洞察 -function heroChips(summary) { - const chips = []; - if (summary.health_status) { - chips.push({ text: summary.health_status, icon: 'check', tone: 'green' }); - } - (summary.insights || []).slice(0, 2).forEach((it) => { - if (it && it.bold) chips.push({ text: it.bold, icon: 'trend', tone: '' }); - }); - return chips; -} +const TL_PAGE = 10; Page({ data: { pet: {}, - summary: { greeting: '', insights: [], week: [], advice: '', health_pct: 0, health_status: '正常' }, + zodiac: '', + daysTogether: 0, + sexIcon: '', + sexTone: '', tasks: [], - quickItems: [], - heroChips: [], - undoneText: '', - firstArticle: null, - selectedDate: '', - taskLabel: '今日任务', - todayDate: '', - calOpen: false, - calCells: [], - calLabel: '', - dayNodes: [], - dayReminders: [], - upcoming: [], - planPct: -1, + planUndone: 0, + monthCost: 0, + timeline: [], + tlPage: 1, + hasMore: false, sheetShow: false, sheetType: '', }, onLoad() { - this.setData({ todayDate: todayStr() }); this._unsub = store.subscribe((pet) => { - this.setData({ pet }); - // 首次由 onShow 统一加载,这里只处理之后的「切换宠物 / 数据变更」,避免重复请求 + this.applyPet(pet); if (!this._inited) return; this.loadAll(); }); @@ -110,143 +53,103 @@ Page({ }, onShow() { syncTabBar(this); - this.loadTypes(); + recordTypes.load().catch(() => {}); store .ready() .then(() => { this._inited = true; - this.setData({ pet: store.getPet() }); + this.applyPet(store.getPet()); this.loadAll(); }) .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(() => {}); + // 门面上那几样都是本地算的,不用等接口 + applyPet(pet) { + const p = pet || {}; + const badge = petmeta.genderBadge(p.gender); + this.setData({ + pet: p, + zodiac: petmeta.zodiac(p.birthday), + daysTogether: petmeta.daysTogether(p), + sexIcon: badge.icon, + sexTone: badge.tone, + }); }, loadAll() { - this.loadSummary(); this.loadTasks(); - this.loadDayExtra(); - this.loadUpcoming(); - // 日历只在展开过之后才跟着刷新,没展开就别白拉一次 - if (this.data.calOpen) this.loadCalendar(); - if (!this.data.firstArticle) { - api - .listArticles() - .then((list) => { - if (list && list[0]) { - this.setData({ firstArticle: { id: list[0].id, icon: list[0].icon, title: list[0].title, desc: list[0].description } }); - } - }) - .catch(() => {}); - } - }, - loadSummary() { - const id = store.currentPetId(); - if (!id) return; - api - .homeSummary(id) - .then((summary) => this.setData({ summary, heroChips: heroChips(summary) })) - .catch((e) => toastErr(e)); + this.loadTimeline(1); + this.loadEntries(); }, loadTasks() { const id = store.currentPetId(); if (!id) return; - const date = this.data.selectedDate || this.data.todayDate; api - .getTasks(id, date) - .then((tasks) => this.setTasks((tasks || []).map(mapTask))) + .getTasks(id) + .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) .catch((e) => toastErr(e)); }, - // 任务列表落地时顺手算「还有几件事没做完」,门面上要用 - setTasks(tasks) { - const undone = tasks.filter((t) => !t.done).length; - this.setData({ - tasks, - undoneText: tasks.length === 0 - ? '这天还没有安排' - : undone > 0 ? '还有 ' + undone + ' 件事没做完' : '今天的事都做完啦', - }); - }, - // 这天挂着的计划节点和到期提醒。任务列表仍走 /tasks(那边会补生成当天任务), - // day-plan 只取 tasks 之外的两类,不拿它的 tasks 覆盖列表 - loadDayExtra() { + // 两个入口上的副标题:计划还剩几件、本月花了多少。 + // 空着的话入口就是两个没有信息量的图标 + loadEntries() { const id = store.currentPetId(); if (!id) return; - const date = this.data.selectedDate || this.data.todayDate; - api - .dayPlan(id, date) - .then((res) => { - this.setData({ - dayNodes: (res.plan_nodes || []).map((n) => ({ title: n.title, done: n.done })), - dayReminders: (res.reminders || []).map((r) => ({ title: r.title })), - }); - }) - .catch(() => this.setData({ dayNodes: [], dayReminders: [] })); - }, - // 30 天计划里最近 3 个还没做的节点 - loadUpcoming() { - const id = store.currentPetId(); - if (!id) return; - const today = this.data.todayDate; api .getPlan(id) - .then((plan) => { - const list = ((plan && plan.tasks) || []) - .filter((t) => !t.done && t.date && t.date >= today) - .sort((a, b) => (a.date < b.date ? -1 : 1)) - .slice(0, 3) - .map((t) => ({ id: t.id, title: t.title, description: t.description, whenLabel: whenLabel(t.date, today) })); - this.setData({ upcoming: list, planPct: plan ? plan.completion_pct || 0 : -1 }); - }) - .catch(() => this.setData({ upcoming: [], planPct: -1 })); + .then((plan) => this.setData({ planUndone: ((plan && plan.tasks) || []).filter((t) => !t.done).length })) + .catch(() => this.setData({ planUndone: 0 })); + api + .getBill(id, 'month') + .then((b) => this.setData({ monthCost: (b && b.total) || 0 })) + .catch(() => this.setData({ monthCost: 0 })); }, - loadCalendar() { + loadTimeline(page) { const id = store.currentPetId(); if (!id) return; api - .planCalendar(id) + .getRecords(id, { page, pageSize: TL_PAGE }) .then((res) => { - this.setData({ - calCells: buildCells(res.year, res.month, res.tasked_days, res.today), - calLabel: `${res.year} 年 ${res.month} 月`, - }); + const list = (res.list || []).map((r) => ({ + id: r.id, + type: r.type, + tone: (recordTypes.get(r.type) || {}).tone || 'tone-1', + title: r.title, + description: r.description, + image: r.image_url || '', + timeText: fmtWhen(r.occurred_at), + })); + const merged = page === 1 ? list : this.data.timeline.concat(list); + this.setData({ timeline: merged, tlPage: page, hasMore: merged.length < (res.total || 0) }); }) .catch(() => {}); }, - toggleCal() { - const open = !this.data.calOpen; - this.setData({ calOpen: open }); - if (open && !this.data.calCells.length) this.loadCalendar(); + loadMore() { + if (this.data.hasMore) this.loadTimeline(this.data.tlPage + 1); }, - onTapDay(e) { - const { date, active } = e.currentTarget.dataset; - if (!date) return; - const parts = date.split('-'); - this.setData({ - selectedDate: date, - taskLabel: active ? '今日任务' : +parts[1] + '月' + +parts[2] + '日', + previewImage(e) { + const url = e.currentTarget.dataset.url; + if (url) wx.previewImage({ urls: [url], current: url }); + }, + onDeleteRecord(e) { + const id = e.currentTarget.dataset.id; + wx.showModal({ + title: '删掉这条记录?', + content: '趋势和统计会跟着变。', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deleteRecord(id) + .then(() => this.loadTimeline(1)) + .catch((err) => toastErr(err, '删除失败')); + }, }); - this.loadTasks(); - this.loadDayExtra(); - }, - backToToday() { - this.setData({ selectedDate: this.data.todayDate, taskLabel: '今日任务' }); - this.loadTasks(); - this.loadDayExtra(); - }, - goPlan() { - wx.navigateTo({ url: '/pages/plan/plan' }); }, onTapTask(e) { const i = e.currentTarget.dataset.index; const t = this.data.tasks[i]; + // 任务关联了记录类型就直接去记那一笔 if (t.sheet) { - this.openSheetType(t.sheet); + wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + t.sheet }); return; } api @@ -254,74 +157,45 @@ Page({ .then((res) => { const tasks = this.data.tasks.slice(); tasks[i] = Object.assign({}, tasks[i], { done: res.done }); - this.setTasks(tasks); - this.loadSummary(); + this.setData({ tasks }); }) - .catch((e) => toastErr(e, '操作失败')); + .catch((err) => toastErr(err, '操作失败')); }, completeTasks() { const id = store.currentPetId(); if (!id) return; api .completeAllTasks(id) - .then((tasks) => { - this.setTasks((tasks || []).map(mapTask)); - this.loadSummary(); - }) + .then((tasks) => this.setData({ tasks: (tasks || []).map(mapTask) })) .catch((e) => toastErr(e, '操作失败')); }, - // 记一笔的每一项都进独立页面。原来是开弹层——24 项的表单塞弹层里太挤, - // 而且弹层高度受 sheet-scroll 的 78vh 限制,字段一多就变成内滚 - goAdd(e) { - wx.navigateTo({ url: '/pages/addrecord/addrecord?type=' + e.currentTarget.dataset.type }); + goPlan() { + wx.navigateTo({ url: '/pages/plan/plan' }); + }, + goExpense() { + wx.navigateTo({ url: '/pages/expense/expense' }); + }, + goRecord() { + wx.navigateTo({ url: '/pages/record/record' }); + }, + onQuickRecord() { + wx.navigateTo({ url: '/pages/record/record' }); }, openSheet(e) { - this.openSheetType(e.currentTarget.dataset.type); - }, - openSheetType(type) { - this.setData({ sheetType: type, sheetShow: true }); + this.setData({ sheetType: e.currentTarget.dataset.type, sheetShow: true }); }, closeSheet() { - // 只关闭。数据变更由 bind:saved 或 store 的宠物变更通知触发刷新, - // 不必每次关弹层(哪怕只是看了眼 AI)都全量重拉一遍 this.setData({ sheetShow: false }); }, onSheetSave() { this.loadTasks(); - this.loadSummary(); - this.loadDayExtra(); - if (this.data.calOpen) this.loadCalendar(); // 加了任务,格子上的圆点要跟着变 + this.loadTimeline(1); }, onAddPet() { - this.openSheetType('addPet'); + this.setData({ sheetType: 'addPet', sheetShow: true }); }, - // 没建档时点门面卡应该去「添加」,而不是打开一个空的编辑表单 onHeroTap() { - if (this.data.pet && this.data.pet.id) this.openSheetType('editPet'); - else this.openSheetType('addPet'); - }, - onEditPet() { - this.openSheetType('editPet'); - }, - // 右下角悬浮键:进记录页。原来做成弹层里的选择器,弹层装 24 项太挤了; - // 记录页顶部本来就是那个宫格,再单独建一个只放宫格的页面等于维护两份 - onQuickRecord() { - wx.navigateTo({ url: '/pages/record/record' }); - }, - goSettings() { - wx.navigateTo({ url: '/pages/settings/settings' }); - }, - goLearn() { - wx.navigateTo({ url: '/pages/learn/learn' }); - }, - // 首页那张卡片直接打开这篇文章正文 - openFirstArticle() { - const a = this.data.firstArticle; - if (a && a.id) wx.navigateTo({ url: `/pages/article/article?id=${a.id}` }); - else this.goLearn(); - }, - goRecord() { - wx.navigateTo({ url: '/pages/record/record' }); + this.setData({ sheetType: this.data.pet && this.data.pet.id ? 'editPet' : 'addPet', sheetShow: true }); }, onShareAppMessage() { return { title: '肉垫计划 · 每天照顾好毛孩子', path: '/pages/home/home' }; diff --git a/pets-fe/pages/home/home.json b/pets-fe/pages/home/home.json index 55fda31..cfab932 100644 --- a/pets-fe/pages/home/home.json +++ b/pets-fe/pages/home/home.json @@ -4,7 +4,6 @@ "pet-switch": "/components/pet-switch/pet-switch", "bottom-sheet": "/components/bottom-sheet/bottom-sheet", "fab": "/components/fab/fab", - "pt-icon": "/components/pt-icon/index", - "stat-ring": "/components/stat-ring/index" + "pt-icon": "/components/pt-icon/index" } } diff --git a/pets-fe/pages/home/home.wxml b/pets-fe/pages/home/home.wxml index 70654dd..3d5c868 100644 --- a/pets-fe/pages/home/home.wxml +++ b/pets-fe/pages/home/home.wxml @@ -2,131 +2,91 @@ - + - - - {{summary.greeting || '你好'}} - 今天也要照顾好 {{pet.name}} - - - - - - - - + - - {{summary.health_pct}}% - 完成度 - + + + {{pet.emoji || '🐾'}} + {{sexIcon}} + {{pet.name || '还没有毛孩子'}} - {{pet.id ? undoneText : '点这里建一份档案,任务和提醒会自动排好'}} - - - {{item.text}} - + + {{pet.breed}} + {{pet.age}} + {{zodiac}} + - {{pet.age}}|{{pet.weight}}|{{pet.stage}} + 我们一起生活的第 {{daysTogether}} - - - - - {{item.weekday}}{{item.day}} - + + + + + 管理计划 + {{planUndone > 0 ? '还有 ' + planUndone + ' 件没做' : '30 天安排'}} - - {{calLabel}} - - - {{item.day || ''}} - - - - {{calOpen ? '收起' : '展开整月'}} + + + 记花销 + {{monthCost > 0 ? '本月 ¥' + monthCost : '还没记过'}} - + + - {{taskLabel}} + 今日任务 管理 - 回今天 - 全部完成 + 全部完成 - + {{item.title}}{{item.sub}} {{item.priority}} - 这天没有任务记录 + - - - - 计划{{item.title}} - - - 提醒{{item.title}} + + + 最近记录 + + 记一笔 + + + + + + + + + {{item.timeText}} + + {{item.title}} + {{item.description}} + - - - - 接下来 - 完整计划 - - 30 天计划完成度{{planPct}}% - - - - {{item.whenLabel}} - {{item.title}}{{item.description}} - - - - - - - 快速记录 - 更多 - - - - {{item.label}} - - - - - - - 新手内容 - 查看 - - - - {{firstArticle.title}}{{firstArticle.desc}} - + + 还没有记录。点上面的「记一笔」开始, + 体重趋势和健康洞察都会从这些记录里长出来。 + 加载更多 diff --git a/pets-fe/pages/home/home.wxss b/pets-fe/pages/home/home.wxss index eb8e99c..4fbce98 100644 --- a/pets-fe/pages/home/home.wxss +++ b/pets-fe/pages/home/home.wxss @@ -1,11 +1,3 @@ -.top-row{display:flex;align-items:center;justify-content:space-between;margin:4rpx 0 var(--sp-4)} -.greet-h2{font-size:var(--fs-xl);line-height:1.15;letter-spacing:-.8rpx;font-weight:var(--fw-b)} -.greet-p{font-size:var(--fs-sm);color:var(--muted);margin-top:var(--sp-1)} -.icon-stack{display:flex;gap:var(--sp-2)} -.icon-btn{ - width:76rpx;height:76rpx;border-radius:50%;background:#fff;box-shadow:var(--sd-1); - color:var(--text-2);display:flex;align-items:center;justify-content:center; -} /* 门面卡 */ .hero-card{ @@ -13,71 +5,69 @@ radial-gradient(circle at 95% 16%, rgba(245,169,71,.22), transparent 32%), linear-gradient(135deg,#FFFFFF,#FFF6E9); } -.hero-row{display:flex;align-items:center;gap:var(--sp-4)} -.ring-num{font-size:var(--fs-xl);font-weight:var(--fw-b);line-height:1} -.ring-unit{font-size:var(--fs-cap)} -.ring-cap{font-size:var(--fs-cap);color:var(--muted);margin-top:4rpx} -.hero-main{flex:1;min-width:0} -.hero-title{font-size:var(--fs-lg);font-weight:var(--fw-b);letter-spacing:-.4rpx} -.hero-sub{color:var(--muted);font-size:var(--fs-sm);margin-top:4rpx} -.chip-row{display:flex;flex-wrap:wrap;gap:var(--sp-1);margin-top:var(--sp-2)} -.hero-meta{ - color:var(--muted);font-size:var(--fs-sm);margin-top:var(--sp-3); - padding-top:var(--sp-3);border-top:1rpx solid rgba(238,230,220,.8); -} /* 一周日期条 */ -.date-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:var(--sp-2);margin-bottom:var(--sp-4)} -.day{ - background:#fff;border-radius:var(--r-md);min-height:112rpx;text-align:center;padding:var(--sp-3) 4rpx; - border:1rpx solid var(--line);color:var(--muted);font-size:var(--fs-cap); -} -.day-b{display:block;color:var(--text);font-size:var(--fs-md);margin-top:6rpx;font-weight:var(--fw-b)} -.day.active{border-color:var(--primary-line);background:var(--primary-soft);color:var(--primary-dark)} -.day.sel{border-color:var(--primary);background:var(--primary-soft);color:var(--primary-dark);box-shadow:0 0 0 2rpx rgba(245,169,71,.35)} -.day.has-dot::after{content:"";display:block;width:8rpx;height:8rpx;border-radius:50%;background:var(--green);margin:6rpx auto 0} -.ai-text{color:var(--text-2);font-size:var(--fs-md);line-height:1.6} -.ai-cta{margin-top:var(--sp-4)} /* ===== 日期带(原计划页「日历」tab 并过来) ===== */ -.date-band{margin-bottom:var(--sp-4)} -.date-band .date-strip{margin-bottom:0} -.cal-card{margin-bottom:0} -.cal-toggle{ - display:flex;align-items:center;justify-content:center;gap:4rpx; - padding:var(--sp-2) 0;font-size:var(--fs-cap);color:var(--muted); -} -.cal-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--sp-3);font-weight:var(--fw-b);font-size:var(--fs-lg)} -.cal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:var(--sp-2);text-align:center} -.cal-w{font-size:var(--fs-cap);color:var(--muted);padding:var(--sp-1) 0} -.cal-cell{ - height:76rpx;border-radius:var(--r-sm);display:flex;align-items:center;justify-content:center; - font-weight:var(--fw-b);font-size:var(--fs-sm);background:var(--surface-2);position:relative; -} -.cal-cell.blank{background:transparent} -.cal-cell.today{background:var(--primary-soft);color:var(--primary-dark)} -.cal-cell.tasked::after{content:"";position:absolute;bottom:8rpx;width:8rpx;height:8rpx;border-radius:50%;background:var(--green)} -.cal-cell.sel{background:var(--primary);color:#fff} -.cal-cell.sel.tasked::after{background:#fff} - /* 某天的安排 */ -.day-detail{margin-top:var(--sp-3);border-top:1rpx solid var(--line);padding-top:var(--sp-4)} .dd-title{font-weight:var(--fw-b);font-size:var(--fs-md);margin-bottom:var(--sp-3)} -.dd-item{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-2) 0} -.dd-tag{flex:none;font-size:var(--fs-cap);font-weight:var(--fw-b);padding:2rpx var(--sp-2);border-radius:var(--r-full)} -.dd-tag.plan{background:var(--primary-soft);color:var(--primary-ink)} -.dd-tag.task{background:var(--green-soft);color:var(--green-ink)} -.dd-tag.rem{background:var(--blue-soft);color:var(--blue-ink)} -.dd-b{font-size:var(--fs-md);color:var(--text)} -.dd-b.done{text-decoration:line-through;color:var(--muted)} /* ===== 接下来 ===== */ -.up-list{margin-top:var(--sp-3)} -.up-item{display:flex;gap:var(--sp-3);padding:var(--sp-3) 0;border-top:1rpx solid var(--line)} -.up-item:first-child{border-top:none;padding-top:0} -.up-when{ - flex:none;width:112rpx;font-size:var(--fs-cap);font-weight:var(--fw-b); - color:var(--primary-dark);padding-top:4rpx; + +/* ===== 门面:宠物名片 ===== */ +.hero-card{padding:var(--sp-5)} +.hero-row{display:flex;align-items:center;gap:var(--sp-4)} +.hero-av{ + position:relative;flex:none;width:128rpx;height:128rpx;border-radius:50%; + background:var(--primary-soft);display:flex;align-items:center;justify-content:center; + font-size:60rpx;overflow:visible; } -.up-body{flex:1;min-width:0} +.hero-av-img{width:100%;height:100%;border-radius:50%} +/* 性别徽章叠在头像右下:一个很省事但很出效果的细节 */ +.hero-sex{ + position:absolute;right:-4rpx;bottom:-4rpx;width:42rpx;height:42rpx;border-radius:50%; + border:4rpx solid #fff;color:#fff;font-size:22rpx;line-height:1; + display:flex;align-items:center;justify-content:center; +} +.hero-sex.male{background:#5B9BE8} +.hero-sex.female{background:#EE85A8} +.hero-main{flex:1;min-width:0} +.hero-title{font-size:var(--fs-xl);font-weight:var(--fw-b);color:var(--text)} +.hero-meta{display:flex;flex-wrap:wrap;gap:var(--sp-3);font-size:var(--fs-sm);color:var(--muted);margin-top:6rpx} +.hero-arrow{flex:none;color:var(--muted2);display:flex;align-items:center} +.hero-days{ + margin-top:var(--sp-4);background:var(--primary-soft);color:var(--primary-ink); + border-radius:var(--r-md);text-align:center;padding:var(--sp-3); + font-size:var(--fs-md);font-weight:var(--fw-b); +} +.hd-n{font-size:var(--fs-lg);margin:0 4rpx} + +/* ===== 两个入口 ===== */ +.entry-card{display:flex;padding:var(--sp-5) var(--sp-3)} +.entry{flex:1;display:flex;flex-direction:column;align-items:center;gap:6rpx} +.entry-ic{ + width:96rpx;height:96rpx;border-radius:var(--r-md); + display:flex;align-items:center;justify-content:center;margin-bottom:4rpx; +} +.entry-b{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.entry-p{font-size:var(--fs-cap);color:var(--muted)} + +/* ===== 记录时间轴 ===== */ +.tl-head{display:flex;align-items:center;justify-content:space-between;margin:var(--sp-5) 0 var(--sp-3)} +.tl-item{display:flex;gap:var(--sp-3)} +.tl-rail{flex:none;width:56rpx;display:flex;flex-direction:column;align-items:center} +.tl-dot{ + width:56rpx;height:56rpx;border-radius:50%;flex:none; + display:flex;align-items:center;justify-content:center; +} +/* 竖线连起来才像一条时间轴;最后一条的线会被 body 的高度自然截断 */ +.tl-line{flex:1;width:2rpx;background:var(--line);margin:6rpx 0} +.tl-item:last-of-type .tl-line{display:none} +.tl-body{flex:1;min-width:0;padding-bottom:var(--sp-4)} +.tl-when{font-size:var(--fs-cap);color:var(--muted);margin-bottom:6rpx} +.tl-card{background:#fff;border-radius:var(--r-md);box-shadow:var(--sd-1);padding:var(--sp-4)} +.tl-title{font-size:var(--fs-md);font-weight:var(--fw-b);color:var(--text)} +.tl-desc{font-size:var(--fs-sm);color:var(--text-2);margin-top:6rpx;line-height:1.5} +.tl-img{width:100%;height:320rpx;border-radius:var(--r-sm);margin-top:var(--sp-3);display:block} +.tl-more{text-align:center;padding:var(--sp-4);font-size:var(--fs-sm);color:var(--muted)} diff --git a/pets-fe/pages/plan/plan.js b/pets-fe/pages/plan/plan.js index 7bd335f..3ce3e4c 100644 --- a/pets-fe/pages/plan/plan.js +++ b/pets-fe/pages/plan/plan.js @@ -2,27 +2,69 @@ const store = require('../../utils/store.js'); const api = require('../../utils/api.js'); const { toastErr } = require('../../utils/ui.js'); +function pad2(n) { + return n < 10 ? '0' + n : '' + n; +} +function ymd(d) { + return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); +} // "2026-07-04" → "7月4日" function fmtMD(date) { - if (!date) return ''; - const p = date.split('-'); - if (p.length < 3) return ''; - return +p[1] + '月' + +p[2] + '日'; + const p = (date || '').split('-'); + return p.length < 3 ? '' : +p[1] + '月' + +p[2] + '日'; +} +function parseYMD(s) { + const p = (s || '').split('-'); + return p.length < 3 ? null : new Date(+p[0], +p[1] - 1, +p[2]); +} + +// 计划覆盖的天数。内置模板会生成 day=30 的节点(第 31 天),所以是 31 格不是 30 格; +// 按 30 画的话模板自己的最后一个节点在日历上没有格子——看得到、点不到。 +// 和后端 service/plan.go 的 planSpanDays 必须一致。 +const SPAN = 31; + +// 只画计划覆盖的这段,不画整月:整月会多出一堆空白格子、跨月还要翻页。 +// 按周对齐排 31 天,首尾补几个空格就够,一屏看完。 +function buildCells(startStr, byDate, todayStr) { + const start = parseYMD(startStr); + if (!start) return []; + const lead = (start.getDay() + 6) % 7; // 周一为一周起点 + const cells = []; + for (let i = 0; i < lead; i++) cells.push({ blank: true }); + for (let i = 0; i < SPAN; i++) { + const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i); + const s = ymd(d); + cells.push({ day: d.getDate(), date: s, count: (byDate[s] || []).length, today: s === todayStr }); + } + // 末尾补齐整周,否则最后一行格子宽度会被 grid 拉歪 + while (cells.length % 7) cells.push({ blank: true }); + return cells; } Page({ data: { pet: {}, - planView: 'timeline', plan: null, + doneCount: 0, + cells: [], + calRange: '', + planStart: '', + planEnd: '', + selDate: '', + selLabel: '', + dayTasks: [], + allOpen: false, + editShow: false, + editId: '', + form: { title: '', date: '', description: '' }, + saving: false, sheetShow: false, sheetType: '', }, onLoad() { this._unsub = store.subscribe((pet) => { this.setData({ pet }); - if (!this._inited) return; // 首次由 onShow 加载 - this.loadTimeline(); + if (this._inited) this.load(); }); }, onUnload() { @@ -34,30 +76,138 @@ Page({ .then(() => { this._inited = true; this.setData({ pet: store.getPet() }); - this.loadTimeline(); + this.load(); }) - .catch((e) => wx.showToast({ title: e.message || '加载失败', icon: 'none' })); + .catch((e) => toastErr(e)); }, - loadTimeline() { + load() { const id = store.currentPetId(); if (!id) return; api .getPlan(id) - .then((plan) => { - if (plan && plan.tasks) { - plan.tasks = plan.tasks.map((t) => ({ ...t, dateLabel: fmtMD(t.date) })); - } - this.setData({ plan }); + .then((plan) => this.apply(plan)) + .catch(() => this.setData({ plan: null, cells: [], dayTasks: [] })); + }, + apply(plan) { + const tasks = (plan.tasks || []).map((t) => ({ ...t, dateLabel: fmtMD(t.date) })); + const byDate = {}; + tasks.forEach((t) => { + if (!t.date) return; + (byDate[t.date] = byDate[t.date] || []).push(t); + }); + const start = (plan.start_date || '').slice(0, 10); + const end = start ? ymd(new Date(parseYMD(start).getTime() + (SPAN - 1) * 86400000)) : ''; + const todayStr = ymd(new Date()); + this._byDate = byDate; + + // 默认选今天;今天不在计划范围内(计划早就过期了)就选开始那天 + const sel = byDate[todayStr] !== undefined || (todayStr >= start && todayStr <= end) ? todayStr : start; + this.setData({ + plan: { ...plan, tasks }, + doneCount: tasks.filter((t) => t.done).length, + cells: buildCells(start, byDate, todayStr), + calRange: start ? fmtMD(start) + ' — ' + fmtMD(end) : '', + planStart: start, + planEnd: end, + }); + this.selectDay(sel, todayStr); + }, + selectDay(date, todayStr) { + const t = todayStr || ymd(new Date()); + this.setData({ + selDate: date, + selLabel: date === t ? '今天的安排' : fmtMD(date) + '的安排', + dayTasks: (this._byDate || {})[date] || [], + }); + }, + onTapDay(e) { + const d = e.currentTarget.dataset.date; + if (d) this.selectDay(d); + }, + backToToday() { + const t = ymd(new Date()); + // 今天不在这 30 天里就退回开始日,否则选中一个日历上不存在的格子 + this.selectDay(t >= this.data.planStart && t <= this.data.planEnd ? t : this.data.planStart); + }, + toggleAll() { + this.setData({ allOpen: !this.data.allOpen }); + }, + toggleTask(e) { + api + .togglePlanTask(e.currentTarget.dataset.id) + .then(() => this.load()) + .catch((err) => toastErr(err, '操作失败')); + }, + + // ── 加 / 改 ── + openAdd() { + this.setData({ + editShow: true, editId: '', + form: { title: '', date: this.data.selDate || this.data.planStart, description: '' }, + }); + }, + openEdit(e) { + const t = (this.data.dayTasks || []).find((x) => x.id === e.currentTarget.dataset.id); + if (!t) return; + this.setData({ + editShow: true, editId: t.id, + form: { title: t.title, date: t.date || this.data.selDate, description: t.description || '' }, + }); + }, + closeEdit() { + this.setData({ editShow: false }); + }, + noop() {}, + onFormTitle(e) { + this.setData({ 'form.title': e.detail.value }); + }, + onFormDate(e) { + this.setData({ 'form.date': e.detail.value }); + }, + onFormDesc(e) { + this.setData({ 'form.description': e.detail.value }); + }, + onSubmit() { + if (this.data.saving) return; + const f = this.data.form; + if (!(f.title || '').trim()) return wx.showToast({ title: '写一下做什么', icon: 'none' }); + const id = store.currentPetId(); + if (!id) return; + this.setData({ saving: true }); + const req = this.data.editId + ? api.updatePlanTask(this.data.editId, f) + : api.addPlanTask(id, f); + req + .then(() => { + this.setData({ saving: false, editShow: false }); + // 加完/改完要跳到那一天,不然用户改了日期却看不到东西去哪了 + this._pendingSel = f.date; + this.load(); }) - .catch(() => this.setData({ plan: null })); + .catch((e) => { + this.setData({ saving: false }); + toastErr(e, '保存失败'); + }); }, - toggleTimelineTask(e) { - const id = e.currentTarget.dataset.id; - api.togglePlanTask(id).then(() => this.loadTimeline()).catch((e) => toastErr(e, '操作失败')); + onDelete(e) { + const { id, title } = e.currentTarget.dataset; + wx.showModal({ + title: '删掉这件事?', + content: title || '', + confirmColor: '#EE7D73', + success: (r) => { + if (!r.confirm) return; + api + .deletePlanTask(id) + .then(() => this.load()) + .catch((err) => toastErr(err, '删除失败')); + }, + }); }, + openSheet(e) { const type = e.currentTarget.dataset.type; - if (!type) return; // 无关联动作的计划项只可勾选,不弹层 + if (!type) return; this.setData({ sheetType: type, sheetShow: true }); }, closeSheet() { diff --git a/pets-fe/pages/plan/plan.wxml b/pets-fe/pages/plan/plan.wxml index e3bde74..64ec331 100644 --- a/pets-fe/pages/plan/plan.wxml +++ b/pets-fe/pages/plan/plan.wxml @@ -3,7 +3,7 @@ - + 还没有毛孩子的档案 @@ -11,8 +11,7 @@ 建一份档案 - - + {{pet.name}}的 30 天计划 @@ -21,21 +20,85 @@ 完成度{{plan.completion_pct || 0}}% - - - {{item.day_label}}{{item.dateLabel}} - - - - {{item.title}}{{item.description}} - + + + + + {{calRange}} + 回今天 + + + + {{item.day || ''}} + + + + + + + {{selLabel}} + + 加一件 + + + + + + + {{item.title}} + {{item.description}} + + + - 暂无计划 + 这天还没有安排,点右上角加一件 - + + + + 完整路线图 + {{allOpen ? '收起' : '展开全部'}} + + + + {{item.day_label}}{{item.dateLabel}} + + + + + {{item.title}}{{item.description}} + + + 暂无计划 + + 共 {{plan.tasks.length || 0}} 件事,已完成 {{doneCount}} 件 + + + + + + {{editId ? '改这件事' : '加一件事'}} + + + + + {{form.date}} + + 只能安排 {{planStart}} 到 {{planEnd}} 之间 + + + + {{saving ? '保存中…' : '保存'}} + + + diff --git a/pets-fe/pages/plan/plan.wxss b/pets-fe/pages/plan/plan.wxss index 0f496cc..ad607b1 100644 --- a/pets-fe/pages/plan/plan.wxss +++ b/pets-fe/pages/plan/plan.wxss @@ -28,3 +28,18 @@ .extracted{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-3);margin:var(--sp-4) 0} .extract{background:var(--surface-2);border-radius:var(--r-sm);padding:var(--sp-3);font-size:var(--fs-sm)} .ex-b{display:block;font-size:var(--fs-sm);margin-bottom:6rpx;font-weight:var(--fw-b);color:var(--muted)} + +/* 计划节点右侧的删除键。计划是用户自己排的,就得能删 */ +.pk-del{flex:none;padding:0 var(--sp-1);color:var(--muted2);display:flex;align-items:center} + +/* 加/改计划的浮层。字段只有三个,再开一页太重 */ +.pe-mask{ + position:fixed;inset:0;z-index:200;background:rgba(45,41,37,.45); + display:flex;align-items:flex-end; +} +.pe-sheet{ + width:100%;background:var(--bg2);border-radius:var(--r-lg) var(--r-lg) 0 0; + padding:var(--sp-5) var(--pad-x) calc(var(--sp-5) + env(safe-area-inset-bottom)); + box-shadow:var(--sd-3);max-height:82vh;overflow-y:auto; +} +.pe-h{font-size:var(--fs-lg);font-weight:var(--fw-b);margin-bottom:var(--sp-4);text-align:center} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index 7027e2a..3162503 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -89,6 +89,10 @@ const api = { // 想开回来把这几行解注释、再把入口接上就行,不用重写。 // createAIPlan: (id, input) => request({ url: `/api/pets/${id}/ai-plan`, method: 'POST', data: { input } }), // applyAIPlan: (planId) => request({ url: `/api/ai-plan/${planId}/apply`, method: 'POST' }), + // 用户自己管理计划节点 + addPlanTask: (petId, body) => request({ url: `/api/pets/${petId}/plan-tasks`, method: 'POST', data: body }), + updatePlanTask: (taskId, body) => request({ url: `/api/plan-tasks/${taskId}`, method: 'PUT', data: body }), + deletePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}`, method: 'DELETE' }), togglePlanTask: (taskId) => request({ url: `/api/plan-tasks/${taskId}/toggle`, method: 'POST' }), // 提醒 diff --git a/pets-fe/utils/petmeta.js b/pets-fe/utils/petmeta.js new file mode 100644 index 0000000..e3f9853 --- /dev/null +++ b/pets-fe/utils/petmeta.js @@ -0,0 +1,42 @@ +// 宠物档案里那些「有数据但没露出」的东西。字段后端早就返回了,只是没人用。 +// 单独放一个文件而不是塞在 home.js 里:身份卡、宠友主页以后都要用同一套算法。 + +// 星座。按公历日期分段,边界日取「这一天起算下一个星座」 +const ZODIAC = [ + [1, 20, '水瓶座'], [2, 19, '双鱼座'], [3, 21, '白羊座'], [4, 20, '金牛座'], + [5, 21, '双子座'], [6, 22, '巨蟹座'], [7, 23, '狮子座'], [8, 23, '处女座'], + [9, 23, '天秤座'], [10, 24, '天蝎座'], [11, 23, '射手座'], [12, 22, '摩羯座'], +]; +function zodiac(birthday) { + if (!birthday) return ''; + const p = String(birthday).slice(0, 10).split('-'); + if (p.length < 3) return ''; + const m = +p[1], d = +p[2]; + if (!m || !d) return ''; + // 12 月 22 日之后到 1 月 19 日都是摩羯 + for (let i = 0; i < ZODIAC.length; i++) { + const [zm, zd, name] = ZODIAC[i]; + if (m === zm) return d >= zd ? name : ZODIAC[(i + 11) % 12][2]; + } + return ''; +} + +// 一起生活了多少天。优先用到家日期——「我们一起生活的第 N 天」说的是 +// 它到你家之后的日子,不是它出生之后的日子。没填到家日就退回建档日。 +function daysTogether(pet) { + const from = (pet && (pet.arrived_at || pet.created_at)) || ''; + if (!from) return 0; + const d = new Date(String(from).slice(0, 10) + 'T00:00:00'); + if (isNaN(d.getTime())) return 0; + const days = Math.floor((Date.now() - d.getTime()) / 86400000) + 1; + return days > 0 ? days : 0; +} + +// 性别徽章。后端存的是「男孩 / 女孩 / 不确定」 +function genderBadge(gender) { + if (gender === '男孩') return { icon: '♂', tone: 'male' }; + if (gender === '女孩') return { icon: '♀', tone: 'female' }; + return { icon: '', tone: '' }; +} + +module.exports = { zodiac, daysTogether, genderBadge };