Files
laoyang ebaed21920 v57:令牌升级为「设备会话」(24h 滑动续期 / 每账号 10 台 / 网页端远程退出)
用户 2026-10-17 定的模型:
  · 一次登录 = 一台设备一条会话;同一台设备重登,旧令牌立即作废
  · 24 小时滑动续期(一直联网就不用每天输密码);离线超 24h 只锁界面、不删数据
  · 网页端「个人设置 → 设备管理」能看见都有谁在登录,并能把丢了的那台踢掉
  · 每账号最多 10 台,超了让用户先踢旧的

服务端配套:
  · api_tokens 加 device_id / device_name / platform / app_version / last_ip
    + idx_api_device;历史令牌回填 legacy-<id>(不回填 = 踢不掉的幽灵会话)
  · ApiToken:issueDevice / listDevices / revokeDevice / revokeOthers / revokeCurrent
    + 滑动续期(剩余不足 4h 才写库);verify 同时返回数字键与关联键(三处调用方在用)
  · 新增 /api/v1/logout、/api/v1/devices;ping 报 features.device_session
  · 登录不再读 days(0 = 永不过期会绕过 24h 这条线);超限回 409 且**不动任何一行**
  · 老客户端兜底固定槽位 auto-unknown(随机设备号会撞满 10 台把账号锁死)
  · Schema 34 → 35

顺带:首页近期任务/往年的今天(Home::memories)、名册派生字段口径统一。

验证:设备会话探针 56/0、设备管理渲染 22/0、登录全链路 30/0、反向验证 12/0、
      e2e 1519/0、静态断言本地空跑 357/0、线上 /api/v1/{ping,devices,logout} 复核通过。
2026-10-18 03:34:47 +08:00

695 lines
34 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 首页 dashboard 的数据装配(用户 2026-10-03 第 3 条)。
*
* 这一版首页要回答的是「今天是个什么日子、家里最近怎么样」:
* · 一句随机问候(按当前时段 + 今日待办/日程给一句人话)
* · 今天的天气
* · 近七天的日程数 / 待办数 / 逾期数
* · 各模块的一张小结(用户可开关与排序)
*
* ⚠️ 天气是**外部依赖**,所以:
* ① 只在配置里给了 key 时才去请求(没配就整块不显示,不报错、不占位);
* ② 结果**缓存 30 分钟**(天气不需要每分钟刷;也避免把外部接口打爆);
* ③ 请求失败一律静默回落成"没有天气"——首页不能因为第三方挂了就打不开。
*/
declare(strict_types=1);
namespace App;
final class Home
{
/** 天气缓存有效期(秒) */
private const WEATHER_TTL = 1800;
/**
* 随机问候语。
*
* 用户原话:「随机给出一段问候语,例如,上午好,xxx,最近心情怎么样之类的」。
* 所以是**按时段 + 带名字 + 有点变化**,而不是固定的"你好"。
* ⚠️ 用 md5(日期+人名) 做种子而不是 random_int:
* 同一天刷新多次看到的应该是**同一句**,否则每次刷新都变、像抽奖。
*
* @param string $name 我怎么称呼自己(家书里"我"就是他)
*/
public static function greeting(string $name, string $today, int $events = 0, int $overdue = 0): string
{
$h = (int)date('G');
if ($h < 5) {
$part = '夜深了';
} elseif ($h < 11) {
$part = '早上好';
} elseif ($h < 13) {
$part = '中午好';
} elseif ($h < 18) {
$part = '下午好';
} else {
$part = '晚上好';
}
$who = trim($name) !== '' ? ',' . trim($name) : '';
// 有逾期先说逾期(那是最该被看见的),其次今天的安排,最后才是寒暄
if ($overdue > 0) {
$tail = '有 ' . $overdue . ' 件事已经过期了,要不要先处理一下?';
} elseif ($events > 0) {
$tail = '今天有 ' . $events . ' 件事,慢慢来。';
} else {
$tails = [
'最近心情怎么样?',
'今天想做点什么?',
'日子慢慢过,不着急。',
'记得给家里打个电话。',
'今天也是好日子。',
'有空翻翻往年的今天。',
];
$seed = crc32($today . '|' . $name);
$tail = $tails[$seed % count($tails)];
}
return $part . $who . ',' . $tail;
}
/** 今天星期几(中文) */
public static function weekday(string $date): string
{
$w = ['日', '一', '二', '三', '四', '五', '六'][(int)date('w', strtotime($date))];
return '星期' . $w;
}
/**
* 「今天是个什么日子」—— 首页信息面板要的那一串标记(v24 第 24 批 用户第 2 条)。
*
* 用户原话:「如果有节气的话,或者有重要的节日的话,在这里显示。
* 比如说今天是这个国庆节啊,今天是端午节,或者是今天是中秋节,
* 或者是今天是芒种,今天是大寒,今天是小寒,今天是什么纪念日,
* 今天是谁的生日等等。显示在这里。」
*
* 一次把五类来源合成**一行可读的短语**:
* ① 法定节假日(Holiday::range —— 含"补班",那也要说,否则用户以为白放假)
* ② 二十四节气(Holiday::solarTermRange)
* ③ 传统节日(Holiday::traditionalRange —— 春节/中秋/端午…)
* ④ 纪念日(Anniversary::all —— 含自建与派生)
* ⑤ 生日(同上,Anniversary 已经把生日一起派生出来了)
*
* ⚠️ 为什么合成"一条字符串"而不是给模板五个数组:
* 面板上这是**一句话的位置**("今天是……"),模板里再拼一遍会把这五类的
* 优先级、去重、连接符全散到视图层 —— 同一件事写两处,下次加一类必漏一处。
* ("生日"与"纪念日"天然会重复:生日本身就是一条派生的纪念日,
* 所以下面按**名字去重**。)
*
* @param int $viewerId 谁在看(纪念日的称呼按他算,如"儿子"而非"杨小全")
* @return array{tags:array<int,array{word:string,kind:string}>, line:string}
* tags 给模板做彩色小标签,line 给纯文本兜底
*/
public static function todayMarks(int $familyId, int $viewerId, string $date): array
{
$tags = [];
$seen = [];
$add = static function (string $word, string $kind) use (&$tags, &$seen): void {
$word = trim($word);
// 同一件事别出现两次(生日既是"生日"也是一条派生纪念日)
if ($word === '' || isset($seen[$word])) {
return;
}
$seen[$word] = true;
$tags[] = ['word' => $word, 'kind' => $kind];
};
$y = (int)substr($date, 0, 4);
// ① 法定节假日(只认"今天在放假区间里";补班也报,但换一种措辞)
foreach (\App\Holiday::range($y, $date, $date) as $h) {
$nm = (string)($h['name'] ?? '');
if ($nm === '') {
continue;
}
$add(!empty($h['off']) ? $nm : ($nm . '(补班)'), 'holiday');
}
// ② 二十四节气
foreach (\App\Holiday::solarTermRange($date, $date) as $s) {
$add((string)($s['name'] ?? ''), 'term');
}
// ③ 中国传统节日
foreach (\App\Holiday::traditionalRange($date, $date) as $t) {
$add((string)($t['name'] ?? ''), 'festival');
}
// ④⑤ 纪念日 + 生日(Anniversary::all 已把生日派生进来)
foreach (\App\Anniversary::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m <= 0 || $d <= 0) {
continue;
}
$isToday = false;
if ($repeat === 'lunar') {
// 农历:把"今天的农历月日"拿来比(闰月单列)
$l = \App\Lunar::solarToLunar($y, (int)substr($date, 5, 2), (int)substr($date, 8, 2));
$isToday = $l !== null
&& (int)$l['month'] === $m && (int)$l['day'] === $d
&& (int)($l['is_leap'] ? 1 : 0) === (int)($a['is_leap'] ?? 0);
} elseif ($repeat === 'once') {
// 一次性:年月日全对上才算(这种不是"每年重复")
$born = (string)($a['born'] ?? '');
$isToday = $born !== '' && substr($born, 0, 10) === $date;
} else {
$isToday = $m === (int)substr($date, 5, 2) && $d === (int)substr($date, 8, 2);
}
if (!$isToday) {
continue;
}
$title = (string)($a['title'] ?? '');
$add($title, (string)($a['kind'] ?? '') === '生日' ? 'birthday' : 'anniv');
}
$words = array_map(static fn(array $t): string => $t['word'], $tags);
return [
'tags' => $tags,
'line' => $words ? implode(' · ', $words) : '',
];
}
/**
* 我在哪儿(v24 用户 2026-10-03 第 1 条)。
*
* 用户原话:「天气这个这样吧,让用户在个人设置中填写自己的城市。」
* 所以地点从"管理员在系统设置里填经纬度"改成"**每个人**在个人设置里填城市"。
*
* 三级回落:
* ① 个人设置里直接填的经纬度(镇 / 村 / 海外,内置表没有的地方)
* ② 个人设置里填的城市名 → App\Geo 的**内置表**(197 个地级市,不联网)
* ③ 个人设置还没配 → 回落到**旧的家庭级坐标**(settings.weather_lat/lon)
* ④ 都没有 → null,首页那一块不显示
*
* ⚠️ 第 ③ 级是**迁移的善意**,不是冗余:天气原来配在系统设置里(管理员填一次),
* 改成个人设置之后,如果只认 user_settings,那么所有人升级完当天首页的
* 天气就凭空消失了 —— 用户会以为"改坏了"。留着这条回落,
* 他自己去个人设置填上城市的那一刻才真正接管(顺序上个人值优先)。
*
* ⚠️ 顺序是"个人经纬度优先于个人城市名":用户亲手填的坐标比城市名更精确,
* 也更明确地表了态 —— 既填了城市又填了坐标,当然是按坐标来。
*
* @param int $familyId 用来取旧的家庭级兜底坐标(0 = 不做兜底)
* @return array{lat:string,lon:string,place:string}|null
*/
public static function weatherLocation(int $personId, int $familyId = 0): ?array
{
$lat = trim((string)UserSetting::get($personId, 'weather_lat', ''));
$lon = trim((string)UserSetting::get($personId, 'weather_lon', ''));
$city = trim((string)UserSetting::get($personId, 'weather_city', ''));
if ($lat !== '' && $lon !== '' && is_numeric($lat) && is_numeric($lon)) {
// 手填坐标时地名就用他填的城市名(没填就只说"我所在的地方")
return ['lat' => $lat, 'lon' => $lon, 'place' => $city !== '' ? $city : '我所在的地方'];
}
if ($city !== '') {
$g = Geo::lookup($city);
if ($g !== null) {
return $g;
}
// 城市名认不出,但用户确实配了别的(见下)→ 继续往下回落,别直接返回 null
}
if ($familyId > 0) {
$flat = trim((string)Setting::get($familyId, 'weather_lat', ''));
$flon = trim((string)Setting::get($familyId, 'weather_lon', ''));
if ($flat !== '' && $flon !== '' && is_numeric($flat) && is_numeric($flon)) {
return [
'lat' => $flat,
'lon' => $flon,
'place' => trim((string)Setting::get($familyId, 'weather_place', '')),
];
}
}
return null;
}
/**
* 今天的天气。没配地点 / 请求失败 / 超时 → null(首页整块不显示)。
*
* 用 Open-Meteo:**不需要申请 key**、允许非商业使用、有中文描述。
* 之所以不选和风/心知:都要申请 key,而"配好 key 才能看见天气"这件事
* 会让用户以为功能没做(配置项藏在哪他未必找得到)。
*
* ⚠️ 只按经纬度请求,不传城市名 —— 城市名 → 坐标的事在 Geo 里离线做了
* (见 Geo 的类注释:在线地理编码对中国地级市实测命中率很差)。
* 这里拿到的 lat/lon 已经是确定的坐标,不再猜。
*
* @return array{temp:string,text:string,icon:string,place:string}|null
*/
public static function weather(int $familyId, string $lat, string $lon, string $place): ?array
{
$lat = trim($lat);
$lon = trim($lon);
if ($lat === '' || $lon === '' || !is_numeric($lat) || !is_numeric($lon)) {
return null;
}
// ⚠️ 缓存存在 settings 表里,而那张表对 family_id 有**外键**:
// 传 0 会插入失败(外键不存在),所以必须带家庭 id。
// ⚠️ 缓存键里带上坐标:v24 起地点是**每人一份**的(一家人可能分处两地),
// 只用家庭 id 做键会让两口子互相看到对方城市的天气。
$cacheKey = 'weather_' . md5($lat . ',' . $lon);
$cached = Setting::get($familyId, $cacheKey, '');
if ($cached !== '') {
$dec = json_decode((string)$cached, true);
if (is_array($dec) && (int)($dec['at'] ?? 0) + self::WEATHER_TTL > time()) {
return self::weatherOut((array)($dec['data'] ?? []), $place);
}
}
$base = getenv('FAMILYBOOK_WEATHER_BASE');
if (!is_string($base) || $base === '') {
$base = 'https://api.open-meteo.com/v1/forecast';
}
// ⚠️ 这个可覆盖的 base 是**为测试留的**(与 Douban 的 FAMILYBOOK_DOUBAN_SEARCH
// 同一套做法):本地/CI 环境取不到 api.open-meteo.com(实测外网直连超时
// 17.8s 后失败),于是"天气那一块"在本地**永远**是空 ——
// 断言只能靠 mock 才验得了。生产环境不设这个变量,走真实地址。
$url = $base
. '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
. '&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,is_day'
. '&daily=temperature_2m_max,temperature_2m_min&forecast_days=1'
. '&timezone=Asia%2FShanghai';
$body = self::httpGet($url);
if ($body === null) {
return null;
}
$j = json_decode($body, true);
if (!is_array($j) || !isset($j['current'])) {
return null;
}
// v24 第 24 批 用户第 2 条:面板上要显示**温度、风力**(用户原话)。
// 风向、昼夜、今日高低温都顺手取回来 —— 它们只在 current/daily 里各加一个字段,
// 不多打一次接口;以后要在面板上加"最高 20° 最低 12°"直接就有。
$data = [
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
'code' => (int)($j['current']['weather_code'] ?? -1),
'wind' => round((float)($j['current']['wind_speed_10m'] ?? 0), 1),
'wdir' => (int)($j['current']['wind_direction_10m'] ?? 0),
'day' => (int)($j['current']['is_day'] ?? 1),
'tmax' => isset($j['daily']['temperature_2m_max'][0])
? round((float)$j['daily']['temperature_2m_max'][0]) : null,
'tmin' => isset($j['daily']['temperature_2m_min'][0])
? round((float)$j['daily']['temperature_2m_min'][0]) : null,
];
// 缓存下来(存在 settings,key 里带 family 0 = 全站级)
Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE));
return self::weatherOut($data, $place);
}
/** 内部结构 → 页面用的天气结构 */
private static function weatherOut(array $data, string $place): ?array
{
if (!isset($data['temp'])) {
return null;
}
[$text, $icon, $tone] = self::wmo((int)($data['code'] ?? -1), (int)($data['day'] ?? 1));
$wind = (float)($data['wind'] ?? -1);
$wdir = (int)($data['wdir'] ?? -1);
return [
'temp' => (string)$data['temp'] . '°C',
'text' => $text,
'icon' => $icon,
// v24 第 24 批:面板要的是**彩色图标**(用户原话),
// 所以除了符号本身,再把"该用哪一档颜色"一起给出来 ——
// 颜色由**后端**决定(晴=暖黄、雨=蓝、雪=青…),前端只管贴上去;
// 让 CSS 去猜天气码等于把同一份判断抄两遍。
'tone' => $tone,
'wind' => $wind >= 0 ? (string)round($wind) . ' km/h' : '',
// 风向文字(八向)。用户没点名要,但"风"这件事光看风速不够 ——
// 顺手给出来,面板上放在风力旁边即可(没数据时是空串,不占位)。
'wdir' => $wdir >= 0 ? self::windDir($wdir) : '',
'tmax' => isset($data['tmax']) && $data['tmax'] !== null ? (string)round((float)$data['tmax']) . '°' : '',
'tmin' => isset($data['tmin']) && $data['tmin'] !== null ? (string)round((float)$data['tmin']) . '°' : '',
'place' => trim($place),
];
}
/** 风向角度 → 八向中文(数据缺失时调用方不会调它) */
private static function windDir(int $deg): string
{
$dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
$i = (int)round((($deg % 360) + 360) % 360 / 45) % 8;
return $dirs[$i] . '风';
}
/**
* WMO 天气代码 → [中文描述, 一个符号, 配色档]。
*
* ⚠️ 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块,
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
* v24 第 24 批 用户第 2 条要"彩色图标"—— 那就用**颜色**把它变彩色,
* 而不是换成 emoji(换 emoji 会把豆腐块的风险直接引进来)。
*
* @param int $day 1=白天 0=夜间(晴天夜间给月亮,不要白天也挂太阳)
* @return array{0:string,1:string,2:string} [描述, 符号, 配色档(sunny/cloud/rain/snow/storm/fog)]
*/
private static function wmo(int $c, int $day = 1): array
{
if ($c === 0) {
return $day === 1 ? ['晴', '☀', 'sunny'] : ['晴', '☾', 'sunny'];
}
if (in_array($c, [1, 2], true)) {
return ['多云', '⛅', 'cloud'];
}
if ($c === 3) {
return ['阴', '☁', 'cloud'];
}
if (in_array($c, [45, 48], true)) {
return ['有雾', '≋', 'fog'];
}
if ($c >= 51 && $c <= 57) {
return ['毛毛雨', '☂', 'rain'];
}
if ($c >= 61 && $c <= 67) {
return ['下雨', '☂', 'rain'];
}
if ($c >= 71 && $c <= 77) {
return ['下雪', '❄', 'snow'];
}
if ($c >= 80 && $c <= 82) {
return ['阵雨', '☂', 'rain'];
}
if ($c >= 85 && $c <= 86) {
return ['阵雪', '❄', 'snow'];
}
if ($c >= 95) {
return ['雷雨', '⚡', 'storm'];
}
return ['天气', '·', 'cloud'];
}
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
private static function httpGet(string $url): ?string
{
if (!function_exists('curl_init')) {
$ctx = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]);
$r = @file_get_contents($url, false, $ctx);
return $r === false ? null : (string)$r;
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 3,
CURLOPT_CONNECTTIMEOUT => 2,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_USERAGENT => 'FamilyBook/1.0',
]);
$r = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($r === false || $code < 200 || $code >= 300) {
return null;
}
return (string)$r;
}
/**
* 首页的「往年今日」:往年**同一天**(月与日都相同)看过的电影、走过的旅程。
*
* 🔴 v54(用户 2026-10-16)—— 从 `index.php` 的 `home_memories()` **搬进来**。
* 用户原话:「近期任务的下面,应该加上**往年的今天**,跟电脑端一样,
* 你看看那个是什么逻辑。」
* 搬的理由:App 端首页也要这一块,而 App 走 `/api/v1/home`;
* **两端必须是同一份实现**,否则会出现"网页说 2 年前、手机说 3 年前"
* (本项目在农历/节气上已经吃过一次这个亏,见 `ApiHandler::home()` 的注释)。
* `index.php` 里那个同名函数现在只是一层转发。
*
* ⚠️ v24(用户 2026-10-02 第 19 条):原来同一天没有命中时会**退回同一个月**,
* 标题随之变成"往年这个月" —— 但用户要的就是**往年的今天**:
* "10 月 2 日"给我看"去年 10 月 8 日去看了场电影",这根本不是同一天的事,
* 凑出来的条目看着像回忆,其实只是同月的随机噪声,反而把"往年今日"的意味毁了。
* 现在只认同一天;没命中就明说"往年的这一天还没有记录"。
*
* ⚠️ 旅程只看 `trip_nodes.stage='actual'`(**实际行程**),不是整趟旅程:
* 用户 2026-10-05:「实际行程,都会出现在日历和首页的往年今日中」,
* 而"标记未成行 / 仍是计划"的都不该出现("就权当没有这个日程")。
* 用 `n.starts_at` 而不是 `events.starts_at` —— 实际行程的时间才是"那天真的走了"。
*
* @return array{rows:array,exact:bool} exact 恒为 true(保留字段只为模板兼容)
*/
public static function memories(int $familyId, int $limit = 6): array
{
$today = date('Y-m-d');
$year = (int)date('Y');
$md = substr($today, 5, 5);
$rows = [];
// ① 观影(events kind=movie)
$sql = "SELECT e.id, e.title, e.starts_at, e.kind,
(SELECT COUNT(*) FROM event_attendees a WHERE a.event_id = e.id) AS n
FROM events e
WHERE e.family_id = ? AND e.kind = 'movie' AND e.starts_at IS NOT NULL
AND YEAR(e.starts_at) < ?
AND DATE_FORMAT(e.starts_at, '%m-%d') = ?
ORDER BY e.starts_at DESC LIMIT " . (int)$limit;
$st = Database::get()->prepare($sql);
$st->execute([$familyId, $year, $md]);
foreach ($st->fetchAll() as $r) {
$rows[] = $r;
}
// ② 旅程的**实际行程**节点
$sql2 = "SELECT n.id, n.title, n.starts_at, 'trip_node' AS kind,
(SELECT COUNT(*) FROM trip_node_members m WHERE m.node_id = n.id) AS n,
e.id AS trip_id, e.title AS trip_title, n.node_type
FROM trip_nodes n
JOIN events e ON e.id = n.event_id AND e.kind = 'trip'
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE n.family_id = ? AND n.stage = 'actual' AND n.starts_at IS NOT NULL
AND COALESCE(d.is_public, 1) = 1
AND YEAR(n.starts_at) < ?
AND DATE_FORMAT(n.starts_at, '%m-%d') = ?
ORDER BY n.starts_at DESC LIMIT " . (int)$limit;
$st2 = Database::get()->prepare($sql2);
$st2->execute([$familyId, $year, $md]);
foreach ($st2->fetchAll() as $r) {
// 标题带上"是哪趟旅程的哪一段",否则只看到一个"栈桥看海"不知道是何时的
$r['title'] = (string)$r['trip_title'] . ' · ' . (string)$r['title'];
$rows[] = $r;
}
// 合并后按时间倒序,取前 N
usort($rows, static fn(array $a, array $b): int => strcmp((string)$b['starts_at'], (string)$a['starts_at']));
$rows = array_slice($rows, 0, $limit);
foreach ($rows as &$r) {
$r['years_ago'] = $year - (int)substr((string)$r['starts_at'], 0, 4);
if ($r['kind'] === 'trip_node') {
$r['href'] = \url('trips/show', ['id' => (int)$r['trip_id']]) . '#node-' . (int)$r['id'];
} else {
$r['href'] = \url($r['kind'] === 'trip' ? 'trips/show' : 'movies/show', ['id' => (int)$r['id']]);
}
}
unset($r);
return ['rows' => $rows, 'exact' => true];
}
/**
* 各模块的一张小结。返回 key => ['name','icon','color','href','big','lines']。
*
* 「big」是一个最该被看见的数字/短句,「lines」是几行补充。
* 每张卡都**点得进去**(href 指向该模块最能回答这张卡的那一页)。
*/
public static function moduleCards(int $familyId, int $me, array $soon, array $tasks): array
{
$cards = [];
// ---- 名册 ----
$people = Person::map($familyId);
$roster = Person::roster($familyId, $me, 'member', '');
$cards['people'] = [
'name' => '名册',
'icon' => self::icon('people'),
'color' => Module::color('people', $familyId),
'href' => url('people'),
'big' => (string)count($roster) . ' 人',
'lines' => [count($people) . ' 人在这个家里'],
];
// ---- 家谱 ----
$graph = Kinship::graph($familyId);
$cards['kin'] = [
'name' => '家谱',
'icon' => self::icon('kin'),
'color' => Module::color('kin', $familyId),
'href' => url('kin'),
'big' => (string)Relations::count($familyId) . ' 条连线',
'lines' => ['父母 / 配偶 / 子女填上就能长出来'],
];
// ---- 观影 ----
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM events WHERE family_id = ? AND kind = 'movie'"
);
$st->execute([$familyId]);
$movieN = (int)$st->fetchColumn();
$st = Database::get()->prepare(
"SELECT title FROM events WHERE family_id = ? AND kind = 'movie'
ORDER BY starts_at DESC, id DESC LIMIT 1"
);
$st->execute([$familyId]);
$lastMovie = (string)($st->fetchColumn() ?: '');
$cards['movies'] = [
'name' => '观影',
'icon' => self::icon('movies'),
'color' => Module::color('movies', $familyId),
'href' => url('movies'),
'big' => $movieN . ' 部',
'lines' => $lastMovie !== '' ? ['最近看了《' . $lastMovie . '》'] : ['还没有观影记录'],
];
// ---- 旅程 ----
$st = Database::get()->prepare(
"SELECT COUNT(*) FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1"
);
$st->execute([$familyId]);
$tripN = (int)$st->fetchColumn();
// ⚠️ 用户第 15 批 第 2 条:活动自己没有时间了 —— "最近去过哪"要按
// **各段旅程里最晚的那个时间**排,不能再看 e.starts_at(那列已经不用了,
// 恒为 NULL,等于每次取到的都是"最后插入的那条",看起来像随机)。
$st = Database::get()->prepare(
"SELECT e.title FROM events e
LEFT JOIN trip_details d ON d.event_id = e.id
WHERE e.family_id = ? AND e.kind = 'trip' AND COALESCE(d.is_public,1) = 1
ORDER BY GREATEST(
COALESCE((SELECT MAX(n.starts_at) FROM trip_nodes n
WHERE n.event_id = e.id AND n.stage IN ('plan','actual')), '1000-01-01'),
'1000-01-01') DESC,
e.id DESC LIMIT 1"
);
$st->execute([$familyId]);
$lastTrip = (string)($st->fetchColumn() ?: '');
$cards['trips'] = [
'name' => '旅程',
'icon' => self::icon('trips'),
'color' => Module::color('trips', $familyId),
'href' => url('trips'),
'big' => $tripN . ' 次',
'lines' => $lastTrip !== '' ? ['最近去了' . $lastTrip] : ['还没有活动记录'],
];
// ---- 日程 ----
$cards['calendar'] = [
'name' => '日程',
'icon' => self::icon('calendar'),
'color' => Module::color('calendar', $familyId),
'href' => url('calendar'),
'big' => count($soon) . ' 条',
'lines' => ['未来七天'],
];
// ---- 资源 ----
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM resources r
LEFT JOIN resource_shares s ON s.resource_id = r.id AND s.person_id = ?
WHERE r.family_id = ? AND (r.owner_id = ? OR s.person_id IS NOT NULL)'
);
$st->execute([$me, $familyId, $me]);
$resN = (int)$st->fetchColumn();
$cards['resources'] = [
'name' => '资源',
'icon' => self::icon('resources'),
'color' => Module::color('resources', $familyId),
'href' => url('resources'),
'big' => $resN . ' 项',
'lines' => ['证件 / 资料 / 附件'],
];
// ---- 任务 ----
// ⚠️ v24 第 14 批 用户第 2 条:**按状态算,不看板算**。
// 看板是"这件事属于哪摊事",状态是"做到哪一步";首页关心的是后者,
// 所以这里用 Task::byStatus()(跨看板)—— 用 board() 只会数到默认看板里的。
$doing = count(Task::byStatus($familyId, $me, 'doing'));
$todo = count(Task::byStatus($familyId, $me, 'todo'));
$late = 0;
foreach (array_merge(Task::byStatus($familyId, $me, 'doing'),
Task::byStatus($familyId, $me, 'todo')) as $t) {
if (Task::isOverdue($t['due_at'] ?? null)) {
$late++;
}
}
$cards['tasks'] = [
'name' => '任务',
'icon' => self::icon('tasks'),
'color' => Module::color('tasks', $familyId),
'href' => url('tasks'),
'big' => $doing . ' 件在做',
'lines' => [$todo . ' 件待办' . ($late > 0 ? ' · ' . $late . ' 件逾期' : '')],
];
// ---- 纪念日 ----
$up = Anniversary::upcoming($familyId, $me, 30);
$next = $up[0] ?? null;
$cards['days'] = [
'name' => '纪念日',
'icon' => self::icon('days'),
'color' => Module::color('days', $familyId),
'href' => url('days'),
'big' => $next ? ((int)$next['days'] === 0 ? '今天' : (int)$next['days'] . ' 天后') : '—',
'lines' => [$next ? (string)$next['title'] : '三十天内没有纪念日'],
];
// ---- 财务 ----
$books = Ledger::mine($familyId, $me);
$net = 0;
$hasBook = false;
foreach ($books as $b) {
$hasBook = true;
$net += Ledger::summary(Ledger::entries((int)$b['id']))['net'];
}
$cards['ledger'] = [
'name' => '财务',
'icon' => self::icon('ledger'),
'color' => Module::color('ledger', $familyId),
'href' => url('ledger'),
'big' => $hasBook ? Ledger::money($net) : '还没记账',
'lines' => [$hasBook ? count($books) . ' 本账(净资产)' : '先建一本家庭账'],
];
// ---- 读书(v24 第 14 批 用户第 5 条)----
$bs = Book::stats($familyId);
$reading = (int)($bs['status']['reading'] ?? 0);
$doneN = (int)($bs['status']['done'] ?? 0);
$cards['books'] = [
'name' => '读书',
'icon' => self::icon('books'),
'color' => Module::color('books', $familyId),
'href' => url('books'),
'big' => $bs['total'] > 0 ? ($reading > 0 ? $reading . ' 本在读' : $doneN . ' 本读过') : '空书架',
'lines' => [$bs['total'] > 0
? ($doneN . ' 本读完 · ' . (int)$bs['notes'] . ' 条笔记')
: '传一本进来,或先记下想读的书'],
];
return $cards;
}
/** 模块图标(行内 SVG;与观影设置下拉同一套做法,不引外部字体/图片) */
public static function icon(string $key): string
{
$paths = [
'people' => '<circle cx="7" cy="6" r="2.6"/><path d="M2.6 14c0-2.4 2-3.9 4.4-3.9s4.4 1.5 4.4 3.9"/><circle cx="13" cy="7.2" r="2"/><path d="M11.6 14c0-1.9 1.2-3 2.8-3s2.6 1.1 2.6 3"/>',
'kin' => '<rect x="6.4" y="1.8" width="5.2" height="4" rx="1"/><rect x="1.8" y="12.2" width="5.2" height="4" rx="1"/><rect x="11" y="12.2" width="5.2" height="4" rx="1"/><path d="M9 5.8v3M4.4 12.2v-1.6h9.2v1.6M4.4 10.6h9.2"/>',
'movies' => '<rect x="1.8" y="3.4" width="14.4" height="11.2" rx="2"/><path d="M1.8 7.2h14.4M5.4 3.4v3.8M10.6 3.4v3.8"/>',
'trips' => '<path d="M9 1.8c-2.9 0-5.2 2.3-5.2 5.2 0 3.9 5.2 9.2 5.2 9.2s5.2-5.3 5.2-9.2c0-2.9-2.3-5.2-5.2-5.2z"/><circle cx="9" cy="7" r="1.9"/>',
'calendar' => '<rect x="2" y="3.6" width="14" height="12.4" rx="2"/><path d="M2 7.6h14M5.6 1.8v3.6M12.4 1.8v3.6"/>',
'resources' => '<path d="M2.4 4.4A1.6 1.6 0 0 1 4 2.8h3.2l1.6 2h5.2A1.6 1.6 0 0 1 15.6 6.4v7.2a1.6 1.6 0 0 1-1.6 1.6H4a1.6 1.6 0 0 1-1.6-1.6z"/>',
'tasks' => '<rect x="2.4" y="2.4" width="13.2" height="13.2" rx="2"/><path d="M5.6 9.2l2.4 2.4 4.4-4.8"/>',
'days' => '<path d="M9 2.2l2 4.2 4.6.6-3.4 3.2.8 4.6L9 12.6l-4 2.2.8-4.6L2.4 7l4.6-.6z"/>',
'ledger' => '<rect x="2.4" y="3" width="13.2" height="12" rx="2"/><path d="M5.6 6.6h6.8M5.6 9.4h6.8M5.6 12.2h4"/>',
// 读书:一本摊开的书(两页 + 书脊)
'books' => '<path d="M2.2 4.2A1.6 1.6 0 0 1 3.8 2.6h3.4c.9 0 1.8.4 1.8 1.3v10.2c0-.9-.9-1.3-1.8-1.3H3.8a1.6 1.6 0 0 1-1.6-1.6z"/><path d="M15.8 4.2a1.6 1.6 0 0 0-1.6-1.6h-3.4c-.9 0-1.8.4-1.8 1.3v10.2c0-.9.9-1.3 1.8-1.3h3.4a1.6 1.6 0 0 0 1.6-1.6z"/>',
];
$d = $paths[$key] ?? '';
if ($d === '') {
return '';
}
return '<svg viewBox="0 0 18 18" width="18" height="18" aria-hidden="true" fill="none"'
. ' stroke="currentColor" stroke-width="1.5" stroke-linecap="round"'
. ' stroke-linejoin="round">' . $d . '</svg>';
}
}