From 09e30d27d464dc169bf1ece3b9f410ee2c9c4c66 Mon Sep 17 00:00:00 2001 From: laoyang Date: Wed, 7 Oct 2026 05:52:40 +0800 Subject: [PATCH] =?UTF-8?q?v24=20=E7=AC=AC=2024=20=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E9=80=89=E4=BA=BA=E5=86=B2=E6=8E=89=E5=B7=B2=E6=9C=89=E6=88=90?= =?UTF-8?q?=E5=91=98=20/=20=E9=A6=96=E9=A1=B5=E6=94=B9=E7=89=88=20/=20?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E5=AF=BC=E8=88=AA=20/=20=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E8=AF=BB=E4=B9=A6=20/=20=E8=B4=A2=E5=8A=A1=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户 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 的 `` 无效**(它没有 `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` --- .htaccess | 8 + app/Home.php | 176 +++++- app/Module.php | 6 +- app/Nav.php | 734 +++++++++++++++++++++++ app/NavFetch.php | 894 +++++++++++++++++++++++++++++ app/Schema.php | 77 ++- app/WeRead.php | 412 +++++++++++++ assets/css/app.css | 200 ++++++- assets/css/nav-show.css | 256 +++++++++ assets/js/nav-show.js | 347 +++++++++++ assets/js/person_picker.js | 86 ++- index.php | 385 +++++++++++++ nav_show.php | 281 +++++++++ nav_suggest.php | 213 +++++++ router.php | 15 + templates/_person_picker.php | 17 + templates/books/_settings_menu.php | 5 + templates/books/weread.php | 167 ++++++ templates/home.php | 366 +++++++----- templates/ledger/index.php | 24 +- templates/nav/index.php | 545 ++++++++++++++++++ 21 files changed, 5034 insertions(+), 180 deletions(-) create mode 100644 app/Nav.php create mode 100644 app/NavFetch.php create mode 100644 app/WeRead.php create mode 100644 assets/css/nav-show.css create mode 100644 assets/js/nav-show.js create mode 100644 nav_show.php create mode 100644 nav_suggest.php create mode 100644 templates/books/weread.php create mode 100644 templates/nav/index.php diff --git a/.htaccess b/.htaccess index 9582d8f..cb488fd 100644 --- a/.htaccess +++ b/.htaccess @@ -27,6 +27,14 @@ # 不会互相抢;写成 `^dav` 在前是因为历史顺序,真要调也不能写成 `^dav(/|$)` 之外的 # 宽松形式 —— 那会把 /dav2/ 之类也吞进来。 RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA] + + # 导航展示页(v24 第 24 批 用户第 4 条):/nav/?<短语> —— 全站唯一免登录页面。 + # ⚠️ suggest 那条**必须排在前面**:`^nav(/.*)?$` 会把 /nav/suggest.php 也吞进去, + # 顺序反了就变成"每次联想都去渲染展示页"(返回一页 HTML、前端拿到非 JSON)。 + # 这也是本批新踩的一条:之前只有 dav/carddav 两条互不包含的前缀, + # 到这里第一次出现"一个前缀包含另一个"的情况,顺序**变成有语义的**了。 + RewriteRule ^nav/suggest\.php$ nav_suggest.php [L,QSA] + RewriteRule ^nav/?$ nav_show.php [L,QSA] # ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作: diff --git a/app/Home.php b/app/Home.php index b0e6ac8..ed2dc6d 100644 --- a/app/Home.php +++ b/app/Home.php @@ -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, 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,绝不抛) */ diff --git a/app/Module.php b/app/Module.php index fa35ba3..28344d2 100644 --- a/app/Module.php +++ b/app/Module.php @@ -47,6 +47,10 @@ final class Module 'ledger' => ['财务', '#5E7A3C', 'ledger'], // v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计) 'books' => ['读书', '#6B5B95', 'books'], + // v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 搬进来,放在**读书后面**) + // 用户原话:「增加一下个人导航的这个模块就放在读书的后面,每个用户都可以 + // 添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统」。 + 'nav' => ['导航', '#3E7A9E', 'nav'], ]; /** @@ -57,7 +61,7 @@ final class Module * 只是不再占顶栏的位置。 */ public const DEFAULT_NAV = ['home', 'people', 'movies', 'trips', 'calendar', 'resources', - 'tasks', 'days', 'ledger', 'books']; + 'tasks', 'days', 'ledger', 'books', 'nav']; /** 已经做过「家谱撤出导航条」这件事的人,记一个标记(只做一次,之后用户勾回来就尊重他) */ private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24'; diff --git a/app/Nav.php b/app/Nav.php new file mode 100644 index 0000000..fb0f424 --- /dev/null +++ b/app/Nav.php @@ -0,0 +1,734 @@ + '我的导航', + 'subtitle' => '', + 'default_engine' => 'bing', + 'primary_color' => '#2F6BF7', + // '1' = 站点链接与搜索框都在**新标签页**打开(与引入该设置前的旧行为一致) + 'open_blank' => '1', + // 'both' = ↓/↑ 与 , . 两组都认(SimpleNav 的默认,也是"严格超集") + 'suggest_keys' => 'both', + // tabs = 合集标签页折叠(默认);flat = 全部平铺 + 'collections_layout' => 'tabs', + ]; + + public static function setting(int $personId, string $k, ?string $default = null): string + { + if ($default === null) { + $default = self::SETTING_DEFAULTS[$k] ?? ''; + } + $st = Database::get()->prepare('SELECT v FROM nav_settings WHERE person_id = ? AND k = ?'); + $st->execute([$personId, $k]); + $v = $st->fetchColumn(); + return $v === false ? $default : (string)$v; + } + + public static function setSetting(int $personId, string $k, string $v): void + { + Database::get()->prepare( + 'INSERT INTO nav_settings (person_id, k, v) VALUES (?,?,?) + ON DUPLICATE KEY UPDATE v = VALUES(v)' + )->execute([$personId, $k, $v]); + } + + /** 一次取全部设置(渲染页面时省 N 次查询) */ + public static function settings(int $personId): array + { + $out = self::SETTING_DEFAULTS; + $st = Database::get()->prepare('SELECT k, v FROM nav_settings WHERE person_id = ?'); + $st->execute([$personId]); + foreach ($st->fetchAll() as $r) { + $out[(string)$r['k']] = (string)$r['v']; + } + return $out; + } + + // ================================================================ + // 板块 + // ================================================================ + + /** 我的板块(可按显示方式筛) */ + public static function groups(int $personId, string $display = '', bool $visibleOnly = false): array + { + $sql = 'SELECT * FROM nav_groups WHERE person_id = ?'; + $arg = [$personId]; + if ($display !== '') { + $sql .= ' AND display = ?'; + $arg[] = $display; + } + if ($visibleOnly) { + $sql .= ' AND is_visible = 1'; + } + $sql .= ' ORDER BY sort_order, id'; + $st = Database::get()->prepare($sql); + $st->execute($arg); + return $st->fetchAll(); + } + + public static function group(int $personId, int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM nav_groups WHERE id = ? AND person_id = ?'); + $st->execute([$id, $personId]); + $r = $st->fetch(); + return $r ?: null; + } + + public static function saveGroup(int $personId, array $d, int $id = 0): int + { + $name = trim((string)($d['name'] ?? '')); + $display = ((string)($d['display'] ?? 'grid')) === 'tabs' ? 'tabs' : 'grid'; + $vis = !empty($d['is_visible']) ? 1 : 0; + if ($name === '') { + return 0; + } + if ($id > 0) { + // ⚠️ 带 person_id 校验:不能凭一个 id 改别人家的板块 + Database::get()->prepare( + 'UPDATE nav_groups SET name = ?, display = ?, is_visible = ? WHERE id = ? AND person_id = ?' + )->execute([$name, $display, $vis, $id, $personId]); + return $id; + } + $sort = self::nextSort('nav_groups', $personId); + Database::get()->prepare( + 'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible) + VALUES (?,?,?,?,?,?)' + )->execute([$personId, $name, self::slugify($name), $display, $sort, $vis]); + return (int)Database::get()->lastInsertId(); + } + + public static function removeGroup(int $personId, int $id): void + { + // 站点靠外键 ON DELETE CASCADE 一起走 + Database::get()->prepare('DELETE FROM nav_groups WHERE id = ? AND person_id = ?') + ->execute([$id, $personId]); + } + + public static function toggleGroup(int $personId, int $id): void + { + Database::get()->prepare( + 'UPDATE nav_groups SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?' + )->execute([$id, $personId]); + } + + // ================================================================ + // 站点 + // ================================================================ + + public static function sites(int $personId, int $groupId, bool $visibleOnly = true): array + { + $sql = 'SELECT * FROM nav_sites WHERE person_id = ? AND group_id = ?' + . ($visibleOnly ? ' AND is_visible = 1' : '') . ' ORDER BY sort_order, id'; + $st = Database::get()->prepare($sql); + $st->execute([$personId, $groupId]); + return $st->fetchAll(); + } + + /** 我的全部站点(管理页用;按板块归好组) */ + public static function sitesByGroup(int $personId): array + { + $st = Database::get()->prepare( + 'SELECT * FROM nav_sites WHERE person_id = ? ORDER BY group_id, sort_order, id' + ); + $st->execute([$personId]); + $out = []; + foreach ($st->fetchAll() as $r) { + $out[(int)$r['group_id']][] = $r; + } + return $out; + } + + public static function site(int $personId, int $id): ?array + { + $st = Database::get()->prepare('SELECT * FROM nav_sites WHERE id = ? AND person_id = ?'); + $st->execute([$id, $personId]); + $r = $st->fetch(); + return $r ?: null; + } + + /** + * 存一条站点(新增或编辑)。 + * + * ⚠️ `icon_custom` 的语义照搬 SimpleNav:「全部更新」抓图标时**不动** + * 用户自己传的图标。漏掉这一条,用户精心挑的图标会被下一次批量更新冲掉。 + */ + public static function saveSite(int $personId, array $d, int $id = 0): int + { + $url = self::normUrl((string)($d['url'] ?? '')); + $name = trim((string)($d['name'] ?? '')); + $short = trim((string)($d['short_name'] ?? '')); + $gid = (int)($d['group_id'] ?? 0); + $vis = !empty($d['is_visible']) ? 1 : 0; + if ($url === '' || $gid <= 0 || self::group($personId, $gid) === null) { + return 0; + } + if ($name === '') { + $name = self::domainOf($url); + } + $now = date('Y-m-d H:i:s'); + if ($id > 0) { + Database::get()->prepare( + 'UPDATE nav_sites SET group_id = ?, url = ?, name = ?, short_name = ?, is_visible = ?, + updated_at = ? WHERE id = ? AND person_id = ?' + )->execute([$gid, $url, $name, $short, $vis, $now, $id, $personId]); + return $id; + } + // ⚠️ 新增一律排**最后**(SimpleNav 的明确行为:不插进用户已排好的顺序里) + $sort = self::nextSort('nav_sites', $personId, 'group_id', $gid); + Database::get()->prepare( + 'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom, + sort_order, is_visible, created_at, updated_at) + VALUES (?,?,?,?,?,\'\',0,?,?,?,?)' + )->execute([$personId, $gid, $url, $name, $short, $sort, $vis, $now, $now]); + return (int)Database::get()->lastInsertId(); + } + + public static function removeSite(int $personId, int $id): void + { + $s = self::site($personId, $id); + if ($s && (string)$s['icon'] !== '' && empty($s['icon_custom'])) { + self::deleteIconFile((string)$s['icon']); + } + Database::get()->prepare('DELETE FROM nav_sites WHERE id = ? AND person_id = ?') + ->execute([$id, $personId]); + } + + public static function toggleSite(int $personId, int $id): void + { + Database::get()->prepare( + 'UPDATE nav_sites SET is_visible = 1 - is_visible WHERE id = ? AND person_id = ?' + )->execute([$id, $personId]); + } + + /** 站点显示名(简称优先 → 抓来的名字 → 域名) */ + public static function siteLabel(array $s): string + { + $short = trim((string)($s['short_name'] ?? '')); + if ($short !== '') { + return $short; + } + $n = trim((string)($s['name'] ?? '')); + return $n !== '' ? $n : self::domainOf((string)($s['url'] ?? '')); + } + + // ================================================================ + // 搜索引擎 + // ================================================================ + + public static function engines(int $personId, bool $activeOnly = false): array + { + $st = Database::get()->prepare( + 'SELECT * FROM nav_engines WHERE person_id = ?' + . ($activeOnly ? ' AND is_active = 1' : '') . ' ORDER BY sort_order, id' + ); + $st->execute([$personId]); + return $st->fetchAll(); + } + + public static function saveEngine(int $personId, array $d, int $id = 0): int + { + $name = trim((string)($d['name'] ?? '')); + $slug = trim((string)($d['slug'] ?? '')); + $url = trim((string)($d['search_url'] ?? '')); + $par = trim((string)($d['param'] ?? 'q')); + if ($name === '' || $url === '') { + return 0; + } + if ($slug === '') { + $slug = self::slugify($name); + } + if ($par === '') { $par = 'q'; } + $color = self::safeColor((string)($d['color'] ?? ''), '#2F6BF7'); + if ($id > 0) { + Database::get()->prepare( + 'UPDATE nav_engines SET name=?, slug=?, search_url=?, param=?, suggest_url=?, + suggest_type=?, color=? WHERE id=? AND person_id=?' + )->execute([$name, $slug, $url, $par, + trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')), + $color, $id, $personId]); + return $id; + } + $sort = self::nextSort('nav_engines', $personId); + Database::get()->prepare( + 'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url, + suggest_type, color, is_active, sort_order) + VALUES (?,?,?,?,?,?,?,?,0,?)' + )->execute([$personId, $name, $slug, $url, $par, + trim((string)($d['suggest_url'] ?? '')), trim((string)($d['suggest_type'] ?? '')), + $color, $sort]); + return (int)Database::get()->lastInsertId(); + } + + public static function removeEngine(int $personId, int $id): void + { + Database::get()->prepare('DELETE FROM nav_engines WHERE id = ? AND person_id = ?') + ->execute([$id, $personId]); + } + + public static function toggleEngine(int $personId, int $id): void + { + Database::get()->prepare( + 'UPDATE nav_engines SET is_active = 1 - is_active WHERE id = ? AND person_id = ?' + )->execute([$id, $personId]); + } + + /** 默认引擎(设置里指定的那个;不在就取第一个启用的) */ + public static function defaultEngine(int $personId, array $engines): ?array + { + $want = self::setting($personId, 'default_engine'); + foreach ($engines as $e) { + if ((string)$e['slug'] === $want) { + return $e; + } + } + return $engines[0] ?? null; + } + + // ================================================================ + // 排序(稀疏 + 整组归一化)—— 照搬 SimpleNav 的两个函数 + // ================================================================ + + /** + * 取「同组最后一条的 sort_order + SORT_STEP」——**新增条目专用**。 + * + * ⚠️ 不要改回 `MAX+1`:SimpleNav 里已经写明过原因 —— + * 那只在"旧数据恰好是连续整数"时才等于"排最后";一旦排序号之间有间隙 + * (拖拽后必然如此),MAX+1 会**撞进已有间隙**里,让新条目落到用户排好的 + * 中间,正是用户抱怨的"排序被打乱"。 + */ + public static function nextSort(string $table, int $personId, string $groupCol = '', int $groupId = 0): int + { + if ($groupCol === '') { + $st = Database::get()->prepare( + "SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ?"); + $st->execute([$personId]); + } else { + $st = Database::get()->prepare( + "SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE person_id = ? AND {$groupCol} = ?"); + $st->execute([$personId, $groupId]); + } + return (int)$st->fetchColumn() + self::SORT_STEP; + } + + /** + * 按给定 id 顺序**整组重写**排序号(第 n 条 = n*SORT_STEP)。 + * + * ⚠️ `$groupId` 非空时做**归属校验**:不属于该组(或不属于我)的 id 直接跳过 —— + * 照搬 SimpleNav 的原话:防止一个被篡改的请求把别的板块的条目拖进来。 + * 这里额外再叠一层 `person_id`:否则能拖别人家的站点。 + */ + public static function applySort(string $table, int $personId, array $ids, + string $groupCol = '', int $groupId = 0): int + { + $db = Database::get(); + $upd = $db->prepare("UPDATE {$table} SET sort_order = ? WHERE id = ? AND person_id = ?"); + $ck = null; + if ($groupCol !== '') { + $ck = $db->prepare("SELECT COUNT(*) FROM {$table} WHERE id = ? AND person_id = ? AND {$groupCol} = ?"); + } + $n = 0; + $i = 0; + foreach ($ids as $rid) { + $rid = (int)$rid; + if ($rid <= 0) { + continue; + } + if ($ck !== null) { + $ck->execute([$rid, $personId, $groupId]); + if ((int)$ck->fetchColumn() === 0) { + continue; // 不属于本组 / 不是我家的 → 忽略 + } + } + $i++; + $upd->execute([$i * self::SORT_STEP, $rid, $personId]); + $n++; + } + return $n; + } + + /** 跨板块移动(拖到另一个板块) */ + public static function moveSite(int $personId, int $id, int $toGroupId): bool + { + if (self::group($personId, $toGroupId) === null) { + return false; + } + $sort = self::nextSort('nav_sites', $personId, 'group_id', $toGroupId); + Database::get()->prepare( + 'UPDATE nav_sites SET group_id = ?, sort_order = ?, updated_at = ? WHERE id = ? AND person_id = ?' + )->execute([$toGroupId, $sort, date('Y-m-d H:i:s'), $id, $personId]); + return true; + } + + // ================================================================ + // 短语(免登录展示页的地址后缀) + // ================================================================ + + /** + * 我的短语(没设过 → 空串,展示页就不可用)。 + * + * ⚠️ 存在 `nav_settings` 的 `link_phrase` 键里,但**必须全局唯一** —— + * 它是免登录地址上的唯一凭据,两个人用同一个短语就等于互相串页。 + * 唯一性由 `phraseTaken()` 在写入前校验(用 SQL 查,不靠应用层缓存)。 + */ + public static function phrase(int $personId): string + { + return self::setting($personId, 'link_phrase', ''); + } + + /** 这个短语是否已被**别人**占用($except 传自己,改自己的短语时不算冲突) */ + public static function phraseTaken(string $phrase, int $except = 0): bool + { + $st = Database::get()->prepare( + "SELECT COUNT(*) FROM nav_settings WHERE k = 'link_phrase' AND v = ? AND person_id <> ?"); + $st->execute([$phrase, $except]); + return (int)$st->fetchColumn() > 0; + } + + /** + * 设短语。返回 ['ok'=>bool, 'error'=>string]。 + * + * ⚠️ 三条校验缺一不可(少了任何一条都会出问题): + * · 形式合法 → 否则地址复制粘贴时会出现"看着一样其实不同"的重码; + * · 未被占用 → 否则两个用户共用一个展示页; + * · 不是保留词 → 否则 `/nav/?nav` 这类地址会有歧义。 + */ + public static function setPhrase(int $personId, string $phrase): array + { + $phrase = trim($phrase); + if ($phrase === '') { + self::setSetting($personId, 'link_phrase', ''); + return ['ok' => true, 'error' => '']; + } + if (!preg_match(self::PHRASE_RE, $phrase)) { + return ['ok' => false, 'error' => '短语只能用 4~32 位的字母、数字、下划线或连字符。']; + } + if (in_array(strtolower($phrase), self::PHRASE_RESERVED, true)) { + return ['ok' => false, 'error' => '「' . $phrase . '」是系统保留词,请换一个。']; + } + if (self::phraseTaken($phrase, $personId)) { + return ['ok' => false, 'error' => '这个短语已经被别人用了,换一个吧。']; + } + self::setSetting($personId, 'link_phrase', $phrase); + return ['ok' => true, 'error' => '']; + } + + /** 生成一个没被占用的随机短语(用户要的"一键换随机码") */ + public static function randomPhrase(): string + { + // 去掉容易读错的 0/O/1/l/I(与 DAV 口令同一套考量) + $abc = 'abcdefghjkmnpqrstuvwxyz23456789'; + for ($try = 0; $try < 30; $try++) { + $s = ''; + for ($i = 0; $i < 10; $i++) { + $s .= $abc[random_int(0, strlen($abc) - 1)]; + } + if (!self::phraseTaken($s)) { + return $s; + } + } + return 'nav' . bin2hex(random_bytes(6)); + } + + /** + * 按短语找主人(展示页用,**免登录**)。 + * + * ⚠️ 返回值只给"这个短语存在吗、属于谁"—— 绝不返回任何其它身份信息。 + * 这页面是公开的,多一个字段就是多一处泄露面。 + */ + public static function ownerByPhrase(string $phrase): int + { + if (!preg_match(self::PHRASE_RE, $phrase)) { + return 0; + } + $st = Database::get()->prepare( + "SELECT person_id FROM nav_settings WHERE k = 'link_phrase' AND v = ? LIMIT 1"); + $st->execute([$phrase]); + return (int)($st->fetchColumn() ?: 0); + } + + // ================================================================ + // 小工具(照搬 SimpleNav 的同名函数,去掉与家书重名的) + // ================================================================ + + public static function normUrl(string $url): string + { + $url = trim($url); + if ($url === '') { + return ''; + } + if (!preg_match('#^https?://#i', $url)) { + $url = 'http://' . $url; + } + return mb_substr($url, 0, 500, 'UTF-8'); + } + + /** + * 主域名(展示用途:首字母回退、标题回退)。 + * + * ⚠️ SimpleNav 原文写明:**不要拿它当图标文件名**(用 iconKey($url))—— + * 同一域名下不同路径是两个不同的站点条目,用域名做键会互相覆盖。 + */ + public static function domainOf(string $url): string + { + $host = parse_url(self::normUrl($url), PHP_URL_HOST); + return $host ? (string)preg_replace('/^www\./i', '', $host) : ''; + } + + /** 图标文件名(按 URL 取 md5,与 SimpleNav 一致) */ + public static function iconKey(string $url): string + { + return md5(self::normUrl($url)); + } + + /** 名称首字配色(无图标时的回退底色)—— 照搬 SimpleNav 的色板 */ + public static function letterColor(string $s): string + { + $palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', + '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; + return $palette[abs(crc32($s)) % count($palette)]; + } + + public static function slugify(string $s): string + { + $s = trim($s); + if (preg_match('/^[a-z0-9\-]+$/i', $s)) { + return strtolower($s); + } + return 'g' . substr(md5($s), 0, 6); + } + + /** 颜色白名单:只接受 #rrggbb,其余一律回退(防手改表单塞进 CSS 注入) */ + public static function safeColor(string $c, string $fallback): string + { + return preg_match('/^#[0-9a-fA-F]{6}$/', trim($c)) ? strtolower(trim($c)) : $fallback; + } + + /** + * 图标显示地址(带版本号)。 + * + * ⚠️ 版本号不能省:图标被重新抓取后**文件名不变**,没有版本号浏览器会一直用旧图 —— + * 用户点了"更新图标"却看不到变化,会以为功能坏了(SimpleNav 原文记过这一点)。 + */ + public static function iconUrl(string $file): string + { + if ($file === '') { + return ''; + } + $abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file; + if (!is_file($abs)) { + return ''; + } + return url('/' . self::ICON_URL . '/' . rawurlencode($file)) . '?v=' . filemtime($abs); + } + + public static function deleteIconFile(string $file): void + { + if ($file === '' || strpos($file, '/') !== false || strpos($file, '..') !== false) { + return; + } + $abs = FB_ROOT . '/' . self::ICON_DIR . '/' . $file; + if (is_file($abs)) { + @unlink($abs); + } + } + + public static function iconDirWritable(): bool + { + $dir = FB_ROOT . '/' . self::ICON_DIR; + if (!is_dir($dir)) { + @mkdir($dir, 0755, true); + } + return is_dir($dir) && is_writable($dir); + } + + /** 打开方式:_blank(新标签页,默认)或 _self */ + public static function openTarget(int $personId): string + { + return self::setting($personId, 'open_blank', '1') === '1' ? '_blank' : '_self'; + } + + /** 联想词按键(照搬 SimpleNav 的白名单回退) */ + public static function suggestKeys(int $personId): string + { + $v = self::setting($personId, 'suggest_keys', 'both'); + return in_array($v, ['arrows', 'punct', 'both'], true) ? $v : 'both'; + } + + /** 合集布局(tabs / flat) */ + public static function collectionsLayout(int $personId): string + { + return self::setting($personId, 'collections_layout', 'tabs') === 'flat' ? 'flat' : 'tabs'; + } + + /** + * **首次进入时**给这个人一份默认导航(照搬 SimpleNav 的种子数据,精选了一部分)。 + * + * ⚠️ 只在"这个人一条板块都没有"时调 —— 否则用户把板块删光后, + * 下一次进页面又会冒出默认的那几块("我删了它怎么又回来了")。 + */ + public static function ensureSeed(int $personId): void + { + $st = Database::get()->prepare('SELECT COUNT(*) FROM nav_groups WHERE person_id = ?'); + $st->execute([$personId]); + if ((int)$st->fetchColumn() > 0) { + return; + } + $db = Database::get(); + $now = date('Y-m-d H:i:s'); + $gs = $db->prepare( + 'INSERT INTO nav_groups (person_id, name, slug, display, sort_order, is_visible) VALUES (?,?,?,?,?,1)'); + $ss = $db->prepare( + 'INSERT INTO nav_sites (person_id, group_id, url, name, short_name, icon, icon_custom, + sort_order, is_visible, created_at, updated_at) VALUES (?,?,?,?,?,\'\',0,?,1,?,?)'); + $i = 0; + foreach (self::SEED as $row) { + [$gname, $display, $sites] = $row; + $i++; + $gs->execute([$personId, $gname, self::slugify($gname), $display, $i * self::SORT_STEP]); + $gid = (int)$db->lastInsertId(); + $j = 0; + foreach ($sites as $site) { + $j++; + $ss->execute([$personId, $gid, $site[1], $site[0], $site[0], + $j * self::SORT_STEP, $now, $now]); + } + } + // 默认搜索引擎(照搬 SimpleNav 的引擎表:必应/百度启用,其余列出但未启用) + $es = $db->prepare( + 'INSERT INTO nav_engines (person_id, name, slug, search_url, param, suggest_url, + suggest_type, color, is_active, sort_order) VALUES (?,?,?,?,?,?,?,?,?,?)'); + $k = 0; + foreach (self::ENGINE_SEED as $e) { + $k++; + $es->execute([$personId, $e[0], $e[1], $e[2], $e[3], $e[4], $e[5], $e[6], $e[7], + $k * self::SORT_STEP]); + } + } + + /** 默认板块(照搬 SimpleNav 的 seed_groups_and_sites,去掉纯广告位,保留通用工具) */ + public const SEED = [ + ['常用', 'grid', [ + ['GitHub', 'https://github.com/'], + ['阿里云', 'https://www.aliyun.com/'], + ['知乎', 'https://www.zhihu.com/'], + ['豆瓣', 'https://www.douban.com/'], + ['少数派', 'https://sspai.com/'], + ['bilibili', 'https://www.bilibili.com/'], + ]], + ['工具', 'grid', [ + ['腾讯文档', 'https://docs.qq.com/'], + ['飞书', 'https://www.feishu.cn/'], + ['Gitee', 'https://gitee.com/'], + ['百度网盘', 'https://pan.baidu.com/'], + ['draw.io', 'https://app.diagrams.net/'], + ['Bitwarden', 'https://bitwarden.com/'], + ['高德地图', 'https://gaode.com/'], + ['微信读书', 'https://weread.qq.com/'], + ]], + ['常用软件', 'tabs', [ + ['WPS', 'https://www.wps.cn/'], + ['VS Code', 'https://code.visualstudio.com/'], + ['7-Zip', 'https://sparanoid.com/lab/7z/'], + ['Everything', 'https://www.voidtools.com/zh-cn/'], + ['DBeaver', 'https://github.com/dbeaver/dbeaver'], + ['VLC', 'https://www.videolan.org/vlc/'], + ['Zotero', 'https://www.zotero.org/'], + ]], + ['图片资源', 'tabs', [ + ['图标库', 'https://www.iconfont.cn/'], + ['插画库', 'https://undraw.co/illustrations'], + ['Pixabay', 'https://pixabay.com/zh/'], + ['Pexels', 'https://www.pexels.com/'], + ]], + ['科研论文', 'tabs', [ + ['DeepL翻译', 'https://www.deepl.com/'], + ['Semantic Scholar', 'https://www.semanticscholar.org/'], + ['LaTeX Table', 'https://www.tablesgenerator.com/'], + ['学堂在线', 'https://www.xuetangx.com/'], + ]], + ]; + + /** 默认搜索引擎(照搬 SimpleNav 的 seed_engines) */ + public const ENGINE_SEED = [ + // 名称, slug, 搜索地址, 参数, 联想地址, 联想类型, 颜色, 是否启用 + ['必应', 'bing', 'https://cn.bing.com/search', 'q', + 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1], + ['百度', 'baidu', 'https://www.baidu.com/s', 'wd', + 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1], + ['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0], + ['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0], + ['360', 'so360', 'https://www.so.com/s', 'q', + 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}', + 'so360', '#1BB04A', 0], + ['DuckGo', 'ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0], + ]; +} \ No newline at end of file diff --git a/app/NavFetch.php b/app/NavFetch.php new file mode 100644 index 0000000..20c1387 --- /dev/null +++ b/app/NavFetch.php @@ -0,0 +1,894 @@ + [ + 'timeout' => $timeout, + 'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n", + 'follow_location' => 1, + ]]); + $body = @file_get_contents($url, false, $ctx, 0, $maxBytes); + if ($body === false) { + return null; + } + return ['body' => $body, 'url' => $url, 'type' => '']; + } + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 5, + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => 0, + CURLOPT_ENCODING => '', + CURLOPT_HTTPHEADER => ['Accept-Language: zh-CN,zh;q=0.9,en;q=0.8'], + ]); + $body = curl_exec($ch); + $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE); + $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $err = curl_errno($ch); + curl_close($ch); + if ($err || $body === false || $code >= 400) { + return null; + } + return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type]; +} + + /* ========================================================================= + * 元信息抓取:标题 / 名称 / 图标候选 + * ========================================================================= */ + + /** + * 抓取网站元信息。 + * icons 为「按清晰度从高到低」排好的候选图标地址列表: + * manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico + * + * @return array{title:string,name:string,icon:string,icons:string[],final_url:string} + */ + + /** + * 抓取网站元信息。 + * icons 为「按清晰度从高到低」排好的候选图标地址列表: + * manifest 大图标(512/192) → apple-touch-icon(180) → link[rel=icon](按 sizes 降序)→ /favicon.ico + * + * @return array{title:string,name:string,icon:string,icons:string[],final_url:string} + */ + private static function fetch_site_meta(string $url): array + { + $url = Nav::normUrl($url); + $out = ['title' => '', 'name' => '', 'icon' => '', 'icons' => [], 'final_url' => $url]; + + $resp = http_get($url); + if ($resp === null) { + $out['icons'] = array_merge(third_party_icon_urls($url), default_icon_candidates($url)); + $out['icon'] = $out['icons'][0] ?? ''; + return $out; + } + + $html = ensure_utf8((string)$resp['body']); + $final = $resp['url'] !== '' ? $resp['url'] : $url; + $out['final_url'] = $final; + + // ---- 标题 / 名称 ---- + if (preg_match('#]*>(.*?)#is', $html, $m)) { + $out['title'] = trim(preg_replace('/\s+/u', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'))); + } + $out['name'] = meta_content($html, 'og:site_name'); + if ($out['name'] === '' && $out['title'] !== '') { + // 用标题的首段(按分隔符切分)作为名称 + $seg = preg_split('#[\|\-_—·::]#u', $out['title']); + $out['name'] = trim($seg[0] ?? ''); + } + + // ---- 图标候选(按清晰度从高到低)---- + // 1. manifest 里的大图标(常见 512/192) + // 2. apple-touch-icon(常见 180) + // 3. ,按 sizes 降序(SVG 视为最高清) + // 4. 联网搜索:第三方图标聚合服务(仅当上面都没拿到高清图时才会用到) + // 5. 兜底:站点自身 /favicon.ico + [$links, $manifestUrl] = parse_icon_links($html, $final); + $icons = []; + if ($manifestUrl !== '') { + foreach (manifest_icons($manifestUrl) as $u) { + $icons[] = $u; + } + } + foreach ($links as $u) { + $icons[] = $u; + } + foreach (third_party_icon_urls($final) as $u) { + $icons[] = $u; + } + foreach (default_icon_candidates($final) as $u) { + $icons[] = $u; + } + + $out['icons'] = array_values(array_unique(array_filter($icons, static function ($u) { + return is_string($u) && preg_match('#^https?://#i', $u); + }))); + $out['icon'] = $out['icons'][0] ?? ''; + return $out; +} + + /** + * 联网搜索高清图标的第三方聚合服务。 + * 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG); + * 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。 + * 可在后台「系统设置」里用 icon_services 关闭。 + */ + + /** + * 联网搜索高清图标的第三方聚合服务。 + * 服务器实测:icon.horse 可达(返回 256×256 PNG)、favicon.yandex.net 可达(120×120 PNG); + * 而 favicon.im / api.iowen.cn / favicon.cccyun.cc / Google s2 / DuckDuckGo 均不可达。 + * 可在后台「系统设置」里用 icon_services 关闭。 + */ + private static function third_party_icon_urls(string $url): array + { + if (self::iconServices() !== '1') { + return []; + } + $host = parse_url(Nav::normUrl($url), PHP_URL_HOST); + if (!$host) { + return []; + } + $host = strtolower($host); + return [ + 'https://favicon.yandex.net/favicon/' . $host . '?size=120', // 实测 100% 可用,120×120 + 'https://icon.horse/icon/' . $host, // 可达时最高 256×256,偶有超时 + ]; +} + + /** 取 的值(属性顺序无关) */ + + /** 取 的值(属性顺序无关) */ + private static function meta_content(string $html, string $key): string + { + if (!preg_match_all('#]*>#i', $html, $tags)) { + return ''; + } + $k = preg_quote($key, '#'); + foreach ($tags[0] as $t) { + if (!preg_match('#\b(?:property|name|itemprop)\s*=\s*(?:"' . $k . '"|\'' . $k . '\'|' . $k . '[\s/>])#i', $t)) { + continue; + } + if (preg_match('#\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $t, $cm)) { + $v = $cm[1] !== '' ? $cm[1] : ($cm[2] !== '' ? $cm[2] : ($cm[3] ?? '')); + return trim(html_entity_decode($v, ENT_QUOTES, 'UTF-8')); + } + } + return ''; +} + + /** + * 解析页面里的 图标声明。 + * @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL] + */ + + /** + * 解析页面里的 图标声明。 + * @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL] + */ + private static function parse_icon_links(string $html, string $base): array + { + $apple = []; + $rel = []; + $manifest = ''; + + if (preg_match_all('#]*>#i', $html, $tags)) { + foreach ($tags[0] as $tag) { + $relv = attr_value($tag, 'rel'); + if ($relv === '') { + continue; + } + $relv = strtolower($relv); + $href = html_entity_decode(attr_value($tag, 'href'), ENT_QUOTES, 'UTF-8'); + if ($href === '') { + continue; + } + if (strpos($relv, 'apple-touch-icon') !== false) { + $apple[] = $href; + continue; + } + if (strpos($relv, 'manifest') !== false) { + if ($manifest === '') { $manifest = $href; } + continue; + } + if (strpos($relv, 'icon') === false) { + continue; + } + $size = 0; + $raw = attr_value($tag, 'sizes'); + if ($raw !== '') { + foreach (preg_split('/[\s,]+/', trim($raw)) ?: [] as $one) { + if (stripos($one, 'any') !== false) { + $size = max($size, 256); + } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { + $size = max($size, (int)$gm[1], (int)$gm[2]); + } + } + } + // SVG 是矢量,视为最高清 + if (preg_match('#\.svg(\?|$)#i', $href) || stripos(attr_value($tag, 'type'), 'svg') !== false) { + $size = max($size, 512); + } + $rel[] = ['size' => $size, 'url' => $href]; + } + } + + usort($rel, static function ($a, $b) { return $b['size'] <=> $a['size']; }); + + $list = []; + foreach ($apple as $h) { $list[] = absolutize($h, $base); } + foreach ($rel as $r) { $list[] = absolutize($r['url'], $base); } + return [$list, $manifest === '' ? '' : absolutize($manifest, $base)]; +} + + /** 读取 web app manifest,返回其中图标(按尺寸降序) */ + + /** 读取 web app manifest,返回其中图标(按尺寸降序) */ + private static function manifest_icons(string $manifestUrl): array + { + $r = http_get($manifestUrl, 200000, 6); + if ($r === null) { + return []; + } + $json = json_decode(trim((string)$r['body']), true); + if (!is_array($json) || empty($json['icons']) || !is_array($json['icons'])) { + return []; + } + $base = $r['url'] !== '' ? $r['url'] : $manifestUrl; + $list = []; + foreach ($json['icons'] as $ic) { + if (empty($ic['src']) || !is_string($ic['src'])) { + continue; + } + $size = 0; + if (!empty($ic['sizes'])) { + foreach (preg_split('/[\s,]+/', (string)$ic['sizes']) ?: [] as $one) { + if (stripos($one, 'any') !== false) { + $size = max($size, 512); + } elseif (preg_match('/^(\d+)\s*x\s*(\d+)$/i', $one, $gm)) { + $size = max($size, (int)$gm[1], (int)$gm[2]); + } + } + } + $list[] = ['size' => $size, 'url' => absolutize($ic['src'], $base)]; + } + usort($list, static function ($a, $b) { return $b['size'] <=> $a['size']; }); + return array_values(array_unique(array_column($list, 'url'))); +} + + /** 兜底候选:站点自身 favicon.ico */ + + /** 兜底候选:站点自身 favicon.ico */ + private static function default_icon_candidates(string $url): array + { + $p = parse_url(Nav::normUrl($url)); + if (empty($p['host'])) { + return []; + } + $scheme = !empty($p['scheme']) ? $p['scheme'] : 'https'; + return [$scheme . '://' . $p['host'] . '/favicon.ico']; +} + + /** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */ + + /** 从标签串里取属性值(支持双引号 / 单引号 / 裸值) */ + private static function attr_value(string $tag, string $attr): string + { + $a = preg_quote($attr, '#'); + if (preg_match('#\b' . $a . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))#i', $tag, $m)) { + $v = $m[1] !== '' ? $m[1] : ($m[2] !== '' ? $m[2] : ($m[3] ?? '')); + return trim($v); + } + return ''; +} + + /* ========================================================================= + * 图标下载与规范化 + * ========================================================================= */ + + /** + * 图标文件的命名键:**按整条网址**算,而不是只按域名。 + * + * 为什么不能只用域名(2026-09-28 修的真实 bug): + * 同一域名下不同路径往往是两个独立的网站条目,例如 + * https://nas.example.com/calendar 与 https://nas.example.com/ + * 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标, + * 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。 + * + * 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异): + * - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目) + * - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个) + * - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息 + * 一句话:**不同网址 → 不同图标文件**。 + * + * 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url, + * 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。 + * + * 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定, + * 不影响任何行为)。 + */ + + /** + * 图标文件的命名键:**按整条网址**算,而不是只按域名。 + * + * 为什么不能只用域名(2026-09-28 修的真实 bug): + * 同一域名下不同路径往往是两个独立的网站条目,例如 + * https://nas.example.com/calendar 与 https://nas.example.com/ + * 若键只取域名,两条记录会指向**同一个图标文件** —— 改其中一个的自定义图标, + * 另一个跟着变;更糟的是保存时会 `unlink` 那个文件名,直接把对方**搞成破图**。 + * + * 归一化规则(尽量少,只抹掉「同一个网址的不同写法」带来的差异): + * - 协议与主机名转小写;主机名**不**去 www(`www.a.com` 与 `a.com` 是两个条目) + * - 空路径补成 `/`(`https://a.com` 与 `https://a.com/` 视为同一个) + * - 查询串、锚点(`#/calendar` 这类 SPA 路由)**保留**,它们是区分条目的信息 + * 一句话:**不同网址 → 不同图标文件**。 + * + * 注意:调用方要用「记录里存的那条 URL」来算,不要用跳转后的 final_url, + * 否则同一个网站在不同次请求里会算出不同的键(每次重下 + 留下孤儿文件)。 + * + * 返回的是键(md5 十六进制串);实际文件名会再哈希一次(历史遗留,只是让文件名长度固定, + * 不影响任何行为)。 + */ + private static function icon_key(string $url): string + { + $u = trim($url); + if ($u === '') { + return ''; + } + if (!preg_match('#^https?://#i', $u)) { + $u = 'http://' . $u; + } + $p = @parse_url($u); + if (!is_array($p) || empty($p['host'])) { + return md5($u); // 解析不出来就整串兜底,至少不会跟别人撞 + } + $norm = strtolower((string)$p['scheme']) . '://' + . strtolower((string)$p['host']) + . (empty($p['port']) ? '' : ':' . $p['port']) + . (isset($p['path']) && $p['path'] !== '' ? $p['path'] : '/') + . (isset($p['query']) ? '?' . $p['query'] : '') + . (isset($p['fragment']) ? '#' . $p['fragment'] : ''); + return md5($norm); +} + + /** + * 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。 + * 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。 + * + * @param string[] $urls 候选图标地址(已按优先级排序) + * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) + */ + + /** + * 按候选顺序抓取图标,返回质量最佳的一张的本地文件名(失败返回 '')。 + * 规则:min(宽,高) 越大越优;达到 $minGood 即停止;另有总耗时预算,避免批量更新超时。 + * + * @param string[] $urls 候选图标地址(已按优先级排序) + * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) + */ + private static function download_best_icon(array $urls, string $key, int $minGood = 120, float $budget = 10.0): string + { + if ($key === '') { // 没有键就不写文件,避免全部落到 md5('') 上互相覆盖 + return ''; + } + if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) { + @mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true); + } + $best = null; + $bestScore = -1; + $tmps = []; + $tried = 0; + $deadline = microtime(true) + $budget; + + foreach ($urls as $u) { + if (!is_string($u) || $u === '' || !preg_match('#^https?://#i', $u)) { + continue; + } + if ($tried >= 8 || microtime(true) > $deadline) { // 候选上限 + 耗时预算 + break; + } + $tried++; + $r = grab_icon($u); + if ($r === null) { + continue; + } + // 第三方聚合服务在「查不到图标」时会返回一块浅灰底 + 单个深灰字母的生成图, + // 那还不如本地首字母块好看 —— 识别出来就丢弃,继续回退到站点自己的图标。 + if ($r['ext'] === 'png' && is_third_party_icon($u) && looks_generated_tile($r['tmp'])) { + @unlink($r['tmp']); + continue; + } + $tmps[] = $r['tmp']; + $score = min($r['w'], $r['h']); + if ($score > $bestScore) { + $bestScore = $score; + $best = $r; + } + if ($score >= $minGood) { + break; + } + } + + // 清理落选临时文件 + foreach ($tmps as $t) { + if (!$best || $t !== $best['tmp']) { + @unlink($t); + } + } + if ($best === null) { + return ''; + } + + $md5 = md5($key); + $file = $md5 . '.' . $best['ext']; + $dest = FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file; + @unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png'); + @unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg'); + if (!@copy($best['tmp'], $dest)) { + @unlink($best['tmp']); + return ''; + } + @unlink($best['tmp']); + return is_file($dest) ? $file : ''; +} + + /** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */ + + /** 是否来自第三方图标聚合服务(这类服务可能返回「生成的占位图」) */ + private static function is_third_party_icon(string $url): bool + { + $host = strtolower((string)parse_url($url, PHP_URL_HOST)); + return $host === 'favicon.yandex.net' || $host === 'icon.horse'; +} + + /** + * 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。 + * 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面, + * 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。 + */ + + /** + * 判断图片是否是「浅灰底 + 深灰字母」的自动生成占位图。 + * 采样不透明像素:若几乎无色(色度极小)、整体偏亮、且八成以上像素都是同一片浅色底面, + * 就认定它是占位图。彩色 logo 与深色 logo 都不会命中。 + */ + private static function looks_generated_tile(string $file): bool + { + $im = @imagecreatefromstring((string)@file_get_contents($file)); + if ($im === false) { + return false; + } + $w = imagesx($im); + $h = imagesy($im); + $sx = max(1, (int)($w / 28)); + $sy = max(1, (int)($h / 28)); + $maxChroma = 0; + $n = 0; + $light = 0; + $sum = 0.0; + for ($y = 0; $y < $h; $y += $sy) { + for ($x = 0; $x < $w; $x += $sx) { + $rgba = imagecolorat($im, $x, $y); + if ((($rgba >> 24) & 0x7F) > 110) { // 透明像素跳过 + continue; + } + $r = ($rgba >> 16) & 0xFF; + $g = ($rgba >> 8) & 0xFF; + $b = $rgba & 0xFF; + $chroma = max($r, $g, $b) - min($r, $g, $b); + if ($chroma > $maxChroma) { $maxChroma = $chroma; } + $v = ($r + $g + $b) / 3; + $sum += $v; + if ($v >= 195 && $chroma < 16) { $light++; } + $n++; + } + } + imagedestroy($im); + if ($n < 24) { + return false; + } + return $maxChroma < 16 && ($sum / $n) > 190 && ($light / $n) >= 0.80; +} + + /** + * 下载单个图标到临时文件。 + * @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优 + */ + + /** + * 下载单个图标到临时文件。 + * @return array{tmp:string,ext:string,w:int,h:int}|null w/h 为「原始」尺寸,用于择优 + */ + private static function grab_icon(string $iconUrl): ?array + { + $resp = http_get($iconUrl, 2 * 1024 * 1024, 5); + if ($resp === null) { + return null; + } + $bin = (string)$resp['body']; + if (strlen($bin) < 64 || strlen($bin) > 3 * 1024 * 1024) { + return null; + } + + $tmp = @tempnam(sys_get_temp_dir(), 'snic'); + if ($tmp === false) { + return null; + } + + // ---- SVG:直接保存(矢量最清晰),挡掉带脚本的 ---- + $head = ltrim(substr($bin, 0, 1024)); + $isSvg = stripos((string)$resp['type'], 'svg') !== false + || stripos($head, ' 512 * 1024 + || preg_match('# $tmp, 'ext' => 'svg', 'w' => 512, 'h' => 512]; + } + + // ---- 位图:GD 归一化为 128×128 PNG(保持比例、透明留白) ---- + if (!function_exists('imagecreatefromstring')) { + @unlink($tmp); + return null; + } + $im = @imagecreatefromstring($bin); + if ($im === false) { + @unlink($tmp); + return null; + } + $w = imagesx($im); + $h = imagesy($im); + if ($w < 8 || $h < 8) { // 太小/损坏,判为无效 + imagedestroy($im); + @unlink($tmp); + return null; + } + + $store = ICON_STORE; + $canvas = imagecreatetruecolor($store, $store); + imagealphablending($canvas, false); + imagesavealpha($canvas, true); + imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127)); + $scale = min($store / $w, $store / $h, 1.0); // 只缩不放,避免把小图硬拉大而变糊 + $nw = max(1, (int)round($w * $scale)); + $nh = max(1, (int)round($h * $scale)); + imagecopyresampled( + $canvas, $im, + (int)(($store - $nw) / 2), (int)(($store - $nh) / 2), 0, 0, + $nw, $nh, $w, $h + ); + imagealphablending($canvas, true); + $ok = imagepng($canvas, $tmp, 6); + imagedestroy($canvas); + imagedestroy($im); + if ($ok === false) { + @unlink($tmp); + return null; + } + return ['tmp' => $tmp, 'ext' => 'png', 'w' => $w, 'h' => $h]; +} + + /** + * 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。 + * + * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) + */ + + /** + * 保存用户上传/指定的自定义图标(文件内容直接给),返回本地文件名。 + * + * @param string $key 图标文件的命名键,**请传 icon_key($url)**(不要传域名) + */ + private static function save_custom_icon(string $binary, string $key): string + { + if ($key === '') { + return ''; + } + if (!is_dir(FB_ROOT . "/" . Nav::ICON_DIR)) { + @mkdir(FB_ROOT . "/" . Nav::ICON_DIR, 0755, true); + } + $mime = ''; + if (function_exists('finfo_open')) { + $fi = finfo_open(FILEINFO_MIME_TYPE); + $mime = (string)finfo_buffer($fi, $binary); + finfo_close($fi); + } + if (strpos($mime, 'svg') !== false || stripos(ltrim(substr($binary, 0, 1024)), ' 文件基名 => 二进制(基名已含扩展名,如 'logo.png') + */ + + /** 用 PNG 二进制拼一个 .ico(微软 BMP 格式,32 位带 alpha);$pngs: 尺寸 => PNG 二进制 */ + private static function make_ico(array $pngs): string + { + $frames = []; + foreach ($pngs as $png) { + $im = @imagecreatefromstring($png); + if ($im === false) { + continue; + } + $size = (int)imagesx($im); + if ($size < 1 || $size > 256 || imagesy($im) !== $size) { + imagedestroy($im); + continue; + } + // BITMAPINFOHEADER 40 字节:biSize biWidth biHeight biPlanes biBitCount + // biCompression biSizeImage biXPels biYPels biClrUsed biClrImportant + // (高度写 2 倍:ICO 的 DIB 把 XOR 位图与 AND 掩码叠在一起算高度) + $header = pack('VVVvvVVVVVV', 40, $size, $size * 2, 1, 32, 0, 0, 0, 0, 0, 0); + $xor = ''; + for ($y = $size - 1; $y >= 0; $y--) { + for ($x = 0; $x < $size; $x++) { + $rgba = imagecolorat($im, $x, $y); + $a = ($rgba >> 24) & 0x7F; + $xor .= chr($rgba & 0xFF) . chr(($rgba >> 8) & 0xFF) + . chr(($rgba >> 16) & 0xFF) . chr((int)round(255 - $a * 2.00787)); + } + } + imagedestroy($im); + $and = str_repeat("\x00", (int)($size / 8) * $size); // 1bpp 掩码,全 0 = 全部可见 + $frames[$size] = $header . $xor . $and; + } + if (!$frames) { + return ''; + } + ksort($frames); + $n = count($frames); + $ico = pack('vvv', 0, 1, $n); + $offset = 6 + $n * 16; + foreach ($frames as $size => $data) { + // 宽高字节:256 要写成 0 + $b = ($size >= 256) ? 0 : $size; + $ico .= pack('CCCCvvVV', $b, $b, 0, 0, 1, 32, strlen($data), $offset); + $offset += strlen($data); + } + foreach ($frames as $data) { + $ico .= $data; + } + return $ico; +} + + /** 整套图标清单:文件基名 => 尺寸 */ + + /** 把可能为相对路径的 URL 补全为绝对 URL */ + private static function absolutize(string $href, string $base): string + { + // 有些页面把链接写成 JSON 转义形式(https:\/\/x\/y.ico),先还原 + $href = str_replace('\\/', '/', trim($href)); + if (preg_match('#^https?://#i', $href)) { + return $href; + } + $p = parse_url($base); + $root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? ''); + if (strpos($href, '//') === 0) { + return ($p['scheme'] ?? 'http') . ':' . $href; + } + if (strpos($href, '/') === 0) { + return $root . $href; + } + $dir = rtrim(dirname($p['path'] ?? '/'), '/'); + return $root . $dir . '/' . $href; +} + + /** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */ + + /** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */ + private static function ensure_utf8(string $s): string + { + if (preg_match('#]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) { + if (function_exists('mb_convert_encoding')) { + return mb_convert_encoding($s, 'UTF-8', 'GB18030'); + } + } + if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) { + return mb_convert_encoding($s, 'UTF-8', 'GB18030'); + } + return $s; +} + + /** 图标显示地址(为空时返回 '',前端回退到首字母块) */ +} diff --git a/app/Schema.php b/app/Schema.php index e719c3a..8cf7748 100644 --- a/app/Schema.php +++ b/app/Schema.php @@ -104,7 +104,7 @@ final class Schema * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。 */ - public const VERSION = '23'; + public const VERSION = '24'; /** * 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。 @@ -840,6 +840,81 @@ CREATE TABLE IF NOT EXISTS `trip_expenses` ( CONSTRAINT `fk_te_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE, CONSTRAINT `fk_te_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)' +SQL, + /* ============================================================ + v24 第 24 批 用户第 4 条:个人导航(从 SimpleNav 完整搬进来) + ------------------------------------------------------------ + 用户原话:「你去读一下个人导航,就是我之前有一个个人导航的项目, + 把这个项目完整的搬过来放在导航条里,就是说增加一下个人导航 + 的这个模块就放在读书的后面,每个用户都可以添加自己的导航, + 所有的功能模块就完全照搬原来的导航的那个系统里面的功能。」 + + ⚠️ 原 SimpleNav 用**独立 SQLite 文件 + 自己的 users 表**。搬进来必须换掉这两样: + · 数据库 → 用家书自己的库(表名 `nav_` 前缀,不会撞) + · 用户 → 用**家书的 person_id**(「每个用户都可以添加自己的导航」) + 所以原来所有查询都多了一个 `person_id = ?` 维度 —— + 这是"照搬"里**唯一不能照抄**的部分:不加这一维,全家人会共用同一份导航。 + ============================================================ */ + <<<'SQL' +CREATE TABLE IF NOT EXISTS `nav_groups` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `person_id` int(10) unsigned NOT NULL COMMENT '这份导航属于谁(家书 person id)', + `name` VARCHAR(60) NOT NULL, + `slug` VARCHAR(60) NOT NULL DEFAULT '', + `display` ENUM('grid','tabs') NOT NULL DEFAULT 'grid' COMMENT 'grid=普通网格 tabs=合集标签页', + `sort_order` INT NOT NULL DEFAULT 0, + `is_visible` TINYINT(1) NOT NULL DEFAULT 1, + PRIMARY KEY (`id`), + KEY `idx_navg_person` (`person_id`, `sort_order`), + CONSTRAINT `fk_navg_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航板块(每人一份)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `nav_sites` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `person_id` int(10) unsigned NOT NULL COMMENT '冗余一份归属:删/校验不必再 JOIN 板块', + `group_id` int(10) unsigned NOT NULL, + `url` VARCHAR(500) NOT NULL, + `name` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '自动抓取的网站名', + `short_name` VARCHAR(60) NOT NULL DEFAULT '' COMMENT '用户自定义简称', + `icon` VARCHAR(120) NOT NULL DEFAULT '' COMMENT '本地图标文件名', + `icon_custom` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=自定义图标(「全部更新」不覆盖)', + `sort_order` INT NOT NULL DEFAULT 0, + `is_visible` TINYINT(1) NOT NULL DEFAULT 1, + `created_at` DATETIME DEFAULT NULL, + `updated_at` DATETIME DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_navs_person` (`person_id`, `group_id`, `sort_order`), + CONSTRAINT `fk_navs_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_navs_group` FOREIGN KEY (`group_id`) REFERENCES `nav_groups` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航站点(每人一份)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `nav_engines` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `person_id` int(10) unsigned NOT NULL, + `name` VARCHAR(40) NOT NULL, + `slug` VARCHAR(40) NOT NULL, + `search_url` VARCHAR(300) NOT NULL, + `param` VARCHAR(20) NOT NULL DEFAULT 'q', + `suggest_url` VARCHAR(300) NOT NULL DEFAULT '', + `suggest_type` VARCHAR(30) NOT NULL DEFAULT '', + `color` VARCHAR(20) NOT NULL DEFAULT '#2F6BF7', + `is_active` TINYINT(1) NOT NULL DEFAULT 0, + `sort_order` INT NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + KEY `idx_nave_person` (`person_id`, `sort_order`), + CONSTRAINT `fk_nave_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航搜索引擎(每人一份)' +SQL, + <<<'SQL' +CREATE TABLE IF NOT EXISTS `nav_settings` ( + `person_id` int(10) unsigned NOT NULL, + `k` VARCHAR(60) NOT NULL, + `v` TEXT NOT NULL, + PRIMARY KEY (`person_id`, `k`), + CONSTRAINT `fk_navset_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='导航设置(每人一份)' SQL, ]; } diff --git a/app/WeRead.php b/app/WeRead.php new file mode 100644 index 0000000..c5a6119 --- /dev/null +++ b/app/WeRead.php @@ -0,0 +1,412 @@ +bool,'error'=>string] */ + public static function saveKey(int $personId, string $rawKey): array + { + $rawKey = trim($rawKey); + if ($rawKey === '') { + // 清空 = 断开授权(保留"他曾经授权过"这件事没有必要) + UserSetting::set($personId, self::SETTING_KEY, ''); + return ['ok' => true, 'error' => '']; + } + if (!preg_match(self::KEY_RE, $rawKey)) { + return ['ok' => false, 'error' => 'API Key 的格式不对(应该是 wrk- 开头的一串)。']; + } + // ⚠️ 加密后再落库 —— 它等价于账号凭据(见文件头) + UserSetting::set($personId, self::SETTING_KEY, Crypto::encrypt($rawKey)); + return ['ok' => true, 'error' => '']; + } + + /** 取出明文 Key(仅供本类发请求用;**绝不要**把它回显到页面上) */ + public static function key(int $personId): string + { + $blob = trim((string)UserSetting::get($personId, self::SETTING_KEY, '')); + if ($blob === '') { + return ''; + } + try { + return (string)Crypto::decrypt($blob); + } catch (\Throwable $e) { + // 换过 db/.settings.key 之后老密文解不开 —— 当成"没配",让用户重填 + return ''; + } + } + + /** 界面上回显用的掩码(wrk-****abcd)—— 绝不回显完整 Key */ + public static function maskedKey(int $personId): string + { + $k = self::key($personId); + if ($k === '') { + return ''; + } + return substr($k, 0, 4) . str_repeat('*', 8) . substr($k, -4); + } + + public static function hasKey(int $personId): bool + { + return self::key($personId) !== ''; + } + + // ================================================================ + // 调官方网关 + // ================================================================ + + /** + * 调一次官方网关。 + * + * @return array{ok:bool,data:array,error:string} + */ + public static function call(int $personId, string $apiName, array $params = []): array + { + $key = self::key($personId); + if ($key === '') { + return ['ok' => false, 'data' => [], 'error' => '还没配置微信读书 API Key。']; + } + // ⚠️ 业务参数必须**平铺在顶层**,不能包进 params(官方文档专门列了这个反例) + $body = array_merge(['api_name' => $apiName], $params, ['skill_version' => self::SKILL_VERSION]); + $json = json_encode($body, JSON_UNESCAPED_UNICODE); + + $ch = curl_init(self::GATEWAY); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + CURLOPT_TIMEOUT => 20, + CURLOPT_CONNECTTIMEOUT => 8, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_HTTPHEADER => [ + 'Authorization: Bearer ' . $key, + 'Content-Type: application/json', + 'Accept: application/json', + ], + ]); + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $err = (string)curl_error($ch); + curl_close($ch); + + if ($raw === false || $err !== '') { + return ['ok' => false, 'data' => [], 'error' => '连不上微信读书网关:' . $err]; + } + if ($code === 401 || $code === 403) { + return ['ok' => false, 'data' => [], + 'error' => 'API Key 无效或已失效,请去微信读书开放平台重新生成一个。']; + } + if ($code >= 400) { + return ['ok' => false, 'data' => [], 'error' => '网关返回 HTTP ' . $code]; + } + $j = json_decode((string)$raw, true); + if (!is_array($j)) { + return ['ok' => false, 'data' => [], 'error' => '网关返回的不是 JSON']; + } + // 有些错误是 200 + 业务错误码 + if (isset($j['errcode']) && (int)$j['errcode'] !== 0) { + return ['ok' => false, 'data' => [], 'error' => '微信读书返回错误:' + . (string)($j['errmsg'] ?? ('errcode ' . $j['errcode']))]; + } + return ['ok' => true, 'data' => $j, 'error' => '']; + } + + // ================================================================ + // 取数据 + // ================================================================ + + /** + * 有笔记的书清单(**游标分页**:官方不支持 offset/limit)。 + * + * @param int $maxBooks 最多取多少本(防止有人几百本书时一次跑太久) + * @return array{ok:bool,books:array,error:string} + * books[] = ['bookId','title','author','reviewCount','noteCount','bookmarkCount'] + */ + public static function notebooks(int $personId, int $maxBooks = 200): array + { + $out = []; + $last = 0; + for ($page = 0; $page < 20; $page++) { + $params = ['count' => 50]; + if ($last > 0) { + $params['lastSort'] = $last; + } + $r = self::call($personId, '/user/notebooks', $params); + if (!$r['ok']) { + return ['ok' => false, 'books' => $out, 'error' => $r['error']]; + } + $books = (array)($r['data']['books'] ?? []); + foreach ($books as $b) { + $bk = (array)($b['book'] ?? []); + $out[] = [ + 'bookId' => (string)($b['bookId'] ?? ''), + 'title' => (string)($bk['title'] ?? ''), + 'author' => (string)($bk['author'] ?? ''), + 'reviewCount' => (int)($b['reviewCount'] ?? 0), + 'noteCount' => (int)($b['noteCount'] ?? 0), + 'bookmarkCount' => (int)($b['bookmarkCount'] ?? 0), + 'sort' => (int)($b['sort'] ?? 0), + ]; + if (count($out) >= $maxBooks) { + break 2; + } + } + // hasMore=1 时用**本页最后一项的 sort** 当下一页游标 + if ((int)($r['data']['hasMore'] ?? 0) !== 1 || !$books) { + break; + } + $lastIdx = count($books) - 1; + $last = (int)($books[$lastIdx]['sort'] ?? 0); + if ($last <= 0) { + break; + } + } + return ['ok' => true, 'books' => $out, 'error' => '']; + } + + /** + * 取一本书的**章节表**(chapterUid → 章节标题)。 + * + * 官方把章节信息分散在 bookmarklist / bestbookmarks 的回包里, + * 但单独拉一次 `/book/chapterinfo` 更省事(一次拿全)。 + */ + public static function chapters(int $personId, string $bookId): array + { + $r = self::call($personId, '/book/chapterinfo', ['bookId' => $bookId]); + if (!$r['ok']) { + return []; + } + $map = []; + foreach ((array)($r['data']['chapters'] ?? []) as $c) { + $uid = (string)($c['chapterUid'] ?? ''); + if ($uid !== '') { + $map[$uid] = (string)($c['title'] ?? ''); + } + } + return $map; + } + + /** + * 取一本书的**划线与想法**,合成统一结构。 + * + * @return array{ok:bool,items:array,error:string} + * items[] = [ + * 'kind' => 'quote'(划线)/ 'note'(划线想法)/ 'review'(整本或章节点评), + * 'content' => 想法正文(划线时为空), + * 'quote' => 划线原文(想法对应的原文,可能为空), + * 'chapter' => 章节名(可能为空), + * 'loc' => 位置范围(可能为空), + * 'at' => 'Y-m-d H:i:s', + * ] + */ + public static function notes(int $personId, string $bookId): array + { + $chap = self::chapters($personId, $bookId); + + // ---- ① 划线(/book/bookmarklist)---- + $r = self::call($personId, '/book/bookmarklist', ['bookId' => $bookId]); + if (!$r['ok']) { + return ['ok' => false, 'items' => [], 'error' => $r['error']]; + } + // 这个接口自己也会带 chapters(省一次请求);优先用它补全章节名 + foreach ((array)($r['data']['chapters'] ?? []) as $c) { + $uid = (string)($c['chapterUid'] ?? ''); + if ($uid !== '' && !isset($chap[$uid])) { + $chap[$uid] = (string)($c['title'] ?? ''); + } + } + $items = []; + foreach ((array)($r['data']['updated'] ?? []) as $u) { + $txt = trim((string)($u['markText'] ?? '')); + if ($txt === '') { + continue; + } + $items[] = [ + 'kind' => 'quote', + 'content' => '', + 'quote' => $txt, + 'chapter' => $chap[(string)($u['chapterUid'] ?? '')] ?? '', + 'loc' => (string)($u['range'] ?? ''), + 'at' => self::ts((int)($u['createTime'] ?? 0)), + ]; + } + + // ---- ② 想法 / 点评(/review/list/mine,**需翻页**)---- + $synckey = 0; + for ($page = 0; $page < 20; $page++) { + $params = ['bookid' => $bookId, 'count' => 50]; + if ($synckey > 0) { + $params['synckey'] = $synckey; + } + $rr = self::call($personId, '/review/list/mine', $params); + if (!$rr['ok']) { + // ⚠️ 想法取不到**不**让整次导入失败:划线已经拿到了, + // 为了"少一个想法"把整本书丢掉,是更糟的取舍。 + break; + } + foreach ((array)($rr['data']['reviews'] ?? []) as $rv) { + $rvv = (array)($rv['review'] ?? []); + $content = trim((string)($rvv['content'] ?? '')); + $abstract = trim((string)($rvv['abstract'] ?? '')); + if ($content === '' && $abstract === '') { + continue; + } + // 有原文对应 = 划线想法(note);没有 = 整本/章节点评(review) + $items[] = [ + 'kind' => $abstract !== '' ? 'note' : 'review', + 'content' => $content, + 'quote' => $abstract, + 'chapter' => trim((string)($rvv['chapterName'] ?? '')) + ?: ($chap[(string)($rvv['chapterUid'] ?? '')] ?? ''), + 'loc' => (string)($rvv['range'] ?? ''), + 'at' => self::ts((int)($rvv['createTime'] ?? 0)), + ]; + } + if ((int)($rr['data']['hasMore'] ?? 0) !== 1) { + break; + } + $next = (int)($rr['data']['synckey'] ?? 0); + if ($next <= 0 || $next === $synckey) { + break; + } + $synckey = $next; + } + + return ['ok' => true, 'items' => $items, 'error' => '']; + } + + /** Unix 时间戳 → 'Y-m-d H:i:s'(0 或非法时给当前时间,别让库里出现 1970) */ + private static function ts(int $t): string + { + if ($t <= 0) { + return date('Y-m-d H:i:s'); + } + // 官方有些字段给的是**毫秒** + if ($t > 99999999999) { + $t = (int)($t / 1000); + } + return date('Y-m-d H:i:s', $t); + } + + /** + * 把这批 items 落库到某本书(**跳过完全重复的**)。 + * + * ⚠️ 去重判据 = (kind + 内容 + 位置)三者相同。为什么不只看内容: + * 同一段原文被划两次(不同时间)在微信读书里是两条,内容是同一个字符串 —— + * 只看内容会误删;而位置(range)不同,正好能把它们区分开。 + * + * @return array{added:int,skipped:int} + */ + public static function store(int $bookId, int $personId, array $items): array + { + $db = Database::get(); + $find = $db->prepare( + 'SELECT id FROM book_notes WHERE book_id = ? AND kind = ? AND content = ? AND COALESCE(loc, \'\') = ?' + ); + $ins = $db->prepare( + 'INSERT INTO book_notes (book_id, person_id, kind, chapter, loc, content, source, created_at) + VALUES (?,?,?,?,?,?,?,?)' + ); + $added = $skipped = 0; + foreach ($items as $it) { + $kind = (string)($it['kind'] ?? 'quote'); + if (!in_array($kind, ['quote', 'note', 'review'], true)) { + $kind = 'quote'; + } + // 划线:内容就是原文;想法/点评:内容优先用想法正文,没有就用原文 + $content = trim((string)($it['content'] ?? '')); + $quote = trim((string)($it['quote'] ?? '')); + if ($kind === 'quote') { + $content = $quote; + } elseif ($content === '') { + $content = $quote; + } + if ($content === '') { + continue; + } + $loc = trim((string)($it['loc'] ?? '')); + $find->execute([$bookId, $kind, $content, $loc]); + if ($find->fetchColumn()) { + $skipped++; + continue; + } + $ins->execute([ + $bookId, $personId > 0 ? $personId : null, $kind, + trim((string)($it['chapter'] ?? '')), $loc, $content, + 'weread', (string)($it['at'] ?? date('Y-m-d H:i:s')), + ]); + $added++; + } + return ['added' => $added, 'skipped' => $skipped]; + } + + /** + * 按书名找本地的书(**用已有的 Book 表**)。 + * + * ⚠️ 只做"去空白后完全相等"的匹配,**不做模糊匹配**: + * 书名相近的两本(《活着》与《活着(余华自选集)》)被合并成一本, + * 用户是看不出来的 —— 而"笔记挂错了书"比"多建一本书"难发现得多。 + * 匹配不上时由调用方决定:默认建新书(让用户自己合并)。 + */ + public static function matchLocalBook(int $familyId, string $title): int + { + $t = trim($title); + if ($t === '') { + return 0; + } + $st = Database::get()->prepare( + 'SELECT id FROM books WHERE family_id = ? AND TRIM(title) = ? ORDER BY id LIMIT 1'); + $st->execute([$familyId, $t]); + return (int)($st->fetchColumn() ?: 0); + } +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index b71e85f..73b05a0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -276,18 +276,14 @@ a:hover { text-decoration: underline; } .icon-btn svg { display: block; } /* ---- 用户第 3 条:首页的统计信息块(问候 + 天气 + 几个数字) ---- */ -.home-brief { display: flex; flex-direction: column; gap: 10px; } -.home-brief-hello { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } -.home-brief-hello b { font-size: 17px; } -.home-brief-weather { - display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; - padding: 8px 12px; border-radius: 10px; background: var(--line-2); -} -.home-brief-weather .w-icon { font-size: 20px; line-height: 1; } -.home-brief-weather .w-temp { font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; } -.home-brief-weather .w-desc { font-size: 14px; } -.home-brief-stats { display: flex; flex-wrap: wrap; gap: 10px 22px; color: var(--muted); font-size: 13px; } -.home-brief-stats .hbs b { color: var(--ink); font-size: 16px; margin-right: 3px; } +/* ⚠️ v24 第 24 批:这里原有 6 条 `.home-brief*` 旧规则(纵向排列的问候/天气/数字), + 本批把首页信息面板改成"左边内容 + 右边天气贴右端"的横排结构后, + 那 6 条**全部成了死样式**(模板里已经没有任何元素用 home-brief-hello / + home-brief-weather / home-brief-stats / w-icon / w-temp / w-desc)。 + ⇒ 就地删掉,不留"两处定义同一个东西": + 上批(第 23 批)就是因为"末尾追加、旧规则留着",漏了 flex-direction, + 四个按钮竖排把标题行撑到 175px —— 那个坑的成因正是**旧规则没删**。 + 新结构见文件末尾「v24 第 24 批 …首页信息面板」那一段(.hb-* / .hb-weather)。 */ /* ---- 用户第 3 条:首页的模块小结卡 ---- */ .mod-cards { @@ -4059,3 +4055,183 @@ button.btn, a.btn, summary.btn, .seg-btn, .icon-btn, .rv-act-btn, .cs-btn, .bf-b /* ---- ③ 图下面的说明:整本账 / 只看某一类,两句话互斥显示 ---- */ .ck-note { margin: 8px 2px 0; } .ck-note > span[hidden] { display: none; } + +/* ============================================================ + v24 第 24 批 用户第 2、3 条:首页信息面板 + 日程/任务左右各半 + ============================================================ */ + +/* ---- ① 信息面板:左(问候/日子/数字) + 右(天气,**贴右端**)---- + ⚠️ 旧的 `.home-brief` 是 `flex-direction: column`,天气自然就落到了下一行。 + 用户明确要求「天气放在这个信息面板的最右侧…靠右放」→ 改成横排 + margin-left:auto。 + ⚠️ 这里**必须**把旧规则的那几行一起改掉(v24 第 23 批:只在末尾追加、 + 漏了 flex-direction,四个按钮竖排撑高标题行 —— 同一个坑不再踩第二次)。 */ +.home-brief { display: flex; flex-direction: row; align-items: flex-start; gap: 18px; flex-wrap: wrap; } +.hb-left { flex: 1 1 320px; min-width: 0; display: flex; flex-direction: column; gap: 8px; } +.hb-hello { font-size: 19px; line-height: 1.3; } +.hb-hello b { font-weight: 700; } +.hb-date { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; color: var(--muted); font-size: 13.5px; } +.hb-date .hb-d { color: var(--ink); font-size: 15px; font-weight: 600; } +.hb-date .hb-lunar { color: var(--brand-ink); } +/* 日子标记:节气 / 法定假日 / 传统节日 / 纪念日 / 生日,各给一个色 */ +.hb-marks { display: flex; flex-wrap: wrap; gap: 6px; } +.hb-mark { + display: inline-flex; align-items: center; padding: 2px 9px; border-radius: 999px; + font-size: 12.5px; white-space: nowrap; border: 1px solid transparent; +} +.hb-mark.is-holiday { background: #FDECEC; color: #A8232B; border-color: #F3C9C9; } +.hb-mark.is-term { background: #EDF4E9; color: #3F6B34; border-color: #CFE2C6; } +.hb-mark.is-festival { background: #FDF2E3; color: #96551A; border-color: #F0D9B8; } +.hb-mark.is-anniv { background: #F2ECF7; color: #6B4A86; border-color: #DCCBE8; } +.hb-mark.is-birthday { background: #FDECF3; color: #A03060; border-color: #F3C9DB; } +.hb-stats { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 13px; color: var(--muted); } +.hb-stats .hbs { display: inline-flex; align-items: baseline; gap: 4px; color: var(--muted); } +.hb-stats .hbs b { color: var(--ink); font-size: 16px; font-variant-numeric: tabular-nums; } +.hb-stats .hbs.is-late b { color: var(--red); } + +/* ---- 天气卡:贴右端 + **彩色**图标(用户原话「彩色图标的形式」)---- + ⚠️ 颜色按天气**色调**(后端给的 tone)分档,而不是让 CSS 猜天气码 —— + 同一份判断写两遍必然飘。晴=琥珀、云=灰蓝、雨=天蓝、雪=青、雷=紫、雾=灰。 */ +.hb-weather { + margin-left: auto; flex: 0 0 auto; + display: grid; grid-template-columns: auto 1fr; grid-template-rows: auto auto; + align-items: center; gap: 2px 12px; min-width: 168px; + padding-left: 16px; border-left: 1px solid var(--line-2); +} +.hbw-icon { + grid-row: 1 / span 2; font-size: 40px; line-height: 1; + /* ⚠️ 不能靠 `-webkit-text-fill-color` 之类给**文字**上渐变(见 emoji-glyph 那课), + 这里就是普通的彩色文字 + 一点投影,稳定且不挑系统字体。 */ + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, .10)); +} +.hb-weather.is-sunny .hbw-icon { color: #E8A33D; } +.hb-weather.is-cloud .hbw-icon { color: #7C8AA0; } +.hb-weather.is-rain .hbw-icon { color: #3B7FBF; } +.hb-weather.is-snow .hbw-icon { color: #5AA6B8; } +.hb-weather.is-storm .hbw-icon { color: #7A5BB5; } +.hb-weather.is-fog .hbw-icon { color: #8C8C8C; } +.hbw-main { display: flex; flex-direction: column; gap: 1px; min-width: 0; } +.hbw-temp { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; } +.hbw-text { font-size: 13px; color: var(--muted); } +.hbw-wind { font-size: 12px; color: var(--faint); } +.hbw-place { grid-column: 2; font-size: 12px; color: var(--faint); } + +/* ---- ② 日程 / 任务 左右各半 ---- + ⚠️ 高度**固定**(用户原话「为了好看高度需要固定」),滚动条**藏起来** + (「鼠标放上去之后向下可以滚动,暂时不要出现滚动条太丑了」)。 + 藏滚动条要三套写法(Firefox / IE-Edge / WebKit 各一套),少一套就在某个浏览器上露出来。 */ +.home-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; } +.hd-pane { display: flex; flex-direction: column; } +.hd-pane-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; } +.hd-pane-head .cinema-h2 { margin: 0; flex: 1 1 auto; } +.hd-pane-body { + height: 360px; overflow-y: auto; overscroll-behavior: contain; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* 旧 Edge / IE */ +} +.hd-pane-body::-webkit-scrollbar { width: 0; height: 0; } /* Chrome / Safari */ + +/* ---- 日程:日视图形式的刻度网格 ---- + ⚠️ `.ag-row` 的高度就是"一小时"(44px);日程块用**百分比 top** 定位, + 所以刻度数与日程块的比例天然一致(都在同一个容器里算)。 */ +.ag-allday { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; } +.ag-allday-item { + display: flex; align-items: center; gap: 8px; padding: 5px 8px; + background: var(--line-2); border-radius: var(--radius-s); font-size: 13.5px; color: var(--ink); +} +.ag-allday-item .ag-ad-t { font-size: 11.5px; color: var(--muted); flex: 0 0 auto; } +.ag-allday-item .ag-ad-w { flex: 1 1 auto; min-width: 0; font-weight: 600; } +.ag-grid { position: relative; } +.ag-row { display: flex; align-items: flex-start; gap: 8px; height: 44px; } +.ag-hour { flex: 0 0 42px; font-size: 11.5px; color: var(--faint); font-variant-numeric: tabular-nums; transform: translateY(-6px); } +.ag-line { flex: 1 1 auto; height: 1px; background: var(--line-2); margin-top: 0; } +/* 当前时刻红线(用户原话「需要在当前的时间或者是时刻那里显示一条红线」) */ +.ag-now { position: absolute; left: 46px; right: 0; height: 0; border-top: 2px solid var(--red); z-index: 2; } +.ag-now-dot { + position: absolute; left: -4px; top: -5px; width: 8px; height: 8px; + border-radius: 50%; background: var(--red); +} +/* 日程块 */ +.ag-ev { + position: absolute; left: 46px; right: 4px; display: flex; align-items: center; gap: 8px; + padding: 5px 9px; min-height: 30px; + background: var(--surface); border: 1px solid var(--line); + border-left: 3px solid var(--brand); border-radius: var(--radius-s); + box-shadow: var(--shadow); font-size: 13.5px; color: var(--ink); + transform: translateY(2px); +} +.ag-ev:hover { box-shadow: var(--shadow-l); } +.ag-ev .ag-ev-t { flex: 0 0 auto; font-size: 11.5px; color: var(--muted); font-variant-numeric: tabular-nums; } +.ag-ev .ag-ev-w { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* ---- 任务行:标题 + 状态 + **看板名** + 截止 ---- */ +.tk-list { display: flex; flex-direction: column; } +.tk-row { align-items: center; } +.tk-board { + flex: 0 0 auto; font-size: 11.5px; color: var(--muted); + background: var(--line-2); border-radius: 999px; padding: 1px 8px; +} +.tk-due { flex: 0 0 auto; font-size: 11.5px; color: var(--faint); } + +/* ---- 窄屏:并排改成上下(各占一半宽度在手机上没法读)---- */ +@media (max-width: 760px) { + .home-duo { grid-template-columns: 1fr; } + .hd-pane-body { height: 300px; } + .hb-weather { margin-left: 0; padding-left: 0; border-left: 0; padding-top: 10px; border-top: 1px solid var(--line-2); width: 100%; } +} + +/* ============================================================ + v24 第 24 批 用户第 4 条:个人导航**管理页**样式 + (展示页样式在 assets/css/nav-show.css —— 那份是从 SimpleNav 整份搬来的, + 这里只管登录后的管理界面,所以用家书自己的视觉语言。) + ============================================================ */ +.nav-grp-block { margin-bottom: 14px; } +.nav-sub { font-size: 13.5px; margin: 10px 0 6px; color: var(--muted); } +.nav-sortlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; } +.nav-sortrow { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + padding: 7px 10px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; +} +/* 隐藏中的条目淡一点,一眼能看出"它还在、只是不显示" */ +.nav-sortrow.is-off { opacity: .55; } +.nav-sortrow.is-dragging { box-shadow: var(--shadow-l); border-color: var(--brand-2); } +.nav-drag { + flex: 0 0 auto; cursor: grab; color: var(--faint); font-size: 13px; + letter-spacing: -1px; user-select: none; padding: 0 2px; +} +.nav-drag:active { cursor: grabbing; } +.nav-ico { + flex: 0 0 auto; width: 20px; height: 20px; border-radius: 5px; object-fit: contain; + border: 1px solid var(--line-2); background: #fff; +} +.nav-ico-letter { + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-size: 11px; border: 0; +} +.nav-sname { flex: 0 0 auto; font-size: 13.5px; font-weight: 600; } +.nav-surl { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.nav-acts { margin-left: auto; display: inline-flex; gap: 6px; flex-wrap: wrap; } +@media (max-width: 560px) { + .nav-acts { margin-left: 0; width: 100%; } + .nav-surl { display: none; } +} + +/* ============================================================ + v24 第 24 批 用户第 5 条:微信读书导入页(书清单 + 样章) + ============================================================ */ +.wr-list { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 14px; } +.wr-book { + display: flex; align-items: flex-start; gap: 10px; + padding: 9px 12px; border: 1px solid var(--line-2); border-radius: var(--radius-s); + background: #FDFBF7; cursor: pointer; +} +.wr-book:hover { border-color: var(--brand-2); } +.wr-book > input[type=checkbox] { margin-top: 3px; flex: 0 0 auto; accent-color: var(--brand); } +.wr-main { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.wr-title { font-size: 14px; font-weight: 600; } +/* 样章:原文用斜体、想法用普通体 —— 与读书详情页里笔记的排法一致 */ +.wr-sample { flex: 1 1 100%; display: flex; flex-direction: column; gap: 2px; margin-left: 22px; } +.wr-q { font-size: 12.5px; color: var(--muted); line-height: 1.6; } +.wr-q i { font-style: normal; color: var(--ink); } +.wr-q em { font-style: normal; color: var(--brand-ink); } +.wr-q em::before { content: '💭 '; } diff --git a/assets/css/nav-show.css b/assets/css/nav-show.css new file mode 100644 index 0000000..1fb2b5f --- /dev/null +++ b/assets/css/nav-show.css @@ -0,0 +1,256 @@ +/* ============================================================ + 导航展示页样式(v24 第 24 批 用户第 4 条) + —— **从用户自己的 SimpleNav 项目整份搬运**(assets/css/style.css)。 + 逻辑与选择器一行未改,只做了两件事: + ① 变量改名:`--primary` → `--n-primary`(家书全站已有一堆 `--primary` 语义的变量, + 撞名会让两边的主题色互相串;加 `n-` 前缀把这份样式**关在自己家里**); + ② 底色/文字/描边换成家书的**纸墨暖色调**(原项目是冷灰蓝), + 让展示页与家书看起来是同一个产品,而不是"外链了一个别的站"。 + ⚠️ 除了上面两样,其余一律照抄 —— 用户要的是「完全照搬原来的导航系统」。 + ============================================================ */ +/* 简航 SimpleNav —— 前台样式 + 在经典「浅灰底 + 白卡片」导航站风格基础上做了现代化处理: + 圆角更大、阴影分层、悬停微动效、顶部品牌区与胶囊式搜索引擎切换。 */ +:root { + --n-primary: #A84A28; + --n-primary-dark: #6E3820; + --n-primary-soft: #FBEFE8; + --bg-1: #F7F4EF; + --bg-2: #F1EBE2; + --card: #FFFFFF; + --text: #2C231D; + --muted: #7E6B5A; + --line: rgba(44, 35, 29, .10); + --radius-lg: 18px; + --radius: 12px; + /* 版心:只推一次 —— 顶栏与正文共用同一个左右内边距,两边算出来严格相等 */ + --wrap: 1140px; + --gutter: 16px; + --pad-x: max(var(--gutter), calc((100% - var(--wrap)) / 2)); + --shadow-sm: 0 1px 2px rgba(23, 43, 77, .05), 0 4px 12px rgba(23, 43, 77, .05); + --shadow: 0 2px 4px rgba(23, 43, 77, .04), 0 10px 30px rgba(23, 43, 77, .08); +} +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; + min-height: 100vh; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%) fixed; + overflow-x: hidden; + -webkit-font-smoothing: antialiased; +} +a { text-decoration: none; color: inherit; } +.container { padding: 0 var(--pad-x); margin: 0 0 40px; } + +/* ---------- 顶部品牌区 ---------- */ +.hero { text-align: center; padding: 52px 0 22px; } +.hero .logo { + display: block; width: 60px; height: 60px; margin: 0 auto 14px; border-radius: 18px; + box-shadow: 0 8px 22px rgba(47, 107, 247, .28); +} +.hero h1 { margin: 0; font-size: 25px; font-weight: 600; letter-spacing: .5px; } +.hero p { margin: 7px 0 0; font-size: 13px; color: var(--muted); } + +/* ---------- 搜索 ---------- */ +.s-search { position: relative; z-index: 2000; max-width: 760px; margin: 0 auto 6px; } +.search-type { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-bottom: 14px; } +.s-engine { + display: inline-block; padding: 6px 16px; border-radius: 999px; cursor: pointer; + background: rgba(255, 255, 255, .72); border: 1px solid rgba(255, 255, 255, .9); + font-size: 14px; line-height: 20px; color: #4a5568; + transition: transform .18s, box-shadow .18s, background .18s, color .18s; + user-select: none; +} +.s-engine span { color: var(--ec, #555); } +.s-engine:hover { background: #fff; transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.s-engine.active { + background: var(--ec, var(--n-primary)); border-color: var(--ec, var(--n-primary)); + box-shadow: 0 6px 16px rgba(47, 107, 247, .26); +} +.s-engine.active span { color: #fff; } + +#search-form { position: relative; } +#search-text { + width: 100%; height: 56px; line-height: 56px; text-indent: 22px; padding-right: 64px; + font-size: 16px; border: 2px solid transparent; border-radius: 28px; background: #fff; + color: var(--text); outline: 0; box-shadow: var(--shadow); + transition: border-color .2s, box-shadow .25s; +} +#search-text::placeholder { color: #a9b3c0; } +#search-text:focus { + border-color: var(--n-primary); + box-shadow: 0 0 0 5px rgba(47, 107, 247, .12), var(--shadow); +} +#search button.submit { + position: absolute; top: 8px; right: 8px; width: 40px; height: 40px; + display: flex; align-items: center; justify-content: center; + border: 0; border-radius: 50%; background: var(--n-primary); color: #fff; + cursor: pointer; outline: none; transition: background .2s, transform .2s; +} +#search button.submit:hover { background: var(--n-primary-dark); transform: scale(1.05); } +#search button.submit:active { transform: scale(.96); } + +#word { + position: absolute; list-style: none; top: 64px; left: 0; width: 100%; margin: 0; + background: #fff; border-radius: 16px; z-index: 20000; padding: 8px; + box-shadow: 0 16px 44px rgba(23, 43, 77, .16); display: none; + max-height: 336px; overflow-y: auto; +} +#word li { + padding: 0 14px; height: 38px; line-height: 38px; cursor: pointer; + font-size: 15px; border-radius: 10px; color: #3d4753; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +#word li:hover, #word li.active { background: var(--n-primary-soft); color: var(--n-primary); } + +/* ---------- 板块卡片 ---------- */ +.mylist { + list-style: none; margin: 18px 0; padding: 16px 20px 14px; + background: var(--card); border-radius: var(--radius-lg); box-shadow: var(--shadow); + display: flex; flex-wrap: wrap; transition: box-shadow .25s; +} +.mylist:hover { box-shadow: 0 4px 8px rgba(23, 43, 77, .05), 0 14px 38px rgba(23, 43, 77, .1); } +.mylist > li.title { + width: 100%; margin: 0 0 12px; padding: 0 0 10px 14px; height: auto; + font-size: 16px; font-weight: 600; line-height: 22px; color: #1f2733; + border-bottom: 1px solid var(--line); position: relative; +} +.mylist > li.title::before { + content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px; + background: linear-gradient(180deg, var(--n-primary), #6f9bff); + border-radius: 2px; +} +.mylist > li.site { margin: 2px; } +.mylist li.site a { + display: flex; flex-direction: column; align-items: center; width: 104px; + padding: 12px 6px 10px; border-radius: 14px; + transition: background .2s, transform .2s; +} +.mylist li.site a:hover { background: linear-gradient(180deg, #f7f9ff, var(--n-primary-soft)); transform: translateY(-3px); } +.mylist li.site .icon { + width: 46px; height: 46px; border-radius: 12px; background: #fff; + object-fit: contain; box-shadow: 0 2px 8px rgba(23, 43, 77, .09); + transition: transform .2s; +} +.mylist li.site a:hover .icon { transform: scale(1.07); } +.mylist li.site .ico-letter { + width: 46px; height: 46px; border-radius: 12px; color: #fff; font-size: 20px; font-weight: 600; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(23, 43, 77, .14); transition: transform .2s; +} +.mylist li.site a:hover .ico-letter { transform: scale(1.07); } +.mylist li.site .name { + width: 100%; margin-top: 8px; text-align: center; font-size: 13px; color: #5b6673; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + transition: color .2s; +} +.mylist li.site a:hover .name { color: var(--n-primary); } + +/* ---------- 合集标签页 ---------- */ +.collections { margin: 24px 0 0; } +.collections-title { + font-size: 16px; font-weight: 600; color: #1f2733; + padding: 0 0 10px 14px; margin: 0 0 14px; position: relative; +} +.collections-title::before { + content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 17px; + background: linear-gradient(180deg, var(--n-primary), #6f9bff); border-radius: 2px; +} +.tabs { display: flex; flex-wrap: wrap; gap: 9px; margin-bottom: 14px; } +.tabs .tab { + border: 1px solid rgba(255, 255, 255, .9); background: rgba(255, 255, 255, .72); + color: #4a5568; cursor: pointer; padding: 8px 18px; border-radius: 999px; + font-size: 14px; transition: all .18s; +} +.tabs .tab:hover { background: #fff; transform: translateY(-1px); box-shadow: var(--shadow-sm); } +.tabs .tab.active { + background: var(--n-primary); border-color: var(--n-primary); color: #fff; + box-shadow: 0 6px 16px rgba(47, 107, 247, .26); +} +.tab-panel { display: none; } +.tab-panel.active { display: flex; animation: fadeUp .28s ease both; } +/* 「全部平铺」模式:每个合集各占一块,与上面的网格板块一致(此时没有 .tabs) */ +.collections.flat .collections-title { margin-bottom: 16px; } +.collections.flat .mylist { margin: 0 0 16px; } +.collections.flat .mylist:last-child { margin-bottom: 0; } +@keyframes fadeUp { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: none; } +} + +/* ---------- 页脚 ---------- */ +.foot { + text-align: center; color: #97a1ae; font-size: 12px; margin: 30px 0 0; + padding-top: 18px; border-top: 1px solid rgba(23, 43, 77, .06); +} +.foot a { color: #97a1ae; transition: color .2s; } +.foot a:hover { color: var(--n-primary); } + +/* ---------- 响应式 ---------- */ +@media (max-width: 767px) { + .hero { padding: 28px 0 16px; } + .hero .logo { width: 52px; height: 52px; border-radius: 15px; } + .hero h1 { font-size: 21px; } + .s-search { margin-bottom: 2px; } + .search-type { gap: 6px; margin-bottom: 10px; } + .s-engine { padding: 5px 13px; font-size: 13px; } + #search-text { height: 50px; line-height: 50px; font-size: 15px; text-indent: 18px; } + #word { top: 58px; } + .mylist { border-radius: 14px; padding: 12px 12px 10px; } + /* ⚠️ 百分比宽度必须写在 **flex item(li)自身**,不能写在它内部的 上: + li 自己没有宽度,a 的 25% 要相对 li 解析、而 li 的宽又由内容 a 反推 —— + 循环依赖浏览器解析不了,会塌成 min-content(实测约 12px), + 于是 42px 图标撑破格子、名称被裁到只剩几像素(2026-09-28 手机端实测)。 + margin 也要归零:4 × (25% + 左右 margin) 会超出一行宽,把第 4 个挤到下一行。 */ + .mylist li.site { width: 25%; margin: 0; } + .mylist li.site a { width: 100%; padding: 10px 2px 8px; } + .mylist li.site .icon, .mylist li.site .ico-letter { width: 42px; height: 42px; } + .mylist li.site .name { font-size: 12px; } +} + +/* ============================================================ + 导航**展示页**的顶部行(v24 第 24 批 用户第 4 条) + ------------------------------------------------------------ + ⚠️ 这一段是**本批新加的,不是从 SimpleNav 搬来的** —— 原项目没有这一行 + (它自己的页面顶部只有品牌区,没有"登录"入口)。 + 用户原话:「在这个页面里面要增加一个功能,右上角增加一个功能,就是登录, + 点击这个页面之后再进入到我们现在这个系统就是家书的这个系统的登录页面。」 + 「导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」 + ⚠️ 我第一版只写了 HTML、**忘了写这段 CSS**,于是按钮落在左边(真机几何实测: + 按钮右边缘 116px,而整行右边缘 1210px)—— 这类"HTML 写了样式没写"的漏项, + e2e 查字符串永远绿,只有真机量几何才看得出来。 + ============================================================ */ +.topline { + display: flex; align-items: center; gap: 12px; + padding: 14px 0 0; +} +.topline .who { display: flex; align-items: center; gap: 8px; min-width: 0; } +.topline .who-av { + width: 28px; height: 28px; border-radius: 50%; object-fit: cover; + border: 1px solid var(--line); flex: 0 0 auto; +} +.topline .who-name { + font-size: 13.5px; color: var(--muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +/* ⚠️ 靠右全靠这一行:不写它按钮就贴在左边 */ +.topline .login-btn { margin-left: auto; flex: 0 0 auto; } +.login-btn { + display: inline-flex; align-items: center; gap: 5px; + padding: 5px 12px; border-radius: 999px; + border: 1px solid var(--line); background: var(--card); color: var(--text); + font-size: 13px; text-decoration: none; +} +.login-btn:hover { border-color: var(--n-primary); color: var(--n-primary); } + +/* ---- 展示页空态(主人还没加过东西时)---- */ +.nav-empty { + text-align: center; padding: 34px 16px; margin: 10px 0 24px; + background: var(--card); border: 1px dashed var(--line); border-radius: var(--radius); + color: var(--muted); +} +.nav-empty p { margin: 0 0 6px; } +.nav-empty p:last-child { margin-bottom: 0; } diff --git a/assets/js/nav-show.js b/assets/js/nav-show.js new file mode 100644 index 0000000..815d14e --- /dev/null +++ b/assets/js/nav-show.js @@ -0,0 +1,347 @@ +/* ============================================================ + 导航展示页脚本(v24 第 24 批 用户第 4 条) + —— **从 SimpleNav 的 assets/js/app.js 整份搬运**,逻辑一行未改。 + 它提供:搜索引擎切换、联想词下拉(↓/↑ 与 , . 两组按键)、合集标签页切换、 + 站点链接打开方式(新标签页 / 当前页,由 body[data-open] 决定)。 + ⚠️ 联想接口地址来自 window.SIMPLENAV.suggest(服务端按当前短语生成), + 不在这个文件里写死。 + ============================================================ */ +/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */ +(function () { + 'use strict'; + var cfg = window.SIMPLENAV || {}; + + /* ---------- 搜索引擎切换 ---------- */ + var tabs = document.querySelectorAll('#engine-tabs .s-engine'); + var form = document.getElementById('search-form'); + var input = document.getElementById('search-text'); + + function activateEngine(el) { + tabs.forEach(function (t) { t.classList.remove('active'); }); + el.classList.add('active'); + var search = el.getAttribute('data-search'); + var param = el.getAttribute('data-param'); + var slug = el.getAttribute('data-slug'); + var ph = el.getAttribute('data-placeholder') || '搜索'; + if (form) { form.setAttribute('action', search); form.setAttribute('data-slug', slug); } + if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); } + hideWord(); + // 已经输了字就顺手换一份建议(auto 模式下各引擎来源不同) + if (input && input.value.trim() !== '') { askSuggest(); } + } + tabs.forEach(function (el) { + el.addEventListener('click', function () { activateEngine(el); input && input.focus(); }); + }); + + /* ---------- 联想下拉 ---------- */ + var word = document.getElementById('word'); + var timer = null; + var lastQ = null; + var seq = 0; // 请求序号:防止先发的慢响应覆盖后发的快响应 + var items = []; // 当前展示的建议词 + var active = -1; // 选中项下标(键盘 ↓/↑、,/. 与鼠标划过共用);-1 = 没选中 + var punctByKeyDown = false; // 本次标点是否已由 keydown 处理(防 keydown+beforeinput 双触发跳两格) + var pendingPunct = null; // 「拦不住的标点」信息 {ch, dir, before}(见下),null = 无 + var pendingTimer = null; // 收口定时器(去抖:组合上屏会连发两次 input) + + /* 联想词的选中按键(后台「设置 → 联想词按键」): + both(默认:两组都认)/ arrows(只认 ↓/↑)/ punct(只认 , 和 .) + ⚠️ 默认已从 arrows 改为 both(2026-09-29):用户反馈「上下键和逗号句号都选不了词」, + 而线上设置恰好是单组 punct —— 单组模式天然让**另一组键静默失效**,极易被当成 bug。 + 两组都认是它们的**严格超集**:想用哪组都行,没有任何损失。 */ + var keyMode = cfg.suggestKeys || 'both'; + function acceptsArrows() { return keyMode === 'arrows' || keyMode === 'both'; } + function acceptsPunct() { return keyMode === 'punct' || keyMode === 'both'; } + /* ⚠️ 中文输入法的标点坑(真实用户反馈,2026-09-28 / 2026-09-29): + 中文输入法下按逗号/句号,上屏的常是**全角** `,`(U+FF0C) / `。`(U+3002), + 而且此时 e.code **不一定**是 'Comma'/'Period'(取决于浏览器与输入法)—— + 早先只认半角的 e.code / e.key(半角逗号点号)就是因此漏判, + 结果标点被当普通字符输进搜索框、没当成选词键。 + 修法:**e.key 与 e.code 两条都认**(互为兜底)—— 有的环境 e.key 是全角而 code 缺, + 有的环境 e.key 仍半角而 code 稳;只取其一都会漏。 + 全角逗号 `,`(U+FF0C);全角句号常见 `。`(U+3002),另兼容全角句点 `.`(U+FF0E)。 + 返回:-1 = 逗号(上一条,同 ↑);+1 = 句号(下一条,同 ↓);0 = 不是标点。 */ + function punctDir(ch) { + if (ch === ',' || ch === '\uFF0C') { return -1; } + if (ch === '.' || ch === '\u3002' || ch === '\uFF0E') { return 1; } + return 0; + } + function keyPunctDir(e) { + var d = punctDir(e.key); + if (d !== 0) { return d; } + if (e.code === 'Comma') { return -1; } + if (e.code === 'Period') { return 1; } + return 0; + } + + function hideWord() { + if (word) { word.innerHTML = ''; word.style.display = 'none'; } + items = []; + active = -1; + } + + function showWord(list) { + if (!word) return; + items = (list || []).slice(); + active = -1; // 换了新建议就取消选中:此时回车应当用输入框原文 + if (!items.length) { hideWord(); return; } + word.innerHTML = items.map(function (t) { + var d = document.createElement('li'); + d.textContent = t; + return d.outerHTML; + }).join(''); + word.style.display = 'block'; + } + + /** 把 .active 标到当前选中项上(键盘与鼠标共用这一个状态) */ + function paintWord() { + if (!word) return; + Array.prototype.forEach.call(word.children, function (li, i) { + if (i === active) { li.classList.add('active'); } else { li.classList.remove('active'); } + }); + } + + function askSuggest() { + if (!input) return; + var q = input.value.trim(); + if (q === '') { lastQ = null; hideWord(); return; } + if (q === lastQ) { return; } + lastQ = q; + var mine = ++seq; + var slug = form ? (form.getAttribute('data-slug') || '') : ''; + fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q)) + .then(function (r) { return r.json(); }) + .then(function (data) { if (mine === seq) { showWord(data && data.items); } }) + .catch(function () { if (mine === seq) { hideWord(); } }); + } + + if (input) { + input.addEventListener('input', function () { + clearTimeout(timer); + active = -1; // 一开始打字就取消选中:之后回车应当用输入框原文 + paintWord(); + timer = setTimeout(askSuggest, 180); + }); + + input.addEventListener('keydown', function (e) { + // ⚠️ IME 组合态保护:用中文输入法**打拼音**时也会触发 keydown(isComposing=true, + // 或 keyCode=229),那些按键属于输入法自己的候选选择,不能当成选联想词的键拦下来。 + // 组合结束(拼音上屏)后 isComposing=false,下面的 , / . 选词才生效。 + // ⚠️ 这里的「返回」**不是**把这次标点丢掉 —— 输入法上屏标点会再走 beforeinput, + // 那条路径会接住(见下面 beforeinput 的 insertCompositionText 分支)。 + // 所以「组合态里按标点」在用户看来照样能选词。 + if (e.isComposing || e.keyCode === 229) { return; } + + var open = !!word && word.style.display === 'block' && items.length > 0; + + // 移动选中项:只动高亮、不动输入框(回车时才落到输入框)。 + // 用哪组键由后台「联想词按键」决定:arrows = ↓/↑ / punct = , 和 . / both = 都行。 + var dir = 0; + var isPunct = false; // 这次移动是否由**标点**触发(决定要不要标去重闸门) + if (acceptsArrows() && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + dir = e.key === 'ArrowDown' ? 1 : -1; + } else if (acceptsPunct()) { + dir = keyPunctDir(e); // , = 上一条(同 ↑)/ . = 下一条(同 ↓),半角全角都认 + isPunct = dir !== 0; + } + + if (dir !== 0) { + // ⚠️ 没有下拉时**必须放行** —— 这时 , 和 . 是正常输入字符(如搜「python, pandas」)。 + if (!open) { return; } + e.preventDefault(); // 别让光标跳到文本首尾、也别让页面跟着滚 + moveSelection(dir); + // ⚠️ 只有**标点**才需要标闸门:同一次按键会先走 keydown、再走 beforeinput, + // 不标就会「跳两格」。方向键压根不产生 beforeinput,标了纯属副作用 + // —— 反而会把紧随其后的真实标点误挡掉(两者相隔可能不足 1ms)。 + if (isPunct) { + punctByKeyDown = true; + setTimeout(function () { punctByKeyDown = false; }, 0); + } + return; + } + + if (e.key === 'Escape') { hideWord(); return; } + + if (e.key === 'Enter') { + // 只有**用 ↓/↑ 选中**了某条建议,回车才按建议词搜索。 + // 没选中就一律用输入框里的原文 —— 老实现是「回车就套用第一条建议」, + // 会把用户自己输入、而建议里没有的词悄悄换掉。 + if (open && active >= 0) { + input.value = items[active]; + hideWord(); + e.preventDefault(); + if (form) { form.submit(); } + } + // active < 0:不拦截,交给浏览器默认的表单提交(即用输入框原文搜) + return; + } + }); + + /* ⚠️ beforeinput 兜底 —— 专治输入法的标点(用户实测反馈,2026-09-28)。 + 为什么需要它:按逗号/句号时,keydown 那一路可能整条接不住 —— + · 输入法上屏的是**全角**「,」「。」,e.key 不是半角、e.code 也不可靠 → 漏判; + · 或者按键发生在 isComposing 组合态里,被上面那道 IME 保护挡掉 → 漏判; + · 甚至**根本没有 keydown**(输入法把标点作为组合内容直接提交)→ 更接不住。 + beforeinput 在**字符即将插入文本框之前**触发,e.data 就是要插入的字符,是最可靠的一道。 + ⚠️ 两种 inputType 都要认(2026-09-29 实测补齐,这正是「选了标点模式也没反应」的元凶): + · `insertText` —— 普通输入 / 部分输入法上屏标点; + · `insertCompositionText` —— 输入法把标点当**组合内容**提交(CDP `imeSetComposition` 复现)。 + 旧代码只认 insertText,于是这条路径完全失效。 + 命中就 preventDefault(不让它插入)并移动选中项。 + 只在「下拉打开 + punct/both 模式」下生效;没有下拉时不拦,逗号句号照常输入。 + + 🔴🔴 【2026-09-29 真实浏览器实测】`insertCompositionText` 的 beforeinput 在 Chromium 里 + **cancelable = false** —— preventDefault() 根本无效!所以「组合上屏标点」这条路 + **只能事后撤销**:这里记住标点,等紧随的 input 事件把字符插进输入框后,再把它删掉 + 并移动选中项(见 undoPendingPunct)。只靠 beforeinput 是拦不住的。 + (CDP 实测记录:beforeinput it:insertCompositionText comp:true data:"," pd:false cc:false) */ + input.addEventListener('beforeinput', function (e) { + var it = e.inputType; + if (it !== 'insertText' && it !== 'insertCompositionText') { return; } + if (punctByKeyDown) { return; } // keydown 已处理过本次标点,别再移一次(防跳两格) + var d = e.data; + if (typeof d !== 'string' || d.length === 0) { return; } + // 只在**恰好是一个标点字符**时当选词键。带别的内容(如整串组合文本)一律放行, + // 免得把输入法正常上屏的字吃掉。 + var dir = punctDir(d); + if (dir === 0) { return; } + var open = !!word && word.style.display === 'block' && items.length > 0; + if (!open || !acceptsPunct()) { return; } + if (e.cancelable) { + // —— 能拦就拦:insertText 路径,preventDefault 立刻生效,字符根本不会进输入框。 + e.preventDefault(); + moveSelection(dir); + return; + } + // —— 拦不住:insertCompositionText 路径(Chromium 实测 cancelable=false)。 + // 只能**事后收口**:先记下「这次标点进来之前」的输入框值,等事件洪峰过去 + // 再统一撤销(见 flushPending)。用快照而不是记字符,是因为这条路径的 + // 事件序列是「先插入标点 → 再回滚成原样」,只记字符会撤错。 + // ⚠️ 目标下标必须**当场算好**:收口跑在宏任务里,那时 input 的 `active = -1` + // 已经把选中清掉了,再从 active 起算就会每次都从第一条重新开始(不累加)。 + pendingPunct = { + ch: d, + before: input.value, + target: nextActive(active, dir), + }; + schedulePendingFlush(); + }); + } + + /** 安排一次「拦不住的标点」收口(去抖:组合上屏会在一帧内连发两次 input) */ + function schedulePendingFlush() { + clearTimeout(pendingTimer); + pendingTimer = setTimeout(flushPending, 0); + } + + /** + * 收口「拦不住的标点」(beforeinput 里 cancelable=false 的那条路径)。 + * + * 为什么必须**延迟到宏任务**而不是当场处理:这条路径的真实事件序列是 + * ① beforeinput(insertCompositionText, cancelable=false) ← 拦不住 + * ② input 值 = 原文 + 「,」 ← 标点真的进去了 + * ③ input 值 = 原文(回滚) ← 又被自己撤掉了 + * 若在 ② 当场处理,会被 ③ 冲掉。等这一帧的事件洪峰结束(setTimeout 0)再收口才稳。 + * + * 处理方式:把输入框值**恢复成标点进来之前的快照**(若此刻值确实被标点污染了), + * 再把选中项设到 beforeinput 时算好的目标下标。 + */ + function flushPending() { + if (!pendingPunct) { return; } + var info = pendingPunct; + pendingPunct = null; + if (!input) { return; } + // 恢复快照:抹掉「拦不住的标点」留下的痕迹。 + // ⚠️ 只在值确实与快照不同、且**差值就是那个标点**时才动,避免抹掉用户真实输入。 + var tv = input.value; + if (tv !== info.before && tv.indexOf(info.ch) >= 0) { + var tb = info.before; + if (tv.length > tb.length && tv.slice(0, tb.length) === tb) { + input.value = tb; // 标点被追加在末尾 + } else if (tv.length === tb.length) { + input.value = tb; // 等长(插入后再回滚的中间态) + } else { + var idx = tv.lastIndexOf(info.ch); + if (idx >= 0) { input.value = tv.slice(0, idx) + tv.slice(idx + info.ch.length); } + } + // ⚠️ 这里**不要**重置 lastQ:值被恢复成「标点进来之前」的原文,而 lastQ 记的正是那份原文, + // 两者本来就相等 → 180ms 后的 askSuggest 会走 `q === lastQ` 直接 return, + // 不会重新请求、也就不会 showWord 把刚设好的选中清掉。重置反而会自找麻烦。 + } + active = info.target; + paintWord(); + } + + /** 移动联想下拉的选中项(keydown 与 beforeinput 共用;dir=-1 上一条 / +1 下一条) */ + function moveSelection(dir) { + active = nextActive(active, dir); + paintWord(); + } + + /** 算出「从 base 再按 dir 一步」应落在哪个下标(items 为空时返回 -1)。 + * 独立出来是为了「拦不住的标点」那条路:它要在 beforeinput 时就算好目标下标, + * 因为收口跑在宏任务里,那时 active 已被 input 的 `active = -1` 清掉了。 */ + function nextActive(base, dir) { + if (!items.length) { return -1; } + if (base < 0) { return dir > 0 ? 0 : items.length - 1; } + return (base + dir + items.length) % items.length; + } + + /* 鼠标划过某条建议 → 同步高亮(与 ↓/↑ 共用 active,回车即可确认) */ + document.addEventListener('mouseover', function (e) { + if (!word || !e.target.closest) { return; } + var hovered = e.target.closest('#word li'); + if (!hovered) { return; } + Array.prototype.forEach.call(word.children, function (li, i) { + if (li === hovered) { active = i; } + }); + paintWord(); + }); + + document.addEventListener('click', function (e) { + if (word && !word.contains(e.target) && e.target !== input) { hideWord(); } + var li = e.target.closest ? e.target.closest('#word li') : null; + if (li && input) { + input.value = li.textContent; + hideWord(); + if (form) { form.submit(); } + } + }); + + /* ---------- 合集标签页 ---------- */ + var collTabs = document.querySelectorAll('#coll-tabs .tab'); + collTabs.forEach(function (tab) { + tab.addEventListener('click', function () { + collTabs.forEach(function (t) { + t.classList.remove('active'); + t.setAttribute('aria-selected', 'false'); + }); + tab.classList.add('active'); + tab.setAttribute('aria-selected', 'true'); + document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); }); + var panel = document.getElementById(tab.getAttribute('data-target')); + if (panel) { panel.classList.add('active'); } + }); + }); + + /* ---------- 图标加载失败 → 退回首字母块(与 PHP 端配色一致) ---------- */ + var PALETTE = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; + function colorOf(s) { + var h = 0; + for (var i = 0; i < s.length; i++) { h = (h * 31 + s.charCodeAt(i)) | 0; } + return PALETTE[Math.abs(h) % PALETTE.length]; + } + document.querySelectorAll('.mylist img.icon').forEach(function (img) { + img.addEventListener('error', function () { + var a = img.closest('a'); + if (!a) { img.style.visibility = 'hidden'; return; } + var nameEl = a.querySelector('.name'); + var label = (nameEl ? nameEl.textContent : '?').trim() || '?'; + var span = document.createElement('span'); + span.className = 'ico-letter'; + span.style.background = colorOf(label); + span.textContent = label.charAt(0); + img.parentNode.replaceChild(span, img); + }, { once: true }); + }); +})(); diff --git a/assets/js/person_picker.js b/assets/js/person_picker.js index 83011b1..ed0f457 100644 --- a/assets/js/person_picker.js +++ b/assets/js/person_picker.js @@ -372,6 +372,14 @@ var type = list.getAttribute('data-pp-type') || 'radio'; var fname = list.getAttribute('data-pp-name') || ''; + // 🔴🔴 先把**承运字段**撤掉(v24 第 24 批 用户第 1 条修的真 bug)。 + // 它是服务端渲染的隐藏 input,承载"当前已选";一旦真的清单建出来, + // 真值的输入项就在 DOM 里了,承运字段必须**立刻移除** —— 否则提交时 + // 同一个 name 会带上两份值(承运一份 + 真值一份),后端收到重复项。 + // 详见 templates/_person_picker.php 里那段长注释。 + Array.prototype.forEach.call( + root.querySelectorAll('.pp-carry'), function (el) { el.parentNode.removeChild(el); }); + var frag = document.createDocumentFragment(); people.forEach(function (p) { var lab = document.createElement('label'); @@ -555,9 +563,81 @@ document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !modal.hidden) { closeModal(false); } - // 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然) - if (e.key === 'Enter' && !modal.hidden && stagedMode - && e.target === search) { + + if (modal.hidden || e.target !== search) { return; } + + /* ============================================================ + 键盘流畅流程(v24 第 24 批 用户第 1 条后半,用户原话): + 「当我输入了一个人的拼音、首字母的几个字母之后,如果第一个列表里面, + 第一个人就是我要选的这个人。这时候我按空格就直接把这个人选中… + 只要是完成了选中一个人,这个操作之后,就把输入框里面的用户已经输入的 + 字母全部给它清空。同时光标回到输入框里面,让用户等待的选出下一个人的 + 字母。…这时候如果它回车,就是确定,就是和点击那个框上的确定按钮 + 是一个效果。」 + + ⇒ 所以这里要三件事(按用户说的顺序): + ① 空格 = 选中**当前筛选结果里的第一个人**; + ② 选中之后:清空输入框 + 光标回到输入框(接着打下一个人的拼音); + ③ 回车 = 等于点「确定」(把已选的人写回页面)。 + + ⚠️ 只在**输入框里**按才生效(`e.target !== search` 直接返回): + 否则用户在弹框其它地方按空格(比如点完按钮后焦点在按钮上)会误选人。 + ⚠️ 空格**必须** preventDefault:`` 里空格默认会打出一个空格 + 字符,不挡的话输入框会变成 "yyq "(多一个空格,看着像拼音打错了)。 + ⚠️ 「第一个人」取的是**当前可见**列表的第一个(applyFilter 已经算好 `hidden`), + 而不是 DOM 里的第一个 —— 用户看到的顺序才是他心里的"第一个"。 + 这也是用户那句"如果第一个列表里面,第一个人就是我要选的这个人"的意思。 + ============================================================ */ + if (e.key === ' ' || e.key === 'Spacebar') { + e.preventDefault(); + // 清单可能还没建(懒建)—— 空格时先补建,否则一个候选都找不到 + buildList(); + // 🔴 「第一个人」要取**当前可见、且还没被选中的**那一个。 + // + // 为什么(这是本轮真机跑出来的一处交互缺陷): + // 用户描述的是「输拼音 → 按空格 → 就选中这个人」。而如果**第一个可见的**恰好 + // **已经选过**(比如他没清筛选、接着按空格),`!checked` 会把它**取消掉** —— + // 与他"我要选人"的意图正好相反,而且他多半看不出来(人少了以为是没选上)。 + // ⇒ 跳过已选中的,选"第一个还没选的";一个都没得选时**什么都不做** + // (不要静默取消任何人)。 + var first = null; + var items = list.querySelectorAll('.pp-item'); + for (var i = 0; i < items.length; i++) { + if (items[i].hidden) { continue; } + var _b = items[i].querySelector('input'); + if (!_b || _b.value === '0') { continue; } + if (!_b.checked) { first = items[i]; break; } // 只认"还没选的" + } + if (!first) { + // 可见的都选过了(或一个候选都没有)→ 什么都不做, + // 只把"没有匹配的人"的提示按需亮一下,**绝不取消任何已选** + var _any = false; + for (var j = 0; j < items.length; j++) { if (!items[j].hidden) { _any = true; break; } } + if (!_any && emptyTip) { emptyTip.hidden = false; } + return; + } + var fbox = first.querySelector('input'); + if (!fbox || fbox.value === '0') { return; } + + if (stagedMode) { + fbox.checked = true; // 见上:到这里它一定是**未选中**的 + // 用户明确要求:**选完一个人就清空输入框 + 光标回来**(见上面的说明) + search.value = ''; + applyFilter(); + refresh(); + search.focus(); + } else { + // 单选:空格 = 选中并关闭(与"点一个人"同义) + fbox.checked = true; + refresh(); + closeModal(true); + } + return; + } + + // 回车 = 确定(多选;单选没有确定这一步,浏览器里没有可提交的表单, + // 所以回车**不**做任何事,避免"不小心把弹窗关了却没选上") + if (e.key === 'Enter' && stagedMode) { e.preventDefault(); refresh(); closeModal(true); diff --git a/index.php b/index.php index c339525..92e8905 100644 --- a/index.php +++ b/index.php @@ -29,14 +29,18 @@ use App\Family; use App\Geo; use App\Home; use App\HomeMods; +use App\Holiday; use App\Anniversary; use App\Ics; use App\Kinship; use App\Ledger; +use App\Lunar; use App\Movie; use App\MovieReview; use App\Module; use App\MovieStats; +use App\Nav; +use App\NavFetch; use App\NodeType; use App\Nickname; use App\Person; @@ -57,6 +61,7 @@ use App\Upload; use App\UserSetting; use App\Vehicle; use App\View; +use App\WeRead; if (!Database::hasTables()) { header('Location: install.php'); @@ -796,6 +801,33 @@ switch ($r) { $meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? ''); $greeting = Home::greeting($meName, $today, count($soon), $lateN); + // ---- v24 第 24 批 用户第 2 条:信息面板要的那几样 ---- + // 「今天是几月几日?星期几?农历几月几日?如果有节气/重要节日/纪念日/生日, + // 在这里显示;紧接着下面显示今天一共有多少条日程、几件待办正在进行。」 + $lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j')); + $lunarLine = $lunarToday === null ? '' : Lunar::label( + (int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'], + !empty($lunarToday['is_leap'])); + // 抬头要的是「农历几月几日」,不要"农历二〇二六年"那一段(面板上塞不下) + $lunarShort = $lunarToday === null ? '' : ( + Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap'])) + . Lunar::dayName((int)$lunarToday['day'])); + $todayMarks = Home::todayMarks($familyId, $me, $today); + + // 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样") + // ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。 + $todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool { + return (string)($e['date'] ?? '') === $today; + })); + usort($todayEvents, static function (array $a, array $b): int { + // 全天事件(无时间)排最前 —— 日视图的习惯就是这样 + $ta = (string)($a['time'] ?? ''); + $tb = (string)($b['time'] ?? ''); + if ($ta === '' && $tb !== '') { return -1; } + if ($tb === '' && $ta !== '') { return 1; } + return strcmp($ta, $tb); + }); + // 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。 // 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。 $weatherLoc = Home::weatherLocation($me, $familyId); @@ -807,11 +839,13 @@ switch ($r) { 'family' => Family::find($familyId), 'me' => $people[$me] ?? null, 'meId' => $me, + 'meName' => $meName, 'kin' => $kin, 'allCount' => count($people), 'relCount' => Relations::count($familyId), 'birthdays' => $birthdays, 'soon' => $soon, + 'todayEvents'=> $todayEvents, 'tasks' => $tasks, 'memories' => $memories, // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) @@ -822,6 +856,12 @@ switch ($r) { 'weekday' => Home::weekday($today), 'weather' => $weather, 'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN], + // v24 第 24 批:信息面板 + 'todayLabel' => date('n 月 j 日'), + 'lunarShort' => $lunarShort, + 'lunarLine' => $lunarLine, + 'todayMarks' => $todayMarks, + 'today' => $today, 'homeModules'=> $homeMods, 'moduleCards'=> $moduleCards, ], 'home'); @@ -5475,6 +5515,101 @@ switch ($r) { ], 'books'); break; + // ---- 微信读书导入(v24 第 24 批 用户第 5 条)---- + // + // 用户原话:「能不能导入微信的读书笔记,就是每一个人设置一下…授权一下」 + // → 后续明确「用微信读书 api key」(官方 2026 已开放,见 App\WeRead 头部) + // + // ⚠️ Key 是**账号凭据**:加密落库、界面只回显掩码(同 DAV 口令那套思路)。 + case 'books/weread': + $me = (int)Auth::personId(); + Auth::requireWrite(); + $imported = null; + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + csrf_check(); + $act = (string)($_POST['act'] ?? ''); + if ($act === 'key_save') { + $r = WeRead::saveKey($me, (string)($_POST['api_key'] ?? '')); + flash($r['ok'] ? 'ok' : 'err', $r['ok'] + ? 'API Key 已保存(加密存放,页面只显示掩码)。' + : (string)$r['error']); + redirect('books/weread'); + } + if ($act === 'fetch') { + // 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定 + $nb = WeRead::notebooks($me); + if (!$nb['ok']) { + flash('err', (string)$nb['error']); + redirect('books/weread'); + } + $preview = []; + foreach (array_slice($nb['books'], 0, 40) as $b) { + $nt = WeRead::notes($me, (string)$b['bookId']); + $preview[] = [ + 'title' => (string)$b['title'], + 'author' => (string)$b['author'], + 'bookId' => (string)$b['bookId'], + 'count' => count((array)($nt['items'] ?? [])), + 'items' => array_slice((array)($nt['items'] ?? []), 0, 3), + 'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']), + ]; + } + View::render('books/weread', [ + 'title' => '微信读书导入', + 'preview' => $preview, + 'nbCount' => count($nb['books']), + ], 'books'); + break; + } + if ($act === 'import') { + // 「真的导入」:只导**勾选的那些书**(用户在预览页里挑) + $want = array_map('strval', (array)($_POST['pick'] ?? [])); + $nb = WeRead::notebooks($me); + if (!$nb['ok']) { + flash('err', (string)$nb['error']); + redirect('books/weread'); + } + $familyId = (int)Auth::familyId(); + $sumBooks = $sumNotes = 0; + foreach ($nb['books'] as $b) { + if (!in_array((string)$b['bookId'], $want, true)) { + continue; + } + $title = trim((string)$b['title']); + if ($title === '') { + continue; + } + $bid = WeRead::matchLocalBook($familyId, $title); + if ($bid <= 0) { + $bid = Book::create($familyId, $me, [ + 'title' => $title, + 'author' => (string)$b['author'], + 'status' => 'reading', + ]); + if ($bid <= 0) { + continue; + } + $sumBooks++; + } + $nt = WeRead::notes($me, (string)$b['bookId']); + if (!$nt['ok']) { + continue; + } + $res = WeRead::store($bid, $me, (array)$nt['items']); + $sumNotes += (int)$res['added']; + } + flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、' + . $sumNotes . ' 条笔记(重复的已自动跳过)。'); + redirect('books'); + } + } + View::render('books/weread', [ + 'title' => '微信读书导入', + 'preview' => $imported, + 'nbCount' => 0, + ], 'books'); + break; + case 'books/notes_import': Auth::requireWrite(); // 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。 @@ -5518,6 +5653,256 @@ switch ($r) { redirect('books'); break; + // ==================== 个人导航(v24 第 24 批 用户第 4 条,从 SimpleNav 搬进来)==================== + // + // ⚠️ 这一块与其它模块最大的不同:**导航展示页不需要登录**(用户明确要求), + // 它的入口在 index.php 之外(`nav_show.php`),因为它必须在 bootstrap + // 的"要求登录"之前就放行。这里的两条路由都是**管理端**(要登录)。 + + case 'nav': + $me = (int)Auth::personId(); + Nav::ensureSeed($me); // 第一次进来给一份默认导航(只在他一条板块都没有时) + $gridGroups = Nav::groups($me, 'grid', true); + $tabGroups = Nav::groups($me, 'tabs', true); + $allGroups = Nav::groups($me); + $sitesBy = Nav::sitesByGroup($me); + $engines = Nav::engines($me); + $settings = Nav::settings($me); + $phrase = Nav::phrase($me); + View::render('nav/index', [ + 'title' => '导航', + 'meId' => $me, + 'gridGroups' => $gridGroups, + 'tabGroups' => $tabGroups, + 'allGroups' => $allGroups, + 'sitesBy' => $sitesBy, + 'engines' => $engines, + 'navSet' => $settings, + 'phrase' => $phrase, + // 展示页地址(短语为空时模板里会提示去设置) + 'showUrl' => $phrase !== '' + ? rtrim((string)fb_config('base_url', ''), '/') . '/nav/?q=' . rawurlencode($phrase) + : '', + // 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」) + 'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null), + ], 'nav'); + break; + + case 'nav/save': + $me = (int)Auth::personId(); + Auth::requireWrite(); + csrf_check(); + // 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路: + // 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。 + $act = (string)($_POST['act'] ?? ''); + $ok = static function (string $msg): void { + // AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302 + if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE); + exit; + } + flash('ok', $msg); + redirect('nav'); + }; + $bad = static function (string $msg): void { + if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE); + exit; + } + flash('err', $msg); + redirect('nav'); + }; + + $gid = (int)($_POST['id'] ?? 0); + switch ($act) { + case 'group_save': + $id = Nav::saveGroup($me, [ + 'name' => (string)($_POST['name'] ?? ''), + 'display' => (string)($_POST['display'] ?? 'grid'), + 'is_visible' => !empty($_POST['is_visible']), + ], $gid); + $id > 0 ? $ok('板块已保存。') : $bad('板块得有个名字。'); + break; + + case 'group_delete': + Nav::removeGroup($me, $gid); + $ok('板块已删除(里面的站点一起删了)。'); + break; + + case 'group_toggle': + Nav::toggleGroup($me, $gid); + $ok('已切换显示。'); + break; + + case 'group_reorder': + // 拖拽后整组顺序一次提交(SimpleNav 的做法:整组归一化,间隙恢复整齐) + // ⚠️ 同 site_reorder:不是数组就拒绝,别静默只改一项(见那边的说明)。 + if (!is_array($_POST['ids'] ?? null)) { + $bad('排序数据格式不对(应是一组 id)。'); + } + $ids = array_map('intval', (array)$_POST['ids']); + $n = Nav::applySort('nav_groups', $me, $ids); + $ok('顺序已保存(' . $n . ' 项)。'); + break; + + case 'site_save': + $url = Nav::normUrl((string)($_POST['url'] ?? '')); + $id = Nav::saveSite($me, [ + 'group_id' => (int)($_POST['group_id'] ?? 0), + 'url' => $url, + 'name' => (string)($_POST['name'] ?? ''), + 'short_name' => (string)($_POST['short_name'] ?? ''), + 'is_visible' => !empty($_POST['is_visible']), + ], $gid); + if ($id <= 0) { + $bad('站点地址和所属板块都不能空。'); + } + // 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢) + if (!empty($_POST['do_fetch'])) { + $meta = NavFetch::meta($url); + $db = Database::get(); + $newName = trim((string)($meta['name'] ?? '')); + $file = NavFetch::fetchIcon($meta, $url); + if ($file !== '') { + // ⚠️ 抓来的图标**不算**自定义(icon_custom=0), + // 下次「全部更新」可以覆盖它 —— 这正是 SimpleNav 的语义。 + $db->prepare('UPDATE nav_sites SET icon = ?, icon_custom = 0, + name = CASE WHEN name = \'\' OR name IS NULL THEN ? ELSE name END + WHERE id = ? AND person_id = ?') + ->execute([$file, $newName !== '' ? $newName : Nav::domainOf($url), $id, $me]); + } + } + $ok('站点已保存。'); + break; + + case 'site_delete': + Nav::removeSite($me, $gid); + $ok('站点已删除。'); + break; + + case 'site_toggle': + Nav::toggleSite($me, $gid); + $ok('已切换显示。'); + break; + + case 'site_reorder': + // ⚠️ 此处**必须**把「不是数组」当成拒绝,不能静默将就: + // 如果前端漏了 `ids[]` 的方括号(或有人手搓请求), + // `$_POST['ids']` 会是**一个字符串**而不是数组。 + // 直接 `(array)` 强转的话它变成 `['8,9,10']` → applySort 里 + // `(int)'8,9,10'` = 8 → **只改了第一项却回「成功」**。 + // (本轮实测:探针正好踩到这个组合,报了"排序成功但没落库"。) + // 宁可回一句明确的话,也不要"看起来成功"。 + $rawIds = $_POST['ids'] ?? null; + if (!is_array($rawIds)) { + $bad('排序数据格式不对(应是一组 id)。'); + } + $ids = array_map('intval', $rawIds); + $to = (int)($_POST['group_id'] ?? 0); + $n = Nav::applySort('nav_sites', $me, $ids, 'group_id', $to); + $ok('顺序已保存(' . $n . ' 项)。'); + break; + + case 'site_move': + Nav::moveSite($me, $gid, (int)($_POST['group_id'] ?? 0)) + ? $ok('已移动到别的板块。') : $bad('目标板块不存在。'); + break; + + case 'engine_save': + $id = Nav::saveEngine($me, $_POST, $gid); + $id > 0 ? $ok('搜索引擎已保存。') : $bad('名称和搜索地址都不能空。'); + break; + + case 'engine_delete': + Nav::removeEngine($me, $gid); + $ok('搜索引擎已删除。'); + break; + + case 'engine_toggle': + Nav::toggleEngine($me, $gid); + $ok('已切换启用状态。'); + break; + + case 'engine_reorder': + if (!is_array($_POST['ids'] ?? null)) { + $bad('排序数据格式不对(应是一组 id)。'); + } + $ids = array_map('intval', (array)$_POST['ids']); + $n = Nav::applySort('nav_engines', $me, $ids); + $ok('顺序已保存(' . $n . ' 项)。'); + break; + + case 'settings_save': + Nav::setSetting($me, 'site_name', trim((string)($_POST['site_name'] ?? '我的导航'))); + // ⚠️ 副标题**允许为空**,而且不能把空串当"未设置"回退成默认值 —— + // SimpleNav 原文把这一条单列为 bug 现场("删了照样显示")。 + Nav::setSetting($me, 'subtitle', trim((string)($_POST['subtitle'] ?? ''))); + Nav::setSetting($me, 'primary_color', + Nav::safeColor((string)($_POST['primary_color'] ?? ''), '#2F6BF7')); + Nav::setSetting($me, 'default_engine', trim((string)($_POST['default_engine'] ?? 'bing'))); + Nav::setSetting($me, 'open_blank', !empty($_POST['open_blank']) ? '1' : '0'); + $sk = (string)($_POST['suggest_keys'] ?? 'both'); + Nav::setSetting($me, 'suggest_keys', + in_array($sk, ['arrows', 'punct', 'both'], true) ? $sk : 'both'); + Nav::setSetting($me, 'collections_layout', + ((string)($_POST['collections_layout'] ?? 'tabs')) === 'flat' ? 'flat' : 'tabs'); + $ok('设置已保存。'); + break; + + case 'phrase_save': + $r = Nav::setPhrase($me, (string)($_POST['phrase'] ?? '')); + $r['ok'] ? $ok('短语已保存,展示页地址已更新。') : $bad((string)$r['error']); + break; + + case 'phrase_random': + $r = Nav::setPhrase($me, Nav::randomPhrase()); + $r['ok'] ? $ok('已换成随机短语。') : $bad((string)$r['error']); + break; + + case 'fetch_all': + // 「全部更新」:把每条站点的名称与图标重新抓一遍。 + // ⚠️ 跳过 icon_custom=1 的(用户自己传的图标不许被覆盖)—— SimpleNav 的明确语义。 + $n = 0; + $db = Database::get(); + foreach (Nav::sitesByGroup($me) as $sites) { + foreach ($sites as $s) { + $meta = NavFetch::meta((string)$s['url']); + $name = trim((string)($meta['name'] ?? '')); + $sets = []; + $args = []; + // 只补**空**的名字,不覆盖用户已经起好的(含自定义简称) + if ($name !== '' && trim((string)$s['name']) === '') { + $sets[] = 'name = ?'; + $args[] = $name; + } + if (empty($s['icon_custom'])) { + $file = NavFetch::fetchIcon($meta, (string)$s['url']); + if ($file !== '') { + $sets[] = 'icon = ?'; + $args[] = $file; + } + } + if ($sets) { + $args[] = date('Y-m-d H:i:s'); + $args[] = (int)$s['id']; + $args[] = $me; + $db->prepare('UPDATE nav_sites SET ' . implode(', ', $sets) + . ', updated_at = ? WHERE id = ? AND person_id = ?') + ->execute($args); + } + $n++; + } + } + $ok('已更新 ' . $n . ' 条站点的名称与图标。'); + break; + + default: + $bad('未知操作。'); + } + break; + default: http_response_code(404); View::render('error', [ diff --git a/nav_show.php b/nav_show.php new file mode 100644 index 0000000..339ac1f --- /dev/null +++ b/nav_show.php @@ -0,0 +1,281 @@ +/nav/?q=<短语>`(也兼容 `?<短语>`,见下面两种都收) + * · 短语由用户自己起(可一键换随机码),全局唯一,就是这页的"凭据" + * · 右上角恒有一个「登录」入口,点它进家书的登录页 + * · 页面顶部的头像用**他自己**的头像 + * + * ⚠️ 安全上的三条硬约束(这是全站唯一免登录页,必须想清楚): + * ① **只暴露这一个用户自己的导航数据** —— 所有查询都按短语解析出的 person_id 走, + * 不接任何来自 URL 的 id(否则 `?q=xxx&person=2` 就能翻别人家); + * ② **不返回任何身份/家庭信息** —— 只有导航本身 + 头像 + 站点名。 + * 连"这是谁"都不主动说(头像已经够了); + * ③ 短语按固定形式校验(4~32 位字母数字_-),拿不到它就是 404(不提示"短语不存在" + * 之外的信息,避免成为枚举靶子)。 + */ + +declare(strict_types=1); + +require __DIR__ . '/app/bootstrap.php'; + +use App\Nav; + +// ---- 1. 取短语(两种写法都收:?q=xxx 与 ?xxx)---- +// +// ⚠️ 为什么两种都收:用户描述的是「地址后面跟着问号带上这个短语」, +// 也就是 `/nav/?yangyq` 这种**不带参数名**的形式;而现代浏览器/分享卡片 +// 更习惯 `?q=`。两种都认,用户怎么写都能进,省得他记规则。 +$phrase = trim((string)($_GET['q'] ?? '')); +if ($phrase === '' && !empty($_SERVER['QUERY_STRING'])) { + // `?yangyq` → QUERY_STRING 就是 `yangyq`(没有 `=` 号) + $qs = (string)$_SERVER['QUERY_STRING']; + if (strpos($qs, '=') === false) { + $phrase = urldecode($qs); + } +} + +$personId = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase); + +// ---- 2. 找不到就是 404(不给多余信息)---- +if ($personId <= 0) { + http_response_code(404); + header('Content-Type: text/html; charset=utf-8'); + ?> + + + + + +没有这个导航页 + + + + + + + +
  • + + title=""> + + + + + + + +
  • + + + + + + +<?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?> + + + + + + + + +
    + + +
    +
    + + + + +
    + +
    + +
    + + + +

    + +

    + +
    + + + + + + +
      +
    • + +
    + + + $g, 'items' => $items]; } + } + ?> + +
    +
    合集
    + + +
      +
    • + +
    + + +
    + $c): ?> + + +
    +
    + $c): ?> +
      + +
    + +
    + +
    + + + + + + + + +
    + + + + + \ No newline at end of file diff --git a/nav_suggest.php b/nav_suggest.php new file mode 100644 index 0000000..c374d39 --- /dev/null +++ b/nav_suggest.php @@ -0,0 +1,213 @@ +&slug=bing&q=关键词` + * + * ⚠️⚠️ **这个接口也是免登录的**(展示页本身免登录,它的联想接口必须一起免登录, + * 否则展示页的搜索框就没法联想)。因此它必须有自己的门禁 —— 见下面"门禁"那段: + * **必须带一个有效的短语**才放行,否则任何人都能拿它当免费代理刷外网请求。 + */ +declare(strict_types=1); + +require __DIR__ . '/app/bootstrap.php'; + +use App\Nav; +use App\NavFetch; + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); +header('X-Robots-Tag: noindex'); + +// ---- 门禁:必须凭一个有效的短语(见文件头说明)---- +// +// ⚠️ 这**不是**身份校验(短语本来就是公开的),它挡的是"随便谁来都能刷": +// 没有它,这个 URL 就是一个匿名的外网请求放大器(每次 GET 都会去打百度/必应)。 +$phrase = trim((string)($_GET['p'] ?? '')); +$owner = $phrase === '' ? 0 : Nav::ownerByPhrase($phrase); +if ($owner <= 0) { + http_response_code(403); + echo json_encode(['q' => '', 'items' => [], 'src' => '', 'error' => 'forbidden'], + JSON_UNESCAPED_UNICODE); + exit; +} + +$slug = isset($_GET['slug']) ? (string)preg_replace('/[^a-z0-9\-_]/i', '', (string)$_GET['slug']) : ''; +$q = isset($_GET['q']) ? trim((string)$_GET['q']) : ''; +// 联想词是给人看着点的短词,限长免得被拿去当搜索接口刷 +$q = mb_substr($q, 0, 60, 'UTF-8'); + +if ($q === '') { + echo json_encode(['q' => '', 'items' => [], 'src' => ''], JSON_UNESCAPED_UNICODE); + exit; +} + +// ---- 取这个人的引擎表(联想来源也按**他的**引擎配置走)---- +$engines = Nav::engines($owner); + +/** 按 slug 找他的某个引擎 */ +$engineBy = static function (string $s) use ($engines): ?array { + if ($s === '') { + return null; + } + foreach ($engines as $e) { + if ((string)$e['slug'] === $s) { + return $e; + } + } + return null; +}; + +/** + * 排出「先试谁、再试谁」的候选顺序 —— 照搬 SimpleNav 的 suggest_order()。 + * + * 兜底链固定「百度 → 必应 → 360」(实测可用的三家,按快慢排序), + * 只保留库里真实存在、且配了 suggest_url 的引擎。 + * + * ⚠️ SimpleNav 那个 setting('suggest_source') 开关这里**没搬** —— + * 它依赖项目级设置,而本模块是**每人一份**的;为了不给用户多一个看不懂的开关, + * 直接用最有用的那一种(即 auto 的兜底链行为)。这是本批唯一有意"少搬"的一处, + * 在此记明,免得下一个人以为是漏了。 + */ +$order = []; +if ($slug !== '') { + $own = $engineBy($slug); + if ($own !== null && (string)$own['suggest_url'] !== '') { + $order[] = $own; + } +} +foreach (['baidu', 'bing', 'so360'] as $s) { + $row = $engineBy($s); + if ($row !== null && (string)$row['suggest_url'] !== '') { + $order[] = $row; + } +} + +$items = []; +$used = ''; +foreach ($order as $en) { + $items = nav_fetch_suggest($en, $q); + if ($items) { + $used = (string)$en['slug']; + break; + } +} + +$items = array_values(array_unique(array_filter(array_map('trim', $items)))); +$items = array_slice($items, 0, 10); + +echo json_encode(['q' => $q, 'items' => $items, 'src' => $used], JSON_UNESCAPED_UNICODE); +exit; + +/* ========================================================================== */ + +/** 抓一次联想并解析;任何异常都退化成空数组,绝不让下拉框卡住(照搬原函数) */ +function nav_fetch_suggest(array $engine, string $q): array +{ + if ((string)($engine['suggest_url'] ?? '') === '') { + return []; + } + $u = str_replace('{q}', rawurlencode($q), (string)$engine['suggest_url']); + // 3 秒。既然有兜底链,宁快速失败也不要让输入框等太久。 + $resp = NavFetch::get($u, 100000, 3); + if ($resp === null) { + return []; + } + return nav_parse_suggest((string)$engine['suggest_type'], (string)$resp['body'], $q); +} + +/** 按引擎类型解析联想结果(照搬原函数,逻辑一行未改) */ +function nav_parse_suggest(string $type, string $body, string $q): array +{ + $body = trim($body); + $out = []; + unset($q); + + switch ($type) { + case 'bing_osjson': + case 'google_firefox': + // ["query", ["s1","s2",...]] + $j = json_decode($body, true); + if (is_array($j) && isset($j[1]) && is_array($j[1])) { + $out = $j[1]; + } + break; + + case 'baidu_sugrec': + // {"q":"x","g":[{"type":"..","q":".."}], ...} + $j = json_decode($body, true); + if (is_array($j)) { + if (!empty($j['g']) && is_array($j['g'])) { + foreach ($j['g'] as $it) { + if (isset($it['q'])) { $out[] = $it['q']; } + } + } elseif (!empty($j['s']) && is_array($j['s'])) { + $out = $j['s']; + } + } + break; + + case 'so360': + // {"s":[".."]} 或 {"result":[{"word":".."}]} + $j = json_decode($body, true); + if (is_array($j)) { + if (!empty($j['s']) && is_array($j['s'])) { + $out = $j['s']; + } elseif (!empty($j['result']) && is_array($j['result'])) { + foreach ($j['result'] as $it) { + if (isset($it['word'])) { $out[] = $it['word']; } + } + } + } + break; + + case 'sogou_ajaj': + // {"query":"x","items":["a","b"]}(可能 GBK,已被 NavFetch 转码) + $j = json_decode($body, true); + if (is_array($j) && !empty($j['items']) && is_array($j['items'])) { + $out = $j['items']; + } elseif (preg_match_all('#"([^"]{1,40})"#u', $body, $m)) { + $out = array_slice($m[1], 1); // 跳过 query 自身 + } + break; + + case 'ddg': + // [{"phrase":".."},...] + $j = json_decode($body, true); + if (is_array($j)) { + foreach ($j as $it) { + if (is_array($it) && isset($it['phrase'])) { + $out[] = $it['phrase']; + } elseif (is_string($it)) { + $out[] = $it; + } + } + } + break; + + default: + // 通用兜底:先试 JSON,再退化正则(照搬) + $j = json_decode($body, true); + if (is_array($j)) { + if (isset($j[1]) && is_array($j[1])) { + $out = $j[1]; + } elseif (!empty($j['s']) && is_array($j['s'])) { + $out = $j['s']; + } + } + } + return is_array($out) ? $out : []; +} \ No newline at end of file diff --git a/router.php b/router.php index 20eac26..edcde68 100644 --- a/router.php +++ b/router.php @@ -30,6 +30,21 @@ if (preg_match('#^/carddav(/.*)?$#', $path)) { return true; } +// 导航**展示页**(v24 第 24 批 用户第 4 条):`/nav/?<短语>` —— 全站唯一免登录页面。 +// ⚠️ 三条顺序**不能换**: +// ① `/nav/suggest.php` 是联想接口(免登录,但凭短语门禁放行); +// ② `/nav/` 恰好是展示页; +// ③ 两者都必须排在下面"真实文件"判断**之前** —— 否则 `/nav/` 会去找一个 +// 不存在的目录 → 404(与上面 carddav 那条同一个坑)。 +if ($path === '/nav/suggest.php') { + require __DIR__ . '/nav_suggest.php'; + return true; +} +if (preg_match('#^/nav/?$#', $path)) { + require __DIR__ . '/nav_show.php'; + return true; +} + // 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理 $file = __DIR__ . $path; if ($path !== '/' && is_file($file)) { diff --git a/templates/_person_picker.php b/templates/_person_picker.php index 6e460d8..b260def 100644 --- a/templates/_person_picker.php +++ b/templates/_person_picker.php @@ -144,6 +144,23 @@ $ppHasSel = count(array_filter($ppSelected)) > 0; + + _ver` / `_nickname` 两个"旁路字段", + 以为 chips 会把它们一起送上去 —— 那是**猜的**,实际 chips 只渲染 + ``,**一个 input 都不产生**。 + 已核实后才写成现在这样:按 $ppName 原样把已选 id 各写一个 hidden。 + (教训:写代码前先把"实际的渲染产物"读出来,别照着自己脑补的结构写。) */ ?> + + + 0): ?> + + + + + + diff --git a/templates/books/_settings_menu.php b/templates/books/_settings_menu.php index ed2c314..4fd5242 100644 --- a/templates/books/_settings_menu.php +++ b/templates/books/_settings_menu.php @@ -21,6 +21,9 @@ $bkSetIcon = static function (string $name): string { 'stats' => '', // 导入笔记:一支笔写在纸上 'import' => '', + // 微信读书授权(v24 第 24 批):钥匙 + 'weread' => '' + . '', ]; $d = $paths[$name] ?? ''; if ($d === '') { @@ -36,6 +39,8 @@ $bkSetIcon = static function (string $name): string { +
    +

    微信读书导入

    + 填一次 API Key,之后就能直接把你微信读书里的划线与想法导进来 +
    + +
    +
    + + +
    +

    + 授权 + + 已授权 + + 还没授权 + +

    +

    + 去 微信读书开放平台 + 扫码登录,页面上点「获取 API Key」,会得到一串 wrk- 开头的字符串, + 复制到下面就行。 +

    +

    + 这串 Key 等于你的账号凭据 —— 我们会加密存放,页面上也只显示掩码; + 不想再用时,把它清空保存即可(或去开放平台重置那串 Key)。 +

    + +
    + + +
    + + + + + 当前已保存:。要换就把新的整串贴进来(会覆盖)。 + + 形如 wrk-NVbX…dgAA,只在你自己的账号里可见。 + + +
    +
    + +
    +
    + +

    只读账号不能配置导入。

    + +
    + + + +
    +

    从微信读书取数据

    +
    + + +
    + +
    +

    + 会去微信读书取一次数据(你有笔记的书 + 每本书的划线/想法),可能要十几秒。 +

    +
    +
    + + + +
    +

    + 发现 本有笔记的书 + 勾选要导入的,然后点下面的「导入选中」 +

    + +

    微信读书里还没有带笔记的书。

    + +
    + + +
    + + + +
    +
    + +
    +
    + +
    + + + +
    +

    或者:手动粘贴笔记

    +

    + 不想配 Key 也行 —— 在微信读书 App 里打开一本书 → 右上角「…」→ 导出笔记, + 把复制到的文字粘到那里,效果一样。 +

    + +
    \ No newline at end of file diff --git a/templates/home.php b/templates/home.php index efeaae5..b550158 100644 --- a/templates/home.php +++ b/templates/home.php @@ -1,21 +1,44 @@ \App\Module::exists((string)$k) )); +$todayMarks = $todayMarks ?? ['tags' => [], 'line' => '']; ?> " title="我的家谱" aria-label="我的家谱">
    -
    - - +
    + + +
    + , +
    +
    + + + + + +
    + +
    + + + +
    + +
    + -
    - - - +
    + +
    +
    +
    + +
    + +
    + +
    - +
    -
    - 条日程(近七天) - 件待办 - 件逾期 -
    - - -
    -
    -

    家里的近况

    -
    - 调整显示 + += 0 && $nowPos <= 1; +?> +
    +
    + +
    +
    +

    今天的日程

    + 看整月 +
    +
    + +

    今天没有安排。

    + + + + +
    + +
    + + +
    + + + +
    + +
    + + + + + + + + + + +
    +
    - -
    - - -
    -
    -

    未来七天 日程 · 观影 · 旅行 · 到期任务

    -
    - 看整月 + +
    +
    +

    手头在做

    + 任务看板 +
    +
    + +

    没有进行中的任务。

    + +
      + + +
    • + + + + + + + + + + + + + 0): ?> + / + + + + +
    • + +
    + +
    - -
    这七天还没有安排有事先记一笔,日后再翻才有得看。
    - -
    -
      - -
    • - - - - - - - - -
    • - -
    -
    -
    - -
    -
    -

    手头在做 进行中 · 已逾期

    - -
    - -
    没有进行中的任务也没有逾期的事,挺好。
    - -
    -
      - - -
    • - - - - - - 进行中 - - - - - - - - - - 0): ?> - / - -
    • - -
    -
    - -
    - - +
    @@ -240,4 +322,4 @@ $homeMods = array_values(array_filter(
    -
    +
    \ No newline at end of file diff --git a/templates/ledger/index.php b/templates/ledger/index.php index 3b416e6..054ef8c 100644 --- a/templates/ledger/index.php +++ b/templates/ledger/index.php @@ -602,16 +602,34 @@ $ledMe = (int)($me ?? 0); } // ③ 多线图:按类别显隐线条(几何不动,只切可见性) + // + // 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的): + // 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。 + // `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`); + // `` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性, + // 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。 + // 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」 + // 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**, + // 所以看起来"筛选基本是好的,只是第二张图有点不太对"。 + // ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。 + // 必须同时给 `g` 与 `li`/`tr` 用同一种写法: + // · `` 是 SVG → 只能 display + // · `.ck-lg-item` / `` 是 HTML,两样都行;统一用 display 少一个特例。 + // ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` —— + // 给 `` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。 Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) { - g.hidden = kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind; + g.style.display = (kind !== '' && (g.getAttribute('data-ck-line') || '') !== kind) + ? 'none' : ''; }); Array.prototype.forEach.call(document.querySelectorAll('.ck-lg-item'), function (li) { - li.hidden = kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind; + li.style.display = (kind !== '' && (li.getAttribute('data-ck-kind') || '') !== kind) + ? 'none' : ''; }); // ④ 账目表 Array.prototype.forEach.call(document.querySelectorAll('[data-ck-row]'), function (tr) { - tr.hidden = kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind; + tr.style.display = (kind !== '' && (tr.getAttribute('data-ck-row') || '') !== kind) + ? 'none' : ''; }); // ⑤ 概览卡里的高亮条 diff --git a/templates/nav/index.php b/templates/nav/index.php new file mode 100644 index 0000000..a8089ca --- /dev/null +++ b/templates/nav/index.php @@ -0,0 +1,545 @@ + 站点列表 + * @param array $engines 我的搜索引擎 + * @param array $navSet 我的导航设置 + * @param string $phrase 我的短语(空 = 还没设,展示页不可用) + * @param string $showUrl 展示页完整地址(短语为空时是空串) + * @param string $meAvatar 我的头像地址 + */ +$canWrite = \App\Auth::canWrite(); +$editSite = null; +$editGroup = null; +$editEngine = null; +// 点「编辑」时用 ?edit_site=12 / edit_group=3 / edit_engine=5 带出既有值 +$eSite = (int)($_GET['edit_site'] ?? 0); +$eGrp = (int)($_GET['edit_group'] ?? 0); +$eEng = (int)($_GET['edit_engine'] ?? 0); +foreach ($sitesBy as $gid => $rows) { + foreach ($rows as $r) { + if ((int)$r['id'] === $eSite) { $editSite = $r; } + } +} +foreach ($allGroups as $g) { + if ((int)$g['id'] === $eGrp) { $editGroup = $g; } +} +foreach ($engines as $e) { + if ((int)$e['id'] === $eEng) { $editEngine = $e; } +} +$sc = static fn($k, $d = '') => (string)($navSet[$k] ?? $d); +?> +
    +

    导航

    + 你个人用的网址导航 · 每人一份 +
    + 刷新 +
    +
    + + + + + +
    +

    导航设置

    +
    + + +
    +
    + + + 展示页最上方的大标题。 +
    +
    + + + + 留空就不显示(不会自动填回默认值)。 +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + 在展示页的搜索框里,用哪组键选中联想词。 +
    +
    + + +
    +
    +
    + + + + +
    +
    +
    + + + +
    +

    +
    + + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + + +
    +
    +
    + + + 取消 + +
    +
    +
    + + +
    +

    + 站点列表 + 按住左边的 ⋮⋮ 可以拖动排序 +

    + +

    还没有站点。上面「新增站点」加一个吧。

    + + + + + + +
    + + +
    +

    +
    + + + + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + + 取消 + +
    +
    +
    + +
    +

    板块列表 拖动排序

    + +
    + + +
    +

    +
    + + + + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + + 百度是 wd,搜狗是 query,多数是 q。 +
    +
    + + + 用 {q} 占位关键词;留空 = 这个引擎没有联想。 +
    +
    + + +
    +
    + + +
    +
    +
    + + + 取消 + +
    +
    +
    + +
    +

    搜索引擎列表

    + +
    + + + \ No newline at end of file