Files
FamilyBook/app/Anniversary.php
T
laoyang a5e559b2b3 v25 第 25 批(9 条):名册分组不刷页 / 纪念日多人 / 首页等高 / 移出不回流 /
名册点击看详情拖动才排序 / 任务复选框与状态 / 财务标签多选 / 微信读书同步与统计 /
导航线上数据与图标

用户本轮 9 条需求(原话见 templates/ 与 app/ 内的逐条注释):

1. 名册:列表里给某人分组改为 AJAX 就地挪卡片(不再整页刷新)。
2. 纪念日:关联人改**多选**(复用 _person_picker,字段 person_ids[]);
   自建纪念日的按钮与其它模块统一(改=蓝 .btn、删=黄 .btn-warn);
   列表新增「关联人」列并给人名前加头像。
3. 首页:日程/任务两块**完全等高**(align-items:stretch + 固定总高 + flex 子项
   min-height:0);修「0 件待办」——数字与右侧列表同源(原来各算各的)。
4. 🔴 名册里被移出的人过一阵又回来:根因是 PersonRoster::seedFromPersons()
   **每次 Schema::ensure() 都无条件重灌**(线上实测每跑一次 +1 行)。
   修:给它加自守卫(只有在 persons 还留着 group_id/sort_order 的**老库**才跑)。
5. 名册:点击=看详情、**只有拖 ⠿ 手柄**才是排序(原来整行既能点又能拖,语义混乱)。
6. 任务:所有任务前加复选框,任何位置点勾即完成;
   状态语义照业界整理为 To Do / Doing / Done 并写出说明(用户要求参考别人的做法)。
7. 财务:类别标签改**多选非互斥**(再点一次取消该类别),上下两图 + 图例 + 表格
   全部按同一个集合联动;高亮条刻意不给「合计」(多选下各类金额不能相加)。
8. 微信读书:新增**懒同步**(访问时顺手做,6h TTL,只补空字段不覆盖用户填的)、
   封面图**拉下来存本站存储**(原来只存外链 → 看不到)、接官方 /readdata/detail 统计。
9. 导航:线上数据只搬了一半(5 板块 29 站 → 7 板块 67 站 + 67 图标)、
   展示页头像不显示 / 搜索按钮错位 / 点登录 404。三个真根因:
   · 相对地址在 /nav/ 下全 404 → 新增 Nav::abs();
   · 搜索容器丢了 id="search"(CSS 是 #search button.submit 绝对定位);
   · Nav::iconKey() 与 SimpleNav 的 icon_key() **算法不一致**(少一次 md5 归一化)
     → 搬来的图标文件名对不上;且 uploads/ 整目录 Require all denied。
   新增两个**免登录公开出口** nav_icon.php / nav_avatar.php。

顺带修掉一个「数据损坏级」的真 bug(做第 4 条线上反证时发现的,之前一直没人注意):
app/Region.php 三处用中文标点当 trim/ltrim 的 charlist,而 PHP 的 charlist 是
**按字节**匹配的 —— 「。」= E3 80 **82** → 字节 82 进了可剥集合 →
「层」= E5 B1 **82** 被切掉尾字节 → 非法 UTF-8 → backfillAddress 的 UPDATE 报
`1366 Incorrect string value` → **地址拆分升级从上线起一直在失败被跳过**(每请求报一次)。
改成字符级 preg_replace('/…/u');线上实测 ensure() 现在无报错、待拆行数真的在下降。

验证战绩:
- e2e 1289/0(新增 Region 字节级用例等)
- 真机探针:选人 19/0 · 首页/导航 24/0 · 导航模块 34/0 · b18 暂存+确定 10/0 ·
  第23批 52/0
- 桌面几何 428/0 · 移动端几何 680/0 · 交互 92/0 · 窄屏 24/0
- 老库升级 153/0(含 anniversary_people 迁移 + 「再跑 ensure() 名册行数不变」)
- 部署静态断言 180 OK / 0 FAIL · 反向验证 3/3 + Region 三变体各被两条断言同时抓住
- 线上 26 个文件与本地**逐字节一致**;展示页 200、图标 66/66 200、登录 200、
  越权/不存在均 404、签发短语后 ensure() 不再报错。
2026-10-07 11:35:31 +08:00

514 lines
21 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 纪念日(v24 用户第 13 条)
*
* 用户原话:「增加纪念日模块,可以根据阴历和阳历来设置纪念日,在纪念日模块中,
* 可以默认看到已经保存的名册中的人的各种生日,生日本身就是纪念日的一种。
* 所有的纪念日,都显示在日历的日程中,作为一个全天日程出现。
* 纪念日,需要按照阴历和阳历进行重复。」
*
* 三条设计决定:
*
* ① **生日不落这张表**,而是像观影/旅程那样**实时派生**。
* 理由:生日已经存在 persons(birthday / birthday_lunar),再复制一份到
* anniversaries 就会出现"改了生日,纪念日那边还是旧的"。派生出来,
* 改生日当天纪念日跟着变 —— 与"日历里观影/旅程是实时派生"同一套口径。
*
* ② 用户自建的纪念日才落库(anniversaries 表)。`repeat_type` 决定怎么"年年重现":
* solar = 每年公历的同月同日;lunar = 每年农历的同月同日(所以库里存的是
* **农历的月/日**,不是公历换算结果 —— 换算结果只对某一年有效)。
*
* ③ 进日历、而且是**全天**日程(用户明确要求)。所以渲染时 all_day=1、
* 时间部分留空。
*/
declare(strict_types=1);
namespace App;
final class Anniversary
{
/** 重复方式 */
public const REPEAT = [
'solar' => '每年(阳历)',
'lunar' => '每年(农历)',
'once' => '只此一次',
];
/** 庆典类纪念日大致的分类,给个默认值方便挑选(用户可自由填) */
public const PRESET_KINDS = ['生日', '结婚纪念日', '相识纪念日', '忌日', '节日', '其它'];
public const ATTACH = 'anniversary';
// ---------------- ① 用户自建的纪念日 ----------------
public static function list(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT * FROM anniversaries WHERE family_id = ? ORDER BY month, day, id'
);
$st->execute([$familyId]);
return $st->fetchAll();
}
public static function find(int $familyId, int $id): ?array
{
$st = Database::get()->prepare('SELECT * FROM anniversaries WHERE id = ? AND family_id = ?');
$st->execute([$id, $familyId]);
$r = $st->fetch();
return $r ?: null;
}
/**
* 新建 / 修改一条纪念日。
*
* @param array $d title / repeat_type / month / day / kind / note / leap
* + **person_ids[]**(v24 第 25 批:可以关联多个人)
* @return int 记录 id
*/
public static function save(int $familyId, array $d, int $id = 0): int
{
$db = Database::get();
$title = trim((string)($d['title'] ?? ''));
if ($title === '') {
$title = '纪念日';
}
$repeat = (string)($d['repeat_type'] ?? 'solar');
if (!isset(self::REPEAT[$repeat])) {
$repeat = 'solar';
}
$month = max(1, min(12, (int)($d['month'] ?? 1)));
$day = max(1, min(31, (int)($d['day'] ?? 1)));
$kind = mb_substr(trim((string)($d['kind'] ?? '')), 0, 40, 'UTF-8');
$note = trim((string)($d['note'] ?? ''));
$leap = !empty($d['leap']) ? 1 : 0;
// ---- 关联的人(v24 第 25 批 用户第 2 条:一人 → 多人)----
//
// ⚠️ 兼容老表单:只传 `person_id`(单值)时也认,包成一个人的数组。
// 这样任何还没来得及改的调用点(老链接、外部脚本)都不会静默丢人。
$pids = [];
if (array_key_exists('person_ids', $d)) {
foreach ((array)$d['person_ids'] as $x) {
$x = (int)$x;
if ($x > 0) {
$pids[] = $x;
}
}
} elseif (array_key_exists('person_id', $d)) {
$x = (int)$d['person_id'];
if ($x > 0) {
$pids[] = $x;
}
}
$pids = array_values(array_unique($pids));
// 只认这个家庭里的人(表单可以改,不能凭它挂到别人家的人身上)
if ($pids) {
$in = implode(',', array_fill(0, count($pids), '?'));
$st = $db->prepare("SELECT id FROM persons WHERE family_id = ? AND id IN ($in)");
$st->execute(array_merge([$familyId], $pids));
$ok = array_map('intval', array_column($st->fetchAll(), 'id'));
$pids = array_values(array_intersect($pids, $ok));
}
// ⚠️ 老列 person_id 继续写**第一个**关联人(不写 NULL):
// 它是老代码/老库的兜底读点,写成 NULL 会让"没升级到的读点"少一个人。
$pid = $pids[0] ?? null;
if ($id > 0) {
$db->prepare(
'UPDATE anniversaries SET title=?, repeat_type=?, month=?, day=?, is_leap=?,
kind=?, note=?, person_id=? WHERE id=? AND family_id=?'
)->execute([$title, $repeat, $month, $day, $leap, $kind !== '' ? $kind : null,
$note !== '' ? $note : null, $pid, $id, $familyId]);
self::savePeople($id, $pids);
return $id;
}
$db->prepare(
'INSERT INTO anniversaries (family_id, title, repeat_type, month, day, is_leap,
kind, note, person_id)
VALUES (?,?,?,?,?,?,?,?,?)'
)->execute([$familyId, $title, $repeat, $month, $day, $leap,
$kind !== '' ? $kind : null, $note !== '' ? $note : null, $pid]);
$newId = (int)$db->lastInsertId();
self::savePeople($newId, $pids);
return $newId;
}
/**
* 整组替换某个纪念日关联的人(v24 第 25 批)。
*
* 「整组替换」而不是"逐条增删":表单交上来的就是**一份完整名单**,
* 逐条比对会产生"先删后加"的中间态;一次 DELETE + 一次 INSERT 是确定结果。
*/
public static function savePeople(int $anniversaryId, array $personIds): int
{
$db = Database::get();
$db->prepare('DELETE FROM anniversary_people WHERE anniversary_id = ?')->execute([$anniversaryId]);
if (!$personIds) {
return 0;
}
$ins = $db->prepare(
'INSERT IGNORE INTO anniversary_people (anniversary_id, person_id, sort_order) VALUES (?,?,?)'
);
$n = 0;
$i = 0;
foreach ($personIds as $pid) {
$pid = (int)$pid;
if ($pid <= 0) {
continue;
}
$i += 10;
$ins->execute([$anniversaryId, $pid, $i]);
$n++;
}
return $n;
}
/**
* 某个纪念日关联的人(**含兜底**)。
*
* ⚠️ 兜底逻辑(v24 第 25 批升级过渡期必须有):
* 新表里没有行时,回落到老列 `person_id` —— 这样"升级跑到一半"
* 或"老数据没回填成功"时,页面上仍然看得到那个人,而不是静默变成"没关联"。
*
* @return int[] person id(按 sort_order)
*/
public static function peopleOf(array $row): array
{
static $cache = [];
$aid = (int)($row['id'] ?? 0);
if ($aid <= 0) {
return [];
}
if (array_key_exists($aid, $cache)) {
return $cache[$aid];
}
$st = Database::get()->prepare(
'SELECT person_id FROM anniversary_people WHERE anniversary_id = ? ORDER BY sort_order, person_id'
);
$st->execute([$aid]);
$out = array_map('intval', array_column($st->fetchAll(), 'person_id'));
if (!$out) {
$one = (int)($row['person_id'] ?? 0);
if ($one > 0) {
$out = [$one];
}
}
$cache[$aid] = $out;
return $out;
}
/**
* 一批纪念日的关联人:`anniversary_id => [person_id, ...]`(避免逐行查库)。
*/
public static function peopleMap(array $ids): array
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (!$ids) {
return [];
}
$in = implode(',', array_fill(0, count($ids), '?'));
$st = Database::get()->prepare(
"SELECT anniversary_id, person_id FROM anniversary_people
WHERE anniversary_id IN ($in) ORDER BY anniversary_id, sort_order, person_id"
);
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(int)$r['anniversary_id']][] = (int)$r['person_id'];
}
return $out;
}
public static function remove(int $familyId, int $id): void
{
Attachment::removeAll(self::ATTACH, $id);
// 关联表由外键 ON DELETE CASCADE 一起走;这里不必手动删
Database::get()->prepare('DELETE FROM anniversaries WHERE id = ? AND family_id = ?')
->execute([$id, $familyId]);
}
// ---------------- ② 派生:名册里每个人的生日 ----------------
/**
* 把名册里所有人的生日,当成"纪念日"派生出来。
*
* 用户原话:「生日本身就是纪念日的一种」——所以这里不去 persons 里写任何东西,
* 只读出生日、包成与自建纪念日**同一个形状**的行,上层(列表页 / 日历)一视同仁。
*
* @return array<int,array> 每项含 title/kind/month/day/repeat_type/person_id/source/…
*/
public static function birthdays(int $familyId, int $viewerId): array
{
$people = Person::map($familyId);
$out = [];
foreach ($people as $pid => $p) {
$pid = (int)$pid;
// 阳历生日(列名叫 birthday,读取一律走 Birthday::solarOf)
$solar = Birthday::dateOnly(Birthday::solarOf($p));
if ($solar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的生日',
'kind' => '生日',
'repeat_type' => 'solar',
'month' => (int)substr($solar, 5, 2),
'day' => (int)substr($solar, 8, 2),
'is_leap' => 0,
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_solar',
'born' => $solar,
];
}
// 农历生日(**日期部分是农历**,所以月/日是农历的月日)
$lunar = Birthday::dateOnly((string)($p['birthday_lunar'] ?? ''));
if ($lunar !== '') {
$out[] = [
'id' => 0,
'title' => (string)($p['name'] ?? '') . ' 的农历生日',
'kind' => '生日',
'repeat_type' => 'lunar',
'month' => (int)substr($lunar, 5, 2),
'day' => (int)substr($lunar, 8, 2),
'is_leap' => (int)($p['birthday_lunar_leap'] ?? 0),
'person_id' => $pid,
'person_name' => (string)($p['name'] ?? ''),
'source' => 'birthday_lunar',
'born' => $lunar,
];
}
}
unset($viewerId);
return $out;
}
/** 自建纪念日 + 派生生日,合成一份"全部纪念日" */
public static function all(int $familyId, int $viewerId): array
{
$out = self::birthdays($familyId, $viewerId);
foreach (self::list($familyId) as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$out[] = $r;
}
return $out;
}
/**
* 两块分开(v24 用户 2026-10-03 第 9 条)。
*
* 用户原话:「纪念日中,手动添加的纪念日和来自其他模块的,例如生日的纪念日,
* 要分开,手动纪念日在上面。」
*
* 所以返回 ['custom' => [...], 'derived' => [...]] 两块,各按日期排。
* ⚠️ 派生块用**这个人的出生年**来算"今年是几号"没法排序(农历要逐年换算),
* 这里统一按 month/day 排(也就是"一年里按日历顺序"),
* 与列表上显示的「X 月 X 日」完全一致 —— 不会出现"排出来的顺序和看到的不一样"。
*
* @return array{custom:array,derived:array}
*/
public static function split(int $familyId, int $viewerId): array
{
$rows = self::list($familyId);
// 一次把所有人的关联人查出来(避免逐行查库)——v24 第 25 批 用户第 2 条
$pmap = self::peopleMap(array_column($rows, 'id'));
$custom = [];
foreach ($rows as $r) {
$r['source'] = 'custom';
$r['person_name'] = '';
$aid = (int)$r['id'];
if (!empty($pmap[$aid])) {
$r['person_ids'] = $pmap[$aid];
} elseif ((int)($r['person_id'] ?? 0) > 0) {
// 兜底:新表没有行就看老列(升级过渡期)
$r['person_ids'] = [(int)$r['person_id']];
} else {
$r['person_ids'] = [];
}
$custom[] = $r;
}
$derived = self::birthdays($familyId, $viewerId);
// 派生行(名册生日)本来就知道是谁,统一挂上 person_ids,模板无须分两条路
foreach ($derived as &$dv) {
$dv['person_ids'] = (int)($dv['person_id'] ?? 0) > 0 ? [(int)$dv['person_id']] : [];
}
unset($dv);
$byMD = static function (array $a, array $b): int {
return ((int)($a['month'] ?? 0) <=> (int)($b['month'] ?? 0))
?: ((int)($a['day'] ?? 0) <=> (int)($b['day'] ?? 0))
?: strcmp((string)($a['title'] ?? ''), (string)($b['title'] ?? ''));
};
usort($custom, $byMD);
usort($derived, $byMD);
return ['custom' => $custom, 'derived' => $derived];
}
// ---------------- 纪念日分类(v24 用户 2026-10-03 第 9 条)----------------
/**
* 用户自定义的分类清单(family 级,存 settings)。
*
* 用户原话:「在新建纪念日和右侧,也增加设置按钮,可以设置纪念日的分类。」
*
* ⚠️ 与分类**落到每条纪念日上的 kind 字段**是两件事:
* · 这里的清单 = 新建时下拉里给哪些选项(可增删);
* · kind = 这一条实际选了哪个。
* 删掉一个分类**不**会把已用的纪念日改掉(那些 kind 仍然是那个字),
* 只是新建时不再出现在下拉里 —— 强改历史数据比留着一个"没人认识的分类"更糟。
*
* @return string[] 有序、去重、非空
*/
public static function kinds(int $familyId): array
{
$raw = (string)Setting::get($familyId, 'anniv_kinds', '');
$dec = $raw !== '' ? json_decode($raw, true) : null;
if (!is_array($dec)) {
return self::PRESET_KINDS; // 没配过 → 用预置那套
}
$out = [];
foreach ($dec as $k) {
$k = trim((string)$k);
if ($k !== '' && mb_strlen($k, 'UTF-8') <= 20 && !in_array($k, $out, true)) {
$out[] = $k;
}
}
// ⚠️ 空数组是**合法**的(用户可能就是不想有分类),所以不在这里兜回预置;
// 只有"从来没配过"($raw === '')才给预置。
return $out;
}
public static function saveKinds(int $familyId, array $kinds): void
{
$out = [];
foreach ($kinds as $k) {
$k = trim((string)$k);
if ($k === '' || mb_strlen($k, 'UTF-8') > 20) {
continue;
}
if (!in_array($k, $out, true)) {
$out[] = $k;
}
if (count($out) >= 30) { // 上限:一个下拉放不下更多了
break;
}
}
Setting::set($familyId, 'anniv_kinds', json_encode($out, JSON_UNESCAPED_UNICODE));
}
/** 每个分类被用了几条(设置页要显示"这个分类有没有在用") */
public static function kindCounts(int $familyId): array
{
$st = Database::get()->prepare(
'SELECT kind, COUNT(*) AS n FROM anniversaries
WHERE family_id = ? AND kind IS NOT NULL AND kind <> "" GROUP BY kind'
);
$st->execute([$familyId]);
$out = [];
foreach ($st->fetchAll() as $r) {
$out[(string)$r['kind']] = (int)$r['n'];
}
return $out;
}
// ---------------- ③ 这一年会落在哪几天(给日历用) ----------------
/**
* 某一年里,这些纪念日各落在哪个公历日期。
*
* ⚠️ 农历那一路必须**逐年换算**:农历新年在公历 1~2 月,
* 所以同一条"农历冬月初一"在不同年份对应的公历日期完全不同,
* 不存在"存一个公历日期年年用"的可能。
*
* @return array<int,array{date:string,title:string,days:int,kind:string,person_id:int,source:string,age:?int}>
*/
public static function occurrences(int $familyId, int $viewerId, int $year): array
{
$out = [];
foreach (self::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m < 1 || $m > 12 || $d < 1 || $d > 31) {
continue;
}
$date = null;
if ($repeat === 'lunar') {
$leap = !empty($a['is_leap']) && Lunar::leapMonth($year) === $m;
$sol = Lunar::lunarToSolar($year, $m, $d, $leap);
if ($sol === null) {
// 这一年这个月没有这一天(如腊月三十遇上 29 天的腊月)→ 退到当月最后一天
$max = $leap ? Lunar::leapDays($year) : Lunar::monthDays($year, $m);
$sol = Lunar::lunarToSolar($year, $m, max(1, min($d, $max)), $leap);
}
if ($sol === null) {
continue;
}
$date = sprintf('%04d-%02d-%02d', $sol['year'], $sol['month'], $sol['day']);
} else {
// 公历(含"只此一次"):2/29 落在平年时退到 2/28
$dd = $d;
if (!checkdate($m, $dd, $year)) {
$dd = min($dd, (int)date('t', mktime(0, 0, 0, $m, 1, $year)));
}
$date = sprintf('%04d-%02d-%02d', $year, $m, $dd);
}
$age = null;
if (($a['source'] ?? '') !== 'custom' && !empty($a['born'])) {
$age = Birthday::ageOn((string)$a['born'], $date);
}
$out[] = [
'date' => $date,
'title' => (string)($a['title'] ?? ''),
'kind' => (string)($a['kind'] ?? ''),
'person_id' => (int)($a['person_id'] ?? 0),
'source' => (string)($a['source'] ?? 'custom'),
'note' => (string)($a['note'] ?? ''),
'id' => (int)($a['id'] ?? 0),
'age' => $age,
'repeat' => $repeat,
];
}
return $out;
}
/**
* 一段时间内的纪念日(日历画格子用)。
* @return array<int,array> 按日期升序
*/
public static function between(int $familyId, int $viewerId, string $from, string $to): array
{
$y1 = (int)substr($from, 0, 4);
$y2 = (int)substr($to, 0, 4);
$out = [];
for ($y = $y1; $y <= $y2; $y++) {
foreach (self::occurrences($familyId, $viewerId, $y) as $o) {
if ($o['date'] >= $from && $o['date'] <= $to) {
$out[] = $o;
}
}
}
usort($out, static fn($a, $b) => strcmp($a['date'], $b['date']));
return $out;
}
/** 接下来 n 天内的纪念日(列表页头部"临近"用) */
public static function upcoming(int $familyId, int $viewerId, int $withinDays = 60): array
{
$today = date('Y-m-d');
$to = date('Y-m-d', strtotime('+' . max(1, $withinDays) . ' days'));
$rows = self::between($familyId, $viewerId, $today, $to);
foreach ($rows as &$r) {
$r['days'] = (int)((strtotime($r['date']) - strtotime($today)) / 86400);
}
unset($r);
return $rows;
}
public static function repeatWord(string $k): string
{
return self::REPEAT[$k] ?? $k;
}
}