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 可改尺寸、调顺序、开关
This commit is contained in:
2026-10-19 12:51:17 +08:00
parent ad7f43af68
commit 9a66068c33
21 changed files with 2172 additions and 685 deletions
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* 纪念日「分类设置」页(v62 用户 2026-10-18 第 2 条)。
*
* 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类,
* 每一类纪念日都有不同的图标,现在的分类,你自己把图标补全。如果是用的
* fontawesome,那么如果用户添加了自己的分类,就让用户自己选。
* 在列表中、日历中,都要根据分类,显示不同的图标。」
*
* ⇒ 这一页做三件事(都在**一张表单**里,改完点一次保存):
* ① 改名(已用它的纪念日**不会**跟着改名 —— 页面底下有一句提示,别让用户以为丢了);
* ② 换图标(下拉里是 `AnnivIcon` 那一套,选完左边立刻能预览);
* ③ 调顺序 / 增 / 删。
*
* ⚠️ 三个动作(保存 / 删除 / 上移下移 / 新增)**共用同一个表单**,靠 submit 按钮的
* `name` 区分(`del` / `move` / `add`)。为什么不用"每行一个小表单":
* 一行一个小表单的话,用户改了 A 行的图标、又点了 B 行的「删」,
* A 行那次修改会被**静默丢弃**(浏览器只提交被点的那张表单)。
* 一张表单 = 一次提交 = 屏幕上看到的改动全部生效,没有"改了一半"的中间态。
*
* ⚠️ 分类是**家庭级**的(settings.anniv_kinds):全家共用一套叫法。
* 所以这一页对**所有能写的成员**开放(不是只有管理员),但改的是同一份。
*
* @param array $kindsFull array<int,array{name:string,icon:string}>
* @param array $kindCounts 分类名 => 用了几条
*/
$kindList = (array)($kindsFull ?? \App\Anniversary::kindsFull((int)\App\Auth::familyId()));
$kindCount = (array)($kindCounts ?? []);
$canEdit = \App\Auth::canWrite();
$n = count($kindList);
?>
<div class="page-head">
<h1>纪念日分类</h1>
<span class="sub">每个分类一个图标 · 全家共用一套</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('days')) ?>">返回纪念日</a>
</div>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">
分类与图标
<span class="hint">列表、日历、卡片上都按这里的图标显示</span>
</h2>
<?php if (!$canEdit): ?>
<p class="empty-hint">你没有编辑权限,这里只显示现有分类。</p>
<ul class="kind-list">
<?php foreach ($kindList as $k): ?>
<li class="kind-row is-ro">
<span class="kind-prev"><?= \App\AnnivIcon::svg((string)$k['icon'], 16) ?></span>
<span class="kind-name-ro"><?= e((string)$k['name']) ?></span>
<span class="kind-use"><?= (int)($kindCount[(string)$k['name']] ?? 0) ?> 条</span>
</li>
<?php endforeach; ?>
</ul>
<?php else: ?>
<form method="post" action="<?= e(url('days/settings')) ?>" id="kindForm">
<?= csrf_field() ?>
<ol class="kind-list" id="kindList">
<?php foreach ($kindList as $i => $k): ?>
<li class="kind-row">
<span class="kind-move">
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="<?= (int)$i ?>:up"
title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="<?= (int)$i ?>:down"
title="下移" <?= $i === $n - 1 ? 'disabled' : '' ?>>▼</button>
</span>
<?php /* 左边这个 svg 是"当前图标"的预览;右边下拉一动,JS 立刻换成新的
(图标库见页面底部的 .kind-icon-bank,不在 JS 里手抄一遍 path)。 */ ?>
<span class="kind-prev" data-kind-prev="<?= (int)$i ?>"><?= \App\AnnivIcon::svg((string)$k['icon'], 16) ?></span>
<select class="select kind-sel" name="k_icon[<?= (int)$i ?>]"
aria-label="<?= e((string)$k['name']) ?>的图标" data-kind-sel="<?= (int)$i ?>">
<?php foreach (\App\AnnivIcon::choices() as $icKey => $icLabel): ?>
<option value="<?= e($icKey) ?>" <?= (string)$k['icon'] === $icKey ? 'selected' : '' ?>><?= e($icLabel) ?></option>
<?php endforeach; ?>
</select>
<input class="input kind-name-in" name="k_name[<?= (int)$i ?>]" maxlength="20"
value="<?= e((string)$k['name']) ?>" aria-label="分类名">
<span class="kind-use"><?= (int)($kindCount[(string)$k['name']] ?? 0) ?> 条</span>
<?php /* ⚠️ confirm 里**不要**拼分类名:e() 把单引号转成 `&#039;`,
而浏览器会先解实体再交给 JS ⇒ 名字里带引号时这段脚本直接语法错,
「删除」按钮点了没反应(本项目踩过同型的坑)。用固定文案最稳。 */ ?>
<button class="btn btn-warn btn-sm" type="submit" name="del" value="<?= (int)$i ?>"
onclick="return confirm('删掉这个分类?已用它的纪念日不变,只是新建时不再出现。')">删除</button>
</li>
<?php endforeach; ?>
</ol>
<?php if (!$kindList): ?>
<p class="empty-hint" style="margin:8px 0 14px">还没有分类。下面加一个。</p>
<?php endif; ?>
<div class="kind-add">
<span class="kind-add-lb">加一个分类</span>
<select class="select" name="new_icon" aria-label="新分类的图标">
<?php foreach (\App\AnnivIcon::choices() as $icKey => $icLabel): ?>
<option value="<?= e($icKey) ?>" <?= $icKey === 'star' ? 'selected' : '' ?>><?= e($icLabel) ?></option>
<?php endforeach; ?>
</select>
<input class="input" name="new_name" maxlength="20" placeholder="如:相识纪念日">
<button class="btn btn-sm" type="submit" name="add" value="1">加分类</button>
</div>
<div class="btn-row" style="margin-top:16px">
<button class="btn" type="submit" name="save" value="1">保存分类</button>
<?php /* 动过图标/名字但还没保存时 JS 会把这句显出来(见页面底部脚本):
这一页**不自动保存**,不提示的话用户挑完图标直接返回,改动就静默丢了。 */ ?>
<span class="small kind-dirty" id="kindDirty" hidden>改过了,记得点左边的「保存分类」</span>
</div>
</form>
<p class="small muted" style="margin:14px 0 0">
改名<b>不会</b>改掉已经用过旧名字的那些纪念日(它们仍记着旧分类);
删掉一个分类也只是"新建时不再出现",历史记录照旧。
<br>分类加得越多,新建时的下拉越长 —— 建议按"这件事是什么性质"来分,不要按人来分(人有关联人那一列)。
</p>
<?php /* 图标库:全部图标在这里渲染一次,JS 只做"挑一个塞进预览",
避免在 JS 里再抄一份 SVG path(两份迟早不一致)。 */ ?>
<div class="kind-icon-bank" hidden aria-hidden="true">
<?php foreach (array_keys(\App\AnnivIcon::LIST) as $icKey): ?>
<span data-ic="<?= e($icKey) ?>"><?= \App\AnnivIcon::svg($icKey, 16) ?></span>
<?php endforeach; ?>
</div>
<script>
(function () {
var bank = document.querySelector('.kind-icon-bank');
if (!bank) { return; }
/* 改完图标/名字之后**一定要点保存**(这一页不自动存)。
不提示的话,用户挑完图标顺手点「返回纪念日」,改动就静默丢了 ——
所以只要动过,就把保存按钮标脏,并挂一句小字。 */
var saveBtn = document.querySelector('button[name=save]');
var dirty = document.getElementById('kindDirty');
function markDirty() {
if (saveBtn) { saveBtn.classList.add('is-dirty'); }
if (dirty) { dirty.hidden = false; }
}
Array.prototype.forEach.call(document.querySelectorAll('[data-kind-sel]'), function (sel) {
sel.addEventListener('change', function () {
var i = sel.getAttribute('data-kind-sel');
var prev = document.querySelector('[data-kind-prev="' + i + '"]');
var src = bank.querySelector('[data-ic="' + sel.value + '"]');
if (prev && src) { prev.innerHTML = src.innerHTML; }
markDirty();
});
});
Array.prototype.forEach.call(document.querySelectorAll('#kindList input.kind-name-in'),
function (inp) { inp.addEventListener('input', markDirty); });
/* 任何一次提交(保存 / 加分类 / 删除 / 上移下移)都算"处理过了",
标志就地清掉 —— 免得提交完回来还挂着红点。 */
var f = document.getElementById('kindForm');
if (f) { f.addEventListener('submit', function () {
if (saveBtn) { saveBtn.classList.remove('is-dirty'); }
if (dirty) { dirty.hidden = true; }
}); }
})();
</script>
<?php endif; ?>
</div>