Files
FamilyBook/templates/days/index.php
T
laoyang 8c651b43eb v43 第 43 批:六条反馈(界面 5 + 条目详情浮层)
用户 2026-10-11 反馈六条,逐条定位真根因后修复:

① 纪念日「日期」多出一个控件 —— 只保留最新的那个
   两层根因:(a)原生 `input[type=date]` 当初没包在 `.dt-native` 里,
   v42 那条"AntD 挂上后收起原生框"的选择器根本不命中;(b)更要命的是
   `.dt-native` 基类写着 `display: contents` —— 该元素**没有盒子**,
   给它 position/width 完全无效 ⇒ 必须把 display 显式改回 block 才收得起来。

② 编辑纪念日把底层列表页弄没 —— 「修改」原来是整页跳 `days/edit`
   (套 layout = 一张只有弹框的空白页)。改为 `?embed=1` 只吐弹框那一块,
   列表页 JS 取回来替换 `#anNewCard` 并打开 —— 与「新建」同一观感。
   内嵌形态标 `$anModalOnly` 以免打 standalone 标记(关闭时不再跳页)。

③ 拖图裁剪的层被弹框盖住 —— `.crop-overlay` 是 z-index:100 而 `.cal-modal`
   是 200 ⇒ 裁剪层被整块盖住。提到 260(与 v41 的 .cn-modal 同一处理)。

④ 行程弹框里"参与的人"弹不出选人框 —— person_picker.js 只绑定一次,
   而弹框表单是 AJAX 片段 ⇒ 里面的 `.pp` 从未被 init。三件事一起:
   暴露 `window.fbInitPickers()`(幂等)+ layout 暴露 `window.fbLoadPicker()`
   (脚本是按需加载的)+ 塞完片段后补调初始化。

⑤ 日程新建排版/控件/文案 —— 改走观影式 `.mv-grid`(标签对齐 + 冒号);
   起止补上 `$dtPlugin='antd'`(试点正式推广到日程);标签「时间范围」→「开始 / 结束」;
   删三句提示(结束留空 / 多选提醒 / 全天)。

⑥ 条目详情浮层(首页日程 / 首页近期任务 / 日程页条目)——
   点条目不再直接跳页,弹浮层显示详情;「详细信息」跳该来源详情页;
   「编辑」就地换成该来源的编辑表单(四处 `?embed=1` 片段)。
   新增 `templates/_entry_popup.php` + `assets/js/entry_popup.js` +
   `fb_entry_json()`(三个入口共用一个编码函数)。

验证:php -l 157 文件全过;div 深度 33 组合全配平;片段体检 20 片段无越界;
本地 e2e 1510/0;v43 真机 33/0;v43 反向验证 8/0;v38 36/0;v42 33/0;
b31/b32 反向验证 8/0 与 5/0;部署断言本地空跑 RC=0;完整流水线 PUSH_RC=0。
2026-10-12 00:24:20 +08:00

356 lines
16 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 用户第 13 条;2026-10-03 第 9 条重排;**2026-10-07 第 2 条加「关联人」列**)
*
* 第 9 条原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。在新建纪念日和右侧,也增加设置按钮,
* 可以设置纪念日的分类。」
*
* 第 2 条原话:「纪念日模块,每个纪念日现在只能关联一个人,应该可以关联多个人。
* 和选择观影人一样,多选模式。自己添加的纪念日,右侧按钮,和其他模块的按钮
* 样式保持一致,修改用蓝色,删除用黄色。再加一列,关联人。列表中,
* 人的前面,都加头像。」
*
* ⇒ 三块(顺序按用户第 9 条要求):
* ① 接下来的日子(未来 60 天)
* ② **手动添加的**纪念日(在上)—— 可改可删;右侧按钮:改=蓝(.btn)、删=黄(.btn-warn)
* ③ 来自名册生日的(在下,实时派生、不能改删)
*
* @param array $custom 用户自建的(行上带 person_ids)
* @param array $derived 从名册生日派生的(行上也有 person_ids)
* @param array $upcoming 接下来 60 天的
* @param array $kinds 分类清单(设置里维护)
* @param array $people 名册(把 person id 换成头像 + 称呼)
*/
$canWrite = \App\Auth::canWrite();
$kindBadge = static function (array $r): string {
$k = (string)($r['kind'] ?? '');
return $k === '' ? '' : '<span class="node-badge">' . e($k) . '</span>';
};
$srcWord = static function (array $r): string {
$src = (string)($r['source'] ?? 'custom');
if ($src === 'birthday_solar') {
return '名册生日(阳历)';
}
if ($src === 'birthday_lunar') {
return '名册生日(农历)';
}
return \App\Anniversary::repeatWord((string)($r['repeat_type'] ?? 'solar'));
};
/* 关联人(用户第 2 条:「再加一列,关联人。列表中,人的前面,都加头像。」)
—— 用全站统一的 person_chip()(它自带头像 + 我对他/她的称呼 + 本人底色)。
⚠️ chips 一律**不传 href**:这一格在一张表格里,`<a>` 套 `<a>` 是非法 HTML,
浏览器会把外层结构切开(person_chip 的注释里记过这个坑)。
⚠️ $peopleMap 由路由层传进来(一次查全,避免逐行查库);缺失时退化成"有几个显示几个"。 */
$peopleMap = $peopleMap ?? [];
$peopleCell = static function (array $r) use ($peopleMap): string {
$ids = (array)($r['person_ids'] ?? []);
if (!$ids) {
return '<span class="small muted">—</span>';
}
$out = '';
$n = 0;
foreach ($ids as $pid) {
$pid = (int)$pid;
$p = $peopleMap[$pid] ?? null;
if ($p === null) {
continue;
}
// 一个人时正常画;多人时最多画 4 个 + 「+N」,免得一行被撑爆
if ($n >= 4) {
$rest = count($ids) - 4;
$out .= '<span class="node-badge" title="还有 ' . $rest . ' 人">+' . $rest . '</span>';
break;
}
$out .= person_chip($p);
$n++;
}
return $out === '' ? '<span class="small muted">—</span>' : $out;
};
/* 画一张纪念日表。两块(手动 / 派生)共用同一个渲染 —— 复制一遍迟早改漏一处。 */
$renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge, $srcWord, $peopleCell): void {
if (!$rows) {
return;
}
?>
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>日期</th><th>纪念日</th><th>关联人</th><th>分类</th><th>重复</th><th></th></tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td class="nowrap"><?= (int)$r['month'] ?> 月 <?= (int)$r['day'] ?> 日</td>
<td>
<?= e((string)$r['title']) ?>
<?php if (!empty($r['is_leap'])): ?><span class="small muted">(闰月)</span><?php endif; ?>
</td>
<td class="ann-people"><?= $peopleCell($r) ?></td>
<td><?= $kindBadge($r) ?></td>
<td class="small muted"><?= e($srcWord($r)) ?></td>
<td class="nowrap ann-acts">
<?php if ($editable && $canWrite): ?>
<?php /* 用户第 2 条:「右侧按钮,和其他模块的按钮样式保持一致,
修改用蓝色,删除用黄色。」
→ 改 = `.btn`(主按钮的**蓝**),删 = `.btn-warn`(**黄**实心)。
⚠️ 原来用的是 `.link-quiet`(纯文字链接)+ `.btn-ghost`(白底描边),
两者都"看不出来是什么动作",与观影/旅程行上那套不一致。 */ ?>
<?php /* 🔴 v43(用户 2026-10-11 第 2 条):「点击"修改纪念日",
怎么好像原来的页面全没了?就是下面那一层页面没了,
这好像不太合理吧?」
⇒ 「修改」不再整页跳 `days/edit`,改成**在列表页之上开同一个弹框**
(与「新建」完全一致的观感)。
⚠️ 保留 `data-href` + `href`:JS 没跑时它仍是可直达的链接
(无 JS 兜底,行为等同旧版整页编辑)。 */
?>
<a class="btn btn-sm" data-an-edit
data-href="<?= e(url('days/edit', ['id' => (int)$r['id'], 'embed' => 1])) ?>"
href="<?= e(url('days/edit', ['id' => (int)$r['id']])) ?>">修改</a>
<form method="post" action="<?= e(url('days/destroy')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="id" value="<?= (int)$r['id'] ?>">
<button class="btn btn-warn btn-sm" type="submit"
onclick="return confirm('删掉这条纪念日?')">删除</button>
</form>
<?php else: ?>
<span class="small muted">来自名册</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
};
?>
<div class="page-head">
<h1>纪念日</h1>
<span class="sub">生日本身就是一种纪念日 · 阳历与农历都能记</span>
<div class="actions">
<?php if ($canWrite): ?>
<?php /* 🔴 v41(用户 2026-10-10 第 10 条):「新建纪念日,采用弹框形式,
排版参考观影模块。」⇒ 改成按钮 + 弹框(原来是跳 `days/new` 页)。
⚠️ 保留 data-href:JS 没跑时它仍是可直接访问的链接。 */ ?>
<button class="btn" type="button" id="anNewBtn"
data-href="<?= e(url('days/new')) ?>"
aria-haspopup="dialog" aria-expanded="false">新建纪念日</button>
<?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a>
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右) */ ?>
<?php $setCurrent = 'kinds'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php if ($canWrite): ?>
<?php /* ---- 新建纪念日弹框(内嵌同一份 form.php;初始关闭)---- */ ?>
<?php
$anModalOnly = true;
$anOpen = false;
$anModal = true;
$anEmbed = false;
$anShell = true;
require __DIR__ . '/form.php';
unset($anModalOnly, $anOpen, $anModal, $anEmbed, $anShell);
?>
<script>
/* ==================== v41:新建纪念日弹框(事件委托 + AJAX)====================
与观影/旅程同一套做法与理由(详见 _tooling 记忆):
· 事件委托(挂 document)—— 校验失败时服务端会把弹框整块重画,
旧元素连监听一起丢;委托一次绑定永久有效。
· 提交主路走 click(表单外的提交按钮上 document 的 submit 监听收不到)。
· 成功后只换 #anListHost,不整页跳。 */
(function () {
if (!window.fetch || !window.FormData) { return; }
function card() { return document.getElementById('anNewCard'); }
function setOpen(on) {
var c = card();
if (!c) { return; }
c.hidden = !on;
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', on ? 'true' : 'false'); }
if (on) {
if (window.fbUpgradeRange) { window.fbUpgradeRange(); }
if (window.fbBootAntd) { window.fbBootAntd(); }
var t = document.getElementById('an-title');
if (t) { t.focus(); }
}
}
/* 🔴 v43(用户 2026-10-11 第 2 条):编辑也走这个弹框。
把 `?embed=1` 吐回来的那块(**含弹框壳**)替换掉当前 `#anNewCard`,
于是"编辑"与"新建"在视觉与交互上是同一个弹框,底层列表页不动。
⚠️ 替换后必须**重跑片段自带的内联脚本** —— `innerHTML` 不执行脚本
(v42 踩过的坑,`<script>` 既不执行也连标签都不保留)。
那两段(关闭自身 / 日期拆分)都要重跑;它们各自有幂等判断,
重跑不会重复绑定。 */
function runInlineScripts(container, html) {
var re = /<script\b[^>]*>([\s\S]*?)<\/script>/gi, m, code = '';
while ((m = re.exec(html)) !== null) {
if (!/\bsrc\s*=/i.test(m[0].split('>')[0])) { code += m[1] + '\n;\n'; }
}
if (!code) { return; }
var s = document.createElement('script');
s.textContent = code;
(container || document.body).appendChild(s);
s.parentNode.removeChild(s);
}
function loadEdit(url) {
var c = card();
if (!c || !window.fetch) { if (url) { window.location.href = url; } return; }
c.hidden = false; // 先打开(显示"载入中"占位)
var lb = c.querySelector('#anNewCardTitle, .cal-modal-head b');
if (lb) { lb.textContent = '编辑纪念日'; }
fetch(url, { credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); })
.then(function (html) {
var tmp = document.createElement('div');
tmp.innerHTML = html;
var fresh = tmp.querySelector('#anNewCard');
if (!fresh) { window.location.href = url.replace(/[?&]embed=1/, ''); return; }
c.parentNode.replaceChild(fresh, c);
runInlineScripts(fresh, html);
setOpen(true);
})
.catch(function () { window.location.href = url.replace(/[?&]embed=1/, ''); });
}
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
if (t.closest('#anNewBtn')) { e.preventDefault(); setOpen(true); return; }
var ed = t.closest('[data-an-edit]');
if (ed) {
e.preventDefault();
loadEdit(ed.getAttribute('data-href') || '');
return;
}
if (t.closest('#anNewX') || t.closest('#anNewCancel')) { setOpen(false); return; }
var c = card();
if (c && t === c) { setOpen(false); }
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') { return; }
var c = card();
if (!c || c.hidden) { return; }
var d = document.querySelector('.ant-picker-dropdown');
if (d && !d.classList.contains('ant-picker-dropdown-hidden')) { return; }
setOpen(false);
});
function save() {
var form = document.getElementById('anForm');
if (!form) { return; }
var s = document.getElementById('anNewSubmit');
if (s) { s.disabled = true; s.textContent = '保存中…'; }
fetch(form.action, {
method: 'POST', body: new FormData(form),
headers: { 'X-Requested-With': 'XMLHttpRequest' },
credentials: 'same-origin',
}).then(function (r) {
var ct = (r.headers.get('content-type') || '');
if (ct.indexOf('application/json') >= 0) {
return r.json().then(function (j) {
var old = card();
if (!old || !j || !j.modal) { return; }
var tmp = document.createElement('div');
tmp.innerHTML = j.modal;
var fresh = tmp.querySelector('#anNewCard');
if (!fresh) { return; }
old.parentNode.replaceChild(fresh, old);
setOpen(true);
});
}
return r.text().then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
var fresh = doc.getElementById('anListHost');
var cur = document.getElementById('anListHost');
if (fresh && cur) { cur.innerHTML = fresh.innerHTML; }
else { window.location.reload(); return; }
var c = card();
if (c) { c.hidden = true; }
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', 'false'); }
});
}).catch(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
form.submit();
}).then(function () {
if (s) { s.disabled = false; s.textContent = '确定'; }
});
}
document.addEventListener('click', function (ev) {
var t = ev.target;
if (!t || !t.closest || !t.closest('#anNewSubmit')) { return; }
ev.preventDefault();
save();
});
document.addEventListener('submit', function (ev) {
var f = ev.target;
if (!f || f.id !== 'anForm') { return; }
ev.preventDefault();
save();
});
})();
</script>
<?php endif; ?>
<?php /* 🔴 v41:列表内容包一层 `#anListHost` —— 弹框里保存成功后前端只换这一块。 */ ?>
<div id="anListHost">
<div class="card card-pad">
<h2 class="cinema-h2">接下来的日子</h2>
<?php if (!$upcoming): ?>
<p class="empty-hint">未来 60 天里没有纪念日。</p>
<?php else: ?>
<ul class="ann-up">
<?php foreach ($upcoming as $u): ?>
<li class="ann-up-item">
<span class="ann-days"><?= (int)$u['days'] === 0 ? '今天' : ((int)$u['days'] . ' 天后') ?></span>
<span class="ann-date"><?= e(substr((string)$u['date'], 5)) ?></span>
<span class="ann-title"><?= e((string)$u['title']) ?></span>
<?php if ($u['age'] !== null): ?><span class="small muted"><?= (int)$u['age'] ?> 岁</span><?php endif; ?>
<?= $kindBadge($u) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php /* ==================== ② 手动添加的(在上,用户第 9 条)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
我自己添加的(<?= count($custom) ?>)
<span class="hint">只有这些能改能删</span>
</h2>
<?php if (!$custom): ?>
<p class="empty-hint">
还没有自己添加的纪念日。
<?php if ($canWrite): ?>
结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。
<?php endif; ?>
</p>
<?php else: ?>
<?php $renderTable($custom, true); ?>
<?php endif; ?>
</div>
<?php /* ==================== ③ 来自名册的生日(在下)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
来自名册的(<?= count($derived) ?>)
<span class="hint">实时读出来的,改生日这里跟着变</span>
</h2>
<?php if (!$derived): ?>
<p class="empty-hint">
名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。
</p>
<?php else: ?>
<?php $renderTable($derived, false); ?>
<p class="tip" style="margin-top:10px">
这一块是**实时派生**的 —— 改生日,这里跟着变,不用重复维护。
不需要在这里新建,也没法单独删(删掉生日就等于删了它)。
</p>
<?php endif; ?>
</div>
</div><?php /* /#anListHost */ ?>