Files
sundynix-pets/pets-fe/pages/expense/expense.js
T
Blizzard 15b52eebd0 feat: 首页改成记录时间轴,计划可自己管,花销独立成页
## 首页
原来是任务卡 + 日期带/日历 + 接下来 + AI建议 + 快速记录 + 新手内容的堆叠,
六张聚合卡,唯独看不到「我记了什么」。改成:

  宠物名片(品种·年龄·星座 + 性别徽章叠头像 + 一起生活的第 N 天)
  两个入口(管理计划 / 记花销)
  今日任务(有才显示)
  记录时间轴 —— 竖线 + 圆点 + 卡片,往下翻,长按删

日历挪回计划页了:它本来就属于那儿。Phase B 当时把它并进首页是因为计划还是
个 tab;现在计划是二级页,日历跟着回去更合理。

情绪点那几样字段后端早就返回了,只是没人用。抽了 utils/petmeta.js:
星座七个边界(含 12/22 跨年那个)+ 天数(到家日优先于建档日,
「一起生活」说的是它到你家之后的日子)+ 性别徽章,都在 node 里跑过。

顺带 stat-ring 组件和 homeSummary 接口现在没人用了,先留着没删。

## 两个入口取代竞品那一排
竞品是买商品/上豪车/记花销/领猫砂盆——除了记花销全是电商和广告位。
副标题给了真信息:「还有 5 件没做」「本月 ¥128」,不然就是两个没信息量的图标。

## 计划页:用户自己能管了
后端原来只有 toggle,节点全靠内置模板生成,用户加不了自己的事。补了

  POST   /api/pets/:id/plan-tasks
  PUT    /api/plan-tasks/:id
  DELETE /api/plan-tasks/:id

三件事值得说:

**30 天边界硬拦在服务端**(dayOffset)。只在前端拦的话,改个请求就能塞一条
第 200 天的节点,日历上没有那格、它就永远不显示也删不掉。

**归属校验单独抽了 ownedPlanTask**。PUT/DELETE /plan-tasks/:id 这种按资源 id
的路由最容易漏——不校验的话拿到别人的 task id 就能改删别人的计划。验过:
别人的 token 改和删都返回「无权操作」。

**边界差一天,是验证时撞出来的**:内置模板生成到 day=30(第 31 天),
我一开始按 0-29 卡,结果模板自己的最后一个节点(生成月度报告)落在窗口外——
日历上没有那一格,用户看得到却改不动也删不掉。前后端都改成 [0,30],
两边各留了一句注释指向对方,别再单方面改。

日历只画计划覆盖的 31 天,不画整月:整月会多出一堆空白格子、跨月还要翻页。

## 花销独立成页
写的是同一张表(type='cost',金额进 num_value、类别进 category),
报告页的账单聚合照样认——独立页的价值在专门的录入体验和当页就有月度汇总,
不是另存一份数据。

类别预置读 record_types 里 cost 的字段配置,后台改一处这页和 addrecord
同时生效,不会出现两套类别把账单切开。另外允许自定义类别,验过「寄养」
(不在预置里)能落库、账单也认。

## 检查器又抓到一次跨页借用
我在计划页复用了花销页的 ex-ph(placeholder 类),检查器直接报了位置。
顺手把三个页面的私有 placeholder 类统一到 app.wxss 已有的 .placeholder。
cal-* 现在首页没有了、计划页在用,也提到了 app.wxss。

## 验证(预生产库实跑)
  计划 CRUD    加/改/删 全过;空标题拒;越权改删拒
  30 天边界     day=30 通过、day=31 拒、day=-1 拒
  模板 day=30   现在能原地改了(修边界前会被自己的规则拒掉)
  首页时间轴    8 条记录按 occurred_at 倒序,type/tone/图标都取到
  两个入口副标题 「还有 5 件没做」「本月 ¥128」
  花销自定义类别 「寄养」落库,账单分类变成 [(医疗,128),(寄养,60)]
  petmeta      星座 7 个边界 + 天数 3 种情况,node 单测全过

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

159 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const store = require('../../utils/store.js');
const 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' };
},
});