Files
FamilyBook/app/chart.php
T
laoyang 5d80b9480b v24 第 11 批:7 大条 20 子项(首页/名册/观影/裁剪/日程/财务)
用户 2026-10-04 第 11 批,7 大条:
- #1 首页:未来七天过滤掉「农历」(每日一条会淹掉真日程);右侧日历本标签
  从灰字(.dash-tag)改成**实心色块**(底色=日历色、白字、text-shadow 兜底浅色)。
- #2 名册:分组改**居中模态框**(用户要"像选观影人一样")。修掉"点第二次失效"
  ——根因是旧实现用**捕获阶段**监听 document click 做"点外面就关",与触发按钮
  的 click 打架;模态把关闭收敛到遮罩/✕/取消。卡片「编辑」后加「删除」
  (→ people/destroy);详情页底部删除改成 btn-danger + btn-block-lg 大红按钮 + 两步确认。
- #3 名册详情页重排:.detail(260px+1fr) → .pcard(头像左上 + 右侧姓名/标签)
  + .detail-stack 全宽往下。旧布局头像卡比右栏矮,头像下留一大片空白。
- #4 观影:影院下拉换**自定义组件**(原生 <option> 画不了色块)——按钮+列表、
  每行色点,值仍写隐藏 input[name=cinema_id];「管理影院」→「新建影院」弹框
  (带 back=movies/new,存完回本页并 ?cinema=<id> 自动选中)。
  修「开始/结束时间标签跑到控件下面」:upgradeRange 原来 insertBefore(host, group.firstChild)
  把控件插到了 label 之上 —— 改成插在第一个原生控件之前。
- #5 裁剪改 **Cropper.js**(assets/vendor/cropper/,按需加载走 fb-cropper meta):
  弹框 overflow:hidden + 固定画布(**无滚动条**)、**去掉缩放条**改滚轮 wheelZoomRatio、
  viewMode:1 + autoCropArea 保证框小于画布、8 个触点全露。
  结果仍写 *_crop = 归一化 x,y,w,h(提交契约不变)。
- #6 日程:内置节假日原来每天只标**一天**(国庆只出一条)→ FIXED 改成
  [名字, 放假天数] 按区间铺;月视图从周日改**周一**起(表头同步);
  跳转日期从"长条+原生框"改成**单个 btn-icon**,点开弹 Semi 日期选择器。
- #7 财务:单条曲线无盘点时只回一个点(图看着像全 0)→ 改成"从创建日(0)画到今天";
  图下条目从"每账目一张小图卡"改成**一张 chart_multi 多线图 + 颜色图例**
  (Ledger::LEGEND_COLORS 按 id 取模,保证颜色稳定);账目页卡顿同源治理
  (行上只留按钮、表单全页一份 #ledPanels);修改按钮改弹框并回填归属人。

## 顺带修的真 bug(本批最要紧的三条)
1. **观影表单提交静默失效**:「新建影院」弹框被误嵌在观影主表单 <form> 内。
   HTML 不允许 form 嵌套 → 浏览器拆开内层,弹框里 required 的"影院名称"变成
   主表单必填项、又因 hidden 无法聚焦 → 点「记下这场电影」什么都不发生
   (Chrome: invalid form control with name='name' is not focusable)。
   → 弹框移到主表单之后。部署脚本加防线:按顺序配平 form 开闭判断是否嵌套。
2. **裁剪取消没清空文件框**(换 Cropper 时丢)→ close(cancelled),4 个取消入口
   传 true 清空;否则"点了取消"仍会把没裁的原图传上去。
3. **裁剪预览退化成原图**(同批丢)→ 用 canvas 按 Cropper 的原图像素矩形重画,
   toDataURL 写回预览,恢复"所见即所得"。

## 连带修复:选人组件懒渲染(线上实测的必要性)
线上真实数据下账目页 6939 KB / 16788 个 input、观影表单 1194 个 input ——
根因是 _person_picker.php 首屏就把**全部候选人**渲成 <label>+<input>。
改成:首屏只留「不选」+ data-pp-people(JSON),**打开弹框时**才 buildList();
无 JS 兜底走 <noscript>(HTML5 规定启用脚本时其内容按纯文本解析,不产生节点)。
新增 window.ppSetSelected / decorateItems(动态项要补算首字母)。
实测账目页 6939 KB → 544 KB。新增 _fb_picker_lazy_check.py(11/0 + 反向 2 红)。

验证:e2e 786/0、交互 90/0、半集成 27/0、batch_e 91/0、batch10 46/0、batch9 38/0、
crop 17/0(+反向)、picker_unify 28/0、picker_lazy 11/0(+反向)、formbloat 9/0、
upgrade 126/0、mobile 688/0、drag 5/0、geom 5/5。
线上自检 19/19;线上真实账号验收 7/0(含"主表单无隐藏必填""线上 Cropper 接管+取消清空")。
2026-10-04 17:42:21 +08:00

250 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) {
$pts[] = ['d' => (string)($p['date'] ?? ''), 'v' => (int)($p['cents'] ?? 0)];
}
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;
}