Files
FamilyBook/app/Home.php
T
laoyang 09e30d27d4 v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图
用户 6 条原话(要点):
  ①「选择名册里面人员的那个弹出框有个问题,就是会把页面里面已有的人员给冲掉。
     比方说电影里面的观影人或者是旅程里面的参与人…这是一个 bug 应该通篇检查。」
    并按「输入拼音 → 空格选中第一个 → 输入框清空回焦 → 再输下一个 → 回车=确定」重做交互。
  ② 首页简要信息改成:问候 + 姓名 + 日期 + 星期 + 农历 + 节气/节日/纪念日/生日,
     下面「今天几条日程 / 几件待办」,天气**彩色图标**放**最右侧**,温度风力,最下面地址。
  ③「家里的镜框这里呃全部不要了」→ 删模块卡片墙 + 未来七天;日程与任务**左右各半**、
     高度固定、内部滚动且**不显滚动条**;日程**掐头去尾** + 当前时刻**红线**;
     任务显示标题+状态+**所在看板名**;最下面往年的今天。
  ④「把个人导航项目完整搬过来,放在读书后面」;免登录展示页 `/nav/?<短语>`;
     右上角给**登录**入口;展示页头像用**用户自己的头像**。
  ⑤ 读书加笔记 + 导入微信读书笔记(用户明确「**用微信读书 api key**」)。
  ⑥ 财务页第二张多线图不随类别标签筛选。

用户拍板(AskUserQuestion):① 首页删「模块卡片墙 + 未来七天」两块;
② 微信读书用官方 API Key;③ 导航短语「自己起名 + 可一键换随机码」都做;
④ 6 条一次做完再上线。

════ 一、 🔴 第 1 条:复现出的真 bug(全站性)════
**根因**:选人清单是**打开弹框时才建 DOM**(懒建,为性能),而承载"已选"的
checkbox 就在那份清单里 → **没打开过弹框就提交**时表单里一个值都没有 →
后端把"表单里没有 attendees"理解成"用户要把人全删掉" → **静默清空**。
实测复现:库里同行人 `2,3,4` → 只改标题保存后变成 **NULL**。

修法:模板加**承运字段**(`.pp-carry` 隐藏 input,承载当前已选);
JS 建清单时**撤掉**它(否则同一个 name 会带两份值)。
⚠️ 裸 POST(完全不带 attendees)**本来就该清空** —— 那是显式地"不选任何人",
所以断言也必须照着"浏览器实际提交的样子"(带承运字段)来测。

**键盘流程**(用户第 1 条后半):空格 = 选**当前可见列表里第一个还没选的**
(⚠️ 必须跳过已选中的 —— 否则会把它**取消**,与"我要选人"正好相反,本轮真机跑出来的缺陷);
选中后清空输入框 + 光标回输入框;回车 = 确定。空格必须 `preventDefault`
(否则输入框里多一个空格字符)。

覆盖范围:4 个多选(`attendees[]` / `members[]` / `shares[]` / 修订 `members[]`)
+ 全部单选(归属人 / 负责人 / 纪念日人等)。

════ 二、 首页改版(第 2、3 条)════
自上而下只有三块:
  ① 信息面板:问候(早/中/晚 + 姓名)→ 日期 / 星期 / 农历 → 日子标记
     (法定节假日 / 节气 / 传统节日 / 纪念日 / 生日,各一色)
     → 今日数字(今天几条日程、几件待办)| **最右**:彩色天气图标 + 温度 + 风力 + 地址
  ② 日程 / 任务 **左右各半**(各 360px 固定高、内部滚动、**不显滚动条**三套写法)
     日程是**日视图形式的刻度**、**掐头去尾**(首末日程前后各 1h)、**当前时刻红线**
     任务是标题 + **状态** + **看板名** + 截止
  ③ 往年的今天

新增 `Home::todayMarks()`(把五类来源合成一句话:"今天是国庆节 · 大暑")与
天气的 `tone` 色调档(彩色图标靠 CSS 上色,**不用 emoji** —— 避免旧系统豆腐块)。
删掉:`mod-cards` 模块卡片墙、`未来七天`。

════ 三、 个人导航(第 4 条,SimpleNav 完整搬运)════
`app/Nav.php`(4 张 `nav_*` 表 + 稀疏排序 + 短语)· `app/NavFetch.php`
(抓取与图标生成,27KB,从原项目 functions.php 机械搬运,**原文那批"线上实测"注释全部保留**)
· `nav_show.php`(**全站唯一免登录页**)· `nav_suggest.php`(联想代理,凭短语门禁)
· `templates/nav/index.php`(管理页:板块/站点/引擎/设置 + 拖拽排序)。

搬运只改两处(不改会坏):① 独立 SQLite → 家书的 MySQL;② **所有查询加 `person_id`**
(原项目是单用户系统,不加这一维全家人会共用同一份导航)。

免登录展示页:`/nav/?<短语>`(也认 `/nav/?<短语>` 不带参数名);
短语用户自起名(4~32 位字母数字`_-`,全局唯一、保留词拦截)或一键换随机码;
右上角**登录**入口(进家书登录页);头像用**他自己**的头像;空态给一句话。

路由三处同步(`router.php` / `.htaccess` / 部署脚本 5c **独立标记块**)——
这是本仓库第三次复用这条规律:**每加一个"站根上的独立入口",就必须是自己的标记块**。
⚠️ 线上重写规则**顺序有语义**:`^nav/suggest\.php$` 必须在 `^nav/?$` 之前
(后者会把前者吞掉)。

════ 四、 微信读书(第 5 条)════
用户提示「用微信读书 api key」是对的 —— **我的知识过时了**:2026 年官方已开放
(网关 `POST https://i.weread.qq.com/api/agent/gateway`、`Bearer wrk-` 认证、
接口 `/user/notebooks`(游标 lastSort 分页)、`/book/bookmarklist`、
`/review/list/mine`、`/book/chapterinfo`)。

`app/WeRead.php`:Key 用 `Crypto::encrypt` **加密落库**、界面只回显掩码
(官方原话:这串 Key 等价于账号凭据);导入走「拉预览 → 勾选 → 导入」,
落 `book_notes`(去重 = kind + 内容 + 位置)。
⚠️ 业务参数必须**平铺在顶层**,不能包进 `params`(官方文档专门列了这个反例)。
另一条路「手动粘贴」保留(`books/notes_import`,早已有)。

════ 五、 财务第二张图(第 6 条,**上一批我引入的 bug**)════
`g.hidden = true` 对 **SVG 的 `<g>` 无效**(它没有 `hidden` 属性)→ 线条一条都没被藏;
**而我的探针也读同一个属性** → 两边一起绿(假绿)。
改为 `style.display`(恢复用 `''` 而不是 `'block'`);探针改读
`getComputedStyle(el).display !== 'none'`(落在**渲染结果**上)。
⚠️ "探针与实现读同一个属性"是**互相掩盖**的典型,比单纯写错断言更危险。

════ 六、 验证 ════
e2e **1218/0**(本批新增 102 条)· 真机探针 **19/0**(选人)+ **24/0**(首页/导航)
+ **34/0**(导航模块)· 桌面几何 0 失败 · 移动端几何 674/0 · 老库升级 149/0
· 部署静态断言空跑 **310/0** · **反向验证 13/13**(逐条改回旧行为,点名断言都变红)
· 线上验收:9 个新文件全在、4 张 nav 表建好(schema_version=24)、
  三条重写规则顺序正确、免登录页 / 空短语 / 错短语 / 联想门禁 全部响应正确。

════ 七、 本批的"夹具与断言"教训(都写进注释了)════
· `calendar_events.all_day` **默认值是 1** → 插夹具不写它就成了全天事件 →
  `time` 被抹成 `''` → "刻度掐头去尾"看着像没做(白查三轮)。
· 日历本要挑 `kind='custom'`(我原以为"普通本没有 kind",实际**普通本就是 custom**)。
· 量"同高/同排"要拿**内容区**(去 padding)比,不能拿 border-box(`.topline` 有 14px 上内边距)。
· 判据不能把**边界上的正确行为**当 bug(22 点有日程时末刻度**就是** 23:00)。
· 「HTML 写了、CSS 没写」e2e 永远查不出来 —— 导航展示页的登录按钮就落到了左边
  (实测按钮右 116px / 整行右 1210px),只有真机量几何才抓得到。
· 反向验证里 e2e 会**重铺副本**把注入覆盖掉 → 给 e2e 加了 `FB_PATCH_FILE` 钩子
  (重铺完自动打补丁),13 例里 8 例"没牙齿"其实是这个假象。

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
2026-10-07 05:52:40 +08:00

614 lines
29 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
/**
* 首页 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;
}
/**
* 各模块的一张小结。返回 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>';
}
}