Files
FamilyBook/app/chart.php
T
laoyang ae3060f552 v24 第 23 批:资源页四色按钮+四个弹框 / 财务页类别标签联动上下两图
用户 2 条原话:
  ①「资源页面,还可以更简洁,资源打开后,没有正文就不显示(没有正文)这句话了,
     直接显示附件。附件列完后,列出来人员,所有者、共享给谁了。然后其他的都不要。
     把编辑、共享、附件、删除,四个按钮,放在每个资源的右上角,和标题一排,
     点击后分别是编辑信息、设置共享人员,包括删除共享人等,附件,点击后弹框,
     允许用户拖动上传。编辑黄色、共享绿色、附件蓝色、删除红色。
     按钮点击后的功能,都是弹框。」
  ②「财务,统计功能和金额变化图表那里,中间,有各类标签,存款保险等。现在点击后
     没有任何效果,这个需要做处理,点击任何一个标签,则统计这个标签下的所有财务条目的
     总数,并在上下两个图上,画出来。」

用户拍板的两个岔路(AskUserQuestion):
  · 筛选某个类别时,上面那三个总数(总资产/总负债/净资产)**保持整本账口径**,
    不跟着切 —— 避免屏幕上只剩一个类别、却被误读成「我家只有存款」。
    当前看的是哪一类,由概览卡里一条高亮条说明(含合计与笔数 + 清除筛选)。
  · 「附件」弹框**既能拖动上传、也列出已有附件并可删**(附件管理只留一个入口);
    展开区的附件墙因此变成**只读**。

════ 一、 资源页(templates/resources/index.php 重写)════

**展开区只留「看」**(用户:"然后其他的都不要"):
  · 正文 —— **没有就不占位**(不再写「(没有正文)」那句话);
  · 附件墙 —— 只读(可预览/下载,不再带删除按钮);
  · 人员行 —— 列在附件之后:**所有者 + 共享给谁**。
原来堆在展开区底部的四坨内联表单(补传附件 / 共享设置 / 编辑 / 删除)**全部撤掉**。
⇒ 全页 <form> 的数量**不再随条目数增长**。这不是洁癖:账目页与名册页都因为
  「每行内联一套表单」被密码管理器(Bitwarden)逐个扫描、把主线程占死过两次,
  是用户报过的真问题。资源页这次一次做对。

**标题行右上角四色按钮**(用户点名配色):
  编辑 `btn-warn`(黄)· 共享 `btn-ok`(绿)· 附件 `btn-info`(蓝,本轮新增该类)·
  删除 `btn-del`(红)。四个都在 `<span class="res-ops">` 里,`margin-left:auto` 吸附右端。

**四个动作全在弹框里**(全页只有一份 `#resPanels`):
  · 编辑 —— 标题 / 正文 / 归属人(用选人组件的对外接口灌数据,且**限定在弹框内**查找
    ——本页有多个同名字段,只按名字找会命中第一个,财务页在这上面栽过);
  · 共享 —— 选人 + 权限(只读/可读写)+ **已共享给谁、逐个「取消共享」**
    (用户原话:「设置共享人员,**包括删除共享人**等」);
  · 附件 —— 已有附件(名字 / 大小 / 下载 / 删除)+ **拖动上传区**
    (复用全站 `_stage_drop.php`:点击或拖动、拖进来立刻上传);
  · 删除 —— 说明「不能撤销,会连 N 个附件一起删」,红色确认按钮。

三条结构性约束(断了浏览器会重新解析 HTML,症状很隐蔽):
  ① `<summary>` 里只能放 **phrasing content** → 按钮组必须是 `<span>`,`<form>` 绝对不能进
     (这也是「四个动作必须进弹框」的一条额外理由:它们本来全是 form);
  ② 点按钮必须 `preventDefault()` —— `<summary>` 内任意点击都会 toggle `<details>`;
     `stopPropagation()` 挡不住它(那是**默认行为**,不是冒泡);
  ③ 弹框容器自己声明了 `display:flex`,就必须补 `[hidden]{display:none}` 规则,
     否则「关不掉」(项目在 `.pv` 与 `.pp-item` 上各栽过一次)。

════ 二、 财务页(templates/ledger/index.php + Ledger.php + chart.php)════

🔴 **「点击后没有任何效果」的根因(本轮定位)**:
  图表卡那条类别 chip 的 JS 在找 `[data-ck-card]` 这个属性来显隐「每账目一张小卡」,
  而 v24 第 11 批 #7 按用户要求把「每账目一张小卡」换成了**一张合并的多线图** ——
  属性随之消失,**而这段 JS 没人删**。于是点标签:`.is-on` 会亮、画面上什么都不变。
  典型的「看着有交互(按钮会变蓝)、实际没作用」:**JS 找的钩子已经不存在了**。
  另外,概览卡里那些标签(存款/基金/保险…)原来是**纯展示的 `<span>`**,压根不可点 ——
  用户说的"中间,有各类标签"多半也指它。本轮把两处**并成同一套筛选入口**。

一处 `applyKind()` 同时做四件事(缺一件用户就会觉得"没生效"):
  ① 两处标签条的高亮**同步**(它们是同一个筛选器的两个入口,只给一处绑事件必然状态不一致);
  ② 上面那张「总金额」图切到**该类别的曲线**(按类别**预渲染**好,靠 hidden 切换
     —— 图是服务端画的 SVG,点了再请求就等于整页刷新,那就不是"点标签"是"点链接"了);
  ③ 下面那张多线图只留该类的线(`chart_multi` 已给每条线标了 `data-ck-line`)
     + 图例同步;
  ④ 下面账目表只列该类条目(`data-ck-row`)。
另外加一条概览高亮:「正在看『存款』:合计 ¥X · N 笔」+「清除筛选」。

服务端口径:
  · `Ledger::timeline($ledgerId, $kind = '')` —— **在取时点之前**按类别过滤。
    ⚠️ 反面做法(先算整本再筛)会把「被筛掉那些账目的创建日」留在时点集合里,
       图右端就凭空多出几个"其实什么都没变"的时点;
  · `Ledger::summary()` 顺手给出 `by_kind_n`(每类**笔数**)——
    用户要的「统计这个标签下的所有财务条目的总数」;
  · `chart_multi` 给每条线包一个 `<g data-ck-line="kind">` —— **纯标注、不改几何**。
    同一份数据多次渲染必须逐字节一致,所以属性值只能是 kind 本身,不许拼时间戳之类的。
  · 三个总数字**不随筛选变**(用户已确认)。

════ 三、 本轮修掉的三个真问题(都不在"功能"层面)════

① 🔴 **覆盖式改 CSS 漏了子属性 → 四个按钮竖排、标题行被撑到 175px 高**
   `.res-ops` 有一条**旧规则** `flex-direction: column`(那是给"展开区四个内联表单竖排"
   写的)。我改完模板后只在文件末尾**追加**了 `display / gap / margin-left`,
   **没写 `flex-direction`** → 四个按钮仍然竖着排。
   真机几何实测:`ops.top=382.9` vs `title.top=445.6`(差 63px)、标题行高 175px。
   ⇒ **改一个类的语义时,旧规则要就地改/删,不要在后面叠一条覆盖** ——
     叠加的写法天生容易漏子属性,而且**下一次改的人只会看到其中一处**。
     这次把那条规则就地改掉,并删掉确已废弃的 `.res-size` / `.res-share-add`。

② 🔴 **静态断言被注释里的字面串撑着 → 恒绿(比恒红危险)**
   部署断言原本是 `grep -qF 'class="res-ops"' 模板`。反证时我把**真代码**改坏,
   断言**照样通过** —— 因为模板注释里(解释"为什么按钮组用 span"时)引用了同一个字面串。
   **恒红会被发现,恒绿会一路伪装成"有覆盖"。**
   ⇒ 判据锚定到代码行(`grep -qE '^[[:space:]]+<span class="res-ops">'`),
     并且注释里不再写完整字面串。

③ **`str.replace(old, new, 0)` 是「替换零次」** —— 写反证脚本时我给某例传 `0`
   想表示"替换全部出现",实际什么都没改 → 报"断言没牙齿",白查一轮。全部替换是 `-1`。

════ 四、 验证 ════
- e2e **1171/0**(本批新增 55 条)· 真机浏览器探针 **45/0**(新增)
- 部署静态断言空跑 **309/0** · 桌面几何 428/0 · 移动端几何(360/375/390/414)684/0
- 老库结构升级 149/0
- **反向验证 18 例**(`_fb_b23_falsify.py`):按钮组拿掉 / 附件按钮摘掉蓝类名 /
  弹框容器拿掉 / 写回「(没有正文)」/ 两处上传区 id 撞名 / 取消共享表单删掉 /
  按钮组改用 div / 去掉 preventDefault / 删掉 [hidden] 规则 /
  写回 flex-direction:column / 「清除筛选」不响应 / 线条不标类别 /
  上图不预渲染 / 账目行不标类别 / timeline 去掉类别筛 / summary 不给笔数 /
  部署断言两条 —— 逐条改回旧行为,点名断言都变红,还原后复绿。

真机探针验的是**行为与几何**(curl 级断言看不见的东西):
  四按钮横排且贴标题行右端 · 点按钮**不会顺带展开**这一条 ·
  弹框 ✕/Esc/遮罩 三种方式都关得掉 · 点标签后**上面那张图真的换了**、
  下面多线图只剩该类 · 账目表跟着收窄 · 高亮条出现且「清除筛选」能回到全部 ·
  375px 窄屏两页都不横向溢出。

════ 五、 新增/改动文件 ════
  重写 `templates/resources/index.php`
  `templates/ledger/index.php`(筛选条合并 + 预渲染各类曲线 + applyKind)
  `app/Ledger.php`(timeline 加类别参数 + summary 补 by_kind_n)
  `app/chart.php`(chart_multi 给线条标 data-ck-line)
  `index.php`(ledger 路由多传 $kindLines)
  `assets/css/app.css`(.res-ops 就地改 + 四色按钮 + 弹框外壳 + .led-focus + 窄屏)
2026-10-06 20:41:04 +08:00

272 lines
13 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 用户 2026-10-03 第 8 条)—— **服务端画 SVG**,零依赖。
*
* 用户原话:「把这种变化,每个账目的金额变化,还有总金额的变化,在账本列表上面,
* 画出一个图,折线图这样的,这样就能清晰的看到账户的金额变化。」
*
* 为什么不用 Chart.js / ECharts:
* · 这个项目是**零依赖**的(没有 Composer,也没有前端构建)——
* 为两张小图引一个几百 KB 的图表库、再配一个 CDN(线上未必连得上),
* 代价和风险都不成比例;
* · 折线图本来就是一个 polyline,SVG 手写十几行就够;
* · 服务端画 = 没有 JS 也能看见(打印、禁用 JS 的场景都在)。
*
* 用法:
* $ckSeries = [['date'=>'2025-01-01','cents'=>123456], …];
* $ckLabel = '招商银行';
* $ckKind = 'asset'; // 'asset' 红 / 'debt' 绿(**A 股口径:涨红跌绿**)
* $ckWidth = 260;
* echo chart_line($ckSeries, $ckLabel, $ckKind, $ckWidth);
*
* ⚠️ 颜色按**中式财务习惯**:金额变多 = 红,变少 = 绿(与 A 股一致)。
* 这是项目里已经定下的口径(见 Ledger 的 led-asset/led-debt 配色),
* 改成欧美口径(涨绿跌红)会让这个页面和账目表对不上。
*/
/**
* 把一串「日期 → 分值」画成一条折线。
*
* @param array $series ['date'=>'Y-m-d','cents'=>int] 列表,按时间正序;空数组返回 ''
* @param string $label 图例上的名字
* @param string $kind 'asset' | 'debt'(只影响配色)
* @param int $width 画布宽(高度按 3:1 推)
* @param bool $showVal 是否在两端标出金额
* @return string SVG(可直接 echo)
*/
function chart_line(array $series, string $label, string $kind = 'asset', int $width = 260, bool $showVal = true): string
{
$pts = [];
foreach ($series as $p) {
// ⚠️ 取值的键要兼容两种数据源(这里曾是真 bug 的现场):
// · entrySeries() / 盘点历史 → 'cents'
// · Ledger::timeline() → 'net'(净资产)/ 'asset' / 'debt'
// 只认 'cents' 的话,**总资产图会把每个点都读成 0** —— 图上线是一条
// 贴着 0 的平线,看起来像"数据全是 0"(用户 2026-10-04 第 12 批 #5
// 原话:「上面那个图,如果是总资产的变化,那么不应该是 0 啊」)。
$v = $p['cents'] ?? $p['net'] ?? $p['asset'] ?? 0;
$pts[] = ['d' => (string)($p['date'] ?? ''), 'v' => (int)$v];
}
if (!$pts) {
return '';
}
$w = max(120, $width);
$h = (int)round($w / 3.2);
// 内边距:左边留给"最高/最低"标签,下边留给日期,上边留给数值标
$padL = 6; $padR = 6; $padT = 14; $padB = 16;
$iw = $w - $padL - $padR;
$ih = $h - $padT - $padB;
$vals = array_column($pts, 'v');
$min = min($vals);
$max = max($vals);
// ⚠️ 只有一个点、或所有值相同 → max==min,除零。
// 此时把线画在**中间**(比贴底好:贴底看起来像"跌到 0 了")。
$span = $max - $min;
$flat = $span <= 0;
if ($flat) {
$span = 1;
}
$n = count($pts);
$xy = [];
foreach ($pts as $i => $p) {
$x = $n === 1 ? ($padL + $iw / 2) : ($padL + $iw * $i / ($n - 1));
$ratio = $flat ? 0.5 : (($p['v'] - $min) / $span);
$y = $padT + $ih * (1 - $ratio);
$xy[] = [round($x, 2), round($y, 2)];
}
$isDebt = $kind === 'debt';
// 中式财务口径:资产涨=红、负债涨=绿(负债变多是"好"的方向相反,同样用绿表示"更多")
$stroke = $isDebt ? '#1F7A4D' : '#B0342C';
$fill = $isDebt ? 'rgba(31,122,77,.10)' : 'rgba(176,52,44,.10)';
$poly = [];
foreach ($xy as $p) {
$poly[] = $p[0] . ',' . $p[1];
}
$polyStr = implode(' ', $poly);
// 面积:从线走一遍再回到基线 —— 让"变化"比一条细线更醒目
$areaStr = '';
if ($n > 1) {
$base = $padT + $ih;
$areaStr = $padL . ',' . $base . ' ' . $polyStr . ' '
. ($padL + $iw) . ',' . $base;
}
$out = '<svg class="ck-svg" viewBox="0 0 ' . $w . ' ' . $h . '" width="100%" height="' . $h . '"'
. ' role="img" aria-label="' . e($label) . ' 金额变化"'
. ' xmlns="http://www.w3.org/2000/svg">';
// 基线(0 或最低值那一条)—— 让图有个水平参照
$out .= '<line x1="' . $padL . '" y1="' . ($padT + $ih) . '" x2="' . ($padL + $iw) . '" y2="' . ($padT + $ih)
. '" stroke="currentColor" stroke-opacity=".18" stroke-width="1"/>';
if ($areaStr !== '') {
$out .= '<polygon points="' . $areaStr . '" fill="' . $fill . '" stroke="none"/>';
}
if ($n === 1) {
$out .= '<circle cx="' . $xy[0][0] . '" cy="' . $xy[0][1] . '" r="3" fill="' . $stroke . '"/>';
} else {
$out .= '<polyline points="' . $polyStr . '" fill="none" stroke="' . $stroke . '"'
. ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
// 每个盘点点一个小圆点:有几处转折要看得出来
foreach ($xy as $p) {
$out .= '<circle cx="' . $p[0] . '" cy="' . $p[1] . '" r="2.4" fill="#fff" stroke="' . $stroke . '" stroke-width="1.6"/>';
}
}
if ($showVal && $n > 0) {
// 首尾各标一个数值:光看形状不知道"多少"
$first = $pts[0];
$last = $pts[$n - 1];
$fy = max(9, $xy[0][1] - 6);
$ly = max(9, $xy[$n - 1][1] - 6);
$out .= '<text x="' . $padL . '" y="' . $fy . '" font-size="9" fill="currentColor" fill-opacity=".65">'
. e(\App\Ledger::money($first['v'])) . '</text>';
if ($n > 1) {
$out .= '<text x="' . ($padL + $iw) . '" y="' . $ly . '" font-size="9" text-anchor="end"'
. ' fill="' . $stroke . '">' . e(\App\Ledger::money($last['v'])) . '</text>';
}
}
// 日期:首尾各一个(中间的点太密,标了反而糊)
$out .= '<text x="' . $padL . '" y="' . ($h - 4) . '" font-size="9" fill="currentColor" fill-opacity=".55">'
. e((string)$pts[0]['d']) . '</text>';
if ($n > 1) {
$out .= '<text x="' . ($padL + $iw) . '" y="' . ($h - 4) . '" font-size="9" text-anchor="end"'
. ' fill="currentColor" fill-opacity=".55">' . e((string)$pts[$n - 1]['d']) . '</text>';
}
$out .= '</svg>';
return $out;
}
/**
* **多条线**的折线图(v24 第 11 批 #7)。
*
* 用户原话:「图下面的条目不用显示详情,只需要显示每个账目的颜色,
* 就是说哪个颜色代表哪个账目,就可以了。」
*
* 所以把原来"每条账目一张小图"合并成**一张图**:每条账目一条线、各自一个颜色,
* 颜色由图例给出(Ledger::legendColor())。这样一眼能比较"哪条在涨、哪条在跌",
* 而不是逐张小图去脑补 —— 那是用户嫌"显示详情"没必要的真正原因。
*
* ⚠️ 时间轴取所有曲线日期的**并集**,每条线在自己的日期上有值、
* 没有的日期就"沿用上一次的值"(阶梯式),否则线会断成一段段虚线。
* 这与 Ledger::timeline() 的口径一致:金额是"状态量",不是"发生量"。
*
* @param array $charts Ledger::charts() 的返回值:[['entry'=>…, 'color'=>…, 'series'=>…], …]
* @param int $width 画布宽
* @return string SVG(无可画内容时返回 '')
*/
function chart_multi(array $charts, int $width = 900): string
{
if (!$charts) {
return '';
}
// ---- 时间轴:所有日期的并集(升序)----
$dateSet = [];
foreach ($charts as $ch) {
foreach ((array)($ch['series'] ?? []) as $p) {
$d = (string)($p['date'] ?? '');
if ($d !== '') { $dateSet[$d] = true; }
}
}
$dates = array_keys($dateSet);
sort($dates);
$n = count($dates);
if ($n === 0) {
return '';
}
// ---- 值域:所有曲线所有点的 min/max(含 0,让"从 0 涨起来"看得出来)----
$vals = [0];
foreach ($charts as $ch) {
foreach ((array)($ch['series'] ?? []) as $p) {
$vals[] = (int)($p['cents'] ?? 0);
}
}
$min = min($vals); $max = max($vals);
$span = $max - $min;
if ($span <= 0) { $span = 1; }
$w = max(200, $width);
$h = (int)round($w / 3.6);
$padL = 8; $padR = 8; $padT = 16; $padB = 18;
$iw = $w - $padL - $padR;
$ih = $h - $padT - $padB;
$idxOf = array_flip($dates); // 日期 => 横轴序号
$out = '<svg class="ck-svg" viewBox="0 0 ' . $w . ' ' . $h . '" width="100%" height="' . $h . '"'
. ' role="img" aria-label="各账目金额变化" xmlns="http://www.w3.org/2000/svg">';
// 底板与基线
$out .= '<line x1="' . $padL . '" y1="' . ($padT + $ih) . '" x2="' . ($padL + $iw) . '"'
. ' y2="' . ($padT + $ih) . '" stroke="currentColor" stroke-opacity=".18" stroke-width="1"/>';
foreach ($charts as $ch) {
$series = (array)($ch['series'] ?? []);
if (!$series) { continue; }
$color = (string)($ch['color'] ?? '#B0342C');
// v24 第 23 批 用户第 2 条:给每条线标上**它属于哪个类别**。
// ⚠️ 标在服务端(而不是让前端去认图例文字):图例上的名字是
// `$en['title']`(用户自己输入的,可能重复、可能含空格),
// 拿它当筛选键迟早对不上;类别 key 是封闭枚举,稳定。
// ⚠️ 这是纯粹的**标注**,线上图画法一字未改 —— 同一份数据多次渲染要完全一致,
// 所以属性值只允许是 kind 本身,不能拼时间戳之类会让输出漂移的东西。
$ck = (string)(($ch['entry']['kind'] ?? ''));
// 把每个日期上的值铺齐(沿用上一次),再转成坐标
$pts = [];
$last = null;
foreach ($dates as $d) {
// 取"这一天或之前最后一次的值"(状态量口径,与 timeline 一致)
foreach ($series as $p) {
if ((string)($p['date'] ?? '') <= $d) { $last = (int)($p['cents'] ?? 0); }
else { break; }
}
if ($last === null) { continue; } // 这条线还没开始(创建日之前)
$i = $idxOf[$d];
$x = $n === 1 ? ($padL + $iw / 2) : ($padL + $iw * $i / ($n - 1));
$ratio = ($last - $min) / $span;
$y = $padT + $ih * (1 - $ratio);
$pts[] = [round($x, 2), round($y, 2)];
}
if (!$pts) { continue; }
// v24 第 23 批 用户第 2 条:整条线包一个 <g>,把"这条线属于哪个类别"写在上面。
// 前端筛选时按 `[data-ck-line]` 显隐,**不动几何**(几何一变,图就看着"跳"了)。
// ⚠️ 只在有 kind 时才包 —— 拼出 `data-ck-line=""` 会让"选中空类别"的
// 选择器意外命中所有线(下一次改这段的人很容易踩)。
$open = $ck !== '' ? '<g data-ck-line="' . e($ck) . '">' : '';
$close = $ck !== '' ? '</g>' : '';
if (count($pts) === 1) {
$out .= $open . '<circle cx="' . $pts[0][0] . '" cy="' . $pts[0][1] . '" r="3" fill="' . $color . '"/>' . $close;
} else {
$poly = [];
foreach ($pts as $p) { $poly[] = $p[0] . ',' . $p[1]; }
$out .= $open . '<polyline points="' . implode(' ', $poly) . '" fill="none" stroke="' . $color . '"'
. ' stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>';
// 每段转折点一个小点:有几个盘要点一眼看得出
foreach ($pts as $p) {
$out .= '<circle cx="' . $p[0] . '" cy="' . $p[1] . '" r="2.2" fill="#fff"'
. ' stroke="' . $color . '" stroke-width="1.5"/>';
}
$out .= $close;
}
}
// 首尾日期(中间太密,标了反糊)
$out .= '<text x="' . $padL . '" y="' . ($h - 5) . '" font-size="10" fill="currentColor"'
. ' fill-opacity=".55">' . e($dates[0]) . '</text>';
if ($n > 1) {
$out .= '<text x="' . ($padL + $iw) . '" y="' . ($h - 5) . '" font-size="10" text-anchor="end"'
. ' fill="currentColor" fill-opacity=".55">' . e($dates[$n - 1]) . '</text>';
}
// 右上角标一下最高/最低,否则不知道纵轴量级
$out .= '<text x="' . ($padL + $iw) . '" y="11" font-size="10" text-anchor="end"'
. ' fill="currentColor" fill-opacity=".6">' . e(\App\Ledger::money($max))
. ' / ' . e(\App\Ledger::money($min)) . '</text>';
$out .= '</svg>';
return $out;
}