Files
FamilyBook/templates/ledger/index.php
T
laoyang c8839fa728 v26 第 26 批(5 条):财务多选按集合取图 / 首页全天置顶与红线定位 / 任务复选框去提示与看板颜色 / 导航搜索引擎切换与站点复用 / 详情页去复选框
- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增
  ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存;
  Ledger::timeline 第二参数放宽为 string|array)
- 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中
  (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow
- 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard),
  首页任务行用该颜色显示所属看板
- 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎;
  新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite)
- 任务详情页页头去掉复选框(已有「标记完成」按钮)
2026-10-07 15:31:26 +08:00

810 lines
44 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>
<?php /* ⚠️ v24 第 25 批 用户第 7 条:标签改成**多选**(互不排斥)。
用户原话:「那几个标签是可以多选的,它们之间不是互斥的,我选中这个标签,
就开始统计这个类别,我再点一次,就取消选择,不统计这个类别。」
⇒ 所以这里的「全部」不再是一颗"选项",而是**一键清空选择**的复位键。
文案写成「全部,点这里复位」比只写「全部」更能说明它不是同类选项。 */ ?>
<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="整本账"
title="清空选择,看整本账">全部(<?= (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)) ?>"
title="点一下开始统计「<?= 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 /* 总金额图("全部")与逐类别图("存款"…)都**预渲染好** —— 但它们现在的身份变了:
v24 第 26 批 用户第 1 条要求"多选时按**选中集合**统计",而集合的组合数是指数级的
(10 个类别 = 1023 种),不可能全预渲染。所以改成:
· 页面里预渲染的这批图,现在只作**缓存的第一层**:
空集(整本账)与单类别这两档直接命中,**零请求、无闪烁**;
· 其它组合(2 类以上)由前端把集合发给
`ledger/total_chart` 现取一张(见本文件 JS 的 ② 段)。
这样既保住了"点一下立刻变"的手感(最常见的前两档),
又不至于为 1023 种组合预渲染 1023 张图。 */ ?>
<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-total-host` 是 JS 唯一要操作的节点。
两个自定义属性把"整本账那张图"和"取图接口"都交给 JS:
· data-ck-cache-base —— 空集时要回显的 HTML(**页面里已经画好了**,
不必再请求一次;也不怕 JS 手里没有它)。
· data-ck-url —— 现取接口(集合作为 &kinds=a,b 追加)。
⚠️ 单类别那几张也**照样渲染在页面里**(`data-ck-seed` 标记),
由 JS 启动时读进缓存 —— 它们不再靠 hidden 切换显示,
但"预渲染一次、JS 直接取用"这个好处要留住(选一类时零延迟)。 */ ?>
<div data-ck-total-host
data-ck-cache-base="<?= e(chart_line($timeline, '净资产', 'asset', 900)) ?>"
data-ck-url="<?= e(url('ledger/total_chart', ['book' => (int)$cur['id']])) ?>">
<?= chart_line($timeline, '净资产', 'asset', 900) ?>
</div>
<?php /* 单类别图:只作 JS 的缓存种子,**不显示**(display:none,不是 hidden——
因为 hidden 会被 JS 里某些 innerHTML 操作顺手带上)。 */ ?>
<?php foreach ($kindLines as $k => $line): ?>
<?php if (!$line) { continue; } ?>
<?php /* 负债类别用绿线(`debt`),资产用红线 —— 与全站"涨红跌绿"口径一致 */ ?>
<script type="application/json"
data-ck-seed="<?= e((string)$k) ?>"><?= json_encode(
chart_line($line, \App\Ledger::kindWord((string)$k),
\App\Ledger::isDebt((string)$k) ? 'debt' : 'asset', 900),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?></script>
<?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 条)
------------------------------------------------------------
用户原话:「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。
现在点击后没有任何效果,这个需要做处理,点击任何一个标签,
则统计这个标签下的所有财务条目的总数,并在上下两个图上,画出来。」
🔴🔴 v24 第 25 批 用户第 7 条:**改成多选(标签之间不互斥)**。
用户原话:「财务的那几个标签,点击之后图片变化的时候,那几个标签是可以多选的,
它们之间不是互斥的,我选中这个标签,就开始统计这个类别,
我再点一次,就取消选择,不统计这个类别。」
⇒ 语义从「选中**一个**类别」变成「持有一个**选中的集合**」:
· 点一个没选中的 → 加进集合(开始统计这一类)
· 点一个已选中的 → 从集合里去掉(不再统计)
· 集合为空 = 等同「全部」(这比"一个都不显示"友好:
用户取消了最后一个类别时,看到的是回到全貌,而不是一张空图)
· 「全部」那颗按钮 = 一键清空集合(不是"再点一下取消它自己")
之所以不是"一个都不选 = 什么都不统计":`空集`与`全选`在图上看是同一件事
(所有线都画出来),而"点掉最后一个 → 整张图空掉"看着像坏了。
一处逻辑,四件事同时做(缺一件用户就会觉得"没生效"):
① 标签高亮(`.is-on`);
② 「总金额」图:集合里**只剩一个**类别时切到该类的单线图,
否则显示整本账(多选时逐类单线图没有意义 —— 那是"把几条线并起来",
而多线图已经在做这件事了);
③ 多线图里只留**集合内**类别(`[data-ck-line]`)+ 图例只留对应项;
④ 账目表只列集合内类别(`[data-ck-row]`)。
另外在概览卡里给一条高亮,并给「清除筛选」。
⚠️ 两处 `.ck-filter`(概览卡的标签 + 图表卡的 chip)共用本函数 ——
它们是**同一个筛选器**的两个入口,两处的高亮必须**完全同步**:
所以这里不重算"哪些该亮",而是**两处按同一个集合**刷一遍。
⚠️ 图例项**不**用 hidden 藏:折中成"藏",并在下面的说明行里写清"只看这几类"。 */
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]');
/** 当前选中的类别集合(空集 = 全部) */
var picked = [];
/* v24 第 26 批 用户第 1 条:总金额图的**按集合缓存**。
key = 排序后的类别串('' = 整本账 → 不进缓存表,直接回显预渲染那张)
value = 该组合对应的 SVG 字符串
⚠️ 缓存的是 **SVG 文本**而不是 DOM 节点:innerHTML 每次要复制一份,
节点被移动来移动去会出各种"元素凭空消失"的怪事。 */
var totalCache = {};
/** 正在请求中的组合(防止连点同一组合发多次) */
var totalFetching = {};
/* 把页面预渲染的单类别图读进缓存(选一类时零请求、零延迟)。
⚠️ 用 `<script type="application/json">` 承载 SVG 而不是塞进 data-*——
属性里的引号会被 HTML 实体化,读出来还得反转义一次;
JSON 块原样读、`JSON.parse` 即可(服务端已 json_encode)。
⚠️ 这里**必须 try/catch**:种子坏了只是"少一层缓存",
绝不能让整个筛选条(连同第 ③ ④ 步)一起挂掉。 */
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-seed]'), function (el) {
try {
var k = el.getAttribute('data-ck-seed') || '';
if (k !== '') { totalCache[k] = JSON.parse(el.textContent || '""'); }
} catch (e) { /* 忽略:少一层缓存而已 */ }
});
/** 读一遍某颗标签上带的说明数据(合计 / 笔数 / 类别名) */
function chipInfo(bar, kind) {
var c = bar.querySelector('.ck-chip[data-ck-kind="' + kind + '"]');
return c ? {
sum: c.getAttribute('data-ck-sum') || '',
n: c.getAttribute('data-ck-n') || '',
word: c.getAttribute('data-ck-word') || '',
} : {sum: '', n: '', word: ''};
}
function reapply() {
// ① 两处标签的高亮保持同步(按同一个集合)
Array.prototype.forEach.call(bars, function (bar) {
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
var k = c.getAttribute('data-ck-kind') || '';
// 「全部」那颗:集合为空时亮(它就是"没有筛选"这个状态)
c.classList.toggle('is-on', k === '' ? picked.length === 0 : picked.indexOf(k) >= 0);
});
});
// ② 总金额图:按**选中集合**换图(v24 第 26 批 用户第 1 条重写)
//
// 🔴 用户原话:「财务模块,多个标签勾选后,第一个图,总额那里,勾选一个会变化,
// 勾选多个后,变成总数了。」
// 上一批只做了"恰好选一类才切图",多选直接**回落成整本账** —— 那就是 bug。
//
// 做法:集合为空 → 用页面里预渲染的「整本账」那张;
// 否则 → 找一张**已经画好该组合**的缓存图;没有就 AJAX 现取一张。
// 为什么要缓存:用户连点几下标签(选中→取消→再选中)时,
// 每一下都打服务器是浪费(同一组合的画法完全确定)。
// key 用**排序后的类别串**,所以 `[存款,保险]` 与 `[保险,存款]` 命中同一张。
//
// ⚠️ 用 `totalHost.innerHTML = svg` 替换而不是 append:
// 否则连点几次会堆一摞图在容器里(每次只藏旧的,越藏越多)。
var totalHost = document.querySelector('[data-ck-total-host]');
var totalKey = picked.slice().sort().join(',');
if (totalHost) {
if (totalKey === '') {
// 整本账:直接回显页面里预渲染的那张(不请求、无闪烁)
totalHost.innerHTML = totalHost.getAttribute('data-ck-cache-base') || '';
} else {
var cached = totalCache[totalKey];
if (cached !== undefined) {
totalHost.innerHTML = cached;
} else if (!totalFetching[totalKey]) {
totalFetching[totalKey] = true;
var url = totalHost.getAttribute('data-ck-url') || '';
if (url) {
fetch(url + '&kinds=' + encodeURIComponent(totalKey), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }, credentials: 'same-origin'
}).then(function (r) { return r.json().catch(function () { return null; }); })
.then(function (d) {
totalFetching[totalKey] = false;
if (!d || !d.ok) { return; }
totalCache[totalKey] = d.svg || '';
// ⚠️ 只有**当前选中的还是这个组合**时才贴上去:
// 用户可能已经又点了几下,慢响应回来时不该覆盖新结果。
if (picked.slice().sort().join(',') === totalKey) {
totalHost.innerHTML = totalCache[totalKey];
var t2 = document.querySelector('[data-ck-total-title]');
if (t2 && d.label) { t2.textContent = d.label + ' · 金额变化'; }
}
}).catch(function () { totalFetching[totalKey] = false; });
}
}
}
}
var ttl = document.querySelector('[data-ck-total-title]');
if (ttl) {
if (picked.length === 0) {
ttl.textContent = '总金额 · 净资产';
} else if (picked.length === 1) {
var info = chipInfo(bars[0], picked[0]);
ttl.textContent = (info && info.word ? info.word : '') + ' · 金额变化';
} else {
var ws2 = [];
picked.forEach(function (k) { ws2.push(chipInfo(bars[0], k).word); });
ttl.textContent = picked.length + ' 类合计 · ' + ws2.join(' + ');
}
}
// ③ 多线图:按集合显隐线条(几何不动,只切可见性)
//
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
function keep(k) {
// 空集 = 全部(都留);否则只留在集合里的
return picked.length === 0 || picked.indexOf(k) >= 0;
}
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) {
g.style.display = keep(g.getAttribute('data-ck-line') || '') ? '' : 'none';
});
Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) {
li.style.display = keep(li.getAttribute('data-ck-kind') || '') ? '' : 'none';
});
// ④ 账目表
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) {
tr.style.display = keep(tr.getAttribute('data-ck-row') || '') ? '' : 'none';
});
// ⑤ 概览卡里的高亮条 + 图下的说明
if (focusBar && focusTxt) {
focusBar.hidden = (picked.length === 0);
if (picked.length > 0) {
var words = [];
var sumAll = 0, nAll = 0;
picked.forEach(function (k) {
var i = chipInfo(bars[0], k);
words.push(i.word);
// 合计:把每个标签上那串金额里的数字抠出来相加(金额是带千分位的字符串)
var digits = String(i.sum).replace(/[^0-9.\-]/g, '');
if (digits !== '' && !isNaN(parseFloat(digits))) { sumAll += parseFloat(digits); }
nAll += parseInt(i.n || '0', 10) || 0;
});
// 合计只对"金额可直接相加"的类别准确(负债类在同一本账里是负数),
// 所以这句说明里把笔数写在前、合计写在后,别让用户把它当成权威口径。
focusTxt.textContent = '正在看 ' + picked.length + ' 类(' + words.join('、') + '):'
+ nAll + ' 笔';
}
}
if (noteAll && noteOne) {
noteAll.hidden = (picked.length > 0);
noteOne.hidden = (picked.length === 0);
if (picked.length > 0) {
var ws = [];
picked.forEach(function (k) { ws.push(chipInfo(bars[0], k).word); });
noteOne.textContent = '只画了「' + ws.join('、') + '」这 ' + picked.length + ' 类;'
+ '再点一次标签可以取消这一类,点「清除筛选」看整本账。';
}
}
}
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();
var kind = chip.getAttribute('data-ck-kind') || '';
if (kind === '') {
picked = []; // 「全部」= 清空集合
} else {
var i = picked.indexOf(kind);
if (i >= 0) { picked.splice(i, 1); } // 再点一次 = 取消这一类
else { picked.push(kind); } // 点一次 = 开始统计这一类
}
reapply();
return;
}
// 清除筛选 = 回到"全部"(不自己拼状态,直接清空集合)
if (t.closest('[data-ck-clear]')) {
e.preventDefault();
picked = [];
reapply();
}
});
}
})();
</script>