Files
FamilyBook/templates/ledger/index.php
T
laoyang 09e30d27d4 v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图
用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
2026-10-07 05:52:40 +08:00

673 lines
35 KiB
PHP
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.
<?php
/**
* 财务 / 账本(v24 用户第 15 条;2026-10-03 第 7、8 条重排)。
*
* 用户要求的页面结构(第 7 条原话):
* 「上面是概览,下面就是具体账目,添加账目的功能和新增日程之类的一样,
* 放在右侧的按钮。……共享账本,改名等内容,都放在账本的设置中,
* 也是一样的,在新建账目按钮旁边,加一个设置按钮,在里面对账本进行设置、共享等。
* 挂在谁名下,应该默认是自己,如果要更改,点一下,弹出选人框。」
*
* 第 8 条(盘点):
* 「我点击盘点,让我对一个账目的金额进行重新输入,但是盘点之前的账目数目不能清空,
* 要记住,这样的话,每个账目的变化,就知道了……然后把这种变化,每个账目的
* 金额变化,还有总金额的变化,在账本列表上面,画出一个图,折线图这样的。」
* → 所以「账目」这块的最上面是**图**(整本账 + 每条有盘点记录的账目),
* 下面是账目表,每行带一个「盘点」入口。
*
* ⚠️ 金额全程以**分**参与计算(Ledger 里做的),这里只负责显示成元。
* 任何地方都不要拿元去加总。
*
* @param array $books 我能看到的账本(带 my_perm / entry_count)
* @param array $cur 当前账本
* @param array $entries 当前账本的账目
* @param array $summary ['asset','debt','net','by_kind','count'](单位:分)
* @param array $shares 当前账本的共享列表
* @param array $charts 有盘点历史的账目 → ['entry'=>行, 'series'=>[…] ]
* @param array $timeline 整本账的净资产时间线
* @param array $people 名册
*/
$canWrite = \App\Auth::canWrite() && \App\Ledger::canWrite($cur);
$curId = (int)$cur['id'];
$money = static fn($c): string => \App\Ledger::money($c);
$charts = $charts ?? [];
$timeline = $timeline ?? [];
// v24 第 23 批 用户第 2 条:每个类别自己的时间线(点标签时上面那张图换成它)。
// ⚠️ 由 index.php 算好传进来(那一处注释说明了"为什么预先算、而不是点了再请求")。
$kindLines = $kindLines ?? [];
// 用户第 8 条:「挂在谁名下,应该默认是自己」。这里的 $me 由 index.php 传进来(当前登录者)。
// ⚠️ 别用 `$me ?? 0` 再判 0 —— 那样一旦没传进来就会静默变成"不选",看着像默认值丢了。
$ledMe = (int)($me ?? 0);
?>
<div class="page-head">
<h1>财务</h1>
<span class="sub"><?= e((string)$cur['name']) ?> · 共享后双方都能记、都能看</span>
<div class="actions">
<?php /* 用户第 7 条:加账目 = 右侧按钮(与「新建日程」同一套) */ ?>
<?php if ($canWrite): ?>
<button class="btn" type="button" id="ledAddBtn">记一笔</button>
<?php else: ?>
<span class="link-quiet">只读</span>
<?php endif; ?>
<?php /* 用户第 7 条:共享 / 改名 / 新建 / 删除 全在账本设置里(☰) */ ?>
<?php $setCurrent = 'book'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if (count($books) > 1): ?>
<div class="seg">
<?php foreach ($books as $b): ?>
<a class="seg-btn<?= (int)$b['id'] === $curId ? ' is-on' : '' ?>"
href="<?= e(url('ledger', ['book' => (int)$b['id']])) ?>">
<?= e((string)$b['name']) ?><?= ($b['my_perm'] ?? '') === 'read' ? '(只读)' : '' ?>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php /* ==================== ① 概览 ==================== */ ?>
<div class="card card-pad led-sum">
<div class="led-sum-head">
<h2 class="cinema-h2" style="margin:0"><?= e((string)$cur['name']) ?></h2>
<span class="small muted">
<?= (int)($cur['entry_count'] ?? count($entries)) ?> 笔账
<?php if (($cur['my_perm'] ?? '') === 'read'): ?>
· <b>只读</b>(不能记账)
<?php endif; ?>
</span>
</div>
<div class="led-total">
<div class="led-total-item">
<span class="led-total-lb">总资产</span>
<span class="led-total-num led-asset"><?= e($money($summary['asset'])) ?></span>
</div>
<div class="led-total-item">
<span class="led-total-lb">总负债</span>
<span class="led-total-num led-debt"><?= e($money($summary['debt'])) ?></span>
</div>
<div class="led-total-item led-net">
<span class="led-total-lb">净资产</span>
<span class="led-total-num"><?= e($money($summary['net'])) ?></span>
</div>
</div>
<?php if ($summary['by_kind']): ?>
<?php /* v24 第 23 批 用户第 2 条(本次改动的重点)
用户原话:「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。
现在点击后没有任何效果,这个需要做处理,点击任何一个标签,
则统计这个标签下的所有财务条目的总数,并在上下两个图上,画出来。」
⚠️ 这里原来是一排**纯展示的 <span>**(根本不可点)—— 用户说的"点击后没有任何
效果"有一部分就是指它(另一部分是图表卡里那条 chip,它的 JS 早已失效,
见下面 ck-filter 的注释)。现在两处**并成一套筛选入口**:
点这里的任意标签 = 选中该类别,上下两张图 + 下面账目表一起联动。
⚠️ 「这个标签下的所有财务条目的**总数**」= 该类别金额合计(by_kind)。
它现在直接写在标签上(原来只是一句话的描述,不显示数),选中后再在下面
高亮条里复述一遍 合计 + 笔数。
⚠️ 三个总数字(总资产/总负债/净资产)**不随筛选变** —— 用户已确认:
选中「存款」后,净值仍代表整本账,避免屏幕上只剩一个类别、
却被误读成"我家只有存款"。选中的类别是谁,由下面那条高亮条说明。 */ ?>
<div class="ck-filter led-kinds" data-ck-filter>
<button type="button" class="ck-chip is-on" data-ck-kind=""
data-ck-sum="<?= e($money($summary['net'])) ?>"
data-ck-n="<?= (int)$summary['count'] ?>"
data-ck-word="整本账">全部(<?= (int)$summary['count'] ?>)</button>
<?php foreach ($summary['by_kind'] as $k => $c): ?>
<button type="button"
class="ck-chip led-kind-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>"
data-ck-kind="<?= e((string)$k) ?>"
data-ck-sum="<?= e($money((int)$c)) ?>"
data-ck-n="<?= (int)($summary['by_kind_n'][$k] ?? 0) ?>"
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>
<b><?= e($money((int)$c)) ?></b>
</button>
<?php endforeach; ?>
</div>
<?php /* 选中某个类别后的高亮条:说清"现在看的是哪一类、合计多少、几笔",
并给一个明确的出口(清除筛选)。没有它的话,图上少了几条线
就成了"数据丢了"而不是"我在筛选"。 */ ?>
<div class="led-focus" data-ck-focus hidden>
<span class="led-focus-txt"></span>
<button type="button" class="btn btn-ghost btn-sm" data-ck-clear>清除筛选</button>
</div>
<?php endif; ?>
</div>
<?php if ($canWrite): ?>
<div class="card card-pad led-add" id="ledAddCard" hidden>
<h2 class="cinema-h2">记一笔</h2>
<form method="post" action="<?= e(url('ledger/entry_add')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<div class="form-grid">
<div class="field">
<label for="le-kind">类别 *</label>
<select class="select" id="le-kind" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label for="le-title">名称 *</label>
<input class="input" id="le-title" name="title" required placeholder="如:工商银行定期">
</div>
<div class="field">
<label for="le-amount">金额(元)*</label>
<input class="input" id="le-amount" name="amount" required inputmode="decimal"
placeholder="如:120000.00">
<span class="tip">负债(贷款/信用卡)也填正数,系统按类别算成负债。</span>
</div>
<div class="field">
<?php /* 用户第 7 条:「挂在谁名下,应该默认是自己,如果要更改,点一下,弹出选人框」
—— 用全局选人组件(与观影选人同一套,含拼音首字母联想)。 */ ?>
<label>挂在谁名下</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
// 默认就是**我自己**(用户第 8 条)。单选组件按 $ppSelected 勾选并渲染 chip,
// 传 [0] 的话会渲染成"未选择"——所以必须传真实 id。
$ppSelected = $ledMe > 0 ? [$ledMe] : [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '这笔账挂在谁名下';
$ppEmptyText= '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field full">
<label for="le-note">备注</label>
<input class="input" id="le-note" name="note" placeholder="可选">
</div>
</div>
<div class="btn-row"><button class="btn" type="submit">添加账目</button></div>
</form>
</div>
<?php endif; ?>
<?php /* ==================== ② 变化图(v24 第 10 批 #3 重做)====================
用户原话:「在账目上面,增加一个图表显示,**显示所有账目和总金额的变化曲线**,
根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
⚠️ 与旧版的差别:① 总金额(净资产)**永远在最前**;② 每条账目都出线(不再只画盘点过的);
③ 加一条"按类别看"的切换条 —— 因为"所有账目"在几百条时会变成一坨,
把筛选交给用户,而不是我们偷偷少画几条。 */ ?>
<?php if ($entries): ?>
<div class="card card-pad led-charts">
<h2 class="cinema-h2">
金额变化
<span class="hint">横轴时间 · 纵轴金额;每次「盘点」记一个点,旧数字不会被清掉</span>
</h2>
<?php if ($timeline): ?>
<?php /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好**,靠 hidden 切换。
为什么不"点了再向服务器要一张":图是**服务端画的 SVG**(见 app/chart.php),
要重画就得整页刷新 —— 那就不是"点标签",是"点链接"了。
预渲染的代价只是每个**用到的**类别多一次查询,而这个页面本来就要
把整本账每条账目都读出来(charts() 已在做),量级一样。 */ ?>
<div class="ck-card ck-total">
<div class="ck-head">
<b data-ck-total-title>总金额 · 净资产</b>
<span class="small muted" data-ck-total-n><?= count($timeline) ?> 个时间点</span>
</div>
<?php /* ⚠️ 每张图外面包一层带 data-ck-tkind 的容器('' = 整本账)。
筛选时只显示匹配的那一张。
⚠️ 不能在 chart_line 内部加属性 —— 那个函数被全站复用,
"为了筛选顺手改公共渲染函数"是往所有调用点塞风险。 */ ?>
<div data-ck-tkind="">
<?= chart_line($timeline, '净资产', 'asset', 900) ?>
</div>
<?php foreach ($kindLines as $k => $line): ?>
<?php if (!$line) { continue; } ?>
<?php /* 负债类别用绿线(`debt`),资产用红线 —— 与全站"涨红跌绿"口径一致 */ ?>
<div data-ck-tkind="<?= e((string)$k) ?>" hidden>
<?= chart_line($line, \App\Ledger::kindWord((string)$k),
\App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900) ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if ($charts): ?>
<?php
// 类别筛选条:只列出**真有账目**的类别,点一下只看这一类。
$kindsPresent = [];
foreach ($charts as $ch) {
$k = (string)$ch['entry']['kind'];
$kindsPresent[$k] = ($kindsPresent[$k] ?? 0) + 1;
}
?>
<?php /* 🔴 v24 第 23 批 用户第 2 条:**这条 chip 原来点了真的没反应**。
根因(务必看懂再改):它的 JS 在找 `[data-ck-card]` 这个属性来显隐"每账目一张小卡",
但 v24 第 11 批 #7 已经把"每账目一张小卡"换成了**一张合并的多线图**
(用户当时要求"图下面不用显示详情,只显示哪个颜色代表哪个账目")——
属性随之消失,而这段 JS 没人删。于是点标签:`.is-on` 会亮、**画面上什么都不变**。
这就是"看着有交互(按钮会变蓝)、实际没作用"的典型:**JS 找的钩子已经不存在了**。
→ 现在改成:显隐多线图里**对应类别的 <g>**(chart_multi 已经给每条线标了
`data-ck-line`)+ 图例项 + 总金额图切到该类曲线。三处一起动。 */ ?>
<div class="ck-filter" data-ck-filter>
<button type="button" class="ck-chip is-on" data-ck-kind=""
data-ck-n="<?= count($charts) ?>" data-ck-word="整本账">全部(<?= count($charts) ?>)</button>
<?php foreach ($kindsPresent as $k => $n): ?>
<button type="button" class="ck-chip<?= \App\Ledger::isDebt((string)$k) ? ' is-debt' : '' ?>"
data-ck-kind="<?= e((string)$k) ?>"
data-ck-n="<?= (int)$n ?>"
data-ck-word="<?= e(\App\Ledger::kindWord((string)$k)) ?>">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>(<?= (int)$n ?>)
</button>
<?php endforeach; ?>
</div>
<?php /* 用户 2026-10-04 第 11 批 #7 两句原话:
· 「那个图,怎么数据都是 0 啊,没有盘点数据,就用账目的初始数据就可以了」
→ 数据源已在 Ledger::entrySeries 修好(没盘点过就从 0 画到当前值)。
· 「图下面的条目不用显示详情,只需要显示每个账目的颜色,
就是说哪个颜色代表哪个账目,就可以了。」
→ 原来每条账目一张小卡(标题 + 类型 + 点数 + 一张小图),信息冗余;
现在改成**一张合并的多色折线图**(每条账目一条线、用图例色),
下面只留一排颜色图例(色块 + 名称),不再堆详情。 */ ?>
<?= chart_multi($charts, 900) ?>
<div class="ck-legend">
<?php foreach ($charts as $ch): ?>
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
<span class="ck-lg-item" data-ck-kind="<?= e($ek) ?>">
<i class="ck-lg-dot" style="background: <?= e((string)$ch['color']) ?>;"></i>
<?= e((string)$en['title']) ?>
</span>
<?php endforeach; ?>
</div>
<p class="small muted ck-note">
<span data-ck-note-all>没盘点过的账目按「从 0 到现在」画 —— 想让它出现真实波动,在下面账目表里点「盘点」。</span>
<span data-ck-note-one hidden></span>
</p>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ==================== ③ 账目 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">账目(<?= count($entries) ?>)</h2>
<?php if (!$entries): ?>
<p class="empty-hint">这本账还没有账目。上面「记一笔」开始记吧。</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>类别</th><th>名称</th><th class="num">金额</th><th>挂在谁名下</th><th>备注</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($entries as $en): ?>
<?php $ek = (string)$en['kind']; $debt = \App\Ledger::isDebt($ek); $eid = (int)$en['id']; ?>
<?php /* v24 第 23 批 用户第 2 条:行上带类别标记 —— 点类别标签时按它显隐。
用户要的是"点击标签 → 统计该类条目并上下两图画出来",
表也跟着收窄到这一类,否则图上只剩存款、表里却还堆着房贷,自相矛盾。 */ ?>
<tr data-ck-row="<?= e($ek) ?>">
<td class="nowrap">
<span class="led-kind-chip<?= $debt ? ' is-debt' : '' ?>">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
</span>
</td>
<td><?= e((string)$en['title']) ?></td>
<td class="num<?= $debt ? ' led-debt' : ' led-asset' ?>">
<?= $debt ? '-' : '' ?><?= e($money((int)$en['amount_cents'])) ?>
</td>
<td class="nowrap">
<?php if (!empty($en['person_name'])): ?>
<?= person_chip([
'id' => (int)$en['person_id'], 'name' => (string)$en['person_name'],
'my_nickname' => (string)($en['person_nick'] ?? ''),
'avatar' => $en['avatar'] ?? null, 'avatar_color' => $en['avatar_color'] ?? null,
]) ?>
<?php else: ?><span class="small muted">—</span><?php endif; ?>
</td>
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
<td class="nowrap led-acts">
<?php /* 用户 2026-10-04 第 3 条原话:「账目列表,右侧两个按钮,
一个**盘点**,点击后,输入最新的金额;一个按钮**修改**,
用来修改名称、挂在谁名下、备注等。」
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
<?php /* 用户第 11 批 #7:「修改按钮,点击后弹框,然后修改信息」;
同时这里也是**账目页卡顿的根源**:原来每行内联「盘点」「修改」
两个面板(各一个 <form> + 一个选人组件),账目多时就是几千个 form
—— 密码管理器(Bitwarden)逐项扫描把主线程占死。
→ 现在行上只留两个带数据的按钮,真正的表单全页只有一份(见页尾 #ledPanels)。 */ ?>
<?php if ($canWrite): $histN = count(\App\Ledger::history($eid)); ?>
<button type="button" class="btn btn-ghost btn-sm led-act"
data-led-open="recon"
data-led-id="<?= $eid ?>"
data-led-title="<?= e((string)$en['title']) ?>"
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
data-led-hist="<?= (int)$histN ?>">盘点</button>
<button type="button" class="btn btn-ghost btn-sm led-act"
data-led-open="edit"
data-led-id="<?= $eid ?>"
data-led-title="<?= e((string)$en['title']) ?>"
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
data-led-note="<?= e((string)($en['note'] ?? '')) ?>"
data-led-kind="<?= e($ek) ?>"
data-led-person="<?= (int)($en['person_id'] ?? 0) ?>">修改</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr>
<th colspan="2">合计(资产 − 负债)</th>
<th class="num led-net"><?= e($money($summary['net'])) ?></th>
<th colspan="3"></th>
</tr>
</tfoot>
</table>
</div>
<?php endif; ?>
</div>
<?php /* ==================== 账目行的两个弹框(全页各一份)====================
与名册的 #rosterMenus 同一套思路:行上只留按钮,表单集中在这里。
这是账目页卡顿的**根治办法** —— 原来每行两个面板,
账目一多就是几千个 form(用户报的 Bitwarden 冲突就是这么来的)。 */ ?>
<?php if ($canWrite): ?>
<div class="led-modal" id="ledPanels" hidden>
<div class="led-mask" data-led-close></div>
<div class="led-panel-lg" id="ledReconPanel" hidden role="dialog" aria-modal="true" aria-label="盘点">
<div class="led-ph"><b>盘点「<span class="ld-title"></span>」</b>
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="led-recon">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="">
<div class="led-pb">
<div class="field">
<label>最新金额(元)</label>
<input class="input" name="amount" inputmode="decimal" required>
<span class="tip">盘点前的金额会记进历史,图上就多一个点。</span>
</div>
<div class="field">
<label>盘点备注</label>
<input class="input" name="note" placeholder="如:2026 年初盘点">
</div>
<details class="led-hist" hidden>
<summary class="link-quiet"></summary>
<div class="led-hist-box"></div>
</details>
</div>
<div class="led-pf">
<button class="btn" type="submit">记下这次盘点</button>
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
</div>
</form>
</div>
<div class="led-panel-lg" id="ledEditPanel" hidden role="dialog" aria-modal="true" aria-label="修改">
<div class="led-ph"><b>修改「<span class="ld-title"></span>」</b>
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('ledger/entry_update')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="">
<div class="led-pb">
<div class="field">
<label>名称</label>
<input class="input" name="title" required>
</div>
<div class="field">
<label>挂在谁名下</label>
<?php /* ⚠️ 选人组件只在这里渲染**一份**(原来每行一份 = 卡顿主因之一) */ ?>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$ppMode = 'single';
$ppOpenBtn = '更换归属人';
$ppTitle = '这笔账挂在谁名下';
$ppEmptyText= '不指定';
$ppAllowEmpty = true;
require __DIR__ . '/../_person_picker.php';
?>
</div>
<div class="field">
<label>类别</label>
<select class="select" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note">
</div>
<p class="tip" style="margin:0">
改的是名称、归属与备注。若只是<b>金额变了</b>(不是录错),请用「盘点」——
那样才会记进历史、在图上多一个点。
</p>
</div>
<div class="led-pf">
<button class="btn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
<button class="btn btn-danger" type="submit" formaction="<?= e(url('ledger/entry_del')) ?>"
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
(function () {
/* ---- 「记一笔」开合 ---- */
var btn = document.getElementById('ledAddBtn');
var card = document.getElementById('ledAddCard');
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
btn.textContent = card.hidden ? '记一笔' : '收起';
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
}
/* ---- 行上「盘点 / 修改」两个按钮 → 打开**全页共享的弹框**(第 11 批 #7)----
⚠️ 旧版是"把面板插到那一行后面",于是每行都得内联一整套表单(含选人组件):
账目多的时候就是几千个 form,密码管理器逐个扫描 → 页面卡死
(用户报的"和之前的名录一样,还是 bitwarden 冲突")。
现在表单只有一份(#ledPanels),打开时把该行的数据灌进去。 */
var wrap = document.getElementById('ledPanels');
if (wrap) {
var reconPanel = document.getElementById('ledReconPanel');
var editPanel = document.getElementById('ledEditPanel');
function closeAll() {
wrap.hidden = true;
if (reconPanel) { reconPanel.hidden = true; }
if (editPanel) { editPanel.hidden = true; }
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var trigger = t.closest('[data-led-open]');
if (trigger) {
e.preventDefault();
var which = trigger.getAttribute('data-led-open');
var id = trigger.getAttribute('data-led-id');
var title = trigger.getAttribute('data-led-title') || '';
var amt = trigger.getAttribute('data-led-amt') || '';
var panel = (which === 'recon') ? reconPanel : editPanel;
if (!panel) { return; }
panel.querySelector('input[name="id"]').value = id;
panel.querySelector('.ld-title').textContent = title;
var amtInp = panel.querySelector('input[name="amount"]');
if (amtInp) { amtInp.value = amt; }
if (which === 'edit') {
var tInp = panel.querySelector('input[name="title"]');
if (tInp) { tInp.value = title; }
var nInp = panel.querySelector('input[name="note"]');
if (nInp) { nInp.value = trigger.getAttribute('data-led-note') || ''; }
var kSel = panel.querySelector('select[name="kind"]');
if (kSel) { kSel.value = trigger.getAttribute('data-led-kind') || 'other'; }
// 归属人:选人组件是**单选**,用它的接口重置选中(见 _person_picker.php)
// ⚠️ 必须把 panel 作为 root 传进去:本页有**两个** person_id 选人组件
// (「记一笔」表单 + 本弹框),只按名字找会命中第一个 →
// 用户看到的"归属人没带进来"就是这么来的。
var pid = trigger.getAttribute('data-led-person') || '0';
if (window.ppSetSelected) { window.ppSetSelected('person_id', pid, panel); }
}
if (which === 'recon') {
// 历史的条数以按钮为准(服务端已算好),点开才展开,避免每行都查一次库
var histN = parseInt(trigger.getAttribute('data-led-hist') || '0', 10) || 0;
var hist = panel.querySelector('.led-hist');
var hsum = panel.querySelector('.led-hist summary');
var hbox = panel.querySelector('.led-hist-box');
if (hist && hsum) {
hist.hidden = histN === 0;
hsum.textContent = '看历史(' + histN + ' 次)';
if (hbox) { hbox.innerHTML = ''; }
}
}
reconPanel.hidden = (which !== 'recon');
editPanel.hidden = (which !== 'edit');
wrap.hidden = false;
var focusInp = panel.querySelector('input:not([type=hidden]), select');
if (focusInp) { focusInp.focus(); }
return;
}
if (t.closest('[data-led-close]')) { e.preventDefault(); closeAll(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(); }
});
}
/* ---- 类别标签 → 上下两张图 + 账目表一起联动(v24 第 23 批 用户第 2 条)
------------------------------------------------------------
用户原话:「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。
现在点击后没有任何效果,这个需要做处理,点击任何一个标签,
则统计这个标签下的所有财务条目的总数,并在上下两个图上,画出来。」
一处逻辑,四件事同时做(缺一件用户就会觉得"没生效"):
① 标签高亮(`.is-on`);
② 「总金额」图切到该类别的曲线(`[data-ck-tkind]`,都预渲染好了);
③ 多线图里只留该类别的线(`[data-ck-line]`)+ 图例只留对应项;
④ 账目表只列该类条目(`[data-ck-row]`)。
另外在概览卡里给一条高亮:看的是哪一类、合计多少、几笔,并给「清除筛选」。
⚠️ 两处 `.ck-filter`(概览卡的标签 + 图表卡的 chip)共用本函数 ——
它们是**同一个筛选器**的两个入口。如果只给其中一处绑事件,
点另一处就会"两处状态不一致"(一处亮着、图上却是另一个类别的结果)。
所以:任何一处的点击都会把两处的高亮 + 全部图表 + 表格一起重画。
⚠️ 图例项**不**用 hidden 藏:`.ck-lg-item[hidden]{display:none}` 的规则本来就有,
但"藏起来"会让图例的长度跳变。折中:藏(保持整齐一致),
并在下面的说明行里写清"只在看这一类"。
⚠️ 「全部」按钮的 data-ck-sum 用的是**净资产**(net),不是资产合计 ——
标签上那句话读起来是"整本账 = 多少钱",净资产才是那个数。 */
var bars = document.querySelectorAll('[data-ck-filter]');
if (bars.length) {
var focusBar = document.querySelector('[data-ck-focus]');
var focusTxt = focusBar ? focusBar.querySelector('.led-focus-txt') : null;
var noteAll = document.querySelector('[data-ck-note-all]');
var noteOne = document.querySelector('[data-ck-note-one]');
function applyKind(kind, sum, n, word) {
// ① 两处标签的高亮保持同步(同一个筛选器)
Array.prototype.forEach.call(bars, function (bar) {
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
c.classList.toggle('is-on', (c.getAttribute('data-ck-kind') || '') === kind);
});
});
// ② 总金额图:只显示匹配的那一张
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-tkind]'), function (w) {
w.hidden = (w.getAttribute('data-ck-tkind') || '') !== kind;
});
// 图上的标题跟着换(不然选着"存款",标题还写"净资产",自相矛盾)
var ttl = document.querySelector('[data-ck-total-title]');
if (ttl) {
ttl.textContent = kind === '' ? '总金额 · 净资产' : (word + ' · 金额变化');
}
// ③ 多线图:按类别显隐线条(几何不动,只切可见性)
//
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
// 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
// 必须同时给 `g` 与 `li`/`tr` 用同一种写法:
// · `<g>` 是 SVG → 只能 display
// · `.ck-lg-item` / `<tr>` 是 HTML,两样都行;统一用 display 少一个特例。
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) {
g.style.display = (kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind)
? 'none' : '';
});
Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) {
li.style.display = (kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind)
? 'none' : '';
});
// ④ 账目表
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) {
tr.style.display = (kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind)
? 'none' : '';
});
// ⑤ 概览卡里的高亮条
if (focusBar && focusTxt) {
focusBar.hidden = (kind === '');
if (kind !== '') {
focusTxt.textContent = '正在看「' + word + '」:合计 ' + sum + ' · ' + n + ' 笔';
}
}
if (noteAll && noteOne) {
noteAll.hidden = (kind !== '');
noteOne.hidden = (kind === '');
if (kind !== '') {
noteOne.textContent = '只画了「' + word + '」这一类(' + n + ' 条线),'
+ '合计 ' + sum + ';点「清除筛选」看整本账。';
}
}
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var chip = t.closest('.ck-filter .ck-chip');
if (chip) {
e.preventDefault();
applyKind(chip.getAttribute('data-ck-kind') || '',
chip.getAttribute('data-ck-sum') || '',
chip.getAttribute('data-ck-n') || '',
chip.getAttribute('data-ck-word') || '');
return;
}
// 清除筛选 = 回到"全部"那一档(不自己拼状态,直接找那颗按钮点它一次)
if (t.closest('[data-ck-clear]')) {
e.preventDefault();
var all = document.querySelector('[data-ck-filter] .ck-chip[data-ck-kind=""]');
if (all) { all.click(); }
}
});
}
})();
</script>