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 };