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
+16 -3
View File
@@ -23,6 +23,17 @@ $dtValue = $dtValue ?? null;
$dtRequired = (bool)($dtRequired ?? false);
$dtId = (string)($dtId ?? '');
$dtSeconds = (bool)($dtSeconds ?? true);
/* 用户 2026-10-04 第 7 条原话:「所有的时间选择,都已更换新插件,但是有个问题,
如果是选择一个时间范围的情况,就是说,有开始时间,有结束时间,应该用这个日期插件的
时间范围选择方式,而不是将这个范围拆成两个,这样不好看,也多一步操作。」
→ 有起止两端的场景不再并列两个控件,而是**一个范围控件**。
实现方式是"服务端照旧渲染两个原生控件(表单契约/e2e 都不动),
由 app.js 在升级时识别成一对、只挂一个 Semi 范围选择器"。所以这里只加标记:
$dtRange = 'start' | 'end' —— 这对控件属于同一个范围(调用方还要用
<div class="dt-range"> 把两者包在一起,见 movies/form.php 等)。
留空 = 普通单点控件(默认,行为完全不变)。 */
$dtRange = (string)($dtRange ?? '');
// ⚠️⚠️ 临时变量**一律带 dt 前缀**,一个都不能漏:这里原来写成
// `$t = strtotime(...)`,把宿主模板里表示「这条任务 / 这条记录」的 `$t`
@@ -42,9 +53,11 @@ $dtSec = $dtTs ? (int)date('s', $dtTs) : 0;
· Semi 资源(3.4 MB JS)只有页面真出现 .dt-input 才由 app.js 懒加载;
· 加载成功 → 隐藏 .dt-native、在 .dt-semi 里挂 DatePicker,值回写原字段;
· 加载失败/断网 → 原生控件原样可见可用,页面不会坏。 */
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired, 'id' => $dtId];
$dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
'id' => $dtId, 'range' => $dtRange];
?>
<div class="dt-input" data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<div class="dt-input<?= $dtRange !== '' ? ' dt-part' : '' ?>"
data-dt="<?= e(json_encode($dtCfg, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<span class="dt-semi" aria-hidden="true"></span>
<span class="dt-native">
<input type="date" class="dt-date" name="<?= e($dtName) ?>_date"
@@ -78,7 +91,7 @@ $dtCfg = ['name' => $dtName, 'seconds' => $dtSeconds, 'required' => $dtRequired,
</span>
</div>
<?php /* 用完即清,避免残留变量影响下一次 include */ unset(
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg,
$dtName, $dtValue, $dtRequired, $dtId, $dtSeconds, $dtCfg, $dtRange,
$dtTs, $dtTsTmp, $dtDate, $dtHour, $dtMin, $dtSec,
$dtH, $dtM, $dtS, $hv, $mv, $sv
); ?>
+11
View File
@@ -50,10 +50,14 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
<?php /* v24 第 5、6 条:起止各一个「日期 + 时分秒」合在一起的控件(全局统一),
不再拆成"日期一格 + 时刻一格"。两个控件长得一样,扫一眼就知道哪个是开始。 */ ?>
<?php /* 用户 2026-10-04 第 7 条:日程的起止也是一个**范围** ——
升级后由一个 Semi dateTimeRange 一次选完。服务端仍收两组 *_date/_hour/… 字段。 */ ?>
<div class="dt-range">
<div class="field ev-dt" data-dt>
<label>开始 *</label>
<?php $dtName = 'starts_at'; $dtId = 'ev-start';
$dtValue = $evF['starts_at'] ?? null; $dtRequired = true; $dtSeconds = true;
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
</div>
<?php /* 用户 2026-10-03 第 6 条:「添加结束日期与时间」——
@@ -64,8 +68,10 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
<label>结束</label>
<?php $dtName = 'ends_at'; $dtId = 'ev-end';
$dtValue = $evF['ends_at'] ?? null; $dtRequired = false; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
</div>
</div><?php /* /dt-range */ ?>
<div class="field" id="ev-end-add">
<label>结束时间</label>
<button type="button" class="btn btn-ghost btn-sm" id="ev-end-add-btn">+ 添加结束时间</button>
@@ -176,6 +182,11 @@ $evIsAllDay = array_key_exists('all_day', $evF) ? !empty($evF['all_day']) : $evA
function showEnd(on) {
if (endField) { endField.hidden = !on; }
if (endAdd) { endAdd.hidden = on; }
// 结束时间刚被显示出来 → 现在两端都可见了,可以合并成一个范围控件(用户第 7 条)。
// 收起时不动已有的范围(它本来就只显示在第一端上,不影响使用)。
if (on && typeof window.fbUpgradeRange === 'function') {
window.fbUpgradeRange();
}
}
var endTouched = false;
endInputs().forEach(function (el) {
+51
View File
@@ -71,6 +71,27 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
<?php if ($canWrite && $writable): ?>
<button class="btn" type="button" id="calAddBtn">新建日程</button>
<?php endif; ?>
<?php /* 用户 2026-10-04 第 6 条原话:「日程页面,在设置按钮的前面,加一个日历按钮,
选择日历后,跳转到那一天。」
→ 一个带日历图标的按钮,点开是日期选择;选中即跳到那天的**日视图**。
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
<form class="cal-jump" method="get" action="index.php">
<input type="hidden" name="r" value="calendar">
<input type="hidden" name="view" value="day">
<label class="btn btn-ghost cal-jump-btn" title="跳到某一天">
<svg viewBox="0 0 16 16" width="17" height="17" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<rect x="2" y="3.2" width="12" height="10.8" rx="1.6"/>
<path d="M2 6.6h12M5.2 1.8v2.6M10.8 1.8v2.6"/>
</svg>
<span class="cal-jump-txt">跳到</span>
<input class="cal-jump-date" type="date" name="d" value="<?= e($anchor) ?>"
aria-label="选择要跳转的日期">
</label>
<noscript><button class="btn btn-ghost btn-sm" type="submit">跳转</button></noscript>
</form>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
<?php $setCurrent = 'view'; require __DIR__ . '/_settings_menu.php'; ?>
@@ -247,6 +268,14 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
var startSec = startField ? startField.querySelector('select.dt-second') : null;
function setStart(dateStr, hh, mm) {
// ⚠️ 优先走全局接口:它会把原生字段与 Semi 控件**一起**更新。
// 直接写 startDate.value 的话,Semi 是非受控组件、显示不会跟着变 ——
// 用户会看到"我点了 15:00,控件里还写着 09:00"(提交的却是 15:00)。
if (typeof window.fbSetDT === 'function'
&& window.fbSetDT('starts_at', dateStr + ' ' + (hh || '09') + ':' + (mm || '00') + ':00')) {
return;
}
// 兜底:Semi 还没加载完(或加载失败)→ 原生控件照旧能用
if (!startDate) return;
startDate.value = dateStr;
if (hh !== null && startHour) { startHour.value = hh; }
@@ -300,4 +329,26 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
}
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
/* ---- 用户第 6 条:「跳到某一天」—— 选中即跳,不用再点确定 ---- */
(function () {
var box = document.querySelector('.cal-jump');
if (!box) { return; }
var inp = box.querySelector('.cal-jump-date');
if (!inp) { return; }
inp.addEventListener('change', function () {
if (inp.value) { box.submit(); }
});
// 点标签区域也应能弹出原生日历(有些浏览器只认输入框本身)
var lab = box.querySelector('.cal-jump-btn');
if (lab) {
lab.addEventListener('click', function (e) {
if (e.target === inp) { return; }
if (typeof inp.showPicker === 'function') {
try { inp.showPicker(); } catch (err) { /* 用户手势之外会被拒,忽略 */ }
}
inp.focus();
});
}
})();
</script>
+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>
+9 -6
View File
@@ -73,18 +73,21 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始时间(精确到秒)</label>
<?php /* 用户 2026-10-04 第 7 条:起止是一个**范围**,应该由日期插件的
范围选择方式一次选完,而不是并列两个控件(不好看、也多一步操作)。
服务端仍然收 starts_at_* / ends_at_* 两组字段(契约不变),
只是升级后由**一个** Semi dateTimeRange 统一填两端(见 app.js)。 */ ?>
<div class="field full dt-range">
<label <?= $isEdit ? '' : 'for="starts_at_date"' ?>>开始 / 结束时间(精确到秒)</label>
<?php $dtName = 'starts_at'; $dtValue = $dtStart; $dtId = 'starts_at_date';
$dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div>
<div class="field full">
<label>结束时间(散场,可不填)</label>
<?php $dtName = 'ends_at'; $dtValue = $dtEnd; $dtId = '';
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
<span class="tip">散场时间可不填。</span>
</div>
<div class="field full">
+173 -43
View File
@@ -27,10 +27,31 @@ foreach ($grouped as $list) {
* 画一栏人。用闭包是因为"分好组的各栏"和"未分类"两处要画同一套卡片,
* 复制一遍迟早会改漏一处。
*
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「调整分组」下拉要列出我能用的分组。
* 漏掉它的后果**不会报错**(PHP 8 只 warning 一句"未定义变量",
* 而 foreach 拿到 null 就是零次循环)—— 于是每张卡片的那个下拉里
* 只剩「未分类」一项,看着像"分组功能没做",其实是变量没传进来。
* ⚠️ `$groups` 必须写进 `use (...)`:卡片里的「分组」按钮要用它(见下)。
* (历史:这个下拉原来长在卡片里,漏掉它会让下拉只剩「未分类」一项,
* 看着像"分组功能没做",其实是变量没传进来。)
*
* 🔴🔴 v24 第 10 批 #6:**卡片里不再内联弹层与表单**。
*
* 用户报「名册列表页面仍然很慢,Firefox 提示这个页面和 Bitwarden 插件有冲突」。
* 线上真实数据实测(1184 人)把这件事量出来是什么样:
* · 全页 `<form>` **2369 个**、`<input>` **27234 个**、`radio` **22496 个**;
* · 单张卡片 83 个 DOM 节点,其中**分组菜单独占 64 个**;
* · 于是总 DOM 98100 个、解压后 7.7 MB。
* 密码管理器(Bitwarden/1Password/LastPass…)会**逐个 form/input 去判定是不是登录框**,
* 2369 个表单足够把主线程占住 —— 这就是"和 Bitwarden 冲突"的真实来源,
* 也是"明明没报错却卡得没法用"的原因。
*
* 修法(不是调 CSS 缓存,而是**把节点数砍掉一个数量级**):
* ① 每卡的「分组」按钮只留一个 **<button data-pg-open="…">**(1 个节点,无表单无 radio);
* ② 每卡的「⋯」只留一个 **<button data-pm-open="…">**;
* ③ 「分组」「移出名册」需要的 **radio / 表单 / csrf** 全页只渲染**一份**,
* 放在列表末尾的共享浮层里(见文件末尾 `#rosterMenus`),点哪个人的按钮就把
* 该按钮的数据灌进去 —— 语义没变,服务端路由也没变(还是 people/group 与 people/forget)。
*
* 代价:没有 JS 时这两个按钮点不动。但**改分组/移出名册本来就只有 JS 可用场景**
* (拖动排序、即时筛选都不是无 JS 能用的),而且卡片上的「编辑」仍是普通链接,
* 全站核心功能不依赖它。页面正文明说这一点,不静默降级。
*/
$renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
?>
@@ -150,51 +171,24 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
容器贴右、内部自然排开,.person-main 也就不用再靠 padding 让位。 */ ?>
<div class="person-actions">
<?php if ($canMove): ?>
<?php /* 用户 2026-10-04 第 1 条:名册没有地方改分组了 → 加一个按钮,
<?php /* 用户 2026-10-04 第 1 条:名册要有地方改分组 → 「分组」按钮,
**直接显示当前分组**,点开就是分组列表,选中即改。
⚠️ 用 <details> 而不是新写一套弹窗 JS:没有 JS 时它照样能开、
照样能提交(里面的「保存」按钮),开了 JS 只是"选中即提交"。
⚠️ 值是原生 radio,**提交的是 form** —— 与名册页原来的
「移动」是同一个路由(people/group),服务端不用改。 */ ?>
<details class="person-group menu" data-no-drag>
<summary class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></summary>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<p class="pg-title">把「<?= e($pshow) ?>」分到</p>
<label class="pg-item">
<input type="radio" name="group_id" value=""<?= $curGid === 0 ? ' checked' : '' ?>>
<span>未分类</span>
</label>
<?php foreach ($groups as $g): $gid = (int)$g['id']; ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= $gid ?>"<?= $curGid === $gid ? ' checked' : '' ?>>
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
<button class="btn btn-sm pg-save" type="submit">保存</button>
</form>
</details>
v24 第 10 批 #6:按钮本身不再内联 <details>+<form>+11 个 radio,
只带数据、交给全页共享的那一份浮层(理由见 $renderGrid 的注释)。 */ ?>
<button type="button" class="btn btn-ghost btn-sm person-group-btn<?= $grpName === '' ? ' is-none' : '' ?>"
data-pg-open="<?= $pid ?>"
data-pg-name="<?= e($pshow) ?>"
data-pg-cur="<?= $curGid ?>"
title="改 <?= e($pshow) ?> 的分组"><?= e($grpName !== '' ? $grpName : '分组') ?></button>
<?php endif; ?>
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php if ($canMove): ?>
<details class="person-more menu" data-no-drag>
<summary aria-label="更多操作">⋯</summary>
<?php /* 「调整分组」那个下拉已经搬到上面的「分组」按钮里 —— 同一件事
不做两处(留着反而会让人以为改了这边、那边没跟上)。 */ ?>
<?php if (($p['roster_source'] ?? '') === 'kin'): ?>
<p class="small muted person-move" style="margin:0">因家谱关系自动加入;解除关系后自动移出。</p>
<?php else: ?>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="<?= $pid ?>">
<button class="btn btn-ghost btn-sm" type="submit" data-no-drag>从我的名册移走</button>
</form>
<?php endif; ?>
</details>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
data-pm-hint="<?= ($p['roster_source'] ?? '') === 'kin' ? '1' : '' ?>"
aria-label="更多操作" title="更多操作">⋯</button>
<?php endif; ?>
</div>
</div>
@@ -313,6 +307,47 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
</p>
<?php endif; ?>
</div>
<?php /* ==================== 卡片动作的**共享浮层**(v24 第 10 批 #6)====================
这里只有**一份**「分组」表单和**一份**「移出名册」表单,全页共用。
每一行的按钮只带数据(data-pg-open / data-pm-open),点开时由 JS 把数据灌进来。
目的:把 2369 个 form / 27234 个 input / 22496 个 radio 压回个位数 ——
这正是 Bitwarden 那类密码管理器把页面拖卡的根源(它们要逐个 form 扫描)。
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
不会掉进"按钮点了没反应"的黑洞。 */ ?>
<?php if ($canMove): ?>
<div class="roster-menus" id="rosterMenus" hidden>
<div class="rm-panel" id="rmGroup" hidden>
<form method="post" action="<?= e(url('people/group')) ?>" class="pg-menu">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<p class="pg-title">把「<b class="pg-who"></b>」分到</p>
<label class="pg-item">
<input type="radio" name="group_id" value="">
<span>未分类</span>
</label>
<?php foreach ($groups as $g): ?>
<label class="pg-item">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
<span><?= e((string)$g['name']) ?></span>
</label>
<?php endforeach; ?>
<button class="btn btn-sm pg-save" type="submit">保存</button>
</form>
</div>
<div class="rm-panel" id="rmMore" hidden>
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
因家谱关系自动加入;解除关系后自动移出。
</p>
<form method="post" action="<?= e(url('people/forget')) ?>" class="person-move is-self">
<?= csrf_field() ?>
<input type="hidden" name="person_id" value="">
<button class="btn btn-ghost btn-sm" type="submit">从我的名册移走</button>
</form>
</div>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($q !== '' || $canMove): ?>
@@ -335,5 +370,100 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
});
syncClear();
})();
<?php /* ==================== 共享浮层的交互(v24 第 10 批 #6)====================
一行只留一个按钮,真正的表单在 #rosterMenus 里(全页一份)。
这里负责:点按钮 → 把该行的数据填进共享表单 → 浮层贴着那个按钮弹出来。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var mPanel = document.getElementById('rmMore');
var openFor = null; // 当前为哪张卡开着
function closeAll() {
wrap.hidden = true;
if (gPanel) { gPanel.hidden = true; }
if (mPanel) { mPanel.hidden = true; }
openFor = null;
}
/* 浮层贴到触发按钮下方;贴太靠右就向左收,贴太靠下就翻到上方 */
function place(panel, btn) {
var b = btn.getBoundingClientRect();
var w = panel.offsetWidth, h = panel.offsetHeight;
var vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight;
var left = Math.min(Math.max(8, b.right - w), vw - w - 8);
var top = b.bottom + 6;
if (top + h > vh - 8) { top = Math.max(8, b.top - h - 6); }
panel.style.left = Math.round(left) + 'px';
panel.style.top = Math.round(top) + 'px';
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
// ---- 分组 ----
var gb = t.closest('[data-pg-open]');
if (gb) {
e.preventDefault();
var pid = gb.getAttribute('data-pg-open');
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
var form = gPanel.querySelector('form');
form.querySelector('input[name="person_id"]').value = pid;
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
// 预选当前分组
Array.prototype.forEach.call(form.querySelectorAll('input[name="group_id"]'), function (r) {
r.checked = (parseInt(r.value || '0', 10) || 0) === cur;
});
mPanel.hidden = true;
gPanel.hidden = false;
wrap.hidden = false;
place(gPanel, gb);
openFor = pid;
return;
}
// ---- ⋯ ----
var mb = t.closest('[data-pm-open]');
if (mb) {
e.preventDefault();
var pid2 = mb.getAttribute('data-pm-open');
var kin = mb.getAttribute('data-pm-hint') === '1';
mPanel.querySelector('.is-kin').hidden = !kin;
mPanel.querySelector('form.is-self').hidden = kin;
if (!kin) {
mPanel.querySelector('form.is-self input[name="person_id"]').value = pid2;
}
gPanel.hidden = true;
mPanel.hidden = false;
wrap.hidden = false;
place(mPanel, mb);
openFor = pid2;
return;
}
// 点浮层内部 → 不动
if (wrap.contains(t)) { return; }
closeAll();
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(); }
});
window.addEventListener('resize', closeAll);
window.addEventListener('scroll', function () { if (openFor) { closeAll(); } }, true);
/* 选中即提交(与原来一样是"锦上添花":面板里那个「保存」按钮一直都在) */
document.addEventListener('change', function (e) {
var inp = e.target;
if (!inp || inp.type !== 'radio' || inp.name !== 'group_id') { return; }
var form = inp.form;
if (!form || !form.classList || !form.classList.contains('pg-menu')) { return; }
if (typeof form.requestSubmit === 'function') { form.requestSubmit(); }
else { form.submit(); }
});
})();
</script>
<?php endif; ?>
+8 -7
View File
@@ -54,15 +54,16 @@ $nfPrefix = $nfIsEdit ? 'ne' . (int)$nfNode['id'] : ('n' . ($nfCategory === 'rea
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>开始</label>
<?php /* 用户 2026-10-04 第 7 条:节点的起止也是**一个范围**,一次选完 */ ?>
<div class="field dt-range">
<label>开始 / 结束</label>
<?php $dtName = 'starts_at'; $dtValue = $nfNode['starts_at'] ?? '';
$dtId = $nfPrefix . '-s'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
</div>
<div class="field">
<label>结束</label>
$dtId = $nfPrefix . '-s'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php $dtName = 'ends_at'; $dtValue = $nfNode['ends_at'] ?? '';
$dtId = $nfPrefix . '-e'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
$dtId = $nfPrefix . '-e'; $dtSeconds = true; $dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<span class="tip">结束时间可不填。</span>
</div>
<div class="field full">
<label for="<?= e($nfPrefix) ?>-detail">记录</label>
+8 -7
View File
@@ -44,18 +44,19 @@ if ($isEdit) {
<?php if (!empty($errors['title'])): ?><span class="err"><?= e($errors['title']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="t-start">出发</label>
<?php /* 用户 2026-10-04 第 7 条:出发/回来是一个**时间范围**,
用一个范围选择器一次选完(不再是并列两个控件)。 */ ?>
<div class="field dt-range">
<label for="t-start">出发 / 回来</label>
<?php $dtName = 'starts_at'; $dtValue = $t['starts_at'] ?? '';
$dtId = 't-start'; $dtSeconds = true; require __DIR__ . '/../_datetime.php'; ?>
$dtId = 't-start'; $dtSeconds = true; $dtRange = 'start';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['starts_at'])): ?><span class="err"><?= e($errors['starts_at']) ?></span><?php endif; ?>
</div>
<div class="field">
<label for="t-end">回来</label>
<?php $dtName = 'ends_at'; $dtValue = $t['ends_at'] ?? ''; $dtId = 't-end'; $dtSeconds = true;
$dtRange = 'end';
require __DIR__ . '/../_datetime.php'; ?>
<?php if (!empty($errors['ends_at'])): ?><span class="err"><?= e($errors['ends_at']) ?></span><?php endif; ?>
<span class="tip">回来的时间可不填。</span>
</div>
<div class="field full">