Files
laoyang 9a66068c33 v62:纪念日列表修正 / 分类图标与设置菜单 / 首页卡片体系
- 纪念日列表:display:flex 从 td 移到内层 div(写在 td 上会破坏表格列算法,
  表现为关联人列与其它列高度不一致);行内列序按表头对齐;
  改完再点「新建」不再带出上次修改的数据;文案改为「与我相关」「来自名册」;
  别人记的、把我挂成关联人的条目一并显示(split() 返回 hiddenIds,
  路由层据此过滤「接下来的日子」,与两张表同一套可见性)
- 分类图标:新增 app/AnnivIcon.php(17 个行内 SVG 图标 + 同 key 的 emoji
  供日历纯文本处使用,内网无 CDN 不用 FontAwesome);去掉「看日历」按钮;
  设置按钮改为菜单,第一项进 days/settings 独立页;分类可自选图标,
  列表徽标与日历条目都按分类显示
- 首页卡片:新增 app/HomeCards.php 与 templates/home_cards.php;
  8 格宽 × 高 2/4/6/8;首页拆成 templates/home/_card_*.php 共 8 个片段;
  home/cards 可改尺寸、调顺序、开关
2026-10-19 12:51:17 +08:00

459 lines
24 KiB
PHP
Raw Permalink 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)
* ③ 来自名册的生日(在下,实时派生、不能改删)
*
* 🔴 v62(用户 2026-10-18 第 1、2 条)改了四处:
* ① 两块标题:「我自己添加的」→「与我相关」、「来自名册的」→「来自名册」,
* 并**删掉**那句"这一块是实时派生的…"的说明;
* ② 「关于我相关」这一块要收进"别人添加、但把我也挂成关联人"的条目
* (判据见 `Anniversary::split()`);
* ③ 「看日历」按钮去掉(日历在顶栏就有,页面里再放一个只是多一个入口);
* ④ 分类徽标带图标(`AnnivIcon`),日历里用同一图标的 emoji 形态。
*
* @param array $custom 与我相关的(行上带 person_ids)
* @param array $derived 从名册生日派生的(行上也有 person_ids)
* @param int $hidden 别人记的、与我无关的条数(只用于一句小字说明)
* @param array $upcoming 接下来 60 天的
* @param array $kinds 分类清单(设置里维护)
* @param array $people 名册(把 person id 换成头像 + 称呼)
*/
$canWrite = \App\Auth::canWrite();
$famId = (int)\App\Auth::familyId();
/* 分类徽标:图标 + 名字(v62 第 2 条「列表中…要根据分类,显示不同的图标」)。
⚠️ 图标走 `Anniversary::iconOf()`(单点来源)—— 不要在模板里再写一遍
"生日用蛋糕、忌日用蜡烛"的映射,否则设置页改了图标这里不跟着变。 */
$kindBadge = static function (array $r) use ($famId): string {
$k = (string)($r['kind'] ?? '');
if ($k === '') {
return '';
}
return '<span class="node-badge kind-badge">'
. \App\AnnivIcon::svg(\App\Anniversary::iconOf($famId, $k), 13, 'kind-ic')
. '<span class="kind-name">' . e($k) . '</span></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; ?>
<?php /* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,
这样才能知道**是第几个纪念日**。」
⚠️ 这一列显示的是**用户填的起始年**与算出来的序号 ——
只在填了年份时才有(NULL 就说"没记年份",不猜、不显示序号)。
⚠️ 序号按**今天所在的年**算("今年是第几个")——
用户要的是这个直觉;不去按"下一次发生的年份"算,
否则一个 12 月的纪念日在 1 月看会显示成"第 N+1 个",反而怪。 */ ?>
<?php if (($r['year'] ?? null) !== null): ?>
<?php /* ⚠️ 序号走**同一把尺**(`Anniversary::nthFor`)——
不要在模板里再写一遍 `date('Y') - 年 + 1`:
两处各算一次,改一处忘一处就会让两个页面显示的序号对不上。 */ ?>
<?php $nthNow = \App\Anniversary::nthFor((int)$r['year']); ?>
<span class="small muted ann-year" title="起始年份"><?= (int)$r['year'] ?> 年起</span>
<?php if ($nthNow !== null): ?>
<span class="small muted ann-nth">第 <?= (int)$nthNow ?> 个</span>
<?php endif; ?>
<?php endif; ?>
</td>
<?php /* 🔴🔴 v62(用户 2026-10-18 第 1 条):「我自己添加的部分,现在显示有错位,
好像关联人和其他列,高度不一样。」
⚠️ 这里有**两个**独立的错位,别只修一个:
① 高度错位(已修):这两格原来把 `display:flex` 直接写在 **`<td>`** 上
(`.ann-people` / `.ann-acts`)。`display` 一改成 flex,这个元素
**就不再是 table-cell** —— 它从表格的列算法里脱了出来,
于是整行的列宽/基线全乱(同一行里有的格按表格排、有的格按 flex 排)。
⇒ `<td>` 恢复成普通单元格,flex 放到**里面一层 div** 上。
② **内容错位(本次修)**:表头写的是「关联人 | 分类 | 重复」,
但行里依次塞的是「分类 | 重复 | 关联人」——
三列的内容整体往左挪了一格,用户看到的"关联人"那一栏里其实是分类。
⇒ **表头保持原顺序不动**(「关联人」在「分类」前是用户当初指定的),
把**行里的顺序**改成跟表头一致:
日期 | 纪念日 | **关联人** | **分类** | 重复。 */ ?>
<td><div class="ann-people"><?= $peopleCell($r) ?></div></td>
<td><?= $kindBadge($r) ?></td>
<td class="small muted"><?= e($srcWord($r)) ?></td>
<td class="nowrap"><div class="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; ?>
</div></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; ?>
<?php /* 🔴 v62(用户 2026-10-18 第 2 条):「纪念日,看日历这个按钮去掉。」
理由(用户没说的那一半):日历在**顶栏**上一直有,这一页再放一个
"看日历"只是同一件事的第二入口,还占掉了本该给「设置」的位置。
⚠️ 不要再"改个名字留着" —— 用户说的是去掉。 */ ?>
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右)。
v62:点开是**菜单**,第一项「分类设置」(可给每个分类配图标)。 */ ?>
<?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);
}
/* 🔴🔴 v62(用户 2026-10-18 第 1 条):「如果我修改的一个纪念日,然后再点击新建纪念日,
竟然把我刚才修改的纪念日带进来了,其实不是新建,而是修改。」
真根因:**「修改」和「新建」共用同一个 `#anNewCard`**(v43 那轮为了"编辑也开弹框"
刻意做的)。而「修改」是把 `days/edit?embed=1` 吐回来的那块**替换**掉当前的卡片 ——
替换之后,卡片里已经躺着一份带 `input[name=id]` 的编辑表单。
这时点「新建」,旧代码只做了一件事:`setOpen(true)`(把同一张卡片显示出来),
于是屏幕上就是"刚才那条被编辑的纪念日",按确定也仍然是**更新**而不是新增。
⇒ 修法:开「新建」之前先看这张卡片**是不是还停在"改过/正在改"的状态**:
· 有 `input[name=id]` → 这是编辑表单,必须换回新建表单;
· 或者标题框里已经有内容(上次新建填了一半又关掉、或刚保存过一条)→ 同样换新,
否则"新建"会带出上一次输了一半的内容,是同一类错觉。
⇒ 换新的方式与「修改」完全对称:fetch `days/new`(内嵌片段)替换卡片。
⚠️ 只有"脏"的时候才发这一趟请求 —— 干净的弹框直接开,不多一次网络往返。 */
function needsFresh() {
var c = card();
if (!c) { return false; }
if (c.querySelector('#anForm input[name="id"]')) { return true; }
var t = c.querySelector('#an-title');
return !!(t && String(t.value || '').trim() !== '');
}
function loadForm(url, title) {
var c = card();
if (!c || !window.fetch || !url) { if (url) { window.location.href = url; } return; }
c.hidden = false; // 先打开(显示"载入中"占位)
var lb = c.querySelector('#anNewCardTitle, .cal-modal-head b');
if (lb) { lb.textContent = title; }
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);
/* 换了新片段之后,标题框里是空的 ⇒ 后面几次开合都不会再发请求 */
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', '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();
var btn = t.closest('#anNewBtn');
if (needsFresh()) {
loadForm(btn.getAttribute('data-href') || '', '新建纪念日');
} else {
setOpen(true);
}
return;
}
var ed = t.closest('[data-an-edit]');
if (ed) {
e.preventDefault();
loadForm(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; ?>
<?php /* 🔴 v53(用户 2026-10-16 第 1 条):「纪念日需要记录年份,
这样才能知道**是第几个纪念日**。」
⚠️ 只在**用户填了起始年份**时才有 `nth`(派生的生日条目走"N 岁"那一套,
不能两样都显示 —— 那会变成"30 岁 · 第 30 个",重复且啰嗦)。 */ ?>
<?php if (($u['nth'] ?? null) !== null): ?>
<span class="small muted ann-nth">第 <?= (int)$u['nth'] ?> 个</span>
<?php endif; ?>
<?= $kindBadge($u) ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php /* ==================== ② 与我相关的(在上,用户第 9 条)====================
🔴 v62(用户 2026-10-18 第 1 条):标题「我自己添加的(4)」+「只有这些能改能删」
→ **「与我相关」**(hint 整条删掉)。
理由:这一块现在不只有"我添加的",还有"别人添加、但把我挂成了关联人"的
(判据见 Anniversary::split())。继续叫"我自己添加的"就是错的;
而"只有这些能改能删"这句在**功能上**没必要跟用户解释(按按钮能改能删,一看就懂)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">与我相关(<?= count($custom) ?>)</h2>
<?php if (!$custom): ?>
<p class="empty-hint">
还没有与你相关的纪念日。
<?php if ($canWrite): ?>
结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。
<?php endif; ?>
</p>
<?php else: ?>
<?php $renderTable($custom, true); ?>
<?php endif; ?>
<?php /* ⚠️ 别人记的、只跟他自己相关的那些不在这儿出现("与我相关"就该是相关的)。
但如果**一条都不提示**,用户可能以为数据丢了 —— 给一句小字,并说清去哪儿找。 */ ?>
<?php if ((int)($hidden ?? 0) > 0): ?>
<p class="small muted" style="margin:10px 0 0">
另有 <?= (int)$hidden ?> 条是别人记的、与你无关,这里不列;日历里照旧能看到。
</p>
<?php endif; ?>
</div>
<?php /* ==================== ③ 来自名册的生日(在下)====================
🔴 v62(用户 2026-10-18 第 1 条):标题「来自名册的(20)」+「实时读出来的,
改生日这里跟着变」→ **「来自名册」**;下面那段"这一块是实时派生的…"整段删掉。
为什么删:那是**实现说明**,用户看的是日子,不是数据从哪来 ——
而"改生日这里跟着变"已经由表格右上那句「来自名册」暗示得够清楚了。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">来自名册(<?= count($derived) ?>)</h2>
<?php if (!$derived): ?>
<p class="empty-hint">
名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。
</p>
<?php else: ?>
<?php $renderTable($derived, false); ?>
<?php endif; ?>
</div>
</div><?php /* /#anListHost —— 这个 div 只开了一次(见上面 `<div id="anListHost">`),
收尾也只能有一次。曾经这一行被复制成两行 ⇒ 整个页面的 div 深度变成 -1,
元素跑到容器外(流水线的"div 嵌套"检查抓到的就是这个)。 */ ?>