v24 第 10 批:名册真性能(根因是每卡内联表单)等九条

- #6 名册线上真实数据(1184 人)卡顿:根因是**每张卡片内联一整份分组弹层**——
  全页 2369 个 form / 27234 个 input / 22496 个 radio / 98100 DOM 节点,
  密码管理器(Bitwarden 等)逐个扫描 form 把主线程占死(用户报的就是这个冲突)。
  改为卡片只带 data 属性 + 全页**唯一一份**共享浮层 #rosterMenus。
  实测:form 2369→3、input 27234→25、DOM 98100→17659、解压 7694→2306 KB、
  长任务 1/52ms → 0。新增哨兵 _fb_formbloat_check.py(含反向验证)。
- #5 日视图翻页失效:day_columns() 无条件吸到周一,天视图只有 1 列 → 永远显示那周周一。
  改为仅 span>1 时吸附(周视图行为不变)。
- #2 财务「欠款」类别(负债);并修一个真 bug:负债填负数会让净资产**反向变大**。
  写入口 normalizeAmount 归一化 + summary/timeline 侧 abs 防御 + Schema v17 纠正老数据。
- #3 账目行「盘点」「修改」两个并列按钮(各自就地弹面板);图移到账目上方,
  总金额图跨整行 + 每个账目一条线 + 类别筛选条;charts() 改为覆盖全部账目。
- #4 日程新增「农历」「节假日」两本不可删除只读日历(app/Holiday.php):
  两级自动更新 —— 内置规则(公历固定 + 清明节气 + 农历节日)保证断网也有;
  官方 JSON 镜像自动抓取补齐调休。抓取**不在渲染路径联网**(响应后 fastcgi_finish_request
  + 频率闸门),缓存落 storage/cache。仅派生本不可写日程。
- #6 日程页设置按钮前加「跳到某一天」(选中即跳)。
- #7 起止范围改用一个 Semi dateTimeRange 控件(观影/旅程/节点/日程共 4 处);
  新增 window.fbSetDT / fbUpgradeRange;修范围重挂(React 非受控组件须 unmount 再 createRoot)
  与范围里 required 未摘除导致表单无法提交两个真实问题。

验证:e2e 786/0、batch10 44/0、batch9 38/0、batch_e 91/0、upgrade 126/0、picker 28/0、
semi_integrate 27/0、formbloat 5/0(+反向 3 红)、mobile 688/0、interact 97/0、drag 5/0、geom 5/5;
线上自检 19/19 + 线上真实账号巡检 28/0(含 Firefox 实测)。
This commit is contained in:
2026-10-04 10:46:50 +08:00
parent 52cc1ecccb
commit b2ca4f336e
16 changed files with 1624 additions and 286 deletions
+199 -83
View File
@@ -152,40 +152,66 @@ $ledMe = (int)($me ?? 0);
</div>
<?php endif; ?>
<?php /* ==================== ② 变化图(用户第 8 条)==================== */ ?>
<?php if ($charts || count($timeline) > 1): ?>
<?php /* ==================== ② 变化图(v24 第 10 批 #3 重做)====================
用户原话:「在账目上面,增加一个图表显示,**显示所有账目和总金额的变化曲线**,
根据盘点的金额进行显示。横轴就是时间,纵轴是金额,用折线图。」
⚠️ 与旧版的差别:① 总金额(净资产)**永远在最前**;② 每条账目都出线(不再只画盘点过的);
③ 加一条"按类别看"的切换条 —— 因为"所有账目"在几百条时会变成一坨,
把筛选交给用户,而不是我们偷偷少画几条。 */ ?>
<?php if ($entries): ?>
<div class="card card-pad led-charts">
<h2 class="cinema-h2">
金额变化
<span class="hint">每次「盘点」记一个点,旧数字不会被清掉</span>
<span class="hint">横轴时间 · 纵轴金额;每次「盘点」记一个点,旧数字不会被清掉</span>
</h2>
<div class="ck-grid">
<?php if (count($timeline) > 1): ?>
<div class="ck-card">
<div class="ck-head">
<b>整本账 · 净资产</b>
<span class="small muted"><?= count($timeline) ?> 个时间点</span>
</div>
<?= chart_line($timeline, '净资产', 'asset', 320) ?>
<?php if ($timeline): ?>
<div class="ck-card ck-total">
<div class="ck-head">
<b>总金额 · 净资产</b>
<span class="small muted"><?= count($timeline) ?> 个时间点</span>
</div>
<?php endif; ?>
<?php foreach ($charts as $ch): ?>
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
<div class="ck-card">
<div class="ck-head">
<b><?= e((string)$en['title']) ?></b>
<span class="small muted">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
· <?= count($ch['series']) ?> 个点
</span>
<?= chart_line($timeline, '净资产', 'asset', 900) ?>
</div>
<?php endif; ?>
<?php if ($charts): ?>
<?php
// 类别筛选条:只列出**真有账目**的类别,点一下只看这一类。
$kindsPresent = [];
foreach ($charts as $ch) {
$k = (string)$ch['entry']['kind'];
$kindsPresent[$k] = ($kindsPresent[$k] ?? 0) + 1;
}
?>
<div class="ck-filter" data-ck-filter>
<button type="button" class="ck-chip is-on" data-ck-kind="">全部(<?= 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) ?>">
<?= \App\Ledger::kindIcon((string)$k) ?> <?= e(\App\Ledger::kindWord((string)$k)) ?>(<?= (int)$n ?>)
</button>
<?php endforeach; ?>
</div>
<div class="ck-grid">
<?php foreach ($charts as $ch): ?>
<?php $en = $ch['entry']; $ek = (string)$en['kind']; ?>
<div class="ck-card" data-ck-card="<?= e($ek) ?>">
<div class="ck-head">
<b><?= e((string)$en['title']) ?></b>
<span class="small muted">
<?= \App\Ledger::kindIcon($ek) ?> <?= e(\App\Ledger::kindWord($ek)) ?>
· <?= count($ch['series']) ?> 个点
</span>
</div>
<?= chart_line($ch['series'], (string)$en['title'], \App\Ledger::isDebt($ek) ? 'debt' : 'asset', 320) ?>
</div>
<?= chart_line($ch['series'], (string)$en['title'], \App\Ledger::isDebt($ek) ? 'debt' : 'asset', 320) ?>
</div>
<?php endforeach; ?>
</div>
<?php if (!$charts): ?>
<?php endforeach; ?>
</div>
<p class="small muted" style="margin:8px 2px 0">
还没有任何盘点记录 —— 下面账目表里每行都有「盘点」,盘过之后这里就会出现它自己的曲线。
没有盘点记录的账目只会显示一个点(它一直没变过)——
在下面账目表里点「盘点」记下当前金额,这条线就长出来了。
</p>
<?php endif; ?>
</div>
@@ -225,32 +251,41 @@ $ledMe = (int)($me ?? 0);
<?php else: ?><span class="small muted">—</span><?php endif; ?>
</td>
<td class="small muted"><?= e((string)($en['note'] ?? '')) ?></td>
<td class="nowrap">
<?php if ($canWrite): ?>
<details class="led-edit">
<summary class="link-quiet">操作</summary>
<td class="nowrap led-acts">
<?php /* 用户 2026-10-04 第 3 条原话:「账目列表,右侧两个按钮,
一个**盘点**,点击后,输入最新的金额;一个按钮**修改**,
用来修改名称、挂在谁名下、备注等。」
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
<?php if ($canWrite): $histN = count(\App\Ledger::history($eid)); ?>
<button type="button" class="btn btn-ghost btn-sm led-act"
data-led-open="recon-<?= $eid ?>">盘点</button>
<button type="button" class="btn btn-ghost btn-sm led-act"
data-led-open="edit-<?= $eid ?>">修改</button>
<?php /* ---- 盘点(用户第 8 条):重新输一个金额,旧值进历史 ---- */ ?>
<form method="post" action="<?= e(url('ledger/reconcile')) ?>" class="node-form led-recon">
<div class="led-panel" id="recon-<?= $eid ?>" hidden>
<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="<?= $eid ?>">
<h4 class="led-form-h">盘点这笔(旧金额会记进历史)</h4>
<div class="nf-grid">
<div class="field">
<label>盘点后的金额(元)</label>
<input class="input" name="amount" inputmode="decimal" required
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
</div>
<div class="field">
<label>盘点备注</label>
<input class="input" name="note" placeholder="如:2026 年初盘点">
</div>
<h4 class="led-form-h">盘点「<?= e((string)$en['title']) ?>」</h4>
<div class="field">
<label>最新金额(元)</label>
<input class="input" name="amount" inputmode="decimal" required
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>">
<span class="tip">盘点前的金额会记进历史,图表上就多一个点。</span>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">盘点</button></div>
<?php if (\App\Ledger::history($eid)): ?>
<div class="field">
<label>盘点备注</label>
<input class="input" name="note" placeholder="如:2026 年初盘点">
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">记下这次盘点</button>
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
</div>
<?php if ($histN): ?>
<details class="led-hist">
<summary class="link-quiet">看历史(<?= count(\App\Ledger::history($eid)) ?> 次)</summary>
<summary class="link-quiet">看历史(<?= $histN ?> 次)</summary>
<ul class="led-hist-list">
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
<li>
@@ -265,48 +300,73 @@ $ledMe = (int)($me ?? 0);
</details>
<?php endif; ?>
</form>
</div>
<?php /* ---- 改 / 删(改 = 修正录错了,与"盘点"是两件事,见 Ledger::reconcile)---- */ ?>
<form method="post" action="<?= e(url('ledger/entry_update')) ?>" class="node-form">
<div class="led-panel" id="edit-<?= $eid ?>" hidden>
<?php /* 用户第 3 条:修改的作用域是**名称 / 挂在谁名下 / 备注等** ——
与"更正金额"分开:改金额属于"当初录错了",那走盘点(留历史);
这里改的是描述性字段,所以**也可以含类别与金额**(都是描述),
但文案上讲清楚它与盘点的区别。 */ ?>
<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="<?= $eid ?>">
<h4 class="led-form-h">更正这笔(当作当初就录错了,不留历史)</h4>
<div class="nf-grid">
<div class="field">
<label>类别</label>
<select class="select" name="kind">
<?php foreach (\App\Ledger::KINDS as $k => $v): ?>
<option value="<?= e($k) ?>" <?= $ek === $k ? 'selected' : '' ?>>
<?= e($v[0]) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>名称</label>
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount"
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
</div>
<h4 class="led-form-h">修改「<?= e((string)$en['title']) ?>」</h4>
<p class="tip" style="margin:0 0 8px">
改的是名称、归属与备注。若只是**金额变了**(不是录错),
请用「盘点」——那样才会记进历史、在图上多一个点。
</p>
<div class="field">
<label>名称</label>
<input class="input" name="title" value="<?= e((string)$en['title']) ?>" required>
</div>
<div class="field">
<label>挂在谁名下</label>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = !empty($en['person_id']) ? [(int)$en['person_id']] : [];
$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) ?>" <?= $ek === $k ? 'selected' : '' ?>>
<?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount"
value="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note" value="<?= e((string)($en['note'] ?? '')) ?>">
</div>
<div class="btn-row">
<button class="btn btn-sm" type="submit">保存</button>
<button class="btn btn-ghost btn-sm" type="button" data-led-close>取消</button>
</div>
<div class="btn-row"><button class="btn btn-sm" type="submit">保存更正</button></div>
</form>
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline">
<form method="post" action="<?= e(url('ledger/entry_del')) ?>" class="res-inline"
style="margin-top:8px">
<?= csrf_field() ?>
<input type="hidden" name="ledger_id" value="<?= $curId ?>">
<input type="hidden" name="id" value="<?= $eid ?>">
<button class="btn btn-ghost btn-sm" type="submit"
onclick="return confirm('删掉这笔账目?')">删除</button>
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
</form>
</details>
</div>
<?php endif; ?>
</td>
</tr>
@@ -326,13 +386,69 @@ $ledMe = (int)($me ?? 0);
<script>
(function () {
/* ---- 「记一笔」开合 ---- */
var btn = document.getElementById('ledAddBtn');
var card = document.getElementById('ledAddCard');
if (!btn || !card) return;
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
btn.textContent = card.hidden ? '记一笔' : '收起';
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
if (btn && card) {
btn.addEventListener('click', function () {
card.hidden = !card.hidden;
btn.textContent = card.hidden ? '记一笔' : '收起';
if (!card.hidden) { card.scrollIntoView({ block: 'nearest' }); }
});
}
/* ---- 每行「盘点 / 修改」两个按钮:弹各自的面板(v24 第 10 批 #3)----
面板贴着那一行展开(不是弹窗遮罩),改完就在原地看得到结果。
同时只开一个;点别处 / Esc 收起。 */
var openPanel = null;
function closePanel() {
if (openPanel) { openPanel.hidden = true; openPanel = null; }
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('[data-led-close]')) { closePanel(); return; }
var trigger = t.closest('[data-led-open]');
if (trigger) {
var id = trigger.getAttribute('data-led-open');
var panel = document.getElementById(id);
if (!panel) { return; }
var wasOpen = (panel === openPanel);
closePanel();
if (wasOpen) { return; } // 再点一次 = 收起
panel.hidden = false;
openPanel = panel;
// 把面板插到这一行**后面**(作为整行的兄弟),而不是塞在 <td> 里 ——
// 表格单元格会被列宽切碎,表单在格子里根本排不开。
var tr = trigger.closest('tr');
if (tr && panel.parentNode !== tr.parentNode) {
tr.parentNode.insertBefore(panel, tr.nextSibling);
}
// 焦点给到第一个输入框:点完就能打字
var inp = panel.querySelector('input:not([type=hidden]), select');
if (inp) { inp.focus(); }
return;
}
if (openPanel && !openPanel.contains(t)) { closePanel(); }
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closePanel(); }
});
/* ---- 图表按类别筛选("所有账目"在几百条时会变成一坨)---- */
var bar = document.querySelector('[data-ck-filter]');
if (bar) {
bar.addEventListener('click', function (e) {
var chip = e.target.closest('.ck-chip');
if (!chip) { return; }
var kind = chip.getAttribute('data-ck-kind') || '';
Array.prototype.forEach.call(bar.querySelectorAll('.ck-chip'), function (c) {
c.classList.toggle('is-on', c === chip);
});
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-card]'), function (c) {
c.hidden = kind !== '' && c.getAttribute('data-ck-card') !== kind;
});
});
}
})();
</script>