Files
FamilyBook/app/chart.php
T
laoyang ecf121772f v24 第 12 批:5 条(删除按钮位置/日期控件三修/日历跳转/节气节日/财务图)
用户 2026-10-05 的 5 条。第 11 批交付后用户追问"确定这些需求都做完了?"—— 没有,
这 5 条是进一步要求。实现过程中查出 4 个真 bug。

#1 详情页删除按钮紧贴「开通账号」
   上一版放在页面最下方独立区块(danger-zone),用户要「就放在开通账号按钮下面,
   紧挨着,长度大小和开通账号一样,颜色改成红色」。→ 搬进「登录账号」卡片内
   (.del-inline + 细分隔线 + .btn-block-lg)。
   线上几何实测:同一张卡片、左边缘对齐 255==255、宽度一致 990==990、红色 rgb(192,83,63)。

#2 日期控件三修(两个真 bug + 一次闪烁)
   · 灰底:Semi **外层** .semi-datepicker-input 自带 rgba(46,50,56,.05)、高 32px;
     内层 wrapper 白底 40px → 灰的露在外面。实测「灰底元素」= 1 个 → 修后 0 个。
   · 不垂直居中:外层 32px < 内层 40px,实测 gapTop=1 / **gapBottom=17**(文字贴顶)
     → 修后 gapTop=1 / gapBottom=1。
   · 闪烁:实测首 **200ms** 原生输入框可见(要等 3.4MB React 解析)。
     用 no-js/js 二态:<html class="no-js"> + head 内联脚本立刻换 js +
     CSS `html.js .dt-input:not(.is-semi) .dt-native { visibility:hidden }`
     (配同高骨架 ::after 防布局跳动)。实测「出现过原生可见」= False。

#3 日历跳转保留视图 + 下拉日历
   原来写死 view=day(在月视图点跳转会被踢进日视图)。→ 表单带当前 $view(月还带 ym);
   选择器从"居中弹窗"改成**按钮下方下拉** .cal-jump-drop。
   实测:月视图跳 8/15 仍在月视图且是 8 月;周视图跳 8/15 → 落 8/10 那一周;
   日视图跳 8/15 → 显示 8/15。

#4 传统节日 + 二十四节气两个系统本(Schema v18)
   Holiday::SOLAR_TERMS(24 项 C 常数近似公式 + 例外表)+ traditionalRange
   (农历推导;除夕用"次日是否正月初一"反推,写死腊月廿九会有一半年份错)。
   实测 2026:节气 24 条全对(清明 4/5、冬至 12/22);春节 2/17、除夕 2/16、中秋 9/25。
   ⚠️ ENUM 必须显式 ALTER(对已存在的表不生效)——同一坑第二次踩,VERSION 17→18。

#5 财务(一个真 bug + 一个假起点 + 一个真 bug)
   · chart_line 读 `cents` 而 timeline() 返回 `net` → 键名不匹配,**总资产图每点读成 0**
     (正是用户说的"图上是 0")。修:兼容 cents ?? net ?? asset。
     实测 timeline net=800000(8000 元),线上 polyline 有真实起伏。
   · 去掉 timeline 人为插的"前一天=0"假起点(用户:"总资产不应该是 0,
     应该是所有账目创建时的金额的总数")。
   · ppSetSelected 只用 document.querySelector 按名字找,而财务页有**两个** person_id
     组件(「记一笔」+ 修改弹框)→ 永远灌到第一个。加 root 参数、调用方传 panel。

验证:e2e 786/0、交互 92/0、升级 129/0(+3 条 v18 断言)、batch_e 91/0、batch10 46/0、
batch9 38/0、picker_unify 28/0、picker_lazy 11/0、crop 17/0、formbloat 9/0、semi 27/0、
mobile 688/0、drag 5/0、geom 5/5;第 12 批专项:财务 7/0、日历 19/0。
部署静态断言新增 5 组(含反向验证:注入回退 → FAIL=1)。
线上自检 19/19;线上真实账号验收 **30/0**(含真实浏览器几何量删除按钮位置)。
2026-10-04 19:43:00 +08:00

257 lines
11 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');
// 把每个日期上的值铺齐(沿用上一次),再转成坐标
$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; }
if (count($pts) === 1) {
$out .= '<circle cx="' . $pts[0][0] . '" cy="' . $pts[0][1] . '" r="3" fill="' . $color . '"/>';
} else {
$poly = [];
foreach ($pts as $p) { $poly[] = $p[0] . ',' . $p[1]; }
$out .= '<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 .= '<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;
}