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
+165
View File
@@ -0,0 +1,165 @@
<?php
/**
* 纪念日分类的图标(v62 用户 2026-10-18 第 2 条)。
*
* 用户原话:「设置按钮,点击后弹菜单,第一个菜单是分类设置,进去之后可以设置分类,
* 每一类纪念日都有不同的图标……如果是用的 fontawesome,那么如果用户
* 添加了自己的分类,就让用户自己选。在列表中、日历中,都要根据分类,
* 显示不同的图标。」
*
* ⇒ 为什么**不自带 FontAwesome**(两条硬理由,别改回去):
* ① 体积:整套 webfont(woff2 + css)≈ 100~300 KB,而这个项目是"点开就是一个
* 家庭站点"的自建站,首页已经背了 antd(1.8 MB)。为了十几个图标再背一整套
* 字体不划算,而且中文字形根本用不上。
* ② 离线/内网:本项目所有静态资源都是本地 vendor 目录,不能挂 CDN(记忆里
* "静态资源地址必须带版本号"那一条的同一个前提)——引 CDN 会在没外网的
* 家庭内网里直接变成空白方块。
* ⇒ 用**内置的行内 SVG 图标集**:一个图标一条 `<path>`(stroke 描边、currentColor
* 取文字颜色),列表 / 卡片里直接内联;日历那种纯文本的地方用同一图标的
* emoji 兜底(`emo()`)—— 日历的事件标题是纯文本,塞不进 SVG。
*
* ⚠️ **图标 key 是**契约**:它存在 settings(anniv_kinds)里,用户自己选。
* 所以:① 已有 key 永远不要改名(老数据会变成"认不出的图标");
* ② 新增图标只能往后加;③ 认不出的 key 一律回落 `dot`,不抛异常
* (用户可能手改过库、或跨版本导入过配置)。
*
* @see \App\Anniversary::kindsFull() 分类清单(名称 + 图标)的读写
*/
declare(strict_types=1);
namespace App;
final class AnnivIcon
{
/** 认不出/没配时用哪个 */
public const FALLBACK = 'dot';
/**
* key => [中文名, emoji, SVG 内容(24×24 画布,stroke 描边)]
*
* ⚠️ SVG 内容里**不要**写 `fill` / `stroke` —— 由 svg() 统一给
* `fill="none" stroke="currentColor"`,这样图标颜色永远跟着所在位置的文字色,
* 浅底深底都不会变成"看不见的黑块"。
*/
public const LIST = [
'cake' => ['蛋糕(生日)', '🎂',
'<path d="M4 20h16"/>'
. '<path d="M5 20v-4.6a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2V20"/>'
. '<path d="M9 13.4V10M15 13.4V10"/>'
. '<path d="M9 7.4c.9-.9.2-2-.2-2.4-.5.6-1.1 1.5-.2 2.4Z"/>'
. '<path d="M15 7.4c.9-.9.2-2-.2-2.4-.5.6-1.1 1.5-.2 2.4Z"/>'],
'ring' => ['戒指(结婚)', '💍',
'<circle cx="12" cy="15" r="5.2"/>'
. '<path d="M8.8 9.4 12 5.2l3.2 4.2"/>'
. '<path d="M8.8 9.4h6.4"/>'],
'heart' => ['爱心(相识/恋爱)', '❤️',
'<path d="M12 20s-7-4.4-7-9.2A3.9 3.9 0 0 1 12 8.2a3.9 3.9 0 0 1 7 2.6C19 15.6 12 20 12 20Z"/>'],
'candle' => ['蜡烛(忌日/追思)', '🕯️',
'<path d="M9 21h6"/>'
. '<rect x="9" y="10.5" width="6" height="10.5" rx="1"/>'
. '<path d="M12 10.5V8.4"/>'
. '<path d="M12 8.4c1-1 1-2.2 0-3-1 .8-1 2 0 3Z"/>'],
'star' => ['星星(节日)', '⭐',
'<path d="M12 3.6l2.6 5.3 5.8.8-4.2 4.1 1 5.8L12 16.9l-5.2 2.7 1-5.8-4.2-4.1 5.8-.8Z"/>'],
'gift' => ['礼物', '🎁',
'<rect x="4" y="11" width="16" height="9" rx="1"/>'
. '<path d="M3 7.4h18v3.6H3zM12 7.4V20"/>'
. '<path d="M12 7.4S10.6 3 8 3a2.2 2.2 0 0 0 0 4.4Z"/>'
. '<path d="M12 7.4S13.4 3 16 3a2.2 2.2 0 0 1 0 4.4Z"/>'],
'baby' => ['孩子(满月/出生)', '👶',
'<circle cx="12" cy="7.6" r="3.4"/>'
. '<path d="M6.4 20c0-3.6 2.5-6 5.6-6s5.6 2.4 5.6 6"/>'
. '<path d="M10.5 7.2h.01M13.5 7.2h.01"/>'],
'home' => ['家(搬家/乔迁)', '🏠',
'<path d="M4 10.6 12 4l8 6.6V20H4z"/>'
. '<path d="M9.6 20v-5.2h4.8V20"/>'],
'book' => ['书(毕业/升学)', '📖',
'<path d="M12 7.2c-1.6-1.4-3.6-2-6-2v11c2.4 0 4.4.6 6 2 1.6-1.4 3.6-2 6-2v-11c-2.4 0-4.4.6-6 2Z"/>'
. '<path d="M12 7.2v11"/>'],
'plane' => ['飞机(远行/旅行)', '✈️',
'<path d="M21 4 3 11.2l6.4 2.2L11.6 20 21 4Z"/>'],
'camera' => ['相机(合影/留念)', '📷',
'<rect x="3.4" y="7.4" width="17.2" height="12" rx="2"/>'
. '<circle cx="12" cy="13.4" r="3.4"/>'
. '<path d="M8.6 7.4 10 4.8h4l1.4 2.6"/>'],
'music' => ['音符(音乐/演出)', '🎵',
'<circle cx="8" cy="17.4" r="2.6"/>'
. '<circle cx="17" cy="15.4" r="2.6"/>'
. '<path d="M10.6 17.4V7l9-2v10.4"/>'],
'trophy' => ['奖杯(成就/晋升)', '🏆',
'<path d="M8 4.6h8v4.8a4 4 0 0 1-8 0Z"/>'
. '<path d="M8 6.2H5.4v1.4a3.2 3.2 0 0 0 3.2 3.2"/>'
. '<path d="M16 6.2h2.6v1.4a3.2 3.2 0 0 1-3.2 3.2"/>'
. '<path d="M12 13.4V17M8.6 20h6.8"/>'],
'clock' => ['时钟(周年/入职)', '⏰',
'<circle cx="12" cy="12" r="7.6"/>'
. '<path d="M12 7.6V12l3 1.8"/>'],
'flower' => ['花(表白/祝福)', '🌸',
'<circle cx="12" cy="12" r="1.6"/>'
. '<circle cx="12" cy="6.6" r="2.3"/>'
. '<circle cx="16.9" cy="10.2" r="2.3"/>'
. '<circle cx="15" cy="16" r="2.3"/>'
. '<circle cx="9" cy="16" r="2.3"/>'
. '<circle cx="7.1" cy="10.2" r="2.3"/>'],
'leaf' => ['叶(自然而然)', '🌿',
'<path d="M20 4C10 4 4.4 9 4.4 15.4c0 1.8.6 3.4 1.6 4.6C9 20 20 16 20 4Z"/>'
. '<path d="M6 20C8.6 14 12 10.6 17 8"/>'],
'bell' => ['铃铛(提醒/纪念)', '🔔',
'<path d="M9 18a3 3 0 0 0 6 0"/>'
. '<path d="M6.6 18h10.8c-1.4-1.4-1.8-2.6-1.8-4.6V11a4.6 4.6 0 0 0-9.2 0v2.4c0 2-.4 3.2-1.8 4.6Z"/>'
. '<path d="M12 6.4V4.8"/>'],
'dot' => ['圆点(其它)', '⚪',
'<circle cx="12" cy="12" r="4"/>'],
];
public static function has(string $key): bool
{
return $key !== '' && isset(self::LIST[$key]);
}
/** 认不出的 key 一律回落,绝不抛异常(配置可能来自旧版本/手改) */
public static function normalize(string $key): string
{
$key = strtolower(trim($key));
return self::has($key) ? $key : self::FALLBACK;
}
/** 中文名(给设置页的下拉用) */
public static function label(string $key): string
{
return self::LIST[self::normalize($key)][0];
}
public static function emo(string $key): string
{
return self::LIST[self::normalize($key)][1];
}
/**
* 行内 SVG。
*
* ⚠️ 返回值是**可信 HTML**(不含任何用户输入:key 先 normalize,size 转 int),
* 模板里直接 `<?= ... ?>` 输出,不要 e() 一遍(那会把标签转义成文字)。
* aria-hidden:图标永远是"文字旁边的装饰",读屏器念分类名就够了。
*/
public static function svg(string $key, int $size = 15, string $cls = ''): string
{
$k = self::normalize($key);
$n = max(8, min(96, $size));
return '<svg class="ai ai-' . e($k) . ($cls !== '' ? ' ' . e($cls) : '')
. '" viewBox="0 0 24 24" width="' . $n . '" height="' . $n . '"'
. ' aria-hidden="true" focusable="false" fill="none" stroke="currentColor"'
. ' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">'
. self::LIST[$k][2] . '</svg>';
}
/** 设置页下拉里能选的全部图标:key => "emoji 中文名" */
public static function choices(): array
{
$out = [];
foreach (self::LIST as $k => $v) {
$out[$k] = $v[1] . ' ' . $v[0];
}
return $out;
}
}
+172 -17
View File
@@ -38,6 +38,23 @@ final class Anniversary
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */ /** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它']; public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
/**
* 预置分类各配哪个图标(v62)。
*
* ⚠️ 这张表只用于**兜底**:分类名 → 图标真正的来源是 settings 里那份
* `anniv_kinds`(用户可以在「分类设置」里逐条改)。这里的作用是
* 「老库升级上来、还没配过图标」时,六个预置分类不至于全变成同一个圆点。
* ⚠️ 值必须是 AnnivIcon 里存在的 key;认不出的会被 normalize 成 dot。
*/
public const PRESET_ICONS = [
'生日' => 'cake',
'结婚纪念日' => 'ring',
'相识纪念日' => 'heart',
'忌日' => 'candle',
'节日' => 'star',
'其它' => 'dot',
];
public const ATTACH = 'anniversary'; public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ---------------- // ---------------- ① 用户自建的纪念日 ----------------
@@ -309,12 +326,24 @@ final class Anniversary
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日, * 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」 * 要分开,手动纪念日在上面。」
* *
* 所以返回 ['custom' => [...], 'derived' => [...]] 两块,各按日期排。 * 所以返回 ['custom' => [...], 'derived' => [...]], 'hidden' => N 三块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算), * ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"), * 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。 * 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
* *
* @return array{custom:array,derived:array} * 🔴 v62(用户 2026-10-18 第 1 条):第一块从「我自己添加的」改叫「与我相关」,
* 原话:「这里要注意,如果是别人分享给我的,就是我是关联人,这里应该也要显示。」
* ⇒ 判据(**满足一条就留下**):
* ① 这条纪念日没填关联人 —— 它是"全家的事"(结婚纪念日、家庭节日…),
* 谁添加的都该看得到;
* ② 我是它的关联人之一 —— 别人添加的、把我挂上了,就该出现在我这儿。
* 反过来:别人给他自己家亲戚记的忌日(关联人只有那位亲戚)**不进我的列表** ——
* 那正是"与我无关"。这类条数放回 `hidden` 里,模板上给一句小字说明,
* 免得用户以为"数据丢了"(它们在日历里照旧出现)。
*
* ⚠️ 派生块(名册生日)**不过这个筛**:生日本来就已经是"与我相关"的结果集。
*
* @return array{custom:array,derived:array,hidden:int,hiddenIds:array}
*/ */
public static function split(int $familyId, int $viewerId): array public static function split(int $familyId, int $viewerId): array
{ {
@@ -322,6 +351,10 @@ final class Anniversary
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条 // 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
$pmap = self::peopleMap(array_column($rows, 'id')); $pmap = self::peopleMap(array_column($rows, 'id'));
$custom = []; $custom = [];
$hidden = 0;
/* 被筛掉的 id:页面上方那块「接下来的日子」也要跟着筛,否则出现
"上面列出来一条、下面两张表都找不到它"的自相矛盾(v62 实测踩到)。 */
$hiddenIds = [];
foreach ($rows as $r) { foreach ($rows as $r) {
$r['source'] = 'custom'; $r['source'] = 'custom';
$r['person_name'] = ''; $r['person_name'] = '';
@@ -334,6 +367,13 @@ final class Anniversary
} else { } else {
$r['person_ids'] = []; $r['person_ids'] = [];
} }
// 见上面 ①②:只有"明确挂了别人、且没挂我"的才排除
if ($r['person_ids'] && $viewerId > 0
&& !in_array((int)$viewerId, array_map('intval', $r['person_ids']), true)) {
$hidden++;
$hiddenIds[] = $aid;
continue;
}
$custom[] = $r; $custom[] = $r;
} }
$derived = self::birthdays($familyId, $viewerId); $derived = self::birthdays($familyId, $viewerId);
@@ -350,54 +390,166 @@ final class Anniversary
}; };
usort($custom, $byMD); usort($custom, $byMD);
usort($derived, $byMD); usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived]; return ['custom' => $custom, 'derived' => $derived, 'hidden' => $hidden,
'hiddenIds' => $hiddenIds];
} }
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)---------------- // ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/** /**
* 用户自定义的分类清单(family 级,存 settings)。 * 用户自定义的分类清单(family 级,存 settings)—— **带图标**(v62)。
* *
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」 * 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* v62 追加:「每一类纪念日都有不同的图标……如果用户添加了自己的分类,就让用户自己选。」
* *
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事: * ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删); * · 这里的清单 = 新建时下拉里给哪些选项(可增删、可换图标);
* · kind = 这一条实际选了哪个。 * · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字), * 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。 * 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
* *
* @return string[] 有序、去重、非空 * ⚠️ 存储形状变过一次(**必须兼容老数据**):
* v24 起:`["生日","结婚纪念日"]` —— 纯字符串数组
* v62 起:`[{"n":"生日","i":"cake"}, ...]` —— 名称 + 图标
* 读到字符串就按"没配图标"处理(用 PRESET_ICONS / 名字猜一个),
* 读到对象就取 n/i。**不改老库、不做一次性迁移**:读的时候兼容,写的时候
* 自然升级 —— 迁移脚本在"用户还没打开过设置页"时没有任何收益。
*
* @return array<int,array{name:string,icon:string}> 有序、去重、非空
*/ */
public static function kinds(int $familyId): array public static function kindsFull(int $familyId): array
{ {
$raw = (string)Setting::get($familyId, 'anniv_kinds', ''); $raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null; $dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) { if (!is_array($dec)) {
return self::PRESET_KINDS; // 没配过 → 用预置那套 // 没配过 → 用预置那套(每个都配好图标)
$out = [];
foreach (self::PRESET_KINDS as $k) {
$out[] = ['name' => $k, 'icon' => self::defaultIcon($k)];
}
return $out;
} }
$out = []; $out = [];
foreach ($dec as $k) { $seen = [];
$k = trim((string)$k); foreach ($dec as $item) {
if ($k !== '' && mb_strlen($k, 'UTF-8') <= 20 && !in_array($k, $out, true)) { if (is_array($item)) {
$out[] = $k; $n = trim((string)($item['n'] ?? $item['name'] ?? ''));
$i = (string)($item['i'] ?? $item['icon'] ?? '');
} else {
$n = trim((string)$item);
$i = '';
} }
if ($n === '' || mb_strlen($n, 'UTF-8') > 20 || isset($seen[$n])) {
continue;
}
$seen[$n] = true;
$out[] = ['name' => $n, 'icon' => $i !== '' ? AnnivIcon::normalize($i) : self::defaultIcon($n)];
} }
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置; // ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。 // 只有"从来没配过"($raw === '')才给预置。
return $out; return $out;
} }
/** 只要名字(老的调用点、下拉、`in_array` 判断都还用它) */
public static function kinds(int $familyId): array
{
return array_column(self::kindsFull($familyId), 'name');
}
/** 名字 → 图标(设置页之外的一切渲染都问这里) */
public static function kindIcons(int $familyId): array
{
$out = [];
foreach (self::kindsFull($familyId) as $r) {
$out[$r['name']] = $r['icon'];
}
return $out;
}
/**
* 某个分类该用哪个图标。
*
* ⚠️ 三条回落,顺序不能反:
* ① 清单里配过的 → 用它(用户自己选的);
* ② 清单里没有(分类被删了、或者是老数据里的 kind)→ 按名字猜(PRESET_ICONS);
* ③ 还猜不出 → dot。
* 这样"删掉一个分类"之后,那些老纪念日**仍然有图标**,不会变成一片空白。
*/
public static function iconOf(int $familyId, string $kind): string
{
$kind = trim($kind);
if ($kind === '') {
return AnnivIcon::FALLBACK;
}
static $cache = [];
if (!isset($cache[$familyId])) {
$cache[$familyId] = self::kindIcons($familyId);
}
if (isset($cache[$familyId][$kind])) {
return $cache[$familyId][$kind];
}
return self::defaultIcon($kind);
}
/** 按名字猜一个图标(预置表 → 关键词 → dot) */
public static function defaultIcon(string $kind): string
{
$kind = trim($kind);
if (isset(self::PRESET_ICONS[$kind])) {
return self::PRESET_ICONS[$kind];
}
// 关键词兜底:用户自己起的名字里带"生日""结婚"……也该有个像样的图标
$guess = [
'生日' => 'cake', '寿' => 'cake',
'结婚' => 'ring', '婚' => 'ring',
'相识' => 'heart', '恋爱' => 'heart', '爱' => 'heart',
'忌' => 'candle', '追思' => 'candle', '逝' => 'candle',
'节' => 'star', '庆祝' => 'star',
'满月' => 'baby', '出生' => 'baby', '孩子' => 'baby',
'搬家' => 'home', '乔迁' => 'home', '家' => 'home',
'毕业' => 'book', '升学' => 'book', '入学' => 'book',
'旅' => 'plane', '行' => 'plane',
'合影' => 'camera', '照片' => 'camera',
'音乐' => 'music', '演出' => 'music',
'奖' => 'trophy', '晋升' => 'trophy', '成就' => 'trophy',
'入职' => 'clock', '周年' => 'clock',
'花' => 'flower', '祝福' => 'flower',
'礼物' => 'gift',
];
foreach ($guess as $needle => $icon) {
if (mb_strpos($kind, $needle, 0, 'UTF-8') !== false) {
return $icon;
}
}
return AnnivIcon::FALLBACK;
}
/**
* 保存分类清单。
*
* @param array $kinds 元素可以是 string(老调用点:只要名字)或
* array{name,icon}(新:带图标)
*/
public static function saveKinds(int $familyId, array $kinds): void public static function saveKinds(int $familyId, array $kinds): void
{ {
$out = []; $out = [];
$seen = [];
foreach ($kinds as $k) { foreach ($kinds as $k) {
$k = trim((string)$k); if (is_array($k)) {
if ($k === '' || mb_strlen($k, 'UTF-8') > 20) { $name = trim((string)($k['name'] ?? $k['n'] ?? ''));
$icon = (string)($k['icon'] ?? $k['i'] ?? '');
} else {
$name = trim((string)$k);
$icon = '';
}
if ($name === '' || mb_strlen($name, 'UTF-8') > 20 || isset($seen[$name])) {
continue; continue;
} }
if (!in_array($k, $out, true)) { $seen[$name] = true;
$out[] = $k; $out[] = [
} 'n' => $name,
'i' => $icon !== '' ? AnnivIcon::normalize($icon) : self::defaultIcon($name),
];
if (count($out) >= 30) { // 上限:一个下拉放不下更多了 if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break; break;
} }
@@ -488,6 +640,9 @@ final class Anniversary
'date' => $date, 'date' => $date,
'title' => (string)($a['title'] ?? ''), 'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''), 'kind' => (string)($a['kind'] ?? ''),
/* v62:分类图标(key)—— 日历拿它拼 emoji 前缀,卡片拿它画 SVG。
在这里一次性算好,别让每个渲染点各自去查一遍配置。 */
'icon' => self::iconOf($familyId, (string)($a['kind'] ?? '')),
'person_id' => (int)($a['person_id'] ?? 0), 'person_id' => (int)($a['person_id'] ?? 0),
'source' => (string)($a['source'] ?? 'custom'), 'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''), 'note' => (string)($a['note'] ?? ''),
+8 -1
View File
@@ -1487,11 +1487,18 @@ final class Calendar
// 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。 // 数据是**实时派生**的(生日读 persons、农历逐年换算),所以这里不查表、直接算。
// ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。 // ⚠️ 农历那一路必须按"这一年"换算 —— 同一条农历生日在不同年份落在不同公历日期。
foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) { foreach (Anniversary::between($familyId, $personId, $from, $to) as $a) {
/* 🔴 v62(用户 2026-10-18 第 2 条):「在列表中、**日历中**,都要根据分类,
显示不同的图标。」
⇒ 日历里的条目是**纯文本**(月格 / 周视图 / 日视图共用一个 title 字符串),
塞不进行内 SVG,所以这里用同一套图标的 emoji 形态(`AnnivIcon::emo`)。
图标 key 由 `Anniversary::occurrences()` 按分类算好带出来 ——
不要在日历里再判一次"这是生日还是忌日"(两处判据迟早分叉)。 */
$annIcon = AnnivIcon::emo((string)($a['icon'] ?? ''));
$out[] = [ $out[] = [
'cal' => 0, 'cal' => 0,
'color' => '#B4633C', 'color' => '#B4633C',
'calname' => '纪念日', 'calname' => '纪念日',
'title' => '🎂 ' . (string)$a['title'], 'title' => $annIcon . ' ' . (string)$a['title'],
'date' => (string)$a['date'], 'date' => (string)$a['date'],
'time' => '', 'time' => '',
'end_time'=> '', 'end_time'=> '',
+323
View File
@@ -0,0 +1,323 @@
<?php
/**
* 首页卡片体系(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「今天,我们正式引入卡片的概念,在首页上,概述是卡片,日程是卡片,
* 近期任务是卡片,往年的今天也是卡片,纪念日,要有一个卡片,电影、日程、
* 读书,都要有自己的卡片。例如,联系人,可以有卡片,让用户选择显示几个
* 联系人在首页上,卡片的规格,我们可以定义一下,例如,把宽度定义为 8,
* 高度可以是 2、4、6、8,这样。如果是 2x2,那么就可以一排放 4 个,
* 如果是 2x8,那就是一行放一个,类似于这样的定义。」
*
* ────────────────────────────────────────────────────────────────
* 规格口径(**先说清楚,后面所有尺寸判断都用这一套**):
* · 一排 **8 格**(`COLS = 8`)。卡片的"宽"就是占几格:2 / 4 / 6 / 8。
* w=2 → 一排 4 张;w=4 → 一排 2 张;w=8 → 独占一排。
* (用户举的"2x2 一排放 4 个"就是这个意思:宽 2、高 2。)
* · 卡片的"高"是**行数**:2 / 4 / 6 / 8。CSS 里一行 76px、间距 10px ——
* 所以 h=2 是"一块信息条",h=8 是"能滚一屏的清单"。
* · 写法统一是 **宽 × 高**(`2×4` = 占 2 格宽、4 行高)。用户在界面上看到的
* 下拉也是"宽 2 / 宽 4…"与"高 2 / 高 4…",不写 `2x8` 这种容易搞反的记法。
*
* 三条纪律(与 HomeMods / Module::navFor 同一套,踩过的坑不再踩):
* ① 存 `user_settings('home_cards')` —— 首页怎么摆是**每个人自己**的事;
* ② 读的时候只留"现在还存在的卡片"(删掉一种卡片之后,老配置里那条认不出来);
* ③ 另存 `home_cards_known` =「做这次决定时世上已知的全部卡片」——
* 只有"现在的卡片 - known"才是真的新加、才补到末尾。**不能**写成
* "不在已选里就补":那样用户主动关掉的卡片下次刷新就自己回来了。
*
* ⚠️ 卡片只负责**取数 + 声明默认尺寸**,渲染在 `templates/home/_card_*.php`。
* 不要在这里拼 HTML —— 模板层才能跟其它页面的片断复用同一套样式。
*/
declare(strict_types=1);
namespace App;
final class HomeCards
{
/** 一排几格(宽度单位) */
public const COLS = 8;
/** 允许的宽度(格)与高度(行) */
public const WIDTHS = [2, 4, 6, 8];
public const HEIGHTS = [2, 4, 6, 8];
/**
* 卡片注册表:key => [名字, 说明, 默认宽, 默认高, 详情页路由, 按钮文字, 是否有"显示几条"]
*
* ⚠️ key 就是模板名(`_card_<key>.php`)与 `data-hc` 的值,三处必须一致。
* ⚠️ 新加一张卡片只改这里 + 写一个 `_card_xxx.php`:布局设置页、默认布局、
* 首页渲染全部自动出现(`HomeMods` 那条"新增模块的唯一入口"的同一条经验)。
*/
public const TYPES = [
'brief' => [
'name' => '概述', 'desc' => '问候、日期、农历、天气,以及今天的数字',
'w' => 8, 'h' => 2, 'route' => '', 'more' => '', 'n' => 0,
],
'agenda' => [
'name' => '今天的日程', 'desc' => '按日视图那样排的今天',
'w' => 4, 'h' => 8, 'route' => 'calendar', 'more' => '更多日程', 'n' => 0,
],
'tasks' => [
'name' => '近期任务', 'desc' => '进行中与待办(含看板与状态)',
'w' => 4, 'h' => 8, 'route' => 'tasks', 'more' => '全部任务', 'n' => 0,
],
'memories' => [
'name' => '往年的今天', 'desc' => '往年同一天的观影与旅程',
'w' => 4, 'h' => 4, 'route' => '', 'more' => '', 'n' => 0,
],
'days' => [
'name' => '纪念日', 'desc' => '接下来 60 天的纪念日与生日',
'w' => 4, 'h' => 4, 'route' => 'days', 'more' => '全部纪念日', 'n' => 0,
],
'movies' => [
'name' => '观影', 'desc' => '最近看过的片子',
'w' => 4, 'h' => 4, 'route' => 'movies', 'more' => '观影记录', 'n' => 0,
],
'books' => [
'name' => '读书', 'desc' => '在读与最近加进书架的书',
'w' => 4, 'h' => 4, 'route' => 'books', 'more' => '书架', 'n' => 0,
],
'contacts' => [
'name' => '联系人', 'desc' => '名册里的人,可以选显示几个',
'w' => 4, 'h' => 4, 'route' => 'people', 'more' => '名册', 'n' => 8,
],
];
/**
* 默认布局(第一次打开首页的人看到的样子)。
*
* 排布理由:概述一条横贯(h=2,正好是一行信息);日程与任务各 4 宽、8 高
* ——它们是"今天要做什么",给足高度;下面 4 宽的几张是"翻一翻"的内容。
*/
public const DEFAULT_LAYOUT = [
['k' => 'brief', 'w' => 8, 'h' => 2],
['k' => 'agenda', 'w' => 4, 'h' => 8],
['k' => 'tasks', 'w' => 4, 'h' => 8],
['k' => 'days', 'w' => 4, 'h' => 4],
['k' => 'memories', 'w' => 4, 'h' => 4],
['k' => 'movies', 'w' => 4, 'h' => 4],
['k' => 'books', 'w' => 4, 'h' => 4],
['k' => 'contacts', 'w' => 4, 'h' => 4],
];
public static function exists(string $key): bool
{
return isset(self::TYPES[$key]);
}
public static function name(string $key): string
{
return self::TYPES[$key]['name'] ?? '';
}
/** 「显示几条」这类数字的上限:卡片再高也放不下更多 */
public const N_MAX = 30;
/**
* 这个人首页上要摆哪些卡片、各自的尺寸(按顺序)。
*
* @return array<int,array{k:string,w:int,h:int,n:int}>
*/
public static function layout(int $personId): array
{
$raw = (string)UserSetting::get($personId, 'home_cards', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::normalize(self::DEFAULT_LAYOUT);
}
$saved = self::normalize($dec);
if (!$saved) {
// 用户把卡片全关了 —— 这是合法状态(他就想要一个空首页),不兜回默认。
// ⚠️ 但"真的全关"与"存坏了"在数据上分不出来;判据是**原始串非空**,
// 所以这里只在 $raw 非空时走到,属于前者。
return [];
}
// ③ 真的新加的卡片补到末尾
$known = [];
$kraw = (string)UserSetting::get($personId, 'home_cards_known', '');
$kdec = $kraw !== '' ? json_decode($kraw, true) : null;
if (is_array($kdec)) {
foreach ($kdec as $k) {
$known[(string)$k] = true;
}
}
$have = array_column($saved, 'k');
foreach (self::TYPES as $k => $def) {
if (!isset($known[$k]) && !in_array($k, $have, true)) {
$saved[] = [
'k' => $k, 'w' => (int)$def['w'], 'h' => (int)$def['h'],
'n' => (int)$def['n'],
];
}
}
return $saved;
}
/**
* 把任意输入洗成合法布局(去重、宽度/高度夹到允许值、n 夹到 1..30)。
*
* @param array $items 元素可以是 ['k'=>..,'w'=>..,'h'=>..,'n'=>..]
* @return array<int,array{k:string,w:int,h:int,n:int}>
*/
public static function normalize(array $items): array
{
$out = [];
$seen = [];
foreach ($items as $it) {
if (!is_array($it)) {
continue;
}
$k = (string)($it['k'] ?? '');
if (!self::exists($k) || isset($seen[$k])) {
continue;
}
$seen[$k] = true;
$w = (int)($it['w'] ?? self::TYPES[$k]['w']);
$h = (int)($it['h'] ?? self::TYPES[$k]['h']);
$n = (int)($it['n'] ?? self::TYPES[$k]['n']);
$out[] = [
'k' => $k,
'w' => in_array($w, self::WIDTHS, true) ? $w : (int)self::TYPES[$k]['w'],
'h' => in_array($h, self::HEIGHTS, true) ? $h : (int)self::TYPES[$k]['h'],
'n' => max(1, min(self::N_MAX, $n > 0 ? $n : 1)),
];
}
return $out;
}
/** 保存(顺序即数组顺序;不在数组里的就是关掉的) */
public static function save(int $personId, array $items): void
{
$out = self::normalize($items);
UserSetting::set($personId, 'home_cards', json_encode($out, JSON_UNESCAPED_UNICODE));
UserSetting::set($personId, 'home_cards_known',
json_encode(array_keys(self::TYPES), JSON_UNESCAPED_UNICODE));
}
/**
* 设置页要用的展示顺序:已开的按现有顺序在前,没开的按注册表顺序在后。
* 每一项都带上"当前尺寸"(没开的用它自己的默认值,勾上就是那个尺寸)。
*/
public static function orderedForPicker(int $personId): array
{
$on = self::layout($personId);
$rows = [];
foreach ($on as $c) {
$rows[] = $c + ['on' => true];
}
$have = array_column($on, 'k');
foreach (self::TYPES as $k => $def) {
if (in_array($k, $have, true)) {
continue;
}
$rows[] = [
'k' => $k, 'w' => (int)$def['w'], 'h' => (int)$def['h'],
'n' => (int)$def['n'], 'on' => false,
];
}
return $rows;
}
// ==================== 取数 ====================
/**
* 按布局取数(**只取用到的**:用户关掉的卡片不该白查一次库)。
*
* brief / agenda / tasks / memories 的数据由首页路由本来就算好了
* (今天日程、近期任务、往年的今天都是"今天"这一屏的核心数据),
* 这里只补那几张"各自一块小查询"的卡片 —— 同一份数据不重复算。
*
* @param array $layout self::layout() 的返回值
* @return array<string,mixed> key => 数据
*/
public static function data(array $layout, int $familyId, int $me): array
{
$out = [];
foreach ($layout as $c) {
$k = (string)$c['k'];
switch ($k) {
case 'days':
$out['days'] = self::daysData($familyId, $me);
break;
case 'movies':
$out['movies'] = self::moviesData($familyId);
break;
case 'books':
$out['books'] = self::booksData($familyId);
break;
case 'contacts':
$out['contacts'] = self::contactsData($familyId, $me, (int)($c['n'] ?? 8));
break;
default:
break;
}
}
return $out;
}
/** 接下来 60 天的纪念日(按"还有几天"升序,最多 12 条 —— 卡片就那么大) */
public static function daysData(int $familyId, int $me): array
{
$rows = Anniversary::upcoming($familyId, $me, 60);
return array_slice($rows, 0, 12);
}
/**
* 最近看过的片子(8 条)。
*
* ⚠️ 不给 `Movie::all()` 加参数而是这里自己查:all() 会顺带
* attachAttendees + review 统计(两次额外查询),首页卡片上根本不显示这些。
*/
public static function moviesData(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT e.id, e.title, e.starts_at, d.poster_key, c.name AS cinema_name
FROM events e
LEFT JOIN movie_details d ON d.event_id = e.id
LEFT JOIN cinemas c ON c.id = d.cinema_id
WHERE e.family_id = ? AND e.kind = "movie"
ORDER BY e.starts_at DESC, e.id DESC
LIMIT 8'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 书架(在读优先,最多 6 本)。
*
* ⚠️ 排序用 `FIELD(status,...)` 而不是"先在 SQL 里筛 reading 再 UNION 其余" ——
* 一条语句、一个索引扫描,读起来也不会分成两段逻辑。
*/
public static function booksData(int $familyId): array
{
$st = Database::get()->prepare(
"SELECT id, title, author, status, progress, chapter_count, cover_key
FROM books
WHERE family_id = ?
ORDER BY FIELD(status, 'reading', 'wish', 'paused', 'done'), id DESC
LIMIT 6"
);
$st->execute([$familyId]);
return $st->fetchAll();
}
/**
* 联系人卡片:只取"我名册里的人",前面 n 个。
*
* 用户原话:「联系人,可以有卡片,让用户选择显示几个联系人在首页上。」
* ⚠️ 用 `Person::roster()` 而不是 `Person::map()`:名册是**每人一份**的
* (我加的人 + 别人分享给我的),首页是"我的首页",不该把别人名册里的人
* 也铺上来。`Person::map()` 会绕过这个边界。
*
* @return array{rows:array,total:int}
*/
public static function contactsData(int $familyId, int $me, int $n): array
{
$people = Person::roster($familyId, $me, 'member', '');
$total = count($people);
$n = max(1, min(self::N_MAX, $n));
return ['rows' => array_slice($people, 0, $n), 'total' => $total];
}
}
+204
View File
@@ -6122,3 +6122,207 @@ h5.md-h, h6.md-h { font-size: 14px; }
.view-choice-item input { margin-top: 3px; flex: 0 0 auto; } .view-choice-item input { margin-top: 3px; flex: 0 0 auto; }
.view-choice-item .vci-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .view-choice-item .vci-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.view-choice-item .vci-body b { font-size: 14px; } .view-choice-item .vci-body b { font-size: 14px; }
/* ==================================================================== */
/* v62(用户 2026-10-18 第 2 条):分类图标 */
/* */
/* 分类徽标 = 图标 + 名字。图标是行内 SVG(AnnivIcon),颜色取 currentColor */
/* ⇒ 放在浅底徽标里就是灰字色,放在深色格子里自动变白,不需要第二套配色。 */
/* ==================================================================== */
.kind-badge {
display: inline-flex; align-items: center; gap: 4px;
vertical-align: middle;
max-width: 100%;
}
.kind-badge .ai { flex: 0 0 auto; opacity: .9; }
.kind-badge .kind-name {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
max-width: 9em; /* 分类名最长 20 字,卡一行不撑破表格 */
}
.ann-ic { display: inline-flex; align-items: center; flex: 0 0 auto; color: var(--muted); }
.ann-ic .ai { display: block; }
/* ---- 分类设置页(days/settings)---- */
.kind-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.kind-row {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
padding: 8px 10px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface);
/* ⚠️ 行内每项都可能收缩(分类名输入框),flex 子项默认 min-width:auto 会顶破 */
min-width: 0;
}
.kind-row.is-ro { gap: 10px; }
.kind-move { display: inline-flex; gap: 2px; flex: 0 0 auto; }
.kind-prev {
flex: 0 0 auto; width: 30px; height: 30px; border-radius: 8px;
display: inline-flex; align-items: center; justify-content: center;
background: var(--line-2); color: var(--ink);
}
.kind-sel { flex: 0 0 auto; width: 190px; max-width: 46vw; }
.kind-name-in { flex: 1 1 140px; min-width: 0; }
.kind-name-ro { font-size: 14px; }
.kind-use { flex: 0 0 auto; font-size: 12px; color: var(--muted); min-width: 46px; text-align: right; }
/* 改过但没保存:保存按钮描一圈告警色 + 旁边一句小字(JS 加 .is-dirty / 去掉 hidden)
⚠️ 用 `.btn-warn` 同一个 #E8A33D(全站"待处理"的语义色),不要另发明一个黄。 */
.btn.is-dirty { box-shadow: 0 0 0 2px #E8A33D; }
.kind-dirty { color: #b57a19; margin-left: 10px; }
.kind-add {
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
margin-top: 14px; padding: 10px; border-radius: var(--radius-s);
background: var(--line-2);
}
.kind-add .select { flex: 0 0 auto; width: 190px; max-width: 46vw; }
.kind-add .input { flex: 1 1 160px; min-width: 0; }
.kind-add-lb { font-size: 13px; color: var(--muted); }
.kind-icon-bank { display: none; }
/* ==================================================================== */
/* v62(用户 2026-10-18 第 3 条):首页卡片网格 */
/* */
/* 口径(与 app/HomeCards.php、templates/home_cards.php 的措辞一致): */
/* · 一排 8 格;宽 2/4/6/8 → 一排 4 张 / 2 张 / 一大一小 / 独占一排 */
/* · 高 2/4/6/8 行;一行 76px、间距 10px */
/* */
/* ⚠️ 为什么用 `grid-template-columns: repeat(8, minmax(0,1fr))` 而不是 */
/* flex + 百分比:8 格制下"宽 6"(一大一小)只有网格能自然表达; */
/* `minmax(0,1fr)` 里的 0 对应 flex 那条 `min-width:0` —— */
/* 少了它,卡片里的长标题会把整列顶宽(v54 那个坑的同源)。 */
/* ⚠️ 高度用 `grid-row: span N` 而不是给每张卡片算像素: */
/* 这样"高 4"的两张卡片并排时天然等高,不依赖内容多少。 */
/* ==================================================================== */
.hc-grid {
display: grid;
grid-template-columns: repeat(8, minmax(0, 1fr));
grid-auto-rows: 76px;
gap: 10px;
align-items: stretch;
}
.hc-card {
display: flex; flex-direction: column;
min-width: 0; min-height: 0;
margin: 0;
padding: 12px 14px;
overflow: hidden;
}
/* ⚠️ 必须单独写这一条,而且选择器要**比 `.card + .card` 更重**:
上面 `.hc-card { margin: 0 }` 只有 0,1,0,而全局相邻间距
`.card + .card { margin-top: 18px }` 是 0,2,0 —— 权重更高,会把
每一张**非首张**的卡片顶下去 18px(实测症状:高 2 期望 162 对得上,
高 4/高 8 恒少 18px,因为网格首个子元素不命中 `+ .card`)。
间距本来就由 grid 的 `gap` 统一给,这里把相邻 margin 彻底清掉。 */
.hc-grid > .card + .card { margin-top: 0; }
.hc-w2 { grid-column: span 2; }
.hc-w4 { grid-column: span 4; }
.hc-w6 { grid-column: span 6; }
.hc-w8 { grid-column: span 8; }
.hc-h2 { grid-row: span 2; }
.hc-h4 { grid-row: span 4; }
.hc-h6 { grid-row: span 6; }
.hc-h8 { grid-row: span 8; }
.hc-head {
display: flex; align-items: center; gap: 10px;
flex: 0 0 auto; min-width: 0; margin-bottom: 8px;
}
.hc-head .cinema-h2 { margin: 0; flex: 1 1 auto; min-width: 0; }
.hc-head .hd-more { flex: 0 0 auto; }
/* 卡片正文:**自己滚**,不撑卡片(用户给的高度就是最终高度)。 */
.hc-body {
flex: 1 1 auto; min-height: 0; min-width: 0;
overflow-y: auto; overscroll-behavior: contain;
scrollbar-width: none; -ms-overflow-style: none;
}
.hc-body::-webkit-scrollbar { width: 0; height: 0; }
/* 概述卡:左右两栏(问候/数字 | 天气),与原来的信息面板一致 */
.hc-body.home-brief { align-content: flex-start; }
/* 日程卡:内部还要再分"全天区 + 时间网格",所以它自己是一个纵向 flex,
滚动交给内层 `.hd-pane-body`(这样全天区才能钉在上面不动)。 */
.hc-body.hc-agenda { display: flex; flex-direction: column; overflow: hidden; }
/* 卡片里的 pane 不再有 440px 的固定高(那是 v24 双栏时代的值) */
.hc-card .hd-pane-body { flex: 1 1 auto; min-height: 0; }
.hc-card .ag-allday { max-height: 40%; }
/* ---- 纪念日卡片 ---- */
.hc-ann .ann-up-item { gap: 6px; padding: 7px 2px; }
.hc-ann .ann-days { min-width: 52px; }
.hc-ann .ann-title { font-size: 13.5px; }
/* ---- 观影卡片:海报 + 片名 ---- */
.hc-mv-list, .hc-bk-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.hc-mv, .hc-bk {
display: flex; align-items: center; gap: 10px; min-width: 0;
padding: 6px 2px; border-bottom: 1px solid var(--line-2);
}
.hc-mv:last-child, .hc-bk:last-child { border-bottom: 0; }
.hc-mv-poster, .hc-bk-cover {
flex: 0 0 auto; width: 34px; height: 46px; border-radius: 4px; overflow: hidden;
background: var(--line-2); display: inline-flex; align-items: center; justify-content: center;
}
.hc-bk-cover { width: 32px; height: 44px; }
.hc-mv-poster img, .hc-bk-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hc-mv-ph, .hc-bk-ph { font-size: 16px; opacity: .7; }
.hc-mv-main, .hc-bk-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.hc-mv-t, .hc-bk-t {
font-size: 13.5px; color: var(--ink); font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.hc-bk-bar { display: block; height: 3px; border-radius: 2px; background: var(--line-2); overflow: hidden; }
.hc-bk-bar i { display: block; height: 100%; background: var(--brand); }
.hc-bk-st {
flex: 0 0 auto; font-size: 11px; padding: 1px 7px; border-radius: 999px;
background: var(--line-2); color: var(--muted);
}
.hc-bk-st.is-reading { background: #E7F0DA; color: #4A7226; }
.hc-bk-st.is-done { background: #E3EAF5; color: #3A5B8C; }
/* ---- 联系人卡片:头像 + 名字,按卡片宽度自动折行 ---- */
.hc-people { display: flex; flex-wrap: wrap; gap: 10px 12px; }
.hc-person {
display: flex; flex-direction: column; align-items: center; gap: 4px;
width: 56px; min-width: 0; text-decoration: none;
}
.hc-person-av {
width: 40px; height: 40px; border-radius: 50%; object-fit: cover;
display: inline-flex; align-items: center; justify-content: center;
font-size: 15px; font-weight: 600;
}
.hc-person-nm {
font-size: 11.5px; color: var(--muted); max-width: 100%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.hc-person:hover .hc-person-nm { color: var(--ink); }
/* ---- 窄屏:8 格 → 4 格 → 1 列 ---- */
@media (max-width: 1180px) {
.hc-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.hc-w2 { grid-column: span 2; }
.hc-w4, .hc-w6, .hc-w8 { grid-column: span 4; }
.hc-h8 { grid-row: span 7; }
}
@media (max-width: 680px) {
.hc-grid { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 64px; }
.hc-w2, .hc-w4, .hc-w6, .hc-w8 { grid-column: span 1; }
.hc-h6, .hc-h8 { grid-row: span 6; }
}
/* ---- 首页卡片布局设置页 ---- */
.hc-set-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.hc-set-row {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-s);
background: var(--surface); min-width: 0;
}
.hc-set-row.is-dragged { opacity: .5; }
.hc-set-row.is-drop-before { border-top: 2px solid var(--brand); }
.hc-set-order { display: inline-flex; align-items: center; gap: 2px; flex: 0 0 auto; }
.hc-set-on {
display: flex; align-items: center; gap: 8px;
flex: 1 1 220px; min-width: 0; cursor: pointer;
}
.hc-set-name { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.hc-set-name b { font-size: 14px; }
.hc-set-name .small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hc-set-dim { display: inline-flex; align-items: center; gap: 4px; flex: 0 0 auto; }
.hc-set-dim .select { width: 66px; }
.hc-set-n { width: 64px; }
.hc-set-null { width: 0; }
+208 -5
View File
@@ -29,6 +29,7 @@ use App\Expense;
use App\Family; use App\Family;
use App\Geo; use App\Geo;
use App\Home; use App\Home;
use App\HomeCards;
use App\HomeMods; use App\HomeMods;
use App\Holiday; use App\Holiday;
use App\Anniversary; use App\Anniversary;
@@ -847,6 +848,16 @@ switch ($r) {
$homeMods = HomeMods::forPerson($me); $homeMods = HomeMods::forPerson($me);
$moduleCards = Home::moduleCards($familyId, $me, $soon, $tasks); $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')); $weekEnd = date('Y-m-d', strtotime('+7 days'));
$st7 = Database::get()->prepare( $st7 = Database::get()->prepare(
@@ -936,6 +947,9 @@ switch ($r) {
'today' => $today, 'today' => $today,
'homeModules'=> $homeMods, 'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards, 'moduleCards'=> $moduleCards,
// v62 卡片体系
'homeCards' => $homeCards,
'cardData' => $cardData,
// v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php) // v30 第 5 条:通知块(放在「往年的今天」之前,见 templates/home.php)
// ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方, // ⚠️ 首页只给**最新的几条**(10 条)就够 —— 它是"瞥一眼"的地方,
// 真正的消息中心在 ?r=notifications。 // 真正的消息中心在 ?r=notifications。
@@ -944,6 +958,84 @@ switch ($r) {
], 'home'); ], 'home');
break; 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': case 'kin':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
@@ -1760,7 +1852,12 @@ switch ($r) {
$navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认 $navKeys = Module::DEFAULT_NAV; // 一个都不勾 = 导航条会空掉,退回默认
} }
Module::saveNav($me, $navKeys); Module::saveNav($me, $navKeys);
/* ⚠️ v62:模块设置里**已经没有「首页」那一列**(首页改由「首页卡片」摆,
见 HomeCards)。所以只有表单真的带了 `home[]` 才写 —— 否则每次保存导航
都会把 HomeMods 清成空数组("关了还在"的反面:"没动也丢")。 */
if (array_key_exists('home', $_POST)) {
HomeMods::save($me, $homeKeys); HomeMods::save($me, $homeKeys);
}
// 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错) // 主题色:家庭一份,只有管理员能改(非管理员提交时**静默忽略**,不报错)
if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) { if (Auth::isAdmin() && isset($_POST['c']) && is_array($_POST['c'])) {
@@ -4638,12 +4735,26 @@ $want = array_keys($want);
foreach ($people as $pp) { foreach ($people as $pp) {
$pmap[(int)$pp['id']] = $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', [ View::render('days/index', [
'title' => '纪念日', 'title' => '纪念日',
'custom' => $sp['custom'], 'custom' => $sp['custom'],
'derived' => $sp['derived'], 'derived' => $sp['derived'],
'upcoming' => Anniversary::upcoming($familyId, $me, 60), // v62:别人记的、与我无关的条数(只在页面上给一句小字)
'hidden' => $sp['hidden'],
'upcoming' => $upcoming,
'kinds' => Anniversary::kinds($familyId), 'kinds' => Anniversary::kinds($familyId),
'kindsFull' => Anniversary::kindsFull($familyId),
'kindCounts' => Anniversary::kindCounts($familyId), 'kindCounts' => Anniversary::kindCounts($familyId),
'people' => $people, 'people' => $people,
'peopleMap' => $pmap, 'peopleMap' => $pmap,
@@ -4651,12 +4762,102 @@ $want = array_keys($want);
break; 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': case 'days/kinds':
$familyId = (int)Auth::familyId(); $familyId = (int)Auth::familyId();
@@ -4724,6 +4925,8 @@ $want = array_keys($want);
$anArgs = [ $anArgs = [
'editing' => $editing, 'editing' => $editing,
'kinds' => Anniversary::kinds($familyId), 'kinds' => Anniversary::kinds($familyId),
// v62:带图标的清单(下拉里用 emoji 打头,见 form.php)
'kindsFull' => Anniversary::kindsFull($familyId),
'people' => Person::roster($familyId, $me, 'member', ''), 'people' => Person::roster($familyId, $me, 'member', ''),
/* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」 /* 🔴 v41(用户第 10 条):「新建纪念日,采用弹框形式。」
直达 `days/new` 时也渲染成**打开着的弹框** —— 直达 `days/new` 时也渲染成**打开着的弹框** ——
+37 -49
View File
@@ -1,63 +1,51 @@
<?php <?php
/** /**
* 纪念日模块的「设置」下拉(v24 用户 2026-10-03 第 9 条) * 纪念日模块的「设置」下拉(v24 用户 2026-10-03 第 9 条;🔴 v62 改成菜单)。
* *
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」 * v24 原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
* —— 当时做成了"下拉里直接铺一张分类增删表单"。
* *
* 分类清单是**家庭级**的(settings.anniv_kinds)—— 全家共用一套叫法, * 🔴 v62(用户 2026-10-18 第 2 条):「设置按钮,点击后弹菜单,**第一个菜单是分类设置**,
* 不然「结婚纪念日」在两个人屏幕上是两套名字,看的时候对不上。 * 进去之后可以设置分类,每一类纪念日都有不同的图标……」
* 所以这里跟「模块主题色」一样:**能改的是所有人**,但改的是同一份。 * ⇒ 与日程/观影/任务三个模块**统一手感**(☰ → 一串列表 → 点进独立页):
* 下拉里**不放表单**,只放一条条目的链接;分类的增删改与图标选择都在
* `days/settings` 那一页上(见 templates/days/settings.php)。
*
* 为什么必须这么改(与日程那条同一个理由,别丢):分类是**家庭级**的,
* 一个家能有十几二十个分类,每个分类还要选图标 —— 全铺在下拉里,
* 下拉会被撑到屏幕外,而"设置"按钮点开本来就该是"一个目录"。
* *
* 用法(require 前 set 好): * 用法(require 前 set 好):
* $kinds 当前分类清单 * $setCurrent 当前页 key('kinds')用来高亮;'' 也行(从别处挂过来时没有当前项)
* $kindCounts 每个分类用了几条(显示"这个分类有没有在用")
*/ */
$daySetKinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS); $setCurrent = (string)($setCurrent ?? '');
$daySetCounts = (array)($kindCounts ?? []); $setOn = static function (string $key) use ($setCurrent): string {
$daySetCanWrite = \App\Auth::canWrite(); return $setCurrent === $key ? ' class="on"' : '';
};
$setIcon = static function (string $name): string {
$paths = [
// 分类:三个小方块("一格一类")
'kinds' => '<rect x="2" y="2.2" width="5" height="5" rx="1.2"/>'
. '<rect x="9" y="2.2" width="5" height="5" rx="1.2"/>'
. '<rect x="2" y="9" width="5" height="5" rx="1.2"/>'
. '<rect x="9" y="9" width="5" height="5" rx="1.2"/>',
];
$d = $paths[$name] ?? '';
if ($d === '') {
return '';
}
return '<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"'
. ' fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
};
?> ?>
<details class="movie-set menu ann-set"> <details class="movie-set menu ann-set">
<summary class="btn btn-icon" title="纪念日设置" aria-label="纪念日设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary> <summary class="btn btn-icon" title="纪念日设置" aria-label="纪念日设置"><svg viewBox="0 0 16 16" width="18" height="18" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"/></svg></summary>
<div class="menu-list ann-set-list"> <div class="menu-list ann-set-list">
<div class="cal-set-h">纪念日分类(全家共用一套)</div> <?php /* 第一项(也是目前唯一一项):分类设置 —— 名称 + 图标 + 删除 + 排序 */ ?>
<a href="<?= e(url('days/settings')) ?>"<?= $setOn('kinds') ?>><?= $setIcon('kinds') ?><span>分类设置</span></a>
<?php if (!$daySetKinds): ?>
<p class="small muted" style="margin:0 0 8px">一个分类都没有。下面加一个。</p>
<?php else: ?>
<ul class="ann-kind-list">
<?php foreach ($daySetKinds as $k): ?>
<li>
<span class="ann-kind-name"><?= e((string)$k) ?></span>
<span class="small muted"><?= (int)($daySetCounts[(string)$k] ?? 0) ?> 条在用</span>
<?php if ($daySetCanWrite): ?>
<form method="post" action="<?= e(url('days/kinds')) ?>" class="res-inline">
<?= csrf_field() ?>
<input type="hidden" name="del" value="<?= e((string)$k) ?>">
<button class="btn btn-ghost btn-sm" type="submit"
title="删掉这个分类(已用它的纪念日不变)">删</button>
</form>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ($daySetCanWrite): ?>
<form method="post" action="<?= e(url('days/kinds')) ?>" class="led-set-form-in" style="margin-top:8px">
<?= csrf_field() ?>
<input class="input" name="add" maxlength="20" placeholder="新分类,如:相识纪念日">
<button class="btn btn-sm" type="submit">加分类</button>
</form>
<?php endif; ?>
<p class="small muted" style="margin:8px 0 0">
删掉一个分类**不会**改已用过它的纪念日 —— 只是新建时不再出现在下拉里。
</p>
</div> </div>
</details> </details>
<?php <?php
// ⚠️ 只 unset **本片段自己定义**的那三个前缀变量。 // ⚠️ 只 unset **本片段自己定义**的变量,绝不碰宿主的 $setCurrent / $kinds / $canWrite。
// 绝不能再写 `unset($kinds, $kindCounts, $canWrite)` —— days/index.php 里 unset($setCurrent, $setOn, $setIcon);
// 同样有 $canWrite(还有 $kinds),那样会把宿主模板的变量一起干掉,
// 后面每一处用到的地方都变成 Undefined variable(账本那个片段刚这么炸过一次)。
unset($daySetKinds, $daySetCounts, $daySetCanWrite);
+14 -5
View File
@@ -30,11 +30,20 @@ $kinds = $kinds ?? null;
$e0 = $editing ?: []; $e0 = $editing ?: [];
$v = static fn(string $k, $def = '') => $e0[$k] ?? $def; $v = static fn(string $k, $def = '') => $e0[$k] ?? $def;
$repeat = (string)($e0['repeat_type'] ?? 'solar'); $repeat = (string)($e0['repeat_type'] ?? 'solar');
// 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单 /* 用户 2026-10-03 第 9 条:分类下拉的选项来自**可配置**的分类清单。
$kinds = (array)($kinds ?? \App\Anniversary::PRESET_KINDS); 🔴 v62:清单改成"名称 + 图标"(`kindsFull`)。下拉里的 `<option>` 放不进 SVG,
所以这里用同一图标的 emoji 形态打头(与日历里一致)—— 用户一眼能对上
"下拉里选的那个"和"列表/日历上显示的那个"是同一个。 */
$kindsFull = (array)($kindsFull ?? \App\Anniversary::kindsFull((int)\App\Auth::familyId()));
$kinds = array_column($kindsFull, 'name');
$curKind = (string)($e0['kind'] ?? ''); $curKind = (string)($e0['kind'] ?? '');
if ($curKind !== '' && !in_array($curKind, $kinds, true)) { if ($curKind !== '' && !in_array($curKind, $kinds, true)) {
array_unshift($kinds, $curKind); // 老数据里的分类名可能已经从清单里删掉了 —— 仍然列出来,否则一保存就被清空
array_unshift($kindsFull, [
'name' => $curKind,
'icon' => \App\Anniversary::defaultIcon($curKind),
]);
$kinds = array_column($kindsFull, 'name');
} }
/* 🔴 v41 弹框开关(与观影/旅程同一套命名习惯): /* 🔴 v41 弹框开关(与观影/旅程同一套命名习惯):
@@ -108,8 +117,8 @@ $backUrl = url('days');
<span class="mv-lb">分类</span> <span class="mv-lb">分类</span>
<select class="select" id="an-kind" name="kind"> <select class="select" id="an-kind" name="kind">
<option value="">不分类</option> <option value="">不分类</option>
<?php foreach ($kinds as $k): ?> <?php foreach ($kindsFull as $kf): ?>
<option value="<?= e((string)$k) ?>" <?= $curKind === (string)$k ? 'selected' : '' ?>><?= e((string)$k) ?></option> <option value="<?= e((string)$kf['name']) ?>" <?= $curKind === (string)$kf['name'] ? 'selected' : '' ?>><?= e(\App\AnnivIcon::emo((string)$kf['icon']) . ' ' . (string)$kf['name']) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</div> </div>
+110 -31
View File
@@ -13,20 +13,39 @@
* *
* ⇒ 三块(顺序按用户第 9 条要求): * ⇒ 三块(顺序按用户第 9 条要求):
* ① 接下来的日子(未来 60 天) * ① 接下来的日子(未来 60 天)
* ② **手动添加的**纪念日(在上)—— 可改可删;右侧按钮:改=蓝(.btn)、删=黄(.btn-warn) * ② **与我相关**的纪念日(在上)—— 可改可删;右侧按钮:改=蓝(.btn)、删=黄(.btn-warn)
* ③ 来自名册生日的(在下,实时派生、不能改删) * ③ 来自名册的生日(在下,实时派生、不能改删)
* *
* @param array $custom 用户自建的(行上带 person_ids) * 🔴 v62(用户 2026-10-18 第 1、2 条)改了四处:
* ① 两块标题:「我自己添加的」→「与我相关」、「来自名册的」→「来自名册」,
* 并**删掉**那句"这一块是实时派生的…"的说明;
* ② 「关于我相关」这一块要收进"别人添加、但把我也挂成关联人"的条目
* (判据见 `Anniversary::split()`);
* ③ 「看日历」按钮去掉(日历在顶栏就有,页面里再放一个只是多一个入口);
* ④ 分类徽标带图标(`AnnivIcon`),日历里用同一图标的 emoji 形态。
*
* @param array $custom 与我相关的(行上带 person_ids)
* @param array $derived 从名册生日派生的(行上也有 person_ids) * @param array $derived 从名册生日派生的(行上也有 person_ids)
* @param int $hidden 别人记的、与我无关的条数(只用于一句小字说明)
* @param array $upcoming 接下来 60 天的 * @param array $upcoming 接下来 60 天的
* @param array $kinds 分类清单(设置里维护) * @param array $kinds 分类清单(设置里维护)
* @param array $people 名册(把 person id 换成头像 + 称呼) * @param array $people 名册(把 person id 换成头像 + 称呼)
*/ */
$canWrite = \App\Auth::canWrite(); $canWrite = \App\Auth::canWrite();
$kindBadge = static function (array $r): string { $famId = (int)\App\Auth::familyId();
/* 分类徽标:图标 + 名字(v62 第 2 条「列表中…要根据分类,显示不同的图标」)。
⚠️ 图标走 `Anniversary::iconOf()`(单点来源)—— 不要在模板里再写一遍
"生日用蛋糕、忌日用蜡烛"的映射,否则设置页改了图标这里不跟着变。 */
$kindBadge = static function (array $r) use ($famId): string {
$k = (string)($r['kind'] ?? ''); $k = (string)($r['kind'] ?? '');
return $k === '' ? '' : '<span class="node-badge">' . e($k) . '</span>'; 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 { $srcWord = static function (array $r): string {
$src = (string)($r['source'] ?? 'custom'); $src = (string)($r['source'] ?? 'custom');
if ($src === 'birthday_solar') { if ($src === 'birthday_solar') {
@@ -105,10 +124,24 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td class="ann-people"><?= $peopleCell($r) ?></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><?= $kindBadge($r) ?></td>
<td class="small muted"><?= e($srcWord($r)) ?></td> <td class="small muted"><?= e($srcWord($r)) ?></td>
<td class="nowrap ann-acts"> <td class="nowrap"><div class="ann-acts">
<?php if ($editable && $canWrite): ?> <?php if ($editable && $canWrite): ?>
<?php /* 用户第 2 条:「右侧按钮,和其他模块的按钮样式保持一致, <?php /* 用户第 2 条:「右侧按钮,和其他模块的按钮样式保持一致,
修改用蓝色,删除用黄色。」 修改用蓝色,删除用黄色。」
@@ -135,7 +168,7 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
<?php else: ?> <?php else: ?>
<span class="small muted">来自名册</span> <span class="small muted">来自名册</span>
<?php endif; ?> <?php endif; ?>
</td> </div></td>
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
@@ -156,8 +189,12 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
data-href="<?= e(url('days/new')) ?>" data-href="<?= e(url('days/new')) ?>"
aria-haspopup="dialog" aria-expanded="false">新建纪念日</button> aria-haspopup="dialog" aria-expanded="false">新建纪念日</button>
<?php endif; ?> <?php endif; ?>
<a class="btn btn-ghost" href="<?= e(url('calendar')) ?>">看日历</a> <?php /* 🔴 v62(用户 2026-10-18 第 2 条):「纪念日,看日历这个按钮去掉。」
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右) */ ?> 理由(用户没说的那一半):日历在**顶栏**上一直有,这一页再放一个
"看日历"只是同一件事的第二入口,还占掉了本该给「设置」的位置。
⚠️ 不要再"改个名字留着" —— 用户说的是去掉。 */ ?>
<?php /* 用户第 9 条:设置按钮就在「新建纪念日」右边(☰,排最右)。
v62:点开是**菜单**,第一项「分类设置」(可给每个分类配图标)。 */ ?>
<?php $setCurrent = 'kinds'; require __DIR__ . '/_settings_menu.php'; ?> <?php $setCurrent = 'kinds'; require __DIR__ . '/_settings_menu.php'; ?>
</div> </div>
</div> </div>
@@ -214,12 +251,34 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
(container || document.body).appendChild(s); (container || document.body).appendChild(s);
s.parentNode.removeChild(s); s.parentNode.removeChild(s);
} }
function loadEdit(url) { /* 🔴🔴 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(); var c = card();
if (!c || !window.fetch) { if (url) { window.location.href = url; } return; } 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; // 先打开(显示"载入中"占位) c.hidden = false; // 先打开(显示"载入中"占位)
var lb = c.querySelector('#anNewCardTitle, .cal-modal-head b'); var lb = c.querySelector('#anNewCardTitle, .cal-modal-head b');
if (lb) { lb.textContent = '编辑纪念日'; } if (lb) { lb.textContent = title; }
fetch(url, { credentials: 'same-origin', fetch(url, { credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' } }) headers: { 'X-Requested-With': 'XMLHttpRequest' } })
.then(function (r) { return r.text(); }) .then(function (r) { return r.text(); })
@@ -231,17 +290,29 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
c.parentNode.replaceChild(fresh, c); c.parentNode.replaceChild(fresh, c);
runInlineScripts(fresh, html); runInlineScripts(fresh, html);
setOpen(true); setOpen(true);
/* 换了新片段之后,标题框里是空的 ⇒ 后面几次开合都不会再发请求 */
var b = document.getElementById('anNewBtn');
if (b) { b.setAttribute('aria-expanded', 'true'); }
}) })
.catch(function () { window.location.href = url.replace(/[?&]embed=1/, ''); }); .catch(function () { window.location.href = url.replace(/[?&]embed=1/, ''); });
} }
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
var t = e.target; var t = e.target;
if (!t || !t.closest) { return; } if (!t || !t.closest) { return; }
if (t.closest('#anNewBtn')) { e.preventDefault(); setOpen(true); 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]'); var ed = t.closest('[data-an-edit]');
if (ed) { if (ed) {
e.preventDefault(); e.preventDefault();
loadEdit(ed.getAttribute('data-href') || ''); loadForm(ed.getAttribute('data-href') || '', '编辑纪念日');
return; return;
} }
if (t.closest('#anNewX') || t.closest('#anNewCancel')) { setOpen(false); return; } if (t.closest('#anNewX') || t.closest('#anNewCancel')) { setOpen(false); return; }
@@ -341,15 +412,17 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php /* ==================== ② 手动添加的(在上,用户第 9 条)==================== */ ?> <?php /* ==================== ② 与我相关的(在上,用户第 9 条)====================
🔴 v62(用户 2026-10-18 第 1 条):标题「我自己添加的(4)」+「只有这些能改能删」
→ **「与我相关」**(hint 整条删掉)。
理由:这一块现在不只有"我添加的",还有"别人添加、但把我挂成了关联人"的
(判据见 Anniversary::split())。继续叫"我自己添加的"就是错的;
而"只有这些能改能删"这句在**功能上**没必要跟用户解释(按按钮能改能删,一看就懂)。 */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"> <h2 class="cinema-h2">与我相关(<?= count($custom) ?>)</h2>
我自己添加的(<?= count($custom) ?>)
<span class="hint">只有这些能改能删</span>
</h2>
<?php if (!$custom): ?> <?php if (!$custom): ?>
<p class="empty-hint"> <p class="empty-hint">
还没有自己添加的纪念日。 还没有与你相关的纪念日。
<?php if ($canWrite): ?> <?php if ($canWrite): ?>
结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。 结婚纪念日、忌日、相识的日子…点右上角「新建纪念日」加一条。
<?php endif; ?> <?php endif; ?>
@@ -357,24 +430,30 @@ $renderTable = function (array $rows, bool $editable) use ($canWrite, $kindBadge
<?php else: ?> <?php else: ?>
<?php $renderTable($custom, true); ?> <?php $renderTable($custom, true); ?>
<?php endif; ?> <?php endif; ?>
<?php /* ⚠️ 别人记的、只跟他自己相关的那些不在这儿出现("与我相关"就该是相关的)。
但如果**一条都不提示**,用户可能以为数据丢了 —— 给一句小字,并说清去哪儿找。 */ ?>
<?php if ((int)($hidden ?? 0) > 0): ?>
<p class="small muted" style="margin:10px 0 0">
另有 <?= (int)$hidden ?> 条是别人记的、与你无关,这里不列;日历里照旧能看到。
</p>
<?php endif; ?>
</div> </div>
<?php /* ==================== ③ 来自名册的生日(在下)==================== */ ?> <?php /* ==================== ③ 来自名册的生日(在下)====================
🔴 v62(用户 2026-10-18 第 1 条):标题「来自名册的(20)」+「实时读出来的,
改生日这里跟着变」→ **「来自名册」**;下面那段"这一块是实时派生的…"整段删掉。
为什么删:那是**实现说明**,用户看的是日子,不是数据从哪来 ——
而"改生日这里跟着变"已经由表格右上那句「来自名册」暗示得够清楚了。 */ ?>
<div class="card card-pad"> <div class="card card-pad">
<h2 class="cinema-h2"> <h2 class="cinema-h2">来自名册(<?= count($derived) ?>)</h2>
来自名册的(<?= count($derived) ?>)
<span class="hint">实时读出来的,改生日这里跟着变</span>
</h2>
<?php if (!$derived): ?> <?php if (!$derived): ?>
<p class="empty-hint"> <p class="empty-hint">
名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。 名册里还没有人填过生日。到「名册 → 编辑成员」把生日填上,这里就会自动出现。
</p> </p>
<?php else: ?> <?php else: ?>
<?php $renderTable($derived, false); ?> <?php $renderTable($derived, false); ?>
<p class="tip" style="margin-top:10px">
这一块是**实时派生**的 —— 改生日,这里跟着变,不用重复维护。
不需要在这里新建,也没法单独删(删掉生日就等于删了它)。
</p>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div><?php /* /#anListHost */ ?> </div><?php /* /#anListHost —— 这个 div 只开了一次(见上面 `<div id="anListHost">`),
收尾也只能有一次。曾经这一行被复制成两行 ⇒ 整个页面的 div 深度变成 -1,
元素跑到容器外(流水线的"div 嵌套"检查抓到的就是这个)。 */ ?>
+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>
+76 -559
View File
@@ -1,60 +1,38 @@
<?php <?php
/** /**
* 首页 —— 一块 dashboard。 * 首页 —— 卡片网格(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「今天,我们正式引入卡片的概念,在首页上,概述是卡片,日程是卡片,
* 近期任务是卡片,往年的今天也是卡片,纪念日,要有一个卡片,电影、日程、
* 读书,都要有自己的卡片。……卡片的规格,我们可以定义一下,例如,把宽度
* 定义为 8,高度可以是 2、4、6、8,这样。如果是 2x2,那么就可以一排放 4 个,
* 如果是 2x8,那就是一行放一个,类似于这样的定义。」
* *
* ──────────────────────────────────────────────────────────────── * ────────────────────────────────────────────────────────────────
* v24 第 24 批 用户第 2、3 条(本次重排,用户原话): * 结构变了,但**内容一字未改**:原来的三块(信息面板 / 日程+任务 / 往年的今天)
* 现在各自是一张卡片,内容块原样搬进 `templates/home/_card_*.php`。
* *
* 第 2 条:「首页上的功能…一个就是上面的简要信息,这样去写。早上好,中午好, * 尺寸口径(与 app.css 的 `.hc-*`、设置页文字完全一致):
* 或者晚上好,某某某今天是几月几日?星期几农历几月几日?如果有节气的 * · 一排 **8 格**;宽 2 / 4 / 6 / 8 → 一排 4 张 / 2 张 / 一大一小 / 独占一排;
* 话,或者有重要的节日的话,在这里显示。比如说今天是这个国庆节啊, * · 高 2 / 4 / 6 / 8 行(一行 76px + 10px 间距)。
* 今天是端午节,或者是今天是中秋节,或者是今天是芒种,今天是大寒, * · 排版完全由 CSS 网格决定:**卡片顺序 = 数组顺序**,不需要任何浮动/清浮。
* 今天是小寒,今天是什么纪念日,今天是谁的生日等等。显示在这里
* 紧接着它的下面显示今天一共有多少条日程,今天有几件代办正在进行。
* 把天气放在这个信息面板的最右侧,以图标的形式,彩色图标的形式去进行
* 展示。比如说晴天多云下雨,然后下面显示呃温度、风力等等,在最下面
* 再显示呃地址。就是比如说他选中的是他在个人设置里面设置的是青岛,
* 那么就显示山东青岛就是让它放在这个信息面板的最右侧,靠右放。」
* *
* 第 3 条:「家里的镜框这里呃全部不要了…紧接着直接显示日程和任务日程和任务在 * ⚠️ 别在模板里自己算"该多宽":宽高由 `$homeCards`(用户设置)给,
* 整个页面里面左右,并排放各占一半,日程就显示今天的日程,也就是 * 模板只负责把类名拼对(`hc-w4 hc-h8`)。
* 日视图那样形式,为了好看高度需要固定。这样的话,鼠标放上去之后, * ⚠️ 通知块仍在**卡片网格之前**:v30 第 5 条要求它出现在「往年的今天」前面,
* 向下可以滚动,暂时不要出现滚动条太丑了。为了节省上下滚动的这个空间, * 而"往年的今天"现在是网格里的一张卡片,位置由用户定 —— 放在网格前是唯一
* 我们可以把没有日程的那些时间掐头去尾给它去掉。…同样的和日视图一样, * 无论用户怎么摆都成立的方案。
* 需要在当前的时间或者是时刻那里显示一条红线。右侧显示任务模块里面的
* 数据显示当前正在进行当中的所有的任务,除了显示任务的标题之外,
* 还要显示他的状态。比如说正在进行中待确认等等。除了这之外,
* 还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面,
* 就后最下面显示的是往年的今天这个模块。」
* *
* ⇒ 所以现在首页自上而下只有三块: * @var array $homeCards HomeCards::layout() 的结果(每项 k/w/h/n)
* ① 信息面板(问候 + 日子标记 + 今日数字 | 最右:彩色天气图标 + 温度风力 + 地址) * @var array $cardData HomeCards::data() 取的那几张卡片的额外数据
* ② 日程 / 任务 **左右各半**(各自固定高度、内部滚动、不显滚动条)
* ③ 往年的今天
*
* ⚠️ 被删掉的(用户明确说"全部不要了"):
* · 「家里的近况」模块卡片墙(moduleCards)—— 那张一片一片的统计卡
* · 「未来七天」清单 —— 与左侧「今天日程」重复
* 两块都不再渲染。服务端仍照旧算 moduleCards(个人设置里那个开关还留着),
* 但首页不再用 —— 留着它是为了"以后要恢复"时不用动后端。
*
* ⚠️ 「家人卡片」「两个月内的生日」早先就已按用户要求撤掉,本批不变。
*/ */
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$today = date('Y-m-d');
// 首页要显示哪些模块、什么顺序 —— 用户在「个人设置 → 首页上的模块」里定(每人一份)
$homeMods = array_values(array_filter(
(array)($homeModules ?? []),
static fn($k) => \App\Module::exists((string)$k)
));
$todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
?> ?>
<?php /* 用户 2026-10-03 第 3 条: <?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**(原来撤掉了,现在要回来); ① 标题下显示**个人 slogan**;
② 右侧那三块统计(与我有关 / 名册总数 / 亲属连线)**去掉**; ② 右侧那三块统计(与我有关 / 名册总数 / 亲属连线)**去掉**;
③ 「我的资料 / 我的家谱」保留,但从文字按钮换成**图标按钮**; ③ 「我的资料 / 我的家谱」保留,但从文字按钮换成**图标按钮**;
④ 「名册」按钮去掉 —— 它与顶部导航条里的「名册」重复。 ④ 「名册」按钮去掉 —— 它与顶部导航条里的「名册」重复。
⚠️ 图标用行内 SVG(不引外部字体/图片),与观影设置下拉同一套做法。 */ ?> v62 追加第五个图标按钮:「首页卡片」布局设置(与这排图标同一套做法)。 */ ?>
<section class="hero"> <section class="hero">
<img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt=""> <img class="hero-logo" src="<?= e(asset('/assets/icons/icon-192.png')) ?>" alt="">
<div class="hero-main"> <div class="hero-main">
@@ -70,6 +48,16 @@ $todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
`$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0 `$me = Auth::check()` 把模板里的 $me 覆盖成布尔值,链接就变成 id=0
("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?> ("补充亲属关系"点进去是空白页)。所以这里显式用 $meId。 */ ?>
<div class="hero-actions"> <div class="hero-actions">
<a class="icon-btn" href="<?= e(url('home/cards')) ?>"
title="首页卡片(摆哪几张、多大)" aria-label="首页卡片">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="2.4" y="2.4" width="6" height="6" rx="1.2"/>
<rect x="11.6" y="2.4" width="6" height="6" rx="1.2"/>
<rect x="2.4" y="11.6" width="6" height="6" rx="1.2"/>
<rect x="11.6" y="11.6" width="6" height="6" rx="1.2"/>
</svg>
</a>
<a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>" <a class="icon-btn" href="<?= e(url('people/show', ['id' => (int)$meId])) ?>"
title="我的资料" aria-label="我的资料"> title="我的资料" aria-label="我的资料">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none" <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
@@ -90,443 +78,11 @@ $todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
</div> </div>
</section> </section>
<?php /* ==================== ① 信息面板(用户第 2 条)====================
左:问候 + 姓名 → 日期/星期/农历 → 日子标记(节气/节日/纪念日/生日)→ 今日数字
右:**彩色**天气图标 + 温度 + 风力 → 地址
⚠️ 天气靠右:用户原话「把天气放在这个信息面板的最右侧…靠右放」。
用 flex + margin-left:auto 把它顶到右端(窄屏会自动折到下一行,见 CSS)。 */ ?>
<section class="section">
<div class="card card-pad home-brief">
<div class="hb-left">
<?php /* ① 问候(早上好/中午好…)+ 我的称呼 —— 问候语里已经带了名字,
但用户明确要求「早上好,某某某」这样的形式,所以名字单独再强调一次。
(Home::greeting 本身会拼 "早上好,老杨,今天有 3 件事",
这里只用它的"时段"部分,尾巴换成面板里更该看的信息。) */ ?>
<?php
$hbPart = '你好';
$hbH = (int)date('G');
if ($hbH < 5) { $hbPart = '夜深了'; }
elseif ($hbH < 11) { $hbPart = '早上好'; }
elseif ($hbH < 13) { $hbPart = '中午好'; }
elseif ($hbH < 18) { $hbPart = '下午好'; }
else { $hbPart = '晚上好'; }
?>
<div class="hb-hello">
<b><?= e($hbPart) ?></b><?php if (trim((string)($meName ?? '')) !== ''): ?>,<?= e((string)$meName) ?><?php endif; ?>
</div>
<div class="hb-date">
<span class="hb-d"><?= e((string)($todayLabel ?? date('n 月 j 日'))) ?></span>
<span class="hb-w"><?= e((string)($weekday ?? '')) ?></span>
<?php if (($lunarShort ?? '') !== ''): ?>
<span class="hb-lunar" title="<?= e((string)($lunarLine ?? '')) ?>"><?= e((string)$lunarShort) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($todayMarks['tags'])): ?>
<div class="hb-marks">
<?php foreach ($todayMarks['tags'] as $mk): ?>
<span class="hb-mark is-<?= e((string)$mk['kind']) ?>"><?= e((string)$mk['word']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hb-stats">
<a class="hbs" href="<?= e(url('calendar')) ?>">
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a>
<?php /* 🔴🔴 v24 第 25 批 用户第 3 条修:「为什么显示 0 条待办呢?
任务看板明明有一个进行中的任务啊。」
根因:这句话原来读的是 `stat7['todos']` = `count(Task::byStatus(...,'todo'))`
—— 只数**待办**。而右边那块「手头在做」列的是 **doing(+ 已逾期的待办)**,
用户刚在看板上拖成「进行中」的那件任务于是**不计入**,数字就显示 0。
两个口径不同源,数字和列表当场对不上。
⇒ 现在这个数字与右侧列表**同源**(与 index.php 传给本页的**同一个数组**)。
🔴 v53(用户 2026-10-16 第 2 条):右侧列表现在**也含待办**了(放在进行中下面)
⇒ 「件在做」不能再数整个数组(那会把待办也算成"在做")。
改成**按状态拆开数**:进行中 →「件在做」,待办 →「件待办」。
⚠️ 两个数字相加 = 右侧列表的行数(列表也是这两段拼起来的),仍然同源。
⚠️ 「已经动手」与「排队等着」是两件事(用户第 6 条要求说清区别):
首页要回答的是"我手头在做什么",所以主数字用「在做」的口径;
排队的那些(todo)另给一个小字,不必让人点进任务页才知道还有多少。 */ ?>
<?php $nDoing = 0; $nTodo = 0;
foreach (($tasks ?? []) as $__t) {
if ((string)($__t['status'] ?? 'todo') === 'doing') { $nDoing++; } else { $nTodo++; }
} ?>
<a class="hbs" href="<?= e(url('tasks')) ?>"
title="近期任务(进行中)">
<b><?= $nDoing ?></b> 件在做
</a>
<?php if ($nTodo > 0): ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
<b><?= $nTodo ?></b> 件待办
</a>
<?php endif; ?>
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
</a>
<?php endif; ?>
</div>
</div>
<?php if (!empty($weather)): ?>
<div class="hb-weather is-<?= e((string)($weather['tone'] ?? 'cloud')) ?>">
<span class="hbw-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<div class="hbw-main">
<div class="hbw-temp"><?= e((string)($weather['temp'] ?? '')) ?></div>
<div class="hbw-text"><?= e((string)($weather['text'] ?? '')) ?></div>
<?php if (($weather['wind'] ?? '') !== ''): ?>
<div class="hbw-wind">
<?= $weather['wdir'] !== '' ? e((string)$weather['wdir']) . ' ' : '' ?><?= e((string)$weather['wind']) ?>
</div>
<?php endif; ?>
</div>
<?php if (($weather['place'] ?? '') !== ''): ?>
<div class="hbw-place"><?= e((string)$weather['place']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</section>
<?php /* ==================== ② 日程 / 任务 左右各半(用户第 3 条)====================
⚠️ 高度**固定**(CSS 里给 .hd-pane-body 一个 max-height):
用户原话「为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,
暂时不要出现滚动条太丑了」——
所以是 `overflow-y:auto` + 隐藏滚动条(`scrollbar-width:none` 等三套写法)。
⚠️ 日程掐头去尾:见下面 $hr 的算法 —— 只画"第一个日程前一小时"到"最后一个日程后一小时",
中间空档不裁(裁了会让人以为日程连在一起)。全天日程永远在顶上。 */ ?>
<?php
// ---- 日程时间轴:掐头去尾(用户第 3 条"没有日程的那些时间掐头去尾给它去掉")----
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') { $allDay[] = $ev; }
else { $timed[] = $ev; }
}
$startH = 0; $endH = 24;
if ($timed) {
$mins = array_map(static function (array $e): int {
$p = explode(':', (string)$e['time']);
return ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
}, $timed);
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾
/* 🔴 v28 用户第 1 条:「今天的日程,并没有定位到红线所在的位置」。
根因:**区间里没有"现在"**,于是红线不画、定位算出来是个越界的值。
(实测:现在 22:44、日程 09:00 与 14:00 → 区间掐成 08–17,
`$nowPos = (22-8)/9 = 1.56` → `$showNow=false` → `class="ag-now"` 整个不渲染;
而定位脚本用同一个越界的 RATIO 去乘网格高度,被 clamp 到最底 → 停在底部。)
⇒ 修法不是"没红线就别定位",而是**让区间永远包含当前时刻**:
用户要的正是"一眼看到现在处在哪儿",那就必须把"现在"纳入刻度。
下面的 `$nowH` 是当前小时(服务端时区,与页面口径一致),
把它并进区间端点 —— 于是红线必然落在区间内,定位也就有了正确的目标。
⚠️ 必须在**算 $nowH 之前**完成这一步(原代码把 $nowH 放在区间之后算,
这里把它提上来,避免"改了区间却没重新算位置"这种漏改)。
🔴🔴 v44(用户 2026-10-12 第 3 条):「首页上的红线刻度显示不准确。
现在明明已经 7:44 了,但它那个红线还停留在最早的 7 点钟,
并没有走到 7:44 那个位置。这个只有在首页上是这样的。我点到日程里面,
那个红线的刻度好像是对的。」
⇒ 真根因:首页用的是 `date('G')`(**只取整小时**),而日程页用的是
`(int)date('G')*60 + (int)date('i')`(**精确到分**)。
7:44 时首页算出 7 → 红线停在 7:00,正是用户看到的。
⇒ 这里改成与日程页**同一套**(小时*60+分钟),两处的口径不再分叉。 */
$nowHNow = (int)date('G');
if ($nowHNow < $startH) { $startH = max(0, $nowHNow - 1); }
if ($nowHNow >= $endH) { $endH = min(24, $nowHNow + 2); }
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
}
/* 红线位置:**分钟级**(与日程页同一算法)。见上面 v44 那条 —— 原来是 `date('G')`
(整小时),所以"7:44 的红线停在 7:00"。 */
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
$nowPos = (($nowMinutes / 60) - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
$showNow = $nowPos >= 0 && $nowPos <= 1;
/* 🔴 v30 第 2/4 条:首页日程也用**和日程模块同一套冲突分列**。
⇒ 复用 App\Calendar::conflictLanes(算法借鉴 SyncCalendar 的 TimelineUtil):
两条撞车就左右各半、三条就三分 —— 首页与日历页看到的是同一个布局。
⚠️ 必须**先按时间排序**再分列(lanes 是按传入顺序返回的):
`$todayEvents` 从路由出来已是"全天在前、其余按时刻"升序,这里再稳一次。
⚠️ 入参只挑**有时间**的那些(全天日程不进时间网格)。 */
usort($timed, static function (array $a, array $b): int {
return strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
});
$laneInfo = \App\Calendar::conflictLanes($timed);
foreach ($timed as $i => &$evRef) {
[$l, $w] = $laneInfo[$i] ?? [0.0, 1.0];
$evRef['left'] = round($l * 100, 4);
$evRef['width'] = round($w * 100, 4);
}
unset($evRef);
?>
<section class="section">
<div class="home-duo">
<?php /* ---- 左:今天的日程(日视图形式)---- */ ?>
<div class="card card-pad hd-pane" id="homeAgenda">
<div class="hd-pane-head">
<h2 class="cinema-h2">今天的日程</h2>
<?php /* 🔴 v28 用户第 2 条:「看正月这个按钮,改成:更多日程,
背景颜色和日程的主题颜色保持一致。」
(「看正月」是用户对「看整月」的口误。) */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('calendar')) ?>"
style="--hd-c: <?= e(\App\Module::color('calendar', (int)\App\Auth::familyId())) ?>;">更多日程</a>
</div>
<?php /* 🔴 v24 第 26 批 用户第 2 条(前半):「今天的日程,除了全天日程**一直显示在上面**外,
其他日程随着滚动」。
⇒ DOM 结构必须分成两层:
① `.ag-allday` **留在 pane 里、不在滚动容器内** —— 它永远钉在标题下方;
② `.hd-pane-body`(滚动容器)**只包** `.ag-grid` —— 只有时段网格随滚动。
为什么不能在同一个滚动容器里给全天区 `position: sticky`:
它在滚动区**内部**,sticky 只能粘在滚动区顶端,而滚动区顶端在标题下面 ——
滚起来时全天区会被"标题 + 滚动区"两层挤着,看起来仍然在动;
而且 sticky 需要不透明底色遮住底下滚过去的日程,与现在的浅色卡片叠色不好看。
直接把它移出滚动容器,是**零副作用**的做法。 */ ?>
<?php if (!$todayEvents): ?>
<div class="hd-pane-body">
<p class="empty-hint">今天没有安排。</p>
</div>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<?php /* 🔴 v43(用户第 6 条):点条目弹详情浮层(不直接跳页)。 */ ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'>
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
<?php /* 🔴 v44(用户 2026-10-12 第 3 条):「红线左侧是有一个红点吗?
这个红点显示也不全,也不太好看。直接把红点去掉就行了,
直接一根红线就可以了。」
⇒ 那颗 `.ag-now-dot` 整个删掉(原来它是个 8×8 的圆,左端 `left:-4px`,
靠外溢一点点来"指示起点";用户觉得显示不全又不好看)。
⚠️ CSS 里那条 `.ag-now-dot` 也一并删(留在那儿是死规则,
还会让"红线只有一根"这件事在样式表里看不出结论)。 */ ?>
<?php if ($showNow): ?>
<?php /* 🔴 v45:把**服务端算红线用的那个分钟数**也写到元素上(`data-now-min`)。
为什么:探针原来只能"拿服务端渲染出的 top% 去和浏览器此刻的时间比" ——
两者的容差被 0.3% 一道卡着,而"整小时 vs 分钟"的差只等于
`分钟/60/跨度`:当分钟数很小时(如第 2 分钟)占比不到 0.3%,
**注入旧行为也不会变红** ⇒ 反向验证当场判它"恒真摆设"。
写上 data-now-min 之后,探针可以做一个**零容差**的判据:
"服务端用的分钟数 == 浏览器此刻的分钟数",整小时注入必然对不上。
⚠️ 它只是给测试用的观测点,不影响任何渲染/行为。 */ ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"
data-now-min="<?= (int)$nowMinutes ?>"></div>
<?php endif; ?>
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
<?php
/* 🔴 v30 第 2 条:「首页上的今天的日程,用色块表示,和日程保持一致。」
用户要的是**和日程模块(周/日视图)一样的实心色块**,而不是原来的
"白底 + 左边一条色线" —— 后者色只占 3px,一眼看不出是哪个日历本。
⇒ 三处对齐日程模块的 `.tg-block`:
① 底色 = 该日程所属日历本的颜色(不是主题色);
② 白字(`.tg-block` 就是 `color:#fff`);
③ 冲突错开(left/width 由 Calendar::conflictLanes 算,见 index.php)。
⚠️ 深浅:`--tg-*` 那几个本子的颜色都是中低明度,白字压得住;
但用户可能自定义成很浅的色 —— 所以标题那一行加 `text-shadow`
兜底(比"再加一层半透明黑罩"更不破坏本色)。
⚠️ 高度用 `min-height` 而不是固定值:15 分钟的日程也要看得见
(与 .tg-block 的 15 分钟最短块口径一致)。 */
foreach ($timed as $ev):
$p = explode(':', (string)$ev['time']);
$evMin = ((int)($p[0] ?? 0)) * 60 + ((int)($p[1] ?? 0));
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
// 结束时刻:缺省时按"下一件事的起点"或 30 分钟(与日历口径一致)
$et2 = (string)($ev['end_time'] ?? '');
if ($et2 !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $et2, $mm2)) {
$endMin = ((int)$mm2[1]) * 60 + ((int)$mm2[2]);
} else {
$endMin = $evMin + 30;
}
if ($endMin <= $evMin) { $endMin = $evMin + 30; }
$endMin = min($endMin, $endH * 60);
$hPct = ($endMin / 60 - $startH) / max(1, ($endH - $startH)) * 100 - $topPct;
$hPct = max(2.2, $hPct); // 至少能塞下一行
?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= number_format($topPct, 2) ?>%;
height: <?= number_format($hPct, 2) ?>%;
<?php /* 🔴 v37 用户第 3 条:「首页上今天的日程…如果它分了三列
或者是几列,这三列之间的距离或者是空隙非常非常的大,
是分开的,中间有非常大的空隙。我看了一下日程里面的那个
显示,那两个色块就是紧挨着的。所以…应该让它们紧挨着。」
根因(与日历页 .tg-block 逐字对比出来的):
· 日历页写的是 `left: calc(2px + L%)` / `width: calc(W% - 4px)`
—— 只留 2px+2px 的缝,所以色块**紧挨**;
· 这里却写死了 `calc(46px + L%)` / `calc(W% - 50px)`
—— 每块左右各扣 23px/25px。两列时那 46px 的扣减
**没法均分**,全挤进了两块之间的缝里 →
实测缝 ≈ 50px(等于把 46px 全加在了中间),
日历页只有 ≈ 4px。
⇒ 改法与日历页对齐:
left = 46px(给小时刻度)+ 列内的 2px
width = 列宽 - 4px(左右各 2px)
这样列间缝恒为 4px(紧挨),而且与"放得下多少列、
每列多宽"的解耦是干净的 —— 列宽由 width% 决定,
缝由那 4px 决定,两者不再互相污染。 */ ?>
left: calc(46px + <?= (float)($ev['left'] ?? 0) ?>% + 2px);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
title="<?= e((string)$ev['title']) ?>"
data-cal="<?= (int)$ev['cal'] ?>">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
<span class="ag-ev-cal"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div><?php /* /.ag-grid */ ?>
</div><?php /* /.hd-pane-body(滚动区,只含时段网格) */ ?>
<?php endif; ?>
</div><?php /* /.hd-pane */ ?>
<?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<div class="card card-pad hd-pane" id="homeTasks">
<div class="hd-pane-head">
<h2 class="cinema-h2">近期任务</h2>
<?php /* 🔴 v28 用户第 3 条:「『手头在做』改成『近期任务』,『任务看板』这个按钮
改成『全部任务』,按钮颜色和任务的主题颜色保持一致。」 */ ?>
<a class="btn btn-sm hd-more" href="<?= e(url('tasks')) ?>"
style="--hd-c: <?= e(\App\Module::color('tasks', (int)\App\Auth::familyId())) ?>;">全部任务</a>
</div>
<div class="hd-pane-body">
<?php /* 用户第 6 条:「所有的任务,前面都带一个复选框,在任何地方,点这个复选框,
这个任务就完成了。」→ 首页这一块也算"任何地方",
所以每行前面也有复选框,点了就地完成(AJAX,不跳页)。
表单与 JS 在页面末尾各一份(见 home.php 的 taskDoneForm 段)。 */ ?>
<?php if (!$tasks): ?>
<p class="empty-hint">没有进行中的任务,也没有待办。</p>
<?php else: ?>
<ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<?php
/* 🔴 v32 第 4 条(用户 2026-10-08):「首页,近期任务,任务的文字和前面的圆点,
颜色并没有跟**看板颜色**一致,这个需要修正。」
⚠️ v30 那轮我把它改成了 `--tk-c`(= **任务模块**的主题色),
当时理解成"用户说的主题色"。**理解错了** —— 用户要的是
「这条任务属于哪个**看板**」的颜色(任务模块自己早就支持
"给看板选颜色",用户原话在第 26 批:「任务在设置看板的时候,
可以选择颜色,这样在首页列表中显示的时候,就可以更加直观的
看到他属于哪个看板。」)。
⇒ 用 `Task::boardColorById(家庭, 看板id)`:
看板没设色时它按**看板名派生**一个稳定色(同名永远同色),
所以这里不需要任何回退分支。
⚠️ 逾期仍用红色(状态语义,不能被看板色盖掉)——
但那是"整行变红",与"没逾期时跟看板一致"并不冲突。
⚠️ 没归看板的(board_id 为空)走"默认看板"的派生色,
与任务模块列表页看到的完全一致(同一函数)。 */
$tkColor = \App\Task::boardColorById((int)\App\Auth::familyId(),
(int)($t['board_id'] ?? 0) ?: null);
?>
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
style="--tk-c: <?= e($tkColor) ?>;">
<?php
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
require __DIR__ . '/_task_check.php';
?>
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--tk-c)') ?>;"></span>
<?php /* 🔴 v43(用户第 6 条):点任务标题也弹详情浮层(不直接跳页)。
要点在**标题那个 <a>** 上而不是整行 ——
行里还有复选框(勾一下完成)与其它链接,
整行可点会让"勾选"和"打开"互相抢。 */ ?>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>"
<?php /* 🔴 v54(用户 2026-10-16 第 1 条):标题被 CSS 截成一行 + 省略号,
所以这里给 `title` —— 鼠标停上去能看全名。
(点开还有详情浮层,但"只看一眼全名"不该逼人点进去。) */ ?>
title="<?= e((string)$t['title']) ?>"
data-fb-entry='<?= fb_entry_json([
'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'note' => (string)($t['note'] ?? ''),
], url('tasks/show', ['id' => (int)$t['id']]),
\App\Auth::canWrite() ? url('tasks/edit', ['id' => (int)$t['id']]) : '',
[['截止', ($t['due_at'] ?? '') !== '' ? substr((string)$t['due_at'], 0, 16) : ''],
['看板', trim((string)($t['board_name'] ?? ''))]]) ?>'
style="color: <?= e($late ? 'var(--red)' : $tkColor) ?>;">
<?= e((string)$t['title']) ?>
</a>
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
比如说正在进行中待确认等等。」 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<?php /* 🔴 v24 第 26 批 用户第 3 条(后半):「任务在设置看板的时候,可以选择颜色,
这样在首页列表中显示的时候,就可以更加直观的看到他属于哪个看板。」
⇒ 徽标**同时带颜色**:左边一个小圆点(看板色)+ 看板名。
为什么不用"整块背景染色":首页这一行已经有状态徽标、看板徽标、逾期等几个
色块,整块染色会互相打架、反而看不出哪个色对应哪个语义。
一个小圆点信息量足够、又不抢戏。 */
$bColor = \App\Task::boardColor($t['board_color'] ?? null, (string)$t['board_name']); ?>
<span class="tk-board" style="--bc: <?= e($bColor) ?>;">
<i class="tk-board-dot" aria-hidden="true"></i><?= e((string)$t['board_name']) ?>
</span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="tk-due"><?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div>
</div>
</section>
<?php /* ==================== 通知消息(v30 第 5 条)==================== <?php /* ==================== 通知消息(v30 第 5 条)====================
用户原话:「…发一个通知,通知出现在首页中,**往年今天的前面**。」 用户原话:「…发一个通知,通知出现在首页中,**往年今天的前面**。」
⇒ 位置是硬性要求:必须在「往年的今天」**之前**。 ⇒ 放在卡片网格**之前**:往年的今天是网格里的一张卡片,位置由用户摆,
⚠️ 没有通知时**整块不渲染** —— 首页是"每天第一眼看的地方", 只有"在网格前面"才能保证永远在它上面。
不要用一个空盒子占掉一屏(与"今天没有安排"那种信息位不同: ⚠️ 没有通知时整块不渲染 —— 首页是"每天第一眼看的地方",不要用一个空盒子占一屏。 */ ?>
通知本来就是事件驱动的,没有就真的不该出现)。 */ ?>
<?php if (!empty($notifs)): ?> <?php if (!empty($notifs)): ?>
<section class="section"> <section class="section">
<div class="page-head" style="margin-bottom:12px"> <div class="page-head" style="margin-bottom:12px">
@@ -564,43 +120,58 @@ unset($evRef);
</section> </section>
<?php endif; ?> <?php endif; ?>
<?php /* ==================== ③ 往年的今天 ==================== */ ?> <?php /* ==================== 卡片网格(v62)====================
⚠️ `require` 进来的每个 `_card_*.php` 与这里**同一作用域**(共享 $tasks / $todayEvents
/ $cardData …):这是刻意的 —— 卡片的取数与首页的取数必须同源,
各自再查一遍就会出现"数字和列表对不上"那类老问题。 */ ?>
<section class="section"> <section class="section">
<div class="page-head" style="margin-bottom:12px"> <?php if (empty($homeCards)): ?>
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
<h1 style="font-size:15px">
往年的今天
<span class="sub"><?= e(date('n 月 j 日')) ?></span>
</h1>
</div>
<?php if (empty($memories['rows'])): ?>
<div class="card card-pad empty"> <div class="card card-pad empty">
<b>往年的这一天还没有记录</b> <b>首页卡片都被关掉了</b>
日子还长,等有了第一次,明年这时候就会自己冒出来。 到 <a href="<?= e(url('home/cards')) ?>">首页卡片</a> 里重新勾几张出来。
</div> </div>
<?php else: ?> <?php else: ?>
<div class="card card-pad"> <div class="hc-grid" id="homeCards">
<ul class="dash-list"> <?php foreach ($homeCards as $card): ?>
<?php foreach ($memories['rows'] as $mm): ?> <?php
<li class="dash-row"> $hcKey = (string)($card['k'] ?? '');
<span class="dash-dot" style="background: <?= $mm['kind'] === 'trip' ? '#3F7D5A' : '#7A4E8C' ?>;"></span> if (!\App\HomeCards::exists($hcKey)) {
<span class="dash-when"><?= (int)$mm['years_ago'] ?> 年前</span> continue;
<a class="dash-what" href="<?= e((string)$mm['href']) ?>"> }
<?= $mm['kind'] === 'trip' ? '🧳 ' : '🎬 ' ?><?= e((string)$mm['title']) ?> $hcDef = \App\HomeCards::TYPES[$hcKey];
</a> $hcTpl = __DIR__ . '/home/_card_' . $hcKey . '.php';
<span class="dash-tag"><?= e(substr((string)$mm['starts_at'], 0, 10)) ?></span> if (!is_file($hcTpl)) {
</li> continue; // 注册了但还没写模板:跳过,不要白屏
}
$hcMore = (string)($hcDef['route'] ?? '') !== '' ? url((string)$hcDef['route']) : '';
// 日程/任务这两个按钮沿用模块主题色(v28 第 2、3 条)
$hcColor = in_array($hcKey, ['agenda', 'tasks'], true)
? \App\Module::color($hcKey === 'agenda' ? 'calendar' : 'tasks', (int)\App\Auth::familyId())
: '';
?>
<section class="card hc-card hc-w<?= (int)$card['w'] ?> hc-h<?= (int)$card['h'] ?>"
data-hc="<?= e($hcKey) ?>" id="homeCard-<?= e($hcKey) ?>">
<div class="hc-head">
<h2 class="cinema-h2"><?= e((string)$hcDef['name']) ?></h2>
<?php if ($hcMore !== ''): ?>
<a class="btn btn-sm hd-more<?= $hcColor === '' ? ' btn-ghost' : '' ?>"
href="<?= e($hcMore) ?>"<?= $hcColor !== '' ? ' style="--hd-c: ' . e($hcColor) . ';"' : '' ?>><?= e((string)$hcDef['more']) ?></a>
<?php endif; ?>
</div>
<?php require $hcTpl; ?>
</section>
<?php endforeach; ?> <?php endforeach; ?>
</ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
</section> </section>
<?php /* ==================== 首页任务复选框的表单与脚本(用户第 6 条)==================== <?php /* ==================== 首页任务复选框的表单与脚本(v24 第 25 批 用户第 6 条)====================
「在任何地方点复选框,这个任务就完成了」—— 首页也算"任何地方"。 「在任何地方点复选框,这个任务就完成了」—— 首页也算"任何地方"。
与任务看板同一套做法:**表单全页一份**,行上只放 data-tk-done。 与任务看板同一套做法:**表单全页一份**,行上只放 data-tk-done。
⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么, ⚠️ 改完**不摘掉这一行**:用户点完正想看自己刚完成什么,立刻消失会让人怀疑
立刻消失会让人怀疑"是不是点到删除了"。刷新时由服务端决定它还在不在。 */ ?> "是不是点到删除了"。刷新时由服务端决定它还在不在。
⚠️ 这一段是**页面级**的(不属于某张卡片):任务卡片被关掉后它也跟着没用,
但留着无害 —— 别为了"省几行"把它挪进卡片模板里,那样卡片一换模板就丢功能。 */ ?>
<?php if (!empty($tasks) && \App\Auth::canWrite()): ?> <?php if (!empty($tasks) && \App\Auth::canWrite()): ?>
<form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form"> <form method="post" action="<?= e(url('tasks/status')) ?>" id="taskDoneForm" class="hidden-form">
<?= csrf_field() ?> <?= csrf_field() ?>
@@ -651,7 +222,6 @@ unset($evRef);
b.setAttribute('data-tk-status', d.status); b.setAttribute('data-tk-status', d.status);
b.classList.toggle('is-done', d.status === 'done'); b.classList.toggle('is-done', d.status === 'done');
b.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false'); b.setAttribute('aria-pressed', d.status === 'done' ? 'true' : 'false');
// 状态徽标(那行「进行中」)跟着换,否则勾上了徽标还写着"进行中"
var row = b.closest('[data-task-row]'); var row = b.closest('[data-task-row]');
var badge = row ? row.querySelector('.kb-st') : null; var badge = row ? row.querySelector('.kb-st') : null;
if (badge) { if (badge) {
@@ -665,56 +235,3 @@ unset($evRef);
})(); })();
</script> </script>
<?php endif; ?> <?php endif; ?>
<?php /* ==================== 日程滚动区:进场定位到当前时刻(v24 第 26 批 用户第 2 条)======
用户原话:「每次进来,都要定位到当前时刻,就是红线出现的位置,让红线在中间。」
⇒ 打开首页时把**滚动容器**滚到一个位置,使"当前时刻"落在可视区**正中**。
⚠️ 触发条件:**今天有日程就定位**(只要 `$todayEvents` 非空,页面上就有
`.ag-grid` 与红线)。曾经写成 `if (!$todayEvents || empty($timed))`,
后果是**今天只有全天日程 / 全天日程都没有分时段的那种日子,脚本整段不输出**
—— 而网格(0–24h)和红线照样渲染,于是"红线在,页面却停在最上面"
(v29 用户第 1 条仍然报「没有定位到红线」,实测 `scrollTop=0`、脚本不在页面上,
就是这个条件卡掉的;有分时段日程时定位其实完全正确、偏差 0px)。
⇒ 只判"有没有网格可滚",别判"是不是分时段日程"。
⚠️ 用 `scrollTop = 目标偏移 - 容器高/2`,**不要**用 `scrollIntoView({block:'center'})`:
它会**连带滚动外层页面**(把整个浏览器窗口也滚下去),
用户一进首页就被拽到"页面中部" —— 那比不定位更糟。
直接给容器赋 `scrollTop` 只动这一个盒子,外层纹丝不动。
⚠️ 容器"没得滚"时(内容比容器矮)赋任何值都是 0,天然安全,不必特判。
⚠️ 目标位置**由服务端算好**传进来(`$nowPos`),不在 JS 里再算一遍时间 ——
两处各算一次迟早不一致(服务端用的是站点时区,JS 用的是浏览器时区)。 */ ?>
<?php if (!$todayEvents): ?>
<?php /* 今天一条日程都没有 → 页面是「今天没有安排」的空态,没有网格可滚 */ ?>
<?php else: ?>
<script>
(function () {
var box = document.querySelector('[data-ag-scroll]');
if (!box) { return; }
// 红线(在的话)优先;不在就用服务端算好的 nowPos 现算一个位置。
var line = box.querySelector('.ag-now');
var RATIO = <?= json_encode(round($nowPos, 6)) ?>; // 0~1(可能落在区间外)
function targetOffset() {
if (line) { return line.offsetTop; }
var grid = box.querySelector('.ag-grid');
if (!grid) { return 0; }
// ⚠️ 用**排掉全天区之后**的内容高度算:全天区现在不在滚动容器里,
// 所以 grid 的高度就是"整条时间轴"的高度(与服务端算 nowPos 的口径一致)。
return grid.offsetHeight * RATIO;
}
function centerOnNow() {
// offsetTop 相对**定位祖先**(.ag-grid,position:relative),即"内容坐标"
box.scrollTop = Math.max(0, targetOffset() - (box.clientHeight / 2));
}
centerOnNow();
/* ⚠️ 再补两拍:字体加载完 / 浏览器恢复滚动位置(back-forward cache)之后,
内容高度会变,第一次算出来的 scrollTop 可能偏。
用 rAF 而不是 setTimeout:rAF 在排版完成后跑,时序更准;
两拍是为了覆盖"布局在渲染后才最终确定"的那一帧。 */
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { requestAnimationFrame(centerOnNow); });
}
})();
</script>
<?php endif; ?>
+186
View File
@@ -0,0 +1,186 @@
<?php
/**
* 首页卡片 · 今天的日程(v62 从 templates/home.php 拆出来的,逻辑一字未改)。
*
* 用户原话(v24 第 24 批 第 3 条):「日程就显示今天的日程,也就是日视图那样形式,
* 为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,暂时不要出现
* 滚动条太丑了。为了节省上下滚动的这个空间,我们可以把没有日程的那些时间
* 掐头去尾给它去掉。…需要在当前的时间或者是时刻那里显示一条红线。」
*
* ⚠️ 高度现在由**卡片高度**决定(`.hc-h*`),不再写死 440px —— 这正是"卡片化"
* 的核心:同一份内容,用户让它多高它就多高。
*
* @var array $todayEvents 今天的事件(全天在前、其余按时刻升序)
*/
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') {
$allDay[] = $ev;
} else {
$timed[] = $ev;
}
}
// ---- 掐头去尾:只画"第一个日程前一小时"到"最后一个日程后一小时" ----
$startH = 0;
$endH = 24;
if ($timed) {
$mins = array_map(static function (array $e): int {
// ⚠️ 变量名带 `ag` 前缀:这是**被 require 进来的片段**,裸赋值 $p 会撞上
// 宿主模板里同名的 $p(流水线的"片段不许覆盖宿主变量"检查会拦下来)。
$agHM = explode(':', (string)$e['time']);
return ((int)($agHM[0] ?? 0)) * 60 + (int)($agHM[1] ?? 0);
}, $timed);
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2);
/* 🔴 v28 用户第 1 条:「今天的日程,并没有定位到红线所在的位置」。
根因:**区间里没有"现在"**,于是红线不画、定位算出来是越界值。
⇒ 让区间永远包含当前时刻(用户要的正是"一眼看到现在处在哪儿")。
🔴🔴 v44 第 3 条:首页红线"停在整点"的真根因是这里以前用 `date('G')`
(只取小时),而日程页用的是 `小时*60+分钟`。现在两处同一口径。 */
$nowHNow = (int)date('G');
if ($nowHNow < $startH) {
$startH = max(0, $nowHNow - 1);
}
if ($nowHNow >= $endH) {
$endH = min(24, $nowHNow + 2);
}
if ($endH <= $startH) {
$endH = min(24, $startH + 2);
}
}
$nowMinutes = ((int)date('G')) * 60 + (int)date('i');
$nowPos = (($nowMinutes / 60) - $startH) / max(1, ($endH - $startH));
$showNow = $nowPos >= 0 && $nowPos <= 1;
/* 冲突分列(v30 第 2/4 条):与日程页同一套算法 —— 两条撞车就左右各半。 */
usort($timed, static function (array $a, array $b): int {
return strcmp((string)($a['time'] ?? ''), (string)($b['time'] ?? ''));
});
$laneInfo = \App\Calendar::conflictLanes($timed);
foreach ($timed as $i => &$evRef) {
[$l, $w] = $laneInfo[$i] ?? [0.0, 1.0];
$evRef['left'] = round($l * 100, 4);
$evRef['width'] = round($w * 100, 4);
}
unset($evRef);
?>
<div class="hc-body hc-agenda">
<?php if (!$todayEvents): ?>
<div class="hd-pane-body">
<p class="empty-hint">今天没有安排。</p>
</div>
<?php else: ?>
<?php /* 全天日程**不在滚动容器里**(v24 第 26 批 第 2 条:要"一直显示在上面")。
为什么不用 sticky:sticky 只能粘在滚动区顶端,滚起来仍会被"标题+滚动区"
两层挤着,看起来还是在动;直接移出滚动容器是零副作用的做法。 */ ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<?php
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'>
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hd-pane-body" data-ag-scroll>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php if ($showNow): ?>
<?php /* `data-now-min` 是给探针的零容差观测点(服务端用的分钟数),
不影响渲染 —— 见 v45 那条注释。 */ ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%"
data-now-min="<?= (int)$nowMinutes ?>"></div>
<?php endif; ?>
<?php
/* 日程块:按"相对第一个刻度的小时数"定位。
⚠️ left/width 与日历页 `.tg-block` 同一套(left = 46px 刻度 + 2px,
width = 列宽 - 4px)—— 曾经这里扣了 46px 的缝,导致两块之间空出 50px。 */
foreach ($timed as $ev):
$agHM = explode(':', (string)$ev['time']);
$evMin = ((int)($agHM[0] ?? 0)) * 60 + ((int)($agHM[1] ?? 0));
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
$et2 = (string)($ev['end_time'] ?? '');
if ($et2 !== '' && preg_match('/^(\d{1,2}):(\d{2})/', $et2, $mm2)) {
$endMin = ((int)$mm2[1]) * 60 + ((int)$mm2[2]);
} else {
$endMin = $evMin + 30;
}
if ($endMin <= $evMin) { $endMin = $evMin + 30; }
$endMin = min($endMin, $endH * 60);
$hPct = ($endMin / 60 - $startH) / max(1, ($endH - $startH)) * 100 - $topPct;
$hPct = max(2.2, $hPct);
$evKind = (string)($ev['kind'] ?? 'custom');
$evRef = (int)($ev['ref_id'] ?? 0);
$evEdit = '';
if ($evKind === 'custom' && $evRef > 0) { $evEdit = url('calendar/event', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'movie' && $evRef > 0) { $evEdit = url('movies/edit', ['id' => $evRef, 'embed' => 1]); }
elseif ($evKind === 'task' && $evRef > 0) { $evEdit = url('tasks/edit', ['id' => $evRef]); }
elseif ($evKind === 'anniv' && $evRef > 0) { $evEdit = url('days/edit', ['id' => $evRef, 'embed' => 1]); }
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
data-fb-entry='<?= fb_entry_json($ev, (string)($ev['href'] ?? ''), $evEdit) ?>'
style="background: <?= e((string)$ev['color']) ?>;
top: <?= number_format($topPct, 2) ?>%;
height: <?= number_format($hPct, 2) ?>%;
left: calc(46px + <?= (float)($ev['left'] ?? 0) ?>% + 2px);
width: calc(<?= (float)($ev['width'] ?? 100) ?>% - 4px);"
title="<?= e((string)$ev['title']) ?>"
data-cal="<?= (int)$ev['cal'] ?>">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
<span class="ag-ev-cal"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<?php if ($todayEvents): ?>
<script>
/* 进场定位到"现在"(v24 第 26 批 第 2 条):让红线落在可视区**正中**。
⚠️ 用 scrollTop 直接赋值,**不要** scrollIntoView({block:'center'}) ——
后者会把整个浏览器窗口也滚下去,一进首页就被拽到页面中部。
⚠️ 只判"有没有网格可滚",别判"有没有分时段日程"(v29 踩过:只有全天日程时
脚本整段不输出,而网格和红线照样渲染 ⇒ 红线在、页面停在最上面)。 */
(function () {
var box = document.querySelector('#homeCard-agenda [data-ag-scroll]')
|| document.querySelector('[data-ag-scroll]');
if (!box) { return; }
var line = box.querySelector('.ag-now');
var RATIO = <?= json_encode(round($nowPos, 6)) ?>;
function targetOffset() {
if (line) { return line.offsetTop; }
var grid = box.querySelector('.ag-grid');
return grid ? grid.offsetHeight * RATIO : 0;
}
function centerOnNow() {
box.scrollTop = Math.max(0, targetOffset() - (box.clientHeight / 2));
}
centerOnNow();
if (window.requestAnimationFrame) {
requestAnimationFrame(function () { requestAnimationFrame(centerOnNow); });
}
})();
</script>
<?php endif; ?>
+47
View File
@@ -0,0 +1,47 @@
<?php
/**
* 首页卡片 · 读书(v62 新增)。
*
* 数据来自 `HomeCards::booksData()`:**在读优先**,其余按新加进来的顺序排(最多 6 本)。
*
* @var array $cardData['books'] 每项含 id/title/author/status/progress/chapter_count/cover_key
*/
$bkRows = (array)($cardData['books'] ?? []);
?>
<div class="hc-body">
<?php if (!$bkRows): ?>
<p class="empty-hint">书架还是空的。</p>
<?php else: ?>
<ul class="hc-bk-list">
<?php foreach ($bkRows as $b): ?>
<?php
$bkStatus = \App\Book::normalizeStatus((string)($b['status'] ?? 'wish'));
$bkCh = (int)($b['chapter_count'] ?? 0);
$bkPr = (int)($b['progress'] ?? 0);
$bkPct = $bkCh > 0 ? min(100, (int)round($bkPr / $bkCh * 100)) : 0;
?>
<li class="hc-bk">
<a class="hc-bk-cover" href="<?= e(url('books/show', ['id' => (int)$b['id']])) ?>">
<?php if (!empty($b['cover_key'])): ?>
<img src="<?= e(media_url((string)$b['cover_key'])) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="hc-bk-ph" aria-hidden="true">📖</span>
<?php endif; ?>
</a>
<div class="hc-bk-main">
<a class="hc-bk-t" href="<?= e(url('books/show', ['id' => (int)$b['id']])) ?>"
title="<?= e((string)$b['title']) ?>"><?= e((string)$b['title']) ?></a>
<span class="small muted">
<?= e((string)($b['author'] ?? '')) ?>
<?php if ($bkCh > 0): ?>
· <?= $bkPr ?>/<?= $bkCh ?> 章
<?php endif; ?>
</span>
<span class="hc-bk-bar" aria-hidden="true"><i style="width: <?= (int)$bkPct ?>%"></i></span>
</div>
<span class="hc-bk-st is-<?= e($bkStatus) ?>"><?= e(\App\Book::statusWord($bkStatus)) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* 首页卡片 · 概述(v62 从 templates/home.php 拆出来的)。
*
* 内容与 v24 第 24 批 用户第 2 条完全一致(问候 → 日期/星期/农历 → 日子标记 →
* 今日数字 | 右侧彩色天气图标 + 温度/风力 + 地址),只换了外壳:
* 以前是 `section.section > div.card.home-brief`,现在是卡片的 `.hc-body`。
*
* ⚠️ 这块**永远是横贯的**(默认 8 宽):它是"首页第一眼",被挤成一半宽就会折行折得很碎。
* ⚠️ 用到的变量都由 home 路由传进来($hbPart 除外,这里自己按时段算)。
*/
$hbPart = '你好';
$hbH = (int)date('G');
if ($hbH < 5) { $hbPart = '夜深了'; }
elseif ($hbH < 11) { $hbPart = '早上好'; }
elseif ($hbH < 13) { $hbPart = '中午好'; }
elseif ($hbH < 18) { $hbPart = '下午好'; }
else { $hbPart = '晚上好'; }
$nDoing = 0;
$nTodo = 0;
foreach (($tasks ?? []) as $__t) {
if ((string)($__t['status'] ?? 'todo') === 'doing') {
$nDoing++;
} else {
$nTodo++;
}
}
?>
<div class="hc-body home-brief">
<div class="hb-left">
<div class="hb-hello">
<b><?= e($hbPart) ?></b><?php if (trim((string)($meName ?? '')) !== ''): ?>,<?= e((string)$meName) ?><?php endif; ?>
</div>
<div class="hb-date">
<span class="hb-d"><?= e((string)($todayLabel ?? date('n 月 j 日'))) ?></span>
<span class="hb-w"><?= e((string)($weekday ?? '')) ?></span>
<?php if (($lunarShort ?? '') !== ''): ?>
<span class="hb-lunar" title="<?= e((string)($lunarLine ?? '')) ?>"><?= e((string)$lunarShort) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($todayMarks['tags'])): ?>
<div class="hb-marks">
<?php foreach ($todayMarks['tags'] as $mk): ?>
<span class="hb-mark is-<?= e((string)$mk['kind']) ?>"><?= e((string)$mk['word']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hb-stats">
<a class="hbs" href="<?= e(url('calendar')) ?>">
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a>
<?php /* 两个数字与右侧「近期任务」卡片**同源**(同一个 $tasks 数组),
「在做」与「待办」按状态拆开数 —— 见 v53 第 2 条那条注释。 */ ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" title="近期任务(进行中)">
<b><?= $nDoing ?></b> 件在做
</a>
<?php if ($nTodo > 0): ?>
<a class="hbs" href="<?= e(url('tasks')) ?>" title="记下来了、还没动手的那些">
<b><?= $nTodo ?></b> 件待办
</a>
<?php endif; ?>
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
</a>
<?php endif; ?>
</div>
</div>
<?php if (!empty($weather)): ?>
<div class="hb-weather is-<?= e((string)($weather['tone'] ?? 'cloud')) ?>">
<span class="hbw-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<div class="hbw-main">
<div class="hbw-temp"><?= e((string)($weather['temp'] ?? '')) ?></div>
<div class="hbw-text"><?= e((string)($weather['text'] ?? '')) ?></div>
<?php if (($weather['wind'] ?? '') !== ''): ?>
<div class="hbw-wind">
<?= $weather['wdir'] !== '' ? e((string)$weather['wdir']) . ' ' : '' ?><?= e((string)$weather['wind']) ?>
</div>
<?php endif; ?>
</div>
<?php if (($weather['place'] ?? '') !== ''): ?>
<div class="hbw-place"><?= e((string)$weather['place']) ?></div>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
+43
View File
@@ -0,0 +1,43 @@
<?php
/**
* 首页卡片 · 联系人(v62 新增)。
*
* 用户原话:「例如,联系人,可以有卡片,让用户选择显示几个联系人在首页上。」
* ⇒ 张数由卡片设置里的「显示 N 个」决定(`HomeCards::contactsData($familyId,$me,$n)`);
* 卡片高度不够时内部滚动 —— 不留一个"被截断但看不出来"的列表。
*
* ⚠️ 只列**我的名册**里的人(含别人分享给我的),不是全家所有人 —— 首页是"我的"。
*
* @var array $cardData['contacts'] ['rows'=>array,'total'=>int]
*/
$ctRows = (array)($cardData['contacts']['rows'] ?? []);
$ctTotal = (int)($cardData['contacts']['total'] ?? 0);
?>
<div class="hc-body">
<?php if (!$ctRows): ?>
<p class="empty-hint">名册里还没有人。</p>
<?php else: ?>
<div class="hc-people">
<?php foreach ($ctRows as $p): ?>
<?php /* 头像 + 称呼(`person_chip` 自带底色与"我该怎么叫他")。
⚠️ 这里用块级排列而不是一行一个 pill:卡片可能很窄(宽 2),
pill 会挤成一列文字。 */ ?>
<a class="hc-person" href="<?= e(url('people/show', ['id' => (int)$p['id']])) ?>"
title="<?= e(person_name($p)) ?>">
<?php if (!empty($p['avatar'])): ?>
<img class="hc-person-av" src="<?= e(media_url((string)$p['avatar'])) ?>" alt="">
<?php else: ?>
<span class="hc-person-av hc-person-tx"
style="background: <?= e(person_bg($p)) ?>; color: <?= e(person_fg($p)) ?>;">
<?= e(mb_substr(person_name($p), 0, 1, 'UTF-8')) ?>
</span>
<?php endif; ?>
<span class="hc-person-nm"><?= e(person_name($p)) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php if ($ctTotal > count($ctRows)): ?>
<p class="small muted" style="margin:8px 0 0">共 <?= $ctTotal ?> 人,这里显示 <?= count($ctRows) ?> 位。</p>
<?php endif; ?>
<?php endif; ?>
</div>
+34
View File
@@ -0,0 +1,34 @@
<?php
/**
* 首页卡片 · 纪念日(v62 新增)。
*
* 数据来自 `Anniversary::upcoming($familyId, $me, 60)`(与「纪念日」页面顶上那块**同一份**),
* 所以"首页看到的"和"点进去看到的"永远一致 —— 不要在卡片里自己再算一遍农历。
*
* ⚠️ 图标按**分类**给(v62 第 2 条):走 `AnnivIcon::svg()`,颜色跟着文字色;
* 与日历里的 emoji 是同一套 key(见 AnnivIcon 的类注释)。
*
* @var array $cardData['days'] 每项含 date/title/kind/icon/age/nth/days
*/
$annRows = (array)($cardData['days'] ?? []);
?>
<div class="hc-body">
<?php if (!$annRows): ?>
<p class="empty-hint">未来 60 天里没有纪念日。</p>
<?php else: ?>
<ul class="ann-up hc-ann">
<?php foreach ($annRows as $a): ?>
<li class="ann-up-item">
<span class="ann-days"><?= (int)$a['days'] === 0 ? '今天' : ((int)$a['days'] . ' 天后') ?></span>
<span class="ann-ic"><?= \App\AnnivIcon::svg((string)($a['icon'] ?? ''), 15) ?></span>
<span class="ann-title"><?= e((string)$a['title']) ?></span>
<?php if (($a['age'] ?? null) !== null): ?>
<span class="small muted"><?= (int)$a['age'] ?> 岁</span>
<?php elseif (($a['nth'] ?? null) !== null): ?>
<span class="small muted">第 <?= (int)$a['nth'] ?> 个</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+30
View File
@@ -0,0 +1,30 @@
<?php
/**
* 首页卡片 · 往年的今天(v62 从 templates/home.php 拆出来的)。
*
* ⚠️ 只认「往年的今天」,**不退回同月**(v24 第 19 条)—— 见 home_memories() 的说明。
* @var array $memories ['rows' => [['kind','years_ago','title','href','starts_at'], ...]]
*/
$mmRows = (array)($memories['rows'] ?? []);
?>
<div class="hc-body">
<?php if (!$mmRows): ?>
<div class="empty">
<b>往年的这一天还没有记录</b>
日子还长,等有了第一次,明年这时候就会自己冒出来。
</div>
<?php else: ?>
<ul class="dash-list">
<?php foreach ($mmRows as $mm): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= $mm['kind'] === 'trip' ? '#3F7D5A' : '#7A4E8C' ?>;"></span>
<span class="dash-when"><?= (int)$mm['years_ago'] ?> 年前</span>
<a class="dash-what" href="<?= e((string)$mm['href']) ?>">
<?= $mm['kind'] === 'trip' ? '🧳 ' : '🎬 ' ?><?= e((string)$mm['title']) ?>
</a>
<span class="dash-tag"><?= e(substr((string)$mm['starts_at'], 0, 10)) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* 首页卡片 · 观影(v62 新增)。
*
* 数据来自 `HomeCards::moviesData()`(最近 8 条,只查卡片要用的那几列 ——
* 不用 Movie::all():那个会顺带查同行人与影评统计,卡片上都不显示)。
*
* @var array $cardData['movies'] 每项含 id/title/starts_at/poster_key/cinema_name
*/
$mvRows = (array)($cardData['movies'] ?? []);
?>
<div class="hc-body">
<?php if (!$mvRows): ?>
<p class="empty-hint">还没有观影记录。</p>
<?php else: ?>
<ul class="hc-mv-list">
<?php foreach ($mvRows as $m): ?>
<li class="hc-mv">
<a class="hc-mv-poster" href="<?= e(url('movies/show', ['id' => (int)$m['id']])) ?>">
<?php if (!empty($m['poster_key'])): ?>
<img src="<?= e(media_url((string)$m['poster_key'])) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="hc-mv-ph" aria-hidden="true">🎬</span>
<?php endif; ?>
</a>
<div class="hc-mv-main">
<a class="hc-mv-t" href="<?= e(url('movies/show', ['id' => (int)$m['id']])) ?>"
title="<?= e((string)$m['title']) ?>"><?= e((string)$m['title']) ?></a>
<span class="small muted">
<?= e(substr((string)($m['starts_at'] ?? ''), 0, 10)) ?>
<?php if (trim((string)($m['cinema_name'] ?? '')) !== ''): ?>
· <?= e((string)$m['cinema_name']) ?>
<?php endif; ?>
</span>
</div>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+64
View File
@@ -0,0 +1,64 @@
<?php
/**
* 首页卡片 · 近期任务(v62 从 templates/home.php 拆出来的)。
*
* 用户原话(v24 第 24 批 第 3 条):「右侧显示任务模块里面的数据,显示当前正在
* 进行当中的所有的任务,除了显示任务的标题之外,还要显示他的状态。比如说正在
* 进行中待确认等等。除了这之外,还要显示他所处的看板的名称。」
*
* 🔴 v53 第 2 条:待办也放进来,放在进行中的**下面**(不是混排)。
* ⚠️ 每行前面有复选框,点了就地完成(AJAX,不跳页);表单与脚本在 home.php 末尾
* 各一份 —— 那份**必须留着**,别跟着卡片一起删。
* ⚠️ 标题的颜色跟着**看板色**(不是任务模块的主题色),见 v32 第 4 条那条注释。
*/
?>
<div class="hc-body">
<?php if (!$tasks): ?>
<p class="empty-hint">没有进行中的任务,也没有待办。</p>
<?php else: ?>
<ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<?php $tkColor = \App\Task::boardColorById((int)\App\Auth::familyId(),
(int)($t['board_id'] ?? 0) ?: null); ?>
<li class="dash-row tk-row" data-task-row="<?= (int)$t['id'] ?>"
style="--tk-c: <?= e($tkColor) ?>;">
<?php
$tkId = (int)$t['id']; $tkStatus = (string)($t['status'] ?? 'todo');
$tkBack = 'home'; $tkStatic = !\App\Auth::canWrite();
require __DIR__ . '/../_task_check.php';
?>
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--tk-c)') ?>;"></span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>"
title="<?= e((string)$t['title']) ?>"
data-fb-entry='<?= fb_entry_json([
'title' => (string)$t['title'],
'status_word' => \App\Task::statusWord((string)($t['status'] ?? 'todo')),
'note' => (string)($t['note'] ?? ''),
], url('tasks/show', ['id' => (int)$t['id']]),
\App\Auth::canWrite() ? url('tasks/edit', ['id' => (int)$t['id']]) : '',
[['截止', ($t['due_at'] ?? '') !== '' ? substr((string)$t['due_at'], 0, 16) : ''],
['看板', trim((string)($t['board_name'] ?? ''))]]) ?>'
style="color: <?= e($late ? 'var(--red)' : $tkColor) ?>;">
<?= e((string)$t['title']) ?>
</a>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<?php $bColor = \App\Task::boardColor($t['board_color'] ?? null, (string)$t['board_name']); ?>
<span class="tk-board" style="--bc: <?= e($bColor) ?>;">
<i class="tk-board-dot" aria-hidden="true"></i><?= e((string)$t['board_name']) ?>
</span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="tk-due"><?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
+145
View File
@@ -0,0 +1,145 @@
<?php
/**
* 首页卡片布局(v62 用户 2026-10-18 第 3 条)。
*
* 用户原话:「今天,我们正式引入卡片的概念……卡片的规格,我们可以定义一下,
* 例如,把宽度定义为 8,高度可以是 2、4、6、8……如果是 2x2,那么就可以
* 一排放 4 个,如果是 2x8,那就是一行放一个,类似于这样的定义。
* 你可以先规划一些,实现出来,我们再讨论。」
*
* ⇒ 这一页是**摆卡片**的地方:勾上要哪几张、各自多宽多高、什么顺序。
* 尺寸口径(与 templates/home.php / app.css 的 `.hc-*` 完全一致):
* · 一排 **8 格**;宽 = 占几格 → 2:一排 4 张 / 4:一排 2 张 / 8:独占一排;
* · 高 = 占几行 → 2 / 4 / 6 / 8(一行 76px + 10px 间距)。
* ⚠️ 卡片宽度**允许 6**(不是只有 2/4/8):6 宽 = 一排"一大一小",
* 这是 8 格制里很自然的一种排法,实现上零成本,就没必要禁掉。
*
* 交互:▲▼ 与拖动都能排序(拖动的状态只存在于 DOM 里,提交时按 DOM 顺序读 order[])。
*
* @param array $rows HomeCards::orderedForPicker() 的结果(含 on/w/h/n)
*/
$hcRows = (array)($rows ?? []);
$hcN = count($hcRows);
$hcWs = \App\HomeCards::WIDTHS;
$hcHs = \App\HomeCards::HEIGHTS;
?>
<div class="page-head">
<h1>首页卡片</h1>
<span class="sub">一排 8 格 · 宽 2 就是一排 4 张,宽 8 就是独占一排</span>
<div class="actions">
<a class="btn btn-ghost" href="<?= e(url('home')) ?>">返回首页</a>
</div>
</div>
<form method="post" action="<?= e(url('home/cards_save')) ?>" class="card card-pad">
<?= csrf_field() ?>
<h2 class="cinema-h2">
摆哪几张、多大
<span class="hint">只影响你自己看到的首页</span>
</h2>
<p class="small muted" style="margin:0 0 16px">
<b>宽</b>:2 = 一排 4 张,4 = 一排 2 张,6 = 一大一小,8 = 独占一排。
<b>高</b>:2 / 4 / 6 / 8 行(一行 76px),卡片内容多的时候内部会自己滚。
<br>关掉的卡片<b>不会丢数据</b>,只是不摆在首页上。
</p>
<ol class="hc-set-list" id="hcSetList">
<?php foreach ($hcRows as $i => $r): $k = (string)$r['k']; $def = \App\HomeCards::TYPES[$k]; ?>
<li class="hc-set-row" draggable="true" data-hc-key="<?= e($k) ?>">
<?php /* 顺序藏在 DOM 里:每行自带 order[],拖动搬整行 ⇒ 提交上来的顺序
就是屏幕上的顺序(与「模块设置」那张表同一手法)。 */ ?>
<input type="hidden" name="order[]" value="<?= e($k) ?>">
<span class="hc-set-order">
<span class="drag-handle" title="拖我排序" aria-hidden="true">⋮⋮</span>
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="up:<?= e($k) ?>"
title="上移" <?= $i === 0 ? 'disabled' : '' ?>>▲</button>
<button class="btn btn-ghost btn-sm" type="submit" name="move" value="down:<?= e($k) ?>"
title="下移" <?= $i === $hcN - 1 ? 'disabled' : '' ?>>▼</button>
</span>
<label class="hc-set-on">
<input type="checkbox" name="on[<?= e($k) ?>]" value="1" <?= !empty($r['on']) ? 'checked' : '' ?>>
<span class="hc-set-name">
<b><?= e((string)$def['name']) ?></b>
<span class="small muted"><?= e((string)$def['desc']) ?></span>
</span>
</label>
<label class="hc-set-dim">
<span class="small muted">宽</span>
<select class="select" name="w[<?= e($k) ?>]">
<?php foreach ($hcWs as $w): ?>
<option value="<?= (int)$w ?>" <?= (int)$r['w'] === (int)$w ? 'selected' : '' ?>><?= (int)$w ?></option>
<?php endforeach; ?>
</select>
</label>
<label class="hc-set-dim">
<span class="small muted">高</span>
<select class="select" name="h[<?= e($k) ?>]">
<?php foreach ($hcHs as $h): ?>
<option value="<?= (int)$h ?>" <?= (int)$r['h'] === (int)$h ? 'selected' : '' ?>><?= (int)$h ?></option>
<?php endforeach; ?>
</select>
</label>
<?php if ((int)$def['n'] > 0): ?>
<?php /* 用户原话:「联系人,可以有卡片,让用户选择显示几个联系人在首页上。」 */ ?>
<label class="hc-set-dim">
<span class="small muted">显示</span>
<input class="input hc-set-n" type="number" name="n[<?= e($k) ?>]"
min="1" max="<?= (int)\App\HomeCards::N_MAX ?>"
value="<?= (int)$r['n'] ?>" aria-label="显示几个联系人">
<span class="small muted">个</span>
</label>
<?php else: ?>
<span class="hc-set-dim hc-set-null"></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ol>
<div class="btn-row" style="margin-top:16px">
<button class="btn" type="submit" name="save" value="1">保存布局</button>
<span class="small muted">改完记得保存 —— 这一页不会边改边生效。</span>
</div>
</form>
<script>
(function () {
/* 拖动排序(▲▼ 是没有 JS 时的兜底,两条路都能用)。
⚠️ 搬的是整行 <li>:它自带的 order[] 隐藏域跟着走,
提交时按 DOM 顺序读,不用再同步一份顺序数组。 */
var list = document.getElementById('hcSetList');
if (!list) { return; }
var dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.hc-set-row'), function (li) {
li.addEventListener('dragstart', function (ev) {
dragEl = li;
li.classList.add('is-dragged');
if (ev.dataTransfer) { ev.dataTransfer.effectAllowed = 'move'; }
});
li.addEventListener('dragend', function () {
li.classList.remove('is-dragged');
dragEl = null;
Array.prototype.forEach.call(list.querySelectorAll('.hc-set-row'), function (x) {
x.classList.remove('is-drop-before');
});
});
li.addEventListener('dragover', function (ev) {
if (!dragEl || dragEl === li) { return; }
ev.preventDefault();
var box = li.getBoundingClientRect();
li.classList.toggle('is-drop-before', ev.clientY < box.top + box.height / 2);
});
li.addEventListener('dragleave', function () { li.classList.remove('is-drop-before'); });
li.addEventListener('drop', function (ev) {
if (!dragEl || dragEl === li) { return; }
ev.preventDefault();
var box = li.getBoundingClientRect();
if (ev.clientY < box.top + box.height / 2) {
list.insertBefore(dragEl, li);
} else {
list.insertBefore(dragEl, li.nextSibling);
}
li.classList.remove('is-drop-before');
});
});
})();
</script>
+13 -13
View File
@@ -428,9 +428,14 @@ $fbCityN = count(\App\Geo::CITIES);
② 导航条上的模块(勾选 + 排序) ② 导航条上的模块(勾选 + 排序)
③ 模块主题色(改色) ③ 模块主题色(改色)
同一串模块名出现两遍、颜色又另起一张 —— 用户看得累、改起来要点三次保存。 同一串模块名出现两遍、颜色又另起一张 —— 用户看得累、改起来要点三次保存。
⇒ 现在合成**一张表**:一行一个模块,四列 = 导航 / 首页 / 颜色 / 顺序。 ⇒ 现在合成**一张表**:一行一个模块,四列 = 顺序 / 模块 / 导航 / 颜色。
提交走 `settings/modules_save`(它内部把三个 save 依次调一遍,见 index.php 的说明)。 提交走 `settings/modules_save`(它内部把三个 save 依次调一遍,见 index.php 的说明)。
⚠️ v62:**「首页」那一列撤掉了** —— 首页上摆什么改由「首页卡片」自己管
(每张卡片可设宽高/顺序/开关,还包括联系人、往年的今天这两张不是模块的卡片),
这张表只留"导航里有没有它 + 它是什么颜色 + 排第几"。
所以路由那边只有表单真的带了 `home[]` 才写 HomeMods,否则会把老数据清空。
⚠️ 颜色那一列的权限**没有变**:它仍是家庭一份、只有管理员能改; ⚠️ 颜色那一列的权限**没有变**:它仍是家庭一份、只有管理员能改;
非管理员那一列显示成只读色块(看得到、改不了)。 非管理员那一列显示成只读色块(看得到、改不了)。
⚠️ 拖动排序仍搬整行(行自带 order[]),▲▼ 是没 JS 时的兜底 —— 两条路都在。 */ ⚠️ 拖动排序仍搬整行(行自带 order[]),▲▼ 是没 JS 时的兜底 —— 两条路都在。 */
@@ -447,14 +452,16 @@ foreach ($plMAll as $k) { if (!in_array($k, $plMOrder, true)) { $plMOrder[] = $k
<div class="card card-pad" id="modules" style="margin-top:20px"> <div class="card card-pad" id="modules" style="margin-top:20px">
<h2 class="section-title"> <h2 class="section-title">
模块设置 模块设置
<span class="hint">导航 / 首页 / 颜色 / 顺序,一页改完</span> <span class="hint">导航 / 颜色 / 顺序,一页改完</span>
</h2> </h2>
<p class="small muted" style="margin:0 0 16px"> <p class="small muted" style="margin:0 0 16px">
模块越来越多,但每个人关心的不一样 —— 这里一行一个模块: 模块越来越多,但每个人关心的不一样 —— 这里一行一个模块:
<b>导航</b>管"顶栏上有没有它",<b>首页</b>管"打开首页看不看得到", <b>导航</b>管"顶栏上有没有它",<b>颜色</b>是进到那个模块后顶栏高亮的色
<b>颜色</b>是进到那个模块后顶栏高亮的色(<b>全家共用一套</b>), (<b>全家共用一套</b>),<b>顺序</b>用拖动手柄或 ▲▼ 调。
<b>顺序</b>用拖动手柄或 ▲▼ 调。 <br>关掉的模块<b>不会丢数据</b>,只是从顶栏收起来,别处仍能进。
<br>关掉的模块<b>不会丢数据</b>,只是从顶栏 / 首页收起来,别处仍能进。 <br>🔴 首页上摆什么,**已经改成「卡片」自己管**(每张卡片可以设宽高与顺序,
还包括联系人、往年的今天这两张不是模块的卡片)——
<a href="<?= e(url('home/cards')) ?>">去「首页卡片」摆</a>。
</p> </p>
<form method="post" action="<?= e(url('settings/modules_save')) ?>" id="modulesForm"> <form method="post" action="<?= e(url('settings/modules_save')) ?>" id="modulesForm">
@@ -466,7 +473,6 @@ foreach ($plMAll as $k) { if (!in_array($k, $plMOrder, true)) { $plMOrder[] = $k
<th class="mod-th-order">顺序</th> <th class="mod-th-order">顺序</th>
<th class="mod-th-name">模块</th> <th class="mod-th-name">模块</th>
<th class="mod-th-on">导航</th> <th class="mod-th-on">导航</th>
<th class="mod-th-on">首页</th>
<th class="mod-th-color">颜色</th> <th class="mod-th-color">颜色</th>
</tr> </tr>
</thead> </thead>
@@ -495,12 +501,6 @@ foreach ($plMAll as $k) { if (!in_array($k, $plMOrder, true)) { $plMOrder[] = $k
<?= in_array($k, $plMNav, true) ? 'checked' : '' ?>> <?= in_array($k, $plMNav, true) ? 'checked' : '' ?>>
</label> </label>
</td> </td>
<td class="mod-td-on">
<label class="mod-chk" title="显示在首页">
<input type="checkbox" name="home[]" value="<?= e($k) ?>"
<?= in_array($k, $plMHome, true) ? 'checked' : '' ?>>
</label>
</td>
<td class="mod-td-color"> <td class="mod-td-color">
<?php if ($plMIsAdmin): ?> <?php if ($plMIsAdmin): ?>
<input type="color" class="color-picker" name="c[<?= e($k) ?>]" <input type="color" class="color-picker" name="c[<?= e($k) ?>]"