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`
This commit is contained in:
+158
-18
@@ -76,6 +76,99 @@ final class Home
|
||||
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 条)。
|
||||
*
|
||||
@@ -164,9 +257,19 @@ final class Home
|
||||
}
|
||||
}
|
||||
|
||||
$url = 'https://api.open-meteo.com/v1/forecast'
|
||||
$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)
|
||||
. '¤t=temperature_2m,weather_code&timezone=Asia%2FShanghai';
|
||||
. '¤t=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;
|
||||
@@ -175,9 +278,19 @@ final class Home
|
||||
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));
|
||||
@@ -190,53 +303,80 @@ final class Home
|
||||
if (!isset($data['temp'])) {
|
||||
return null;
|
||||
}
|
||||
[$text, $icon] = self::wmo((int)($data['code'] ?? -1));
|
||||
[$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 在部分旧系统上会变豆腐块,
|
||||
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。
|
||||
* 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): array
|
||||
private static function wmo(int $c, int $day = 1): array
|
||||
{
|
||||
if ($c === 0) {
|
||||
return ['晴', '☀'];
|
||||
return $day === 1 ? ['晴', '☀', 'sunny'] : ['晴', '☾', 'sunny'];
|
||||
}
|
||||
if (in_array($c, [1, 2], true)) {
|
||||
return ['多云', '⛅'];
|
||||
return ['多云', '⛅', 'cloud'];
|
||||
}
|
||||
if ($c === 3) {
|
||||
return ['阴', '☁'];
|
||||
return ['阴', '☁', 'cloud'];
|
||||
}
|
||||
if (in_array($c, [45, 48], true)) {
|
||||
return ['有雾', '≋'];
|
||||
return ['有雾', '≋', 'fog'];
|
||||
}
|
||||
if ($c >= 51 && $c <= 57) {
|
||||
return ['毛毛雨', '☂'];
|
||||
return ['毛毛雨', '☂', 'rain'];
|
||||
}
|
||||
if ($c >= 61 && $c <= 67) {
|
||||
return ['下雨', '☂'];
|
||||
return ['下雨', '☂', 'rain'];
|
||||
}
|
||||
if ($c >= 71 && $c <= 77) {
|
||||
return ['下雪', '❄'];
|
||||
return ['下雪', '❄', 'snow'];
|
||||
}
|
||||
if ($c >= 80 && $c <= 82) {
|
||||
return ['阵雨', '☂'];
|
||||
return ['阵雨', '☂', 'rain'];
|
||||
}
|
||||
if ($c >= 85 && $c <= 86) {
|
||||
return ['阵雪', '❄'];
|
||||
return ['阵雪', '❄', 'snow'];
|
||||
}
|
||||
if ($c >= 95) {
|
||||
return ['雷雨', '⚡'];
|
||||
return ['雷雨', '⚡', 'storm'];
|
||||
}
|
||||
return ['天气', '·'];
|
||||
return ['天气', '·', 'cloud'];
|
||||
}
|
||||
|
||||
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
|
||||
|
||||
Reference in New Issue
Block a user