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
+209 -6
View File
@@ -29,6 +29,7 @@ use App\Expense;
use App\Family;
use App\Geo;
use App\Home;
use App\HomeCards;
use App\HomeMods;
use App\Holiday;
use App\Anniversary;
@@ -847,6 +848,16 @@ switch ($r) {
$homeMods = HomeMods::forPerson($me);
$moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks);
/* 🔴 v62(用户 2026-10-18 第 3 条):「今天,我们正式引入卡片的概念。」
⇒ 首页从"三块写死的区块"改成**卡片网格**:
· `$homeCards` 是这个人要摆哪几张、各自多宽多高(user_settings.home_cards);
· `$cardData` 只给那几张需要额外查询的卡片取数(纪念日/观影/读书/联系人)。
⚠️ 缺的卡片不查库 —— 关掉的卡片不该白跑一次 SQL。
⚠️ 日程/任务/往年的今天/概述这四张用的还是下面那几个变量(同一份数据),
不要在模板里再查第二遍。 */
$homeCards = HomeCards::layout($me);
$cardData = HomeCards::data($homeCards, $familyId, $me);
// 统计信息块:随机问候 + 今日天气 + 近七天的数字
$weekEnd = date('Y-m-d', strtotime('+7 days'));
$st7 = Database::get()->prepare(
@@ -936,6 +947,9 @@ switch ($r) {
'today' => $today,
'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards,
// v62 卡片体系
'homeCards' => $homeCards,
'cardData' => $cardData,
// v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php)
// ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方,
// 真正的消息中心在 ?r=notifications。
@@ -944,6 +958,84 @@ switch ($r) {
], 'home');
break;
/**
* 首页卡片布局设置(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「卡片的规格,我们可以定义一下,例如,把宽度定义为 8,高度可以是
* 2、4、6、8……你可以先规划一些,实现出来,我们再讨论。」
*
* ⇒ 这是**每人一份**的设置(首页怎么摆是很个人的事),存 user_settings。
* ⚠️ 与「模块设置 → 导航」那套完全分开:导航管顶栏,这里只管首页的卡片。
*/
case 'home/cards':
Auth::requireLogin();
$me = (int)Auth::personId();
View::render('home_cards', [
'title' => '首页卡片',
'rows' => HomeCards::orderedForPicker($me),
], 'home');
break;
/**
* 保存首页卡片布局。
*
* 表单形状:每行一个 `order[]`(屏幕顺序)+ `on[<key>]` 勾选 + `w[<key>]` / `h[<key>]`
* + `n[<key>]`(只有"联系人"用得上)。
* ⚠️ 顺序**按 order[] 取**,不按 POST 里 on 的顺序 —— 浏览器提交复选框的顺序
* 并不保证与 DOM 顺序一致(有些浏览器按 name 分组提交)。
*/
case 'home/cards_save':
Auth::requireLogin();
csrf_check();
$me = (int)Auth::personId();
$order = array_map('strval', (array)($_POST['order'] ?? []));
/* ⚠️ `on[<key>]` 是**按卡片 key 作数组下标**提交的(`on[brief]=1`),
所以判"勾没勾"必须 `isset($on[$k])` ——
写成 `in_array($k, $on)` 是错的:那比的是**值**(全是 "1"),
结果一个都匹配不上,保存出来是**空布局**(首页变成"卡片都关掉了")。 */
$on = (array)($_POST['on'] ?? []);
$ws = (array)($_POST['w'] ?? []);
$hs = (array)($_POST['h'] ?? []);
$ns = (array)($_POST['n'] ?? []);
/* ▲▼ 是**没有 JS 时的兜底**(页面里还有拖动排序,两条路都要能走):
先把屏幕顺序里这两个换位,再照新顺序建卡片列表 —— 于是"点了上移"
与"改完再点保存"得到的是同一个结果。 */
$move = (string)($_POST['move'] ?? '');
if ($move !== '' && strpos($move, ':') !== false) {
[$dir, $mkey] = explode(':', $move, 2);
$mi = array_search($mkey, $order, true);
if ($mi !== false) {
$mj = $dir === 'up' ? $mi - 1 : $mi + 1;
if ($mj >= 0 && $mj < count($order)) {
[$order[$mi], $order[$mj]] = [$order[$mj], $order[$mi]];
}
}
}
$items = [];
foreach ($order as $k) {
if (!isset($on[$k])) {
continue;
}
$items[] = [
'k' => $k,
'w' => (int)($ws[$k] ?? 0),
'h' => (int)($hs[$k] ?? 0),
'n' => (int)($ns[$k] ?? 0),
];
}
// 一个都没勾 = 空首页。这是用户的选择(不是错误),照存。
HomeCards::save($me, $items);
/* ⚠️ ▲▼ 只是"编辑中的一个动作",**不能跳去首页**:
跳走等于把编辑器关掉,用户想再挪一格就得重新进来(实测就是这样,
连点两下只会生效一次)。只有真正"保存"才回首页。 */
if ($move !== '') {
flash('ok', '顺序已调整。');
redirect('home/cards');
}
flash('ok', '首页卡片已保存。');
redirect('home');
break;
// ==================== 家谱(以我为中心) ====================
case 'kin':
$familyId = (int)Auth::familyId();
@@ -1760,7 +1852,12 @@ switch ($r) {
$navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认
}
Module::saveNav($me, $navKeys);
HomeMods::save($me, $homeKeys);
/* ⚠️ v62:模块设置里**已经没有「首页」那一列**(首页改由「首页卡片」摆,
见 HomeCards)。所以只有表单真的带了 `home[]` 才写 —— 否则每次保存导航
都会把 HomeMods 清成空数组("关了还在"的反面:"没动也丢")。 */
if (array_key_exists('home', $_POST)) {
HomeMods::save($me, $homeKeys);
}
// 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错)
if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) {
@@ -4638,12 +4735,26 @@ $want = array_keys($want);
foreach ($people as $pp) {
$pmap[(int)$pp['id']] = $pp;
}
/* 「接下来的日子」必须和下面两张表**用同一套可见性**:
否则别人记的、与你无关的那条会孤零零出现在最上面,
而下面「与我相关」「来自名册」里都找不到它(v62 实测踩到)。
⚠️ 只筛这个页面;日历是全家共享视图,照旧全显示。 */
$upcoming = Anniversary::upcoming($familyId, $me, 60);
if (!empty($sp['hiddenIds'])) {
$hid = array_flip(array_map('intval', $sp['hiddenIds']));
$upcoming = array_values(array_filter($upcoming, static function ($u) use ($hid) {
return !isset($hid[(int)($u['id'] ?? 0)]);
}));
}
View::render('days/index', [
'title' => '纪念日',
'custom' => $sp['custom'],
'derived' => $sp['derived'],
'upcoming' => Anniversary::upcoming($familyId, $me, 60),
// v62:别人记的、与我无关的条数(只在页面上给一句小字)
'hidden' => $sp['hidden'],
'upcoming' => $upcoming,
'kinds' => Anniversary::kinds($familyId),
'kindsFull' => Anniversary::kindsFull($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'people' => $people,
'peopleMap' => $pmap,
@@ -4651,12 +4762,102 @@ $want = array_keys($want);
break;
/**
* 纪念日分类设置(v24 用户 2026-10-03 第 9 条)。
* 纪念日分类设置页(v62 用户 2026-10-18 第 2 条)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类,
* 每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」
*
* 分类清单是**家庭级**的(全家共用一套,不然「结婚纪念日」在两个人屏幕上
* 是两套叫法,筛选/看的时候对不上)。所以放在 settings 而不是 user_settings。
* ⇒ 与日程/观影/任务三个模块同一套形态:☰ 下拉 → 点条目 → 独立页面。
*
* 表单是**一张**(改图标 / 改名 / 排序 / 删 / 加 全在里面),靠按钮的 name 区分动作:
* save / del / move / add。为什么不用"每行一个小表单":那样改了 A 行图标再点
* B 行删除,A 行那次修改会被浏览器丢掉(只提交被点的那张表单)——
* "改了一半"是这类设置页最常见、也最难 debug 的投诉。
*/
case 'days/settings':
$familyId = (int)Auth::familyId();
$me = (int)Auth::personId();
if (strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'POST') {
Auth::requireWrite();
csrf_check();
$names = (array)($_POST['k_name'] ?? []);
$icons = (array)($_POST['k_icon'] ?? []);
// 用**原始下标**当键:空名字被跳过时下标也不能重排,
// 否则「删第 3 行」会删到第 3 个非空行(两者不是一回事)。
$items = [];
$tooLong = 0;
foreach ($names as $i => $nm) {
$nm = trim((string)$nm);
if ($nm === '') {
continue;
}
if (mb_strlen($nm, 'UTF-8') > 20) {
$tooLong++;
continue;
}
$items[(string)$i] = [
'name' => $nm,
'icon' => (string)($icons[$i] ?? ''),
];
}
$notice = '分类已保存。';
if (isset($_POST['del'])) {
unset($items[(string)(int)$_POST['del']]);
$notice = '分类已删除(已用它的纪念日不变,只是新建时不再出现)。';
} elseif (isset($_POST['move'])) {
$mv = explode(':', (string)$_POST['move'], 2);
$idx = (string)(int)($mv[0] ?? -1);
$keys = array_keys($items);
$pos = array_search($idx, $keys, true);
if ($pos !== false) {
$to = ($mv[1] ?? 'up') === 'up' ? $pos - 1 : $pos + 1;
if ($to >= 0 && $to < count($keys)) {
$tmp = $keys[$pos];
$keys[$pos] = $keys[$to];
$keys[$to] = $tmp;
$reordered = [];
foreach ($keys as $k) {
$reordered[$k] = $items[$k];
}
$items = $reordered;
}
}
$notice = '顺序已调整。';
} elseif (isset($_POST['add'])) {
$nn = trim((string)($_POST['new_name'] ?? ''));
if ($nn === '') {
flash('err', '新分类的名字是空的。');
redirect('days/settings');
}
if (mb_strlen($nn, 'UTF-8') > 20) {
flash('err', '分类名最多 20 个字。');
redirect('days/settings');
}
$items[] = ['name' => $nn, 'icon' => (string)($_POST['new_icon'] ?? '')];
$notice = '分类已添加。';
}
Anniversary::saveKinds($familyId, array_values($items));
if ($tooLong > 0) {
flash('err', '有 ' . $tooLong . ' 个分类名超过 20 个字,已跳过。');
}
flash('ok', $notice);
redirect('days/settings');
}
View::render('days/settings', [
'title' => '纪念日分类',
'kindsFull' => Anniversary::kindsFull($familyId),
'kindCounts' => Anniversary::kindCounts($familyId),
'meId' => $me,
], 'days');
break;
/**
* 纪念日分类设置(v24 用户 2026-10-03 第 9 条)—— **老入口,保留兼容**。
*
* v62 起前端的入口已经改成 `days/settings` 那一页(带图标)。这个 POST 端点留着:
* ① 老页面/书签里还有提交到它的表单;
* ② 反向验证脚本要能单独打它(证明"删一个分类"这条链路没断)。
* ⚠️ 它只处理**名字**(add / del / 整份 kinds)——新格式的图标由 saveKinds 兜底补。
*/
case 'days/kinds':
$familyId = (int)Auth::familyId();
@@ -4724,6 +4925,8 @@ $want = array_keys($want);
$anArgs = [
'editing' => $editing,
'kinds' => Anniversary::kinds($familyId),
// v62:带图标的清单(下拉里用 emoji 打头,见 form.php)
'kindsFull' => Anniversary::kindsFull($familyId),
'people' => Person::roster($familyId, $me, 'member', ''),
/* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」
直达 `days/new` 时也渲染成**打开着的弹框** ——