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 接管+取消清空")。
This commit is contained in:
2026-10-04 17:42:21 +08:00
parent b2ca4f336e
commit 5d80b9480b
17 changed files with 1592 additions and 637 deletions
+48 -14
View File
@@ -47,7 +47,9 @@ $ppInputType = $ppIsMulti ? 'checkbox' : 'radio';
// 直接 !$ppSelected 会误判成"选了人",于是「不选」那一项反而不勾。
$ppHasSel = count(array_filter($ppSelected)) > 0;
?>
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>" data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
<div class="pp" id="<?= e($ppId) ?>" data-pp="<?= e($ppId) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-mode="<?= e($ppIsMulti ? 'multi' : 'single') ?>">
<div class="pp-chips" id="<?= e($ppId) ?>-chips">
<?php /* ⚠️ 循环变量一律用 $ppRow,绝不叫 $p:宿主模板里 $p 常常是别的东西
(高级搜索页的 $p 是"查询条件数组"),被这个片段一覆盖,
@@ -79,27 +81,59 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<input class="input pp-search" id="<?= e($ppId) ?>-search" type="text"
placeholder="输入名字或拼音首字母,如 yyq 找「杨永全」" autocomplete="off">
</div>
<div class="pp-list" id="<?= e($ppId) ?>-list">
<?php
/* ⚠️ v24 第 11 批 #7 续:候选人清单**首屏不建 DOM**。
线上真实数据下,每页 1~3 个选人组件 × 1184 人 = 几千个 <label>+<input>,
账目页因此到 6.9 MB / 16788 个 input,密码管理器逐个扫描把页面拖死。
现在按 data-pp-people(JSON)在**打开弹框时**建清单;
无 JS 时由下面的 <noscript> 兜底(启用脚本时它只是纯文本,不产生节点)。 */
$ppJson = [];
foreach ($ppPeople as $ppRow) {
$ppJson[] = [
'id' => (int)$ppRow['id'],
'name' => (string)$ppRow['name'],
'shown' => person_name($ppRow),
'avatar' => avatar_url($ppRow['avatar'] ?? null),
];
}
unset($ppRow);
?>
<div class="pp-list" id="<?= e($ppId) ?>-list"
data-pp-people="<?= e(json_encode($ppJson, JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>"
data-pp-name="<?= e($ppName) ?>"
data-pp-type="<?= e($ppInputType) ?>"
data-pp-selected="<?= e(implode(',', array_map('intval', $ppSelected))) ?>"
data-pp-allow-empty="<?= (!$ppIsMulti && $ppAllowEmpty) ? '1' : '0' ?>">
<?php /* 「不选」很轻(1 个 input)且是"默认不指定"语义的载体 → 留在服务端渲染。
⚠️ 它必须留在**普通容器**里(不是 noscript),否则 ppSetSelected 找不到它。 */ ?>
<?php if (!$ppIsMulti && $ppAllowEmpty): ?>
<label class="pp-item pp-item-none" data-name="不选" data-shown="不选">
<input type="radio" name="<?= e($ppName) ?>" value="0" <?= $ppHasSel ? '' : 'checked' ?>>
<span class="pp-name">不选</span>
</label>
<?php endif; ?>
<?php foreach ($ppPeople as $ppRow): ?>
<label class="pp-item"
data-name="<?= e((string)$ppRow['name']) ?>"
data-shown="<?= e(person_name($ppRow)) ?>">
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
<span class="pp-initials" hidden></span>
</label>
<?php endforeach; ?>
<?php unset($ppRow); ?>
<div class="pp-empty" hidden>没有匹配的人。</div>
</div>
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
不会变成 DOM 节点、也就不会造成上面的膨胀;
禁用脚本时才渲染出来,选人照样能用。 */ ?>
<noscript>
<div class="pp-list pp-list-noscript">
<?php foreach ($ppPeople as $ppRow): ?>
<label class="pp-item"
data-name="<?= e((string)$ppRow['name']) ?>"
data-shown="<?= e(person_name($ppRow)) ?>">
<input type="<?= e($ppInputType) ?>" name="<?= e($ppName) ?>" value="<?= (int)$ppRow['id'] ?>"
<?= in_array((int)$ppRow['id'], $ppSelected, true) ? 'checked' : '' ?>>
<img class="pp-av" src="<?= e(avatar_url($ppRow['avatar'] ?? null)) ?>" alt="" loading="lazy">
<span class="pp-name"><?= e(person_name($ppRow)) ?></span>
<span class="pp-initials" hidden></span>
</label>
<?php endforeach; ?>
<?php unset($ppRow); ?>
</div>
</noscript>
</div>
</div>
<noscript><style>.pp-open { display: none; } .pp-modal { position: static; display: block; background: none; padding: 0; } .pp-modal-box { box-shadow: none; border: 1px solid var(--line); max-height: none; }</style></noscript>
+84 -27
View File
@@ -77,20 +77,29 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
⚠️ 用原生 <input type="date"> 而不是再引一个弹层:它的原生日历足够用,
而且**选中就有值**(change 事件)→ 直接跳转,不用再点一次"确定"。
桌面/手机/无 JS 三种情况下的行为都是可预期的。 */ ?>
<?php /* 用户 2026-10-04 第 11 批 #6 原话(两句):
· 「跳转日期那个日期选择框,用我之前给你的那个,保持所有样式和格式的统一。」
· 「跳转日期那个地方,不要那么长,只需要和设置一样,一个按钮即可。」
→ 从"一个长条(图标 + 文字 + 原生日期框)"改成**与设置按钮同规格的单个图标按钮**
(btn-icon,与 ☰ 同宽同高),点开弹出 Semi 日期选择器(与全站日期控件同一套样式),
选中即跳到那天的日视图。
原生 input[type=date] 仍然保留(noscript 里)——"没 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">
<input type="hidden" name="d" id="calJumpD" value="<?= e($anchor) ?>">
<button type="button" class="btn btn-icon cal-jump-btn" id="calJumpBtn"
title="跳到某一天" aria-label="跳到某一天">
<svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" 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>
</button>
<noscript>
<input class="input" type="date" name="d" value="<?= e($anchor) ?>" aria-label="选择日期">
<button class="btn btn-sm" type="submit">跳转</button>
</noscript>
</form>
<?php /* 用户第 4、5 条:日历本 / 颜色 / 导入 / 默认视图 全收进这个 ☰ ——
它顶替了原来左边那一栏,所以排在**最右**(靠 .page-head .actions 的顺序)。 */ ?>
@@ -151,6 +160,8 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
<div class="tg-gutter"></div>
<?php foreach (($cols ?? []) as $c): ?>
<div class="tg-dayname<?= $c['is_today'] ? ' is-today' : '' ?>">
<?php /* $c['w'] 是 date('w')(0=周日),所以这张索引表保持"日"开头——
它只是把数字换成汉字,与列的排列顺序无关。 */ ?>
<span class="tg-wd"><?= e(['日','一','二','三','四','五','六'][$c['w']]) ?></span>
<span class="tg-dd"><?= (int)$c['day'] ?></span>
</div>
@@ -201,7 +212,9 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
<?php else: ?>
<div class="card cal-grid-card">
<div class="cal-head">
<?php foreach (['日', '一', '二', '三', '四', '五', '六'] as $wd): ?>
<?php /* ⚠️ 用户第 11 批 #6:「月视图,从周一开始。而不是周日。」
表头顺序必须与 month_grid_range() 的起算规则一致(那里已改成周一)。 */ ?>
<?php foreach (['一', '二', '三', '四', '五', '六', '日'] as $wd): ?>
<div class="cal-wd"><?= e($wd) ?></div>
<?php endforeach; ?>
</div>
@@ -330,25 +343,69 @@ $headText = $view === 'month' ? date('Y 年 n 月', $ts)
/* 「全天」勾上藏掉时刻那一段的逻辑在 _event_fields.php 里(两个页面共用一份) */
})();
/* ---- 用户第 6 条:「跳到某一天」—— 选中即跳,不用再点确定 ---- */
/* ---- 用户第 6 条:「跳到某一天」—— 单个图标按钮 + Semi 日期选择器 ---- */
(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();
});
var form = document.querySelector('.cal-jump');
var btn = document.getElementById('calJumpBtn');
var hid = document.getElementById('calJumpD');
if (!form || !btn || !hid) { return; }
function jump(v) {
if (!v) { return; }
hid.value = v;
form.submit();
}
btn.addEventListener('click', function () {
var meta = document.querySelector('meta[name="fb-semi"]');
var conf = null;
try { conf = meta ? JSON.parse(meta.getAttribute('content')) : null; } catch (e) { conf = null; }
function fallback() {
// Semi 没就绪 → 用一个临时原生日期框(无 JS 时的兜底与这里同一行为)
var t = document.createElement('input');
t.type = 'date';
t.value = hid.value;
t.style.position = 'fixed';
t.style.left = '-9999px';
document.body.appendChild(t);
t.addEventListener('change', function () { jump(t.value); });
if (typeof t.showPicker === 'function') { try { t.showPicker(); } catch (e2) {} }
else { t.focus(); t.click(); }
}
if (!window.SemiUI || !window.SemiUI.DatePicker || !window.ReactDOM) { fallback(); return; }
// 弹一个**只有一个日期选择器**的轻浮层(与全站日期控件同一套样式)
var host = document.getElementById('calJumpHost');
if (!host) {
host = document.createElement('div');
host.id = 'calJumpHost';
host.className = 'cal-jump-host';
document.body.appendChild(host);
}
var wrap = document.createElement('div');
wrap.className = 'cal-jump-pop';
wrap.innerHTML = '<div class="cal-jump-pop-head">跳到哪一天?' +
'<button type="button" class="cal-jump-x" aria-label="关闭">✕</button></div>';
var inner = document.createElement('div');
wrap.appendChild(inner);
host.innerHTML = '';
host.appendChild(wrap);
ReactDOM.createRoot(inner).render(React.createElement(SemiUI.DatePicker, {
type: 'date',
format: 'yyyy-MM-dd',
defaultValue: hid.value ? new Date(hid.value + 'T00:00:00') : new Date(),
open: true,
// 选中即跳,不用再点"确定"
onChange: function (d, ds) {
if (ds) { jump(ds); }
},
onOpenChange: function (o) { if (!o) { host.innerHTML = ''; } }
}));
wrap.querySelector('.cal-jump-x').addEventListener('click', function () { host.innerHTML = ''; });
});
})();
</script>
+6 -1
View File
@@ -154,7 +154,12 @@ $homeMods = array_values(array_filter(
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
</span>
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
<span class="dash-tag"><?= e((string)$ev['calname']) ?></span>
<?php /* 用户 2026-10-04 第 11 批 #1:「右侧的日历本,现在看不清,
应该用一个色块,颜色就用日历的颜色,文字是日历本的文字,用白色。」
原来是 .dash-tag(--faint 灰色 11px 小字),在浅色纸上几乎看不见 ——
现在做成实心色块:底色 = 这本日历的颜色,字 = 白,一眼能对上左侧那个小圆点。 */ ?>
<span class="dash-cal"
style="background: <?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</li>
<?php endforeach; ?>
</ul>
+7
View File
@@ -48,6 +48,13 @@ $isLoggedIn = \App\Auth::check();
'dom' => asset('/assets/vendor/semi/react-dom.min.js'),
'ui' => asset('/assets/vendor/semi/semi-ui.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
<?php /* v24 第 11 批 #5:Cropper.js(裁剪)也是**按需加载** ——
只有页面上真有 input[type=file][data-crop-ratio] 时才去拉(见 app.js)。
同样在这里登记带版本号的地址,绝不在没有裁剪的页面白拉。 */ ?>
<meta name="fb-cropper" content="<?= e(json_encode([
'css' => asset('/assets/vendor/cropper/cropper.min.css'),
'js' => asset('/assets/vendor/cropper/cropper.min.js'),
], JSON_UNESCAPED_UNICODE | JSON_HEX_APOS | JSON_HEX_QUOT)) ?>">
</head>
<?php /* 主题色:把 --brand / --brand-2 / --brand-ink 三个变量在 <body> 上换成本模块的色,
下面所有用到品牌色的地方(按钮、链接、页头高亮…)自动跟着变。
+202 -156
View File
@@ -194,24 +194,26 @@ $ledMe = (int)($me ?? 0);
<?php endforeach; ?>
</div>
<div class="ck-grid">
<?php /* 用户 2026-10-04 第 11 批 #7 两句原话:
· 「那个图,怎么数据都是 0 啊,没有盘点数据,就用账目的初始数据就可以了」
→ 数据源已在 Ledger::entrySeries 修好(没盘点过就从 0 画到当前值)。
· 「图下面的条目不用显示详情,只需要显示每个账目的颜色,
就是说哪个颜色代表哪个账目,就可以了。」
→ 原来每条账目一张小卡(标题 + 类型 + 点数 + 一张小图),信息冗余;
现在改成**一张合并的多色折线图**(每条账目一条线、用图例色),
下面只留一排颜色图例(色块 + 名称),不再堆详情。 */ ?>
<?= chart_multi($charts, 900) ?>
<div class="ck-legend">
<?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>
<span class="ck-lg-item" data-ck-kind="<?= e($ek) ?>">
<i class="ck-lg-dot" style="background: <?= e((string)$ch['color']) ?>;"></i>
<?= e((string)$en['title']) ?>
</span>
<?php endforeach; ?>
</div>
<p class="small muted" style="margin:8px 2px 0">
没有盘点记录的账目只会显示一个点(它一直没变过)——
在下面账目表里点「盘点」记下当前金额,这条线就长出来了。
没盘点过的账目按「从 0 到现在」画 —— 想让它出现真实波动,在下面账目表里点「盘点」。
</p>
<?php endif; ?>
</div>
@@ -257,116 +259,26 @@ $ledMe = (int)($me ?? 0);
用来修改名称、挂在谁名下、备注等。」
→ 所以这两个是**并列的按钮**,各自弹自己的面板;
不再是一个「操作」里塞两坨表单(那要展开两层才够得着)。 */ ?>
<?php /* 用户第 11 批 #7:「修改按钮,点击后弹框,然后修改信息」;
同时这里也是**账目页卡顿的根源**:原来每行内联「盘点」「修改」
两个面板(各一个 <form> + 一个选人组件),账目多时就是几千个 form
—— 密码管理器(Bitwarden)逐项扫描把主线程占死。
→ 现在行上只留两个带数据的按钮,真正的表单全页只有一份(见页尾 #ledPanels)。 */ ?>
<?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>
data-led-open="recon"
data-led-id="<?= $eid ?>"
data-led-title="<?= e((string)$en['title']) ?>"
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
data-led-hist="<?= (int)$histN ?>">盘点</button>
<button type="button" class="btn btn-ghost btn-sm led-act"
data-led-open="edit-<?= $eid ?>">修改</button>
<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">盘点「<?= 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="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">看历史(<?= $histN ?> 次)</summary>
<ul class="led-hist-list">
<?php foreach (\App\Ledger::history($eid) as $hs): ?>
<li>
<span class="nowrap"><?= e(substr((string)$hs['created_at'], 0, 10)) ?></span>
<b><?= e($money((int)$hs['amount_cents'])) ?></b>
<?php if (($hs['note'] ?? '') !== ''): ?>
<span class="muted small"><?= e((string)$hs['note']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</details>
<?php endif; ?>
</form>
</div>
<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">修改「<?= 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>
</form>
<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>
</form>
</div>
data-led-open="edit"
data-led-id="<?= $eid ?>"
data-led-title="<?= e((string)$en['title']) ?>"
data-led-amt="<?= e(\App\Ledger::amountYuan((int)$en['amount_cents'])) ?>"
data-led-note="<?= e((string)($en['note'] ?? '')) ?>"
data-led-kind="<?= e($ek) ?>"
data-led-person="<?= (int)($en['person_id'] ?? 0) ?>">修改</button>
<?php endif; ?>
</td>
</tr>
@@ -384,6 +296,102 @@ $ledMe = (int)($me ?? 0);
<?php endif; ?>
</div>
<?php /* ==================== 账目行的两个弹框(全页各一份)====================
与名册的 #rosterMenus 同一套思路:行上只留按钮,表单集中在这里。
这是账目页卡顿的**根治办法** —— 原来每行两个面板,
账目一多就是几千个 form(用户报的 Bitwarden 冲突就是这么来的)。 */ ?>
<?php if ($canWrite): ?>
<div class="led-modal" id="ledPanels" hidden>
<div class="led-mask" data-led-close></div>
<div class="led-panel-lg" id="ledReconPanel" hidden role="dialog" aria-modal="true" aria-label="盘点">
<div class="led-ph"><b>盘点「<span class="ld-title"></span>」</b>
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
<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="">
<div class="led-pb">
<div class="field">
<label>最新金额(元)</label>
<input class="input" name="amount" inputmode="decimal" required>
<span class="tip">盘点前的金额会记进历史,图上就多一个点。</span>
</div>
<div class="field">
<label>盘点备注</label>
<input class="input" name="note" placeholder="如:2026 年初盘点">
</div>
<details class="led-hist" hidden>
<summary class="link-quiet"></summary>
<div class="led-hist-box"></div>
</details>
</div>
<div class="led-pf">
<button class="btn" type="submit">记下这次盘点</button>
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
</div>
</form>
</div>
<div class="led-panel-lg" id="ledEditPanel" hidden role="dialog" aria-modal="true" aria-label="修改">
<div class="led-ph"><b>修改「<span class="ld-title"></span>」</b>
<button type="button" class="led-x" data-led-close aria-label="关闭">✕</button></div>
<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="">
<div class="led-pb">
<div class="field">
<label>名称</label>
<input class="input" name="title" required>
</div>
<div class="field">
<label>挂在谁名下</label>
<?php /* ⚠️ 选人组件只在这里渲染**一份**(原来每行一份 = 卡顿主因之一) */ ?>
<?php
$ppName = 'person_id';
$ppPeople = $people;
$ppSelected = [];
$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) ?>"><?= e($v[2] . ' ' . $v[0]) ?><?= $v[1] === 'debt' ? '(负债)' : '' ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>金额(元)</label>
<input class="input" name="amount" required>
</div>
<div class="field">
<label>备注</label>
<input class="input" name="note">
</div>
<p class="tip" style="margin:0">
改的是名称、归属与备注。若只是<b>金额变了</b>(不是录错),请用「盘点」——
那样才会记进历史、在图上多一个点。
</p>
</div>
<div class="led-pf">
<button class="btn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-led-close>取消</button>
<button class="btn btn-danger" type="submit" formaction="<?= e(url('ledger/entry_del')) ?>"
onclick="return confirm('删掉这笔账目?')">删除这笔</button>
</div>
</form>
</div>
</div>
<?php endif; ?>
<script>
(function () {
/* ---- 「记一笔」开合 ---- */
@@ -397,43 +405,81 @@ $ledMe = (int)($me ?? 0);
});
}
/* ---- 每行「盘点 / 修改」两个按钮:弹各自的面板(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;
/* ---- 行上「盘点 / 修改」两个按钮 → 打开**全页共享的弹框**(第 11 批 #7)----
⚠️ 旧版是"把面板插到那一行后面",于是每行都得内联一整套表单(含选人组件):
账目多的时候就是几千个 form,密码管理器逐个扫描 → 页面卡死
(用户报的"和之前的名录一样,还是 bitwarden 冲突")。
现在表单只有一份(#ledPanels),打开时把该行的数据灌进去。 */
var wrap = document.getElementById('ledPanels');
if (wrap) {
var reconPanel = document.getElementById('ledReconPanel');
var editPanel = document.getElementById('ledEditPanel');
function closeAll() {
wrap.hidden = true;
if (reconPanel) { reconPanel.hidden = true; }
if (editPanel) { editPanel.hidden = true; }
}
if (openPanel && !openPanel.contains(t)) { closePanel(); }
}, true);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closePanel(); }
});
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var trigger = t.closest('[data-led-open]');
if (trigger) {
e.preventDefault();
var which = trigger.getAttribute('data-led-open');
var id = trigger.getAttribute('data-led-id');
var title = trigger.getAttribute('data-led-title') || '';
var amt = trigger.getAttribute('data-led-amt') || '';
var panel = (which === 'recon') ? reconPanel : editPanel;
if (!panel) { return; }
panel.querySelector('input[name="id"]').value = id;
panel.querySelector('.ld-title').textContent = title;
var amtInp = panel.querySelector('input[name="amount"]');
if (amtInp) { amtInp.value = amt; }
if (which === 'edit') {
var tInp = panel.querySelector('input[name="title"]');
if (tInp) { tInp.value = title; }
var nInp = panel.querySelector('input[name="note"]');
if (nInp) { nInp.value = trigger.getAttribute('data-led-note') || ''; }
var kSel = panel.querySelector('select[name="kind"]');
if (kSel) { kSel.value = trigger.getAttribute('data-led-kind') || 'other'; }
// 归属人:选人组件是**单选**,用它的接口重置选中(见 _person_picker.php)
var pid = trigger.getAttribute('data-led-person') || '0';
if (window.ppSetSelected) { window.ppSetSelected('person_id', pid); }
}
if (which === 'recon') {
// 历史的条数以按钮为准(服务端已算好),点开才展开,避免每行都查一次库
var histN = parseInt(trigger.getAttribute('data-led-hist') || '0', 10) || 0;
var hist = panel.querySelector('.led-hist');
var hsum = panel.querySelector('.led-hist summary');
var hbox = panel.querySelector('.led-hist-box');
if (hist && hsum) {
hist.hidden = histN === 0;
hsum.textContent = '看历史(' + histN + ' 次)';
if (hbox) { hbox.innerHTML = ''; }
}
}
reconPanel.hidden = (which !== 'recon');
editPanel.hidden = (which !== 'edit');
wrap.hidden = false;
var focusInp = panel.querySelector('input:not([type=hidden]), select');
if (focusInp) { focusInp.focus(); }
return;
}
if (t.closest('[data-led-close]')) { e.preventDefault(); closeAll(); }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { closeAll(); }
});
}
/* ---- 图表按类别筛选("所有账目"在几百条时会变成一坨)---- */
var bar = document.querySelector('[data-ck-filter]');
+167 -11
View File
@@ -24,7 +24,9 @@ $dtFromOld = static function (string $name, ?string $dbValue): ?string {
};
$dtStart = $dtFromOld('starts_at', $m['starts_at'] ?? null);
$dtEnd = $dtFromOld('ends_at', $m['ends_at'] ?? null);
$selCinema = (int)old('cinema_id', $m['cinema_id'] ?? 0);
// 刚用「新建影院」弹框加完一家 → 回到这里要**自动选中**它(见 index.php 的 cinemas_save)。
// 优先级:老输入 > ?cinema=(刚新建的)> 记录里的值。
$selCinema = (int)old('cinema_id', (int)($_GET['cinema'] ?? 0) ?: ($m['cinema_id'] ?? 0));
$curAttendees = array_map('intval', array_column($m['attendees'] ?? [], 'id'));
$selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
?>
@@ -90,20 +92,43 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
<span class="tip">散场时间可不填。</span>
</div>
<?php /* 用户 2026-10-04 第 11 批 #4(三件事):
① 「选择影院的时候,影院的颜色要出现在下拉列表中,用来显示不同的影院」
→ 原生 <option> **画不了色块**(浏览器只渲染纯文本),
所以这里换成自定义下拉:按钮 + 弹出列表,每行左侧一个影院色点。
真实值仍然提交给隐藏的 <input name="cinema_id">(表单契约不变)。
② 「管理影院按钮,改为新建影院,点击后弹框,添加新影院,不要跳转到管理影院的页面」
→ 弹框里放"地方/名称/颜色"三个字段(影院管理页那张表单的核心),
提交时带 back=movies/new,服务端存完回到本页并自动选中新影院。
③ 下拉/弹框的样式与全站统一(宣纸底色、品牌色描边)。 */ ?>
<div class="field full">
<label for="cinema_id">影院</label>
<div class="cinema-select-row">
<select class="select" id="cinema_id" name="cinema_id">
<option value="0">不记录影院</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= (int)$c['id'] ?>" <?= $selCinema === (int)$c['id'] ? 'selected' : '' ?>>
<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>
</option>
<?php endforeach; ?>
</select>
<a class="btn btn-ghost btn-sm" href="<?= e(url('movies/cinemas')) ?>">管理影院</a>
<div class="cs" id="cinemaPicker" data-open="0">
<button type="button" class="cs-btn" id="csBtn" aria-haspopup="listbox" aria-expanded="false">
<span class="cs-dot" id="csDot"<?= $selCinema > 0 ? '' : ' hidden' ?>></span>
<span class="cs-text" id="csText">不记录影院</span>
<span class="cs-caret" aria-hidden="true">▾</span>
</button>
<div class="cs-menu" id="csMenu" hidden role="listbox">
<button type="button" class="cs-item" data-id="0" data-color="" role="option">
<span class="cs-dot is-none" aria-hidden="true"></span><span>不记录影院</span>
</button>
<?php foreach ($cinemas as $c): ?>
<button type="button" class="cs-item" role="option"
data-id="<?= (int)$c['id'] ?>"
data-color="<?= e((string)($c['color'] ?: '#888888')) ?>"
data-text="<?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?>">
<span class="cs-dot" style="background: <?= e((string)($c['color'] ?: '#888888')) ?>;"></span>
<span><?= e(trim(((string)($c['place'] ?? '') !== '' ? $c['place'] . ' · ' : '') . $c['name'])) ?></span>
</button>
<?php endforeach; ?>
</div>
<input type="hidden" id="cinema_id" name="cinema_id" value="<?= (int)$selCinema ?>">
</div>
<button type="button" class="btn btn-ghost btn-sm" id="cinemaNewBtn">新建影院</button>
</div>
<span class="tip">想记一家还没录过的影院?先去「管理影院」里添加,回来这里就能选。</span>
<span class="tip">刚新建的影院会自动选中;下拉里的色点就是它在日历与列表里的颜色。</span>
</div>
<div class="field full">
@@ -172,6 +197,48 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
</div>
</form>
<?php /* ---- 新建影院:弹框(不跳转,见上)---- */ ?>
<div class="cn-modal" id="cinemaNewModal" hidden>
<div class="cn-mask" data-cn-close></div>
<div class="cn-panel" role="dialog" aria-modal="true" aria-label="新建影院">
<div class="cn-head"><b>新建影院</b>
<button type="button" class="cn-x" data-cn-close aria-label="关闭">✕</button></div>
<form method="post" action="<?= e(url('movies/cinemas_save')) ?>">
<?= csrf_field() ?>
<?php /* back=movies/new 让服务端存完回到**本页**(而不是甩去影院管理页) */ ?>
<input type="hidden" name="back" value="movies/new">
<div class="cn-body">
<div class="field">
<label>地方(可不填)</label>
<input class="input" name="place" placeholder="如:青岛" maxlength="100">
</div>
<div class="field">
<label>影院名称 *</label>
<input class="input" name="name" required placeholder="如:万象城 CGV" maxlength="100">
</div>
<div class="field">
<label>主题颜色</label>
<div class="color-row">
<input type="color" name="color" class="color-picker cn-color" value="#A65C30">
<span class="color-hex cn-hex">#A65C30</span>
<span class="cn-presets">
<?php foreach (['#A63030','#A65C30','#A68930','#6BA630','#30A67A','#3078A6','#6B30A6','#A6305C'] as $pc): ?>
<button type="button" class="preset-color cn-preset" data-color="<?= $pc ?>"
style="background: <?= $pc ?>;" title="<?= $pc ?>"></button>
<?php endforeach; ?>
</span>
</div>
<span class="tip">这个颜色会出现在下拉、日历与观影列表上。</span>
</div>
</div>
<div class="cn-foot">
<button class="btn" type="submit">添加</button>
<button class="btn btn-ghost" type="button" data-cn-close>取消</button>
</div>
</form>
</div>
</div>
<!-- 豆瓣自动补充:下拉联想(320ms 防抖 + 中文输入法组字期不请求)+ 一键抓取 -->
<script>
(function () {
@@ -327,4 +394,93 @@ $selAttendees = array_map('intval', (array)old('attendees', $curAttendees));
xhr.send();
}
})();
/* ==================== 影院自定义下拉 + 新建影院弹框(第 11 批 #4)====================
① 下拉:原生 <option> 画不了色块,所以自己做一个"按钮 + 弹出列表"。
⚠️ 值仍然写进隐藏的 <input name="cinema_id">,服务端与既有测试都不用改。
② 新建影院:弹框里提交到 movies/cinemas_save(带 back=movies/new),
存完服务端会跳回本页并把新影院 id 放在 ?cinema= 上 → 模板已自动选中它。 */
(function () {
var picker = document.getElementById('cinemaPicker');
if (!picker) { return; }
var btn = document.getElementById('csBtn');
var menu = document.getElementById('csMenu');
var dot = document.getElementById('csDot');
var text = document.getElementById('csText');
var input = document.getElementById('cinema_id');
function close() { menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); }
function openMenu() { menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); }
function apply(id, color, label) {
input.value = String(id);
text.textContent = label || '不记录影院';
if (color) {
dot.hidden = false;
dot.style.background = color;
dot.classList.remove('is-none');
} else {
dot.hidden = true;
}
}
// 初始状态:按当前值把按钮上的色点/文字同步过来
(function init() {
var cur = String(input.value || '0');
var items = menu.querySelectorAll('.cs-item');
for (var i = 0; i < items.length; i++) {
if (items[i].getAttribute('data-id') === cur) {
var lbl = items[i].querySelector('span:last-child');
apply(cur, items[i].getAttribute('data-color'), lbl ? lbl.textContent : '');
break;
}
}
})();
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) { openMenu(); } else { close(); }
});
menu.addEventListener('click', function (e) {
var it = e.target.closest ? e.target.closest('.cs-item') : null;
if (!it) { return; }
e.stopPropagation();
var lbl2 = it.querySelector('span:last-child');
apply(it.getAttribute('data-id'), it.getAttribute('data-color'), lbl2 ? lbl2.textContent : '');
close();
});
// 点外面关(冒泡阶段,只关下拉本身)
document.addEventListener('click', function () { close(); });
// ---- 新建影院弹框 ----
var modal = document.getElementById('cinemaNewModal');
var nbtn = document.getElementById('cinemaNewBtn');
if (modal && nbtn) {
nbtn.addEventListener('click', function () {
modal.hidden = false;
var f = modal.querySelector('input[name="name"]');
if (f) { f.focus(); }
});
modal.addEventListener('click', function (e) {
if (e.target.closest && e.target.closest('[data-cn-close]')) { modal.hidden = true; }
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { modal.hidden = true; }
});
// 颜色选择器与预设色(复刻影院管理页那套,只是缩在弹框里)
var cp = modal.querySelector('.cn-color');
var hx = modal.querySelector('.cn-hex');
if (cp && hx) {
cp.addEventListener('input', function () { hx.textContent = cp.value; });
}
modal.addEventListener('click', function (e) {
var pv = e.target.closest ? e.target.closest('.cn-preset') : null;
if (!pv || !cp) { return; }
cp.value = pv.getAttribute('data-color');
if (hx) { hx.textContent = cp.value; }
});
}
})();
</script>
+111 -55
View File
@@ -184,6 +184,18 @@ $renderGrid = function (array $list) use ($meId, $canMove, $groups): void {
<a class="btn btn-ghost btn-sm person-edit" data-no-drag
href="<?= e(url('people/edit', ['id' => $pid])) ?>"
title="编辑 <?= e($pshow) ?> 的资料">编辑</a>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「在编辑后面,放一个按钮,删除,
用来删除一个人。」
⚠️ 它与「⋯→从我的名册移走」不是一件事,别混:
· 「从我的名册移走」= 只是不在**我的**名册里显示,人还在家里;
· 「删除」= 真的把这个人删掉(账号与亲属关系一起走,不可恢复)。
所以它必须是显式的红色按钮 + 二次确认,不能收进 ⋯ 里。
没有删除权限(非管理员)时不画,并给出原因提示。 */ ?>
<?php if (\App\Auth::isAdmin()): ?>
<button type="button" class="btn btn-danger btn-sm person-del"
data-pd-open="<?= $pid ?>" data-pd-name="<?= e($pshow) ?>"
title="删除 <?= e($pshow) ?>">删除</button>
<?php endif; ?>
<?php if ($canMove): ?>
<button type="button" class="btn btn-ghost btn-sm person-more-btn"
data-pm-open="<?= $pid ?>"
@@ -317,26 +329,65 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
⚠️ 表单里留了原生「保存」按钮:即使 JS 出错,用户手动点它也能提交,
不会掉进"按钮点了没反应"的黑洞。 */ ?>
<?php if ($canMove): ?>
<div class="roster-menus" id="rosterMenus" hidden>
<div class="rm-panel" id="rmGroup" hidden>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「名册的分组功能,点击一次之后,再次点击就失效了,
应该**弹出一个框,就像选择观影人一样**,选择分组。」
所以分组从"贴在按钮下方的浮层"改成**居中弹框 + 遮罩**(与选人弹窗同一形态)。
⚠️ "第二次点击失效"的根因:旧实现把浮层贴在按钮下方,并在**捕获阶段**监听
文档点击来"点外面就关"——于是点第二次时,mousedown 先被关闭逻辑吃掉、
随后的 click 又把面板打开,视觉上就是"闪一下没反应/要用第三次才行"。
改成居中的模态框后,外部点击只关模态本身,触发按钮的语义不再冲突。 */ ?>
<div class="rm-modal" id="rosterMenus" hidden>
<div class="rm-mask" data-rm-close></div>
<div class="rm-panel" id="rmGroup" role="dialog" aria-modal="true" aria-label="选择分组" hidden>
<div class="rm-head"><b>把「<span class="pg-who"></span>」分到</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<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): ?>
<div class="rm-body">
<label class="pg-item">
<input type="radio" name="group_id" value="<?= (int)$g['id'] ?>">
<span><?= e((string)$g['name']) ?></span>
<input type="radio" name="group_id" value="">
<span>未分类</span>
</label>
<?php endforeach; ?>
<button class="btn btn-sm pg-save" type="submit">保存</button>
<?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; ?>
</div>
<div class="rm-foot">
<button class="btn" type="submit">保存</button>
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
</div>
</form>
</div>
<div class="rm-panel" id="rmMore" hidden>
<div class="rm-panel" id="rmDel" role="dialog" aria-modal="true" aria-label="删除成员" hidden>
<div class="rm-head"><b>删除「<span class="pd-who"></span>」</b>
<button type="button" class="rm-x" data-rm-close aria-label="关闭">✕</button></div>
<?php /* ⚠️ 路由是 people/destroy(与详情页底部那个删除同一个)——
不要写成 people/delete,那会 404 且看起来像"点了没反应"。 */ ?>
<form method="post" action="<?= e(url('people/destroy')) ?>" class="pd-form">
<?= csrf_field() ?>
<input type="hidden" name="id" value="">
<div class="rm-body">
<p class="rm-warn">
会把他从名册中删除,<b>并且</b>删掉他的登录账号与全部亲属关系连线。
<b>不可恢复。</b>
</p>
<p class="small muted" style="margin:8px 0 0">
只是"不想在名册里看到他"?那用卡片上的「⋯ → 从我的名册移走」,人还留在家里。
</p>
</div>
<div class="rm-foot">
<button class="btn btn-danger" type="submit">确认删除</button>
<button class="btn btn-ghost" type="button" data-rm-close>取消</button>
</div>
</form>
</div>
<div class="rm-panel" id="rmMore" role="dialog" aria-modal="true" aria-label="更多操作" hidden>
<p class="small muted person-move is-kin" hidden style="margin:0 0 6px">
因家谱关系自动加入;解除关系后自动移出。
</p>
@@ -371,91 +422,96 @@ $renderBlock = function (int $gid, string $name, array $list) use ($renderGrid):
syncClear();
})();
<?php /* ==================== 共享浮层的交互(v24 第 10 批 #6)====================
一行只留一个按钮,真正的表单在 #rosterMenus 里(全页一份)。
这里负责:点按钮 → 把该行的数据填进共享表单 → 浮层贴着那个按钮弹出来。 */ ?>
<?php /* ==================== 卡片动作弹框的交互(v24 第 11 批 #2 重写)====================
一行只留按钮,真正的表单在 #rosterMenus 里(全页一份,见前面的说明)。
这一版把"贴按钮的浮层"换成**居中模态框**(与选人弹窗同形态)—— 用户明确要求
「应该弹出一个框,就像选择观影人一样」。
⚠️ 为什么旧版"点第二次会失效":
旧实现在**捕获阶段**监听 document 的 click,只要目标不在浮层里就 closeAll()。
点第二次时事件顺序是 mousedown → (捕获阶段 closeAll)→ click(又打开)→ …
于是按钮像是"时灵时不灵"。模态框把"关闭"收敛到遮罩/关闭键/取消键上,
按钮自己只负责"打开",语义不再打架。
⚠️ 外部点击用**冒泡**阶段监听,且只认遮罩本身(data-rm-close),
不用"点哪儿都关"那种一刀切 —— 那正是上一版出问题的写法。 */ ?>
(function () {
var wrap = document.getElementById('rosterMenus');
if (!wrap) { return; }
var gPanel = document.getElementById('rmGroup');
var dPanel = document.getElementById('rmDel');
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;
[gPanel, dPanel, mPanel].forEach(function (p) { if (p) { p.hidden = true; } });
}
/* 浮层贴到触发按钮下方;贴太靠右就向左收,贴太靠下就翻到上方 */
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';
function open(panel) {
[gPanel, dPanel, mPanel].forEach(function (p) { if (p && p !== panel) { p.hidden = true; } });
panel.hidden = false;
wrap.hidden = false;
// 焦点进面板:点开就能用键盘选(Esc 也能关,见下)
var first = panel.querySelector('input:not([type=hidden]), button');
if (first) { first.focus(); }
}
// ---- 打开:分组 ----
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;
form.querySelector('input[name="person_id"]').value = gb.getAttribute('data-pg-open');
gPanel.querySelector('.pg-who').textContent = gb.getAttribute('data-pg-name') || '';
// 预选当前分组
// 预选当前分组(点开就知道他现在在哪一栏)
var cur = parseInt(gb.getAttribute('data-pg-cur') || '0', 10) || 0;
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;
open(gPanel);
return;
}
// ---- ⋯ ----
// ---- 打开:删除 ----
var db = t.closest('[data-pd-open]');
if (db) {
e.preventDefault();
dPanel.querySelector('form input[name="id"]').value = db.getAttribute('data-pd-open');
dPanel.querySelector('.pd-who').textContent = db.getAttribute('data-pd-name') || '';
open(dPanel);
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;
mPanel.querySelector('form.is-self input[name="person_id"]').value =
mb.getAttribute('data-pm-open');
}
gPanel.hidden = true;
mPanel.hidden = false;
wrap.hidden = false;
place(mPanel, mb);
openFor = pid2;
open(mPanel);
return;
}
// 点浮层内部 → 不动
if (wrap.contains(t)) { return; }
closeAll();
}, true);
// ---- 关闭:只有遮罩 / ✕ / 取消 会关(不做"点哪儿都关")----
if (t.closest('[data-rm-close]')) {
e.preventDefault();
closeAll();
}
});
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; }
+94 -29
View File
@@ -67,24 +67,53 @@ $shortKind = \App\Birthday::KINDS;
<p class="motto-line">“<?= e((string)$p['slogan']) ?>”</p>
<?php endif; ?>
<div class="detail">
<aside class="detail-photo">
<?php /* 用户 2026-10-04 第 11 批 #3 原话:「名册详情页面,需要修改。现在左侧是一个头像,
下面都是空白的,太浪费空间了。应该这样,左上角是头像,头像右侧开始排放姓名什么的基本信息,
然后就是其他信息开始往下顺序排放。」
⚠️ 旧布局是 `.detail { grid-template-columns: 260px 1fr }` 两栏:
左栏(头像卡)只有 ~330px 高,右栏好几张卡片 → **头像下面空出一大片**。
现在改成:
· 顶部一张「名片卡」:头像(左上)+ 右侧姓名/称呼/性别/账号身份等基本信息;
· 名片卡**下方**,其余各块全宽依次往下排。
这样无论信息多少都不会留白。 */ ?>
<div class="pcard">
<div class="pcard-avatar">
<img src="<?= e(avatar_url($p['avatar'])) ?>" alt="">
<div class="name"><?= e($pShown) ?></div>
<?php if ($isMyNick): ?><div class="sub">本名 <?= e((string)$p['name']) ?></div><?php endif; ?>
<div class="sub">
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
家庭管理员
<?php elseif ($role === 'guest'): ?>
访客(只读)
<?php elseif (!empty($p['account_id'])): ?>
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
<?php else: ?>
还没有开通登录
</div>
<div class="pcard-main">
<div class="pcard-name">
<?= e($pShown) ?>
<?php if ($isMyNick): ?><span class="pcard-real">本名 <?= e((string)$p['name']) ?></span><?php endif; ?>
<?php if (!empty($p['is_placeholder'])): ?><span class="badge">虚拟人物</span><?php endif; ?>
</div>
<div class="pcard-meta">
<?php if (!$isSelf && (string)$labelToMe !== ''): ?>
<span class="rel-label"><?= e($labelToMe) ?></span>
<?php endif; ?>
<span class="pcard-chip">
<?php if ($role === 'admin' && $p['account_status'] === 'active'): ?>
<b>家庭管理员</b>
<?php elseif ($role === 'guest'): ?>
访客(只读)
<?php elseif (!empty($p['account_id'])): ?>
已开通登录<?= $p['account_status'] === 'disabled' ? '(已停用)' : '' ?>
<?php else: ?>
还没有开通登录
<?php endif; ?>
</span>
<span class="pcard-chip"><?= e(\App\Person::genderWord((string)($p['gender'] ?? '')) ?: '性别未填') ?></span>
<?php if (!empty($p['birth_rank'])): ?>
<span class="pcard-chip">第 <?= (int)$p['birth_rank'] ?> 个孩子</span>
<?php endif; ?>
<?php if ($age !== null): ?>
<span class="pcard-chip"><?= (int)$age ?> 岁</span>
<?php endif; ?>
</div>
</aside>
</div>
</div>
<div class="detail-stack">
<div>
<div class="card card-pad">
<dl class="kv">
@@ -593,7 +622,7 @@ $shortKind = \App\Birthday::KINDS;
</div>
<?php endif; ?>
</div>
</div>
</div><?php /* /detail-stack —— 名片卡下方的全宽堆叠到此结束 */ ?>
<?php /* ==================== 危险操作:删除(用户 2026-10-03 第 2 条)====================
「删除」从右上角搬到了页面**最下方**、「开通登录账号」下面。
@@ -602,26 +631,62 @@ $shortKind = \App\Birthday::KINDS;
原来那句"从名册里移除"听着像"只是从我的名册里移走",
实际会删账号与亲属关系,说法与后果不符。 */ ?>
<?php if ($admin): ?>
<?php /* 用户 2026-10-04 第 11 批 #2 原话:「个人详情页,最下面的删除,竟然是个链接,
应该是个按钮,跟开通账号一样,是个大按钮,只不过背景用红色。」
→ 从 <details> + 文字链接 + 小按钮,改成**与「开通登录账号」同规格的大按钮**,
只把底色换成红色。确认仍保留(破坏性操作),但改成弹框式的二次确认,
不再用浏览器原生 confirm(那玩意儿在不同浏览器里长相差异大)。 */ ?>
<div class="danger-zone">
<details class="del-wrap">
<summary class="link-quiet">删除这个人…</summary>
<div class="del-confirm">
<p class="small muted" style="margin:0 0 8px;flex:1 1 100%">
会把他从名册中删除,**并且**删掉他的登录账号与全部亲属关系连线(不可恢复)。
如果你只是想让他从<b>你的</b>名册里消失,用名册列表上的「⋯ → 从我的名册移走」。
</p>
<form method="post" action="<?= e(url('people/destroy')) ?>"
onsubmit="return confirm('确定要删除 <?= e($p['name']) ?> 吗?账号与亲属关系会一起删掉,无法恢复。')">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $pid ?>">
<button class="btn btn-danger btn-sm" type="submit">确认删除</button>
</form>
<div class="del-big">
<p class="del-big-tip">
删除会把他从名册中移除,<b>并且</b>删掉登录账号与全部亲属关系连线,<b>不可恢复</b>。<br>
如果你只是想让他从<b>你的</b>名册里消失,用名册列表上的「⋯ → 从我的名册移走」。
</p>
<button type="button" class="btn btn-danger btn-block-lg" id="delBigBtn">
删除 <?= e((string)$p['name']) ?>
</button>
<form method="post" action="<?= e(url('people/destroy')) ?>" id="delBigForm" hidden>
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= $pid ?>">
</form>
<div class="del-big-confirm" id="delBigConfirm" hidden>
<span>确定删除?此操作无法撤销。</span>
<button class="btn btn-danger" type="button" id="delBigYes">确认删除</button>
<button class="btn btn-ghost" type="button" id="delBigNo">取消</button>
</div>
</details>
</div>
</div>
<?php endif; ?>
<script>
/* 大删除按钮的两步确认(用户第 11 批 #2):第一次点露出"确认删除/取消",
第二次点才真的提交。比原生 confirm 可控,也与页面其它确认长得一致。 */
(function () {
var btn = document.getElementById('delBigBtn');
var box = document.getElementById('delBigConfirm');
var form = document.getElementById('delBigForm');
if (!btn || !box || !form) { return; }
btn.addEventListener('click', function () {
box.hidden = false;
btn.hidden = true;
});
var no = document.getElementById('delBigNo');
if (no) {
no.addEventListener('click', function () {
box.hidden = true;
btn.hidden = false;
});
}
var yes = document.getElementById('delBigYes');
if (yes) {
yes.addEventListener('click', function () {
yes.disabled = true;
yes.textContent = '正在删除…';
form.submit();
});
}
})();
// 关系表单的「是哪一位」下拉有**两种含义**:
// kind=parent → 对方是父亲/母亲(填的是对方)
// kind=child → 我是孩子的父亲/母亲(填的是自己)