v24 第 24 批:选人冲掉已有成员 / 首页改版 / 个人导航 / 微信读书 / 财务图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

════ 八、 新增/改动文件 ════
新增:`app/Nav.php` · `app/NavFetch.php` · `app/WeRead.php` · `nav_show.php` ·
`nav_suggest.php` · `templates/nav/index.php` · `templates/books/weread.php` ·
`assets/css/nav-show.css` · `assets/js/nav-show.js`
改动:`app/Schema.php`(v24 + 4 张 nav 表)· `app/Module.php`(+nav)· `app/Home.php` ·
`index.php` · `templates/_person_picker.php` · `assets/js/person_picker.js` ·
`templates/home.php` · `templates/ledger/index.php` · `templates/books/_settings_menu.php` ·
`assets/css/app.css` · `router.php` · `.htaccess`
This commit is contained in:
2026-10-07 05:52:40 +08:00
parent ae3060f552
commit 09e30d27d4
21 changed files with 5034 additions and 180 deletions
+8
View File
@@ -27,6 +27,14 @@
# 不会互相抢;写成 `^dav` 在前是因为历史顺序,真要调也不能写成 `^dav(/|$)` 之外的 # 不会互相抢;写成 `^dav` 在前是因为历史顺序,真要调也不能写成 `^dav(/|$)` 之外的
# 宽松形式 —— 那会把 /dav2/ 之类也吞进来。 # 宽松形式 —— 那会把 /dav2/ 之类也吞进来。
RewriteRule ^carddav(/.*)?$ carddav.php [L,QSA] 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]
</IfModule> </IfModule>
# ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作: # ⚠️⚠️ DAV 的 Basic 鉴权**必须**靠这一行才能在 Apache 上工作:
+158 -18
View File
@@ -76,6 +76,99 @@ final class Home
return '星期' . $w; return '星期' . $w;
} }
/**
* 「今天是个什么日子」—— 首页信息面板要的那一串标记(v24 第 24 批 用户第 2 条)。
*
* 用户原话:「如果有节气的话,或者有重要的节日的话,在这里显示。
* 比如说今天是这个国庆节啊,今天是端午节,或者是今天是中秋节,
* 或者是今天是芒种,今天是大寒,今天是小寒,今天是什么纪念日,
* 今天是谁的生日等等。显示在这里。」
*
* 一次把五类来源合成**一行可读的短语**:
* ① 法定节假日(Holiday::range —— 含"补班",那也要说,否则用户以为白放假)
* ② 二十四节气(Holiday::solarTermRange)
* ③ 传统节日(Holiday::traditionalRange —— 春节/中秋/端午…)
* ④ 纪念日(Anniversary::all —— 含自建与派生)
* ⑤ 生日(同上,Anniversary 已经把生日一起派生出来了)
*
* ⚠️ 为什么合成"一条字符串"而不是给模板五个数组:
* 面板上这是**一句话的位置**("今天是……"),模板里再拼一遍会把这五类的
* 优先级、去重、连接符全散到视图层 —— 同一件事写两处,下次加一类必漏一处。
* ("生日"与"纪念日"天然会重复:生日本身就是一条派生的纪念日,
* 所以下面按**名字去重**。)
*
* @param int $viewerId 谁在看(纪念日的称呼按他算,如"儿子"而非"杨小全")
* @return array{tags:array<int,array{word:string,kind:string}>, line:string}
* tags 给模板做彩色小标签,line 给纯文本兜底
*/
public static function todayMarks(int $familyId, int $viewerId, string $date): array
{
$tags = [];
$seen = [];
$add = static function (string $word, string $kind) use (&$tags, &$seen): void {
$word = trim($word);
// 同一件事别出现两次(生日既是"生日"也是一条派生纪念日)
if ($word === '' || isset($seen[$word])) {
return;
}
$seen[$word] = true;
$tags[] = ['word' => $word, 'kind' => $kind];
};
$y = (int)substr($date, 0, 4);
// ① 法定节假日(只认"今天在放假区间里";补班也报,但换一种措辞)
foreach (\App\Holiday::range($y, $date, $date) as $h) {
$nm = (string)($h['name'] ?? '');
if ($nm === '') {
continue;
}
$add(!empty($h['off']) ? $nm : ($nm . '(补班)'), 'holiday');
}
// ② 二十四节气
foreach (\App\Holiday::solarTermRange($date, $date) as $s) {
$add((string)($s['name'] ?? ''), 'term');
}
// ③ 中国传统节日
foreach (\App\Holiday::traditionalRange($date, $date) as $t) {
$add((string)($t['name'] ?? ''), 'festival');
}
// ④⑤ 纪念日 + 生日(Anniversary::all 已把生日派生进来)
foreach (\App\Anniversary::all($familyId, $viewerId) as $a) {
$repeat = (string)($a['repeat_type'] ?? 'solar');
$m = (int)($a['month'] ?? 0);
$d = (int)($a['day'] ?? 0);
if ($m <= 0 || $d <= 0) {
continue;
}
$isToday = false;
if ($repeat === 'lunar') {
// 农历:把"今天的农历月日"拿来比(闰月单列)
$l = \App\Lunar::solarToLunar($y, (int)substr($date, 5, 2), (int)substr($date, 8, 2));
$isToday = $l !== null
&& (int)$l['month'] === $m && (int)$l['day'] === $d
&& (int)($l['is_leap'] ? 1 : 0) === (int)($a['is_leap'] ?? 0);
} elseif ($repeat === 'once') {
// 一次性:年月日全对上才算(这种不是"每年重复")
$born = (string)($a['born'] ?? '');
$isToday = $born !== '' && substr($born, 0, 10) === $date;
} else {
$isToday = $m === (int)substr($date, 5, 2) && $d === (int)substr($date, 8, 2);
}
if (!$isToday) {
continue;
}
$title = (string)($a['title'] ?? '');
$add($title, (string)($a['kind'] ?? '') === '生日' ? 'birthday' : 'anniv');
}
$words = array_map(static fn(array $t): string => $t['word'], $tags);
return [
'tags' => $tags,
'line' => $words ? implode(' · ', $words) : '',
];
}
/** /**
* 我在哪儿(v24 用户 2026-10-03 第 1 条)。 * 我在哪儿(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) . '?latitude=' . urlencode($lat) . '&longitude=' . urlencode($lon)
. '&current=temperature_2m,weather_code&timezone=Asia%2FShanghai'; . '&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m,is_day'
. '&daily=temperature_2m_max,temperature_2m_min&forecast_days=1'
. '&timezone=Asia%2FShanghai';
$body = self::httpGet($url); $body = self::httpGet($url);
if ($body === null) { if ($body === null) {
return null; return null;
@@ -175,9 +278,19 @@ final class Home
if (!is_array($j) || !isset($j['current'])) { if (!is_array($j) || !isset($j['current'])) {
return null; return null;
} }
// v24 第 24 批 用户第 2 条:面板上要显示**温度、风力**(用户原话)。
// 风向、昼夜、今日高低温都顺手取回来 —— 它们只在 current/daily 里各加一个字段,
// 不多打一次接口;以后要在面板上加"最高 20° 最低 12°"直接就有。
$data = [ $data = [
'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)), 'temp' => (string)round((float)($j['current']['temperature_2m'] ?? 0)),
'code' => (int)($j['current']['weather_code'] ?? -1), '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 = 全站级) // 缓存下来(存在 settings,key 里带 family 0 = 全站级)
Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE)); Setting::set($familyId, $cacheKey, json_encode(['at' => time(), 'data' => $data], JSON_UNESCAPED_UNICODE));
@@ -190,53 +303,80 @@ final class Home
if (!isset($data['temp'])) { if (!isset($data['temp'])) {
return null; 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 [ return [
'temp' => (string)$data['temp'] . '°C', 'temp' => (string)$data['temp'] . '°C',
'text' => $text, 'text' => $text,
'icon' => $icon, '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), 'place' => trim($place),
]; ];
} }
/** 风向角度 → 八向中文(数据缺失时调用方不会调它) */
private static function windDir(int $deg): string
{
$dirs = ['北', '东北', '东', '东南', '南', '西南', '西', '西北'];
$i = (int)round((($deg % 360) + 360) % 360 / 45) % 8;
return $dirs[$i] . '风';
}
/** /**
* WMO 天气代码 → [中文描述, 一个符号]。 * WMO 天气代码 → [中文描述, 一个符号, 配色档]。
* 符号用**普通字符**(不是 emoji):emoji 在部分旧系统上会变豆腐块, *
* 而这是每天都要看的一块,宁可用朴素符号也不要出现方框。 * ⚠️ 符号用**普通字符**(不是 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) { if ($c === 0) {
return ['晴', '☀']; return $day === 1 ? ['晴', '☀', 'sunny'] : ['晴', '☾', 'sunny'];
} }
if (in_array($c, [1, 2], true)) { if (in_array($c, [1, 2], true)) {
return ['多云', '⛅']; return ['多云', '⛅', 'cloud'];
} }
if ($c === 3) { if ($c === 3) {
return ['阴', '☁']; return ['阴', '☁', 'cloud'];
} }
if (in_array($c, [45, 48], true)) { if (in_array($c, [45, 48], true)) {
return ['有雾', '≋']; return ['有雾', '≋', 'fog'];
} }
if ($c >= 51 && $c <= 57) { if ($c >= 51 && $c <= 57) {
return ['毛毛雨', '☂']; return ['毛毛雨', '☂', 'rain'];
} }
if ($c >= 61 && $c <= 67) { if ($c >= 61 && $c <= 67) {
return ['下雨', '☂']; return ['下雨', '☂', 'rain'];
} }
if ($c >= 71 && $c <= 77) { if ($c >= 71 && $c <= 77) {
return ['下雪', '❄']; return ['下雪', '❄', 'snow'];
} }
if ($c >= 80 && $c <= 82) { if ($c >= 80 && $c <= 82) {
return ['阵雨', '☂']; return ['阵雨', '☂', 'rain'];
} }
if ($c >= 85 && $c <= 86) { if ($c >= 85 && $c <= 86) {
return ['阵雪', '❄']; return ['阵雪', '❄', 'snow'];
} }
if ($c >= 95) { if ($c >= 95) {
return ['雷雨', '⚡']; return ['雷雨', '⚡', 'storm'];
} }
return ['天气', '·']; return ['天气', '·', 'cloud'];
} }
/** 极简 GET(3 秒超时;失败返回 null,绝不抛) */ /** 极简 GET(3 秒超时;失败返回 null,绝不抛) */
+5 -1
View File
@@ -47,6 +47,10 @@ final class Module
'ledger' => ['财务', '#5E7A3C', 'ledger'], 'ledger' => ['财务', '#5E7A3C', 'ledger'],
// v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计) // v24 第 14 批:读书(书架 + 上传解析 + 在线读 + 笔记 + 统计)
'books' => ['读书', '#6B5B95', 'books'], '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', 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'; private const NAV_KIN_OFF_FLAG = 'nav_kin_off_v24';
+734
View File
@@ -0,0 +1,734 @@
<?php
/**
* 个人导航(v24 第 24 批 用户第 4 条)—— 从用户自己的 SimpleNav 项目**完整搬运**。
*
* 用户原话:
* 「你去读一下个人导航,就是我之前有一个个人导航的项目,把这个项目完整的搬过来
* 放在导航条里,就是说增加一下个人导航的这个模块就放在读书的后面,每个用户
* 都可以添加自己的导航,所有的功能模块就完全照搬原来的导航的那个系统里面的功能。
* 但是这里面有一个问题需要注意现在的系统,每一个功能模块都是需要登录的。
* 但是导航展示页面却不需要登录。这就意味着我们需要给他一个非常特殊的地址。
* 地址后面可以跟着用户添加的一个短语,就跟类似于一个密钥一样,他可以给自己
* 起一个短语。然后这个地址后面跟着问号带上这个短语的形式去进行展示。这样的话
* 我们是为了区分不同的用户,他的不同的导航页面,用户选了一个短语之后,通过
* 这个短语,我们就能知道要访问的是哪一个用户的面板,这个页面是不需要登录的。
* 在这个页面里面要增加一个功能,右上角增加一个功能,就是登录点击这个页面之后
* 再进入到我们现在这个系统就是家书的这个系统的登录页面。否则的话,那个导航的
* 展示的页面是跟之前的一样,是不需要进行登录的。这样的话,导航的展示页面,
* 导航页的展示页面上面的那个头像就变成用户自己的头像就可以了。」
*
* ────────────────────────────────────────────────────────────────
* **搬运时改了什么、没改什么**(这是唯一需要判断的地方)
*
* 没改(照搬):板块/站点/引擎/设置四张表的字段语义、稀疏排序 SORT_STEP、
* 拖拽整组归一化、图标抓取与 GD 生成站点图标、联想词接口、打开方式、
* 合集 tabs/flat 两种布局 —— 用户要的是「完全照搬」。
*
* 改了两处(不改会坏):
* ① **存储**:原项目是独立 SQLite(data/nav.db)+ 自己的 users 表。
* 搬进来改成家书的 MySQL(`nav_*` 表)+ **家书的 person_id**。
* ② **归属维度**:原项目只有一份全局数据(单用户系统);
* 现在是「每个用户都可以添加自己的导航」→ 所有查询都带上 `person_id`。
* ⚠️ 这正是"照搬"里唯一不能照抄的部分:漏掉这一维,全家人会共用同一份导航,
* 而且一个人加一条链接,全家人的导航页里都会冒出来。
* ③ 原来的 `url()` / `asset()` / `e()` 等与家书重名,**一律改用家书自己的**
* (家书已有 `e()` / `asset()` / `url()`),不再搬一份进来 ——
* 两份同名函数会直接 Fatal(函数重定义)。
*/
declare(strict_types=1);
namespace App;
final class Nav
{
/** 排序步长(与 SimpleNav 一致) */
public const SORT_STEP = 1000;
/** 图标目录(相对站点根,Web 可访问;与 SimpleNav 的 uploads/icons 同构) */
public const ICON_DIR = 'uploads/nav-icons';
public const ICON_URL = 'uploads/nav-icons';
/**
* 导航短语的合法形式:4~32 位,字母/数字/下划线/连字符。
*
* ⚠️ 为什么限这么死:这个短语是**免登录地址**上的唯一凭据
* (`/nav/?yangyq`)。允许中文/符号会让复制粘贴时出现"看起来一样但实际不同"
* 的重码(中文空格、全角下标…),而这种错误排查起来极烦。
* 长度下限 4 是因为 1~3 位太容易被穷举(这页面是公开的)。
*/
public const PHRASE_RE = '/^[A-Za-z0-9_-]{4,32}$/';
/** 保留短语:不能与真实路由/常见词撞(否则地址会有歧义) */
public const PHRASE_RESERVED = [
'nav', 'login', 'logout', 'index', 'admin', 'api', 'static', 'assets',
'help', 'about', 'null', 'undefined', 'true', 'false', 'test',
];
// ================================================================
// 设置(原 SimpleNav 的 settings 表 → 这里按人一份)
// ================================================================
/** 设置的默认值(照搬 SimpleNav 的 seed_settings) */
public const SETTING_DEFAULTS = [
'site_name' => '我的导航',
'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],
];
}
+894
View File
@@ -0,0 +1,894 @@
<?php
/**
* 导航的「网站元信息与图标抓取」(v24 第 24 批 用户第 4 条)——
* **从用户自己的 SimpleNav 项目整段搬运**,逻辑一行未改,只做了三处机械改写:
*
* ① 自由函数 → `final class NavFetch` 的私有静态方法(家书是类的风格);
* ② 常量 `ICON_PATH` / `ICON_URL` → `Nav::ICON_DIR` / `Nav::ICON_URL`;
* ③ 与家书**重名**的几个(`e` / `norm_url` / `domain_of` / `letter_color`)改调
* `Nav::` 上的同名方法 —— 不搬一份进来,否则函数重定义直接 Fatal。
*
* ⚠️ 原文里那些"线上实测"的注释**全部保留**(例如"哪几个第三方图标服务可达、
* 哪几个不可达")。它们是这个子系统里最贵的部分 —— 重踩一遍要花一整轮。
*
* ⚠️ 这一批方法是**要联网的**(每个站点一次 HTTP + 可能多次图标下载)。
* 所以只在用户**主动点「抓取」/「全部更新」**时调,页面渲染绝不调。
*/
declare(strict_types=1);
namespace App;
final class NavFetch
{
/** 第三方图标聚合服务开关(原项目是后台设置 icon_services) */
private static bool $iconServices = true;
public static function setIconServices(bool $on): void
{
self::$iconServices = $on;
}
private static function iconServices(): string
{
return self::$iconServices ? '1' : '0';
}
/** HTTP 抓取(带 UA / 超时 / 跟随跳转)—— 供外部复用 */
public static function get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
return self::http_get($url, $maxBytes, $timeout);
}
/**
* 抓一个站点的元信息(标题 / 名称 + 图标候选)—— 对外的唯一入口。
*
* 搬进来的原函数叫 `fetch_site_meta()`,这里只是换一个符合家书命名风格的
* public 名字,逻辑一行没动。
*
* @return array{title:string,name:string,icon:string,icons:string[],final_url:string}
*/
public static function meta(string $url): array
{
return self::fetch_site_meta($url);
}
/**
* 抓取并**落盘**一个站点的图标,返回文件名(失败返回 '')。
*
* 这是 SimpleNav「抓图标」那段业务逻辑的落点:按清晰度依次尝试候选
* (manifest 大图 → apple-touch → link[rel=icon] → 第三方聚合 → /favicon.ico),
* 内部就是 `download_best_icon()` —— 逐个下载、挑最清晰的一张、存成统一尺寸。
*
* @param array $meta self::meta() 的返回值
* @param string $url 站点地址(文件名 = md5(url),与 SimpleNav 同规则)
* @param bool $allowThirdParty 允许用第三方图标聚合服务
* (会让这一步多发几次外网请求;哪些可达见
* third_party_icon_urls 里那批实测注释)
*/
public static function fetchIcon(array $meta, string $url, bool $allowThirdParty = true): string
{
self::$iconServices = $allowThirdParty;
$key = self::icon_key($url);
if ($key === '') {
return '';
}
$dir = FB_ROOT . '/' . Nav::ICON_DIR;
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::download_best_icon((array)($meta['icons'] ?? []), $key);
}
/**
* 用户自传的图标二进制落盘,返回文件名。
*
* ⚠️ 落盘后调用方**必须**把 `nav_sites.icon_custom` 置 1 ——
* 否则下一次「全部更新」会把用户精心挑的图标冲掉(SimpleNav 原文明确要求)。
*/
public static function saveUploaded(string $binary, string $url): string
{
return self::save_custom_icon($binary, self::icon_key($url));
}
/** curl 抓取(带 UA、超时、跟随跳转) */
private static function http_get(string $url, int $maxBytes = 300000, int $timeout = 8): ?array
{
if (!function_exists('curl_init')) {
// 兜底:allow_url_fopen
$ctx = stream_context_create(['http' => [
'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('#<title[^>]*>(.*?)</title>#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. <link rel="icon">,按 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,偶有超时
];
}
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
/** 取 <meta property|name|itemprop="key" content="..."> 的值(属性顺序无关) */
private static function meta_content(string $html, string $key): string
{
if (!preg_match_all('#<meta\b[^>]*>#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 '';
}
/**
* 解析页面里的 <link> 图标声明。
* @return array{0:string[],1:string} [候选 URL(高优先级在前), manifest URL]
*/
/**
* 解析页面里的 <link> 图标声明。
* @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('#<link\b[^>]*>#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, '<svg') !== false
|| strpos($head, '<?xml') === 0;
if ($isSvg) {
if (strlen($bin) > 512 * 1024
|| preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $bin)) {
@unlink($tmp);
return null;
}
if (@file_put_contents($tmp, $bin) === false) {
@unlink($tmp);
return null;
}
return ['tmp' => $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)), '<svg') !== false) {
if (preg_match('#<script|<foreignObject|onload\s*=|javascript:#i', $binary)) {
return '';
}
$md5 = md5($key);
$file = $md5 . '.svg';
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.png');
@file_put_contents(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, $binary);
return is_file(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file) ? $file : '';
}
if (!function_exists('imagecreatefromstring')) {
return '';
}
$im = @imagecreatefromstring($binary);
if ($im === false) {
return '';
}
$w = imagesx($im);
$h = imagesy($im);
if ($w < 8 || $h < 8) {
imagedestroy($im);
return '';
}
$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);
$md5 = md5($key);
$file = $md5 . '.png';
$ok = imagepng($canvas, FB_ROOT . "/" . Nav::ICON_DIR . '/' . $file, 6);
imagedestroy($canvas);
imagedestroy($im);
if ($ok === false) {
return '';
}
@unlink(FB_ROOT . "/" . Nav::ICON_DIR . '/' . $md5 . '.svg');
return $file;
}
/* =========================================================================
* 站点整体图标(浏览器标签页 favicon + 首页品牌图标)
*
* 用户上传一张图 → 服务端生成**一整套**尺寸,前台各处的写法都不变:
* 静态优先(有就直出文件,兼容「部署目录只读」的保守环境)
* → 没有静态再回退到动态输出(新上传立刻生效,无需给站点目录写权限)
* 「一整套」是必须的:标签页 favicon 与「添加到手机主屏」的 PWA 图标读的是不同文件,
* 只换其中一个 = 半生效(用户会看到标签页新图、主屏图标还是旧的)。
* ========================================================================= */
/** 自定义站点图标的存在性(唯一事实来源:上传成功后写的标记文件) */
/** 载入任意位图(png/jpg/gif/webp…)为 GD 资源,失败返回 null。
* ⚠️ 不要写 `?object` 返回类型:PHP 7.4 的 GD 返回的是 **resource**,加类型会直接 TypeError。 */
private static function gd_load(string $binary)
{
if (!function_exists('imagecreatefromstring')) {
return null;
}
$im = @imagecreatefromstring($binary);
return $im === false ? null : $im;
}
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
/** 把一张已载入的图居中裁剪成正方形并缩到 $size,返回 PNG 二进制(失败返回 '') */
private static function gd_square_png($im, int $size): string
{
$w = imagesx($im);
$h = imagesy($im);
$side = min($w, $h);
$ox = (int)(($w - $side) / 2);
$oy = (int)(($h - $side) / 2);
$canvas = imagecreatetruecolor($size, $size);
imagealphablending($canvas, false);
imagesavealpha($canvas, true);
imagefill($canvas, 0, 0, imagecolorallocatealpha($canvas, 0, 0, 0, 127));
imagecopyresampled($canvas, $im, 0, 0, $ox, $oy, $size, $size, $side, $side);
imagealphablending($canvas, true);
ob_start();
$ok = imagepng($canvas, null, 9);
$png = (string)ob_get_clean();
imagedestroy($canvas);
return ($ok === false || $png === '') ? '' : $png;
}
/**
* 把任意图**居中裁剪成正方形**并生成整套尺寸。
*
* 为什么是「裁剪」而不是「留白」:站点图标是正方形用途(favicon / PWA 图标),
* 直接拉伸会把 logo 拉变形,留白则四周一圈透明、缩小后主体看不清。
*
* @return array<string,string> 文件基名 => 二进制(基名已含扩展名,如 '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('#<meta[^>]+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;
}
/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
}
+76 -1
View File
@@ -104,7 +104,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。 * ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 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_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 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='行程花销(一笔一行;旅程总花销 = 各行程之和,读时算)' ) 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, SQL,
]; ];
} }
+412
View File
@@ -0,0 +1,412 @@
<?php
/**
* 微信读书笔记导入(v24 第 24 批 用户第 5 条)。
*
* 用户原话:
* 「读书这个模块应该加上读书笔记的功能。另外你看一下能不能导入微信的读书笔记,
* 就是每一个人设置一下,比如说扫一下微信的这个码呀,或者什么之类的授权一下。
* 这样的话,每个人都可以把自己的微信读书的笔记给导入进来,放在我们这个读书的
* 模块里面,看看以什么样的形式去进行展示,比较合适。」
*
* ────────────────────────────────────────────────────────────────
* 用户后续补充「用微信读书 api key」—— 这个方向是对的,而且**2026 年官方已经开了**:
*
* 官方开放平台(微信读书 Skill):
* · 网关:`POST https://i.weread.qq.com/api/agent/gateway`
* · 认证:`Authorization: Bearer wrk-xxxxxxxx`(**API Key 绑定账号身份**)
* · Key 获取:https://weread.qq.com/r/weread-skills(扫码登录后一键生成)
* · 请求体:`{api_name, ...业务参数平铺..., skill_version}`(**不能包在 params 里**)
*
* 本类用到的三个接口(都来自官方 `notes.md`):
* · `/user/notebooks` —— 有笔记的书清单(游标分页,用 lastSort 翻页)
* · `/book/bookmarklist` —— 单本书的**划线**(markText + chapterUid + createTime)
* · `/review/list/mine` —— 单本书的**想法/点评**(content + abstract 原文 + chapterName)
* · `/book/chapterinfo` —— 章节表(把 chapterUid 换成章节标题)
*
* ⚠️ API Key **等价于账号凭据**(官方原话:绑定用户身份,泄露等于账号泄露)。
* 所以:
* ① 一律 **Crypto::encrypt 加密后落库**,明文只在那一次表单提交里存在;
* ② 界面上只回显**掩码**(wrk-****abcd),绝不回显完整 Key;
* ③ 与 DAV 口令同一套思路(那个也是"长期凭据",也是加密/只显示一次)。
*
* ⚠️ 这个类**只负责取数据**,不碰数据库 —— 落库交给 BookNoteImport(已有)。
* 这样"网络"与"存储"分开,测试可以只测解析、不必连外网。
*/
declare(strict_types=1);
namespace App;
final class WeRead
{
/** 官方 Agent 网关(见文件头) */
public const GATEWAY = 'https://i.weread.qq.com/api/agent/gateway';
/** 官方 Skill 版本号(每次请求必须带;对不上官方可能拒) */
public const SKILL_VERSION = '1.0.4';
/** Key 的形式:wrk- 开头 */
public const KEY_RE = '/^wrk-[A-Za-z0-9_-]{8,80}$/';
/** 用户设置里存加密 Key 的键名 */
public const SETTING_KEY = 'weread_api_key';
// ================================================================
// Key 的存取(加密)
// ================================================================
/** 用户填了原始 Key → 校验形式后加密落库。返回 ['ok'=>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);
}
}
+188 -12
View File
@@ -276,18 +276,14 @@ a:hover { text-decoration: underline; }
.icon-btn svg { display: block; } .icon-btn svg { display: block; }
/* ---- 用户第 3 条:首页的统计信息块(问候 + 天气 + 几个数字) ---- */ /* ---- 用户第 3 条:首页的统计信息块(问候 + 天气 + 几个数字) ---- */
.home-brief { display: flex; flex-direction: column; gap: 10px; } /* ⚠️ v24 第 24 批:这里原有 6 条 `.home-brief*` 旧规则(纵向排列的问候/天气/数字),
.home-brief-hello { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } 本批把首页信息面板改成"左边内容 + 右边天气贴右端"的横排结构后,
.home-brief-hello b { font-size: 17px; } 那 6 条**全部成了死样式**(模板里已经没有任何元素用 home-brief-hello /
.home-brief-weather { home-brief-weather / home-brief-stats / w-icon / w-temp / w-desc)。
display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; ⇒ 就地删掉,不留"两处定义同一个东西":
padding: 8px 12px; border-radius: 10px; background: var(--line-2); 上批(第 23 批)就是因为"末尾追加、旧规则留着",漏了 flex-direction,
} 四个按钮竖排把标题行撑到 175px —— 那个坑的成因正是**旧规则没删**。
.home-brief-weather .w-icon { font-size: 20px; line-height: 1; } 新结构见文件末尾「v24 第 24 批 …首页信息面板」那一段(.hb-* / .hb-weather)。 */
.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; }
/* ---- 用户第 3 条:首页的模块小结卡 ---- */ /* ---- 用户第 3 条:首页的模块小结卡 ---- */
.mod-cards { .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 { margin: 8px 2px 0; }
.ck-note > span[hidden] { display: none; } .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: '💭 '; }
+256
View File
@@ -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)自身**,不能写在它内部的 <a> 上:
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; }
+347
View File
@@ -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 });
});
})();
+83 -3
View File
@@ -372,6 +372,14 @@
var type = list.getAttribute('data-pp-type') || 'radio'; var type = list.getAttribute('data-pp-type') || 'radio';
var fname = list.getAttribute('data-pp-name') || ''; 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(); var frag = document.createDocumentFragment();
people.forEach(function (p) { people.forEach(function (p) {
var lab = document.createElement('label'); var lab = document.createElement('label');
@@ -555,9 +563,81 @@
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !modal.hidden) { closeModal(false); } if (e.key === 'Escape' && !modal.hidden) { closeModal(false); }
// 多选弹窗里按回车 = 点确定(用户填完最后一个字母顺手回车很自然)
if (e.key === 'Enter' && !modal.hidden && stagedMode if (modal.hidden || e.target !== search) { return; }
&& e.target === search) {
/* ============================================================
键盘流畅流程(v24 第 24 批 用户第 1 条后半,用户原话):
「当我输入了一个人的拼音、首字母的几个字母之后,如果第一个列表里面,
第一个人就是我要选的这个人。这时候我按空格就直接把这个人选中…
只要是完成了选中一个人,这个操作之后,就把输入框里面的用户已经输入的
字母全部给它清空。同时光标回到输入框里面,让用户等待的选出下一个人的
字母。…这时候如果它回车,就是确定,就是和点击那个框上的确定按钮
是一个效果。」
⇒ 所以这里要三件事(按用户说的顺序):
① 空格 = 选中**当前筛选结果里的第一个人**;
② 选中之后:清空输入框 + 光标回到输入框(接着打下一个人的拼音);
③ 回车 = 等于点「确定」(把已选的人写回页面)。
⚠️ 只在**输入框里**按才生效(`e.target !== search` 直接返回):
否则用户在弹框其它地方按空格(比如点完按钮后焦点在按钮上)会误选人。
⚠️ 空格**必须** preventDefault:`<input type="text">` 里空格默认会打出一个空格
字符,不挡的话输入框会变成 "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(); e.preventDefault();
refresh(); refresh();
closeModal(true); closeModal(true);
+385
View File
@@ -29,14 +29,18 @@ use App\Family;
use App\Geo; use App\Geo;
use App\Home; use App\Home;
use App\HomeMods; use App\HomeMods;
use App\Holiday;
use App\Anniversary; use App\Anniversary;
use App\Ics; use App\Ics;
use App\Kinship; use App\Kinship;
use App\Ledger; use App\Ledger;
use App\Lunar;
use App\Movie; use App\Movie;
use App\MovieReview; use App\MovieReview;
use App\Module; use App\Module;
use App\MovieStats; use App\MovieStats;
use App\Nav;
use App\NavFetch;
use App\NodeType; use App\NodeType;
use App\Nickname; use App\Nickname;
use App\Person; use App\Person;
@@ -57,6 +61,7 @@ use App\Upload;
use App\UserSetting; use App\UserSetting;
use App\Vehicle; use App\Vehicle;
use App\View; use App\View;
use App\WeRead;
if (!Database::hasTables()) { if (!Database::hasTables()) {
header('Location: install.php'); header('Location: install.php');
@@ -796,6 +801,33 @@ switch ($r) {
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? ''); $meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
$greeting = Home::greeting($meName, $today, count($soon), $lateN); $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 条),每人一份。 // 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。 // 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
$weatherLoc = Home::weatherLocation($me, $familyId); $weatherLoc = Home::weatherLocation($me, $familyId);
@@ -807,11 +839,13 @@ switch ($r) {
'family' => Family::find($familyId), 'family' => Family::find($familyId),
'me' => $people[$me] ?? null, 'me' => $people[$me] ?? null,
'meId' => $me, 'meId' => $me,
'meName' => $meName,
'kin' => $kin, 'kin' => $kin,
'allCount' => count($people), 'allCount' => count($people),
'relCount' => Relations::count($familyId), 'relCount' => Relations::count($familyId),
'birthdays' => $birthdays, 'birthdays' => $birthdays,
'soon' => $soon, 'soon' => $soon,
'todayEvents'=> $todayEvents,
'tasks' => $tasks, 'tasks' => $tasks,
'memories' => $memories, 'memories' => $memories,
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页) // 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
@@ -822,6 +856,12 @@ switch ($r) {
'weekday' => Home::weekday($today), 'weekday' => Home::weekday($today),
'weather' => $weather, 'weather' => $weather,
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN], 'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
// v24 第 24 批:信息面板
'todayLabel' => date('n 月 j 日'),
'lunarShort' => $lunarShort,
'lunarLine' => $lunarLine,
'todayMarks' => $todayMarks,
'today' => $today,
'homeModules'=> $homeMods, 'homeModules'=> $homeMods,
'moduleCards'=> $moduleCards, 'moduleCards'=> $moduleCards,
], 'home'); ], 'home');
@@ -5475,6 +5515,101 @@ switch ($r) {
], 'books'); ], 'books');
break; 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': case 'books/notes_import':
Auth::requireWrite(); Auth::requireWrite();
// 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。 // 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。
@@ -5518,6 +5653,256 @@ switch ($r) {
redirect('books'); redirect('books');
break; 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: default:
http_response_code(404); http_response_code(404);
View::render('error', [ View::render('error', [
+281
View File
@@ -0,0 +1,281 @@
<?php
/**
* 家书 FamilyBook · 个人导航**展示页**(v24 第 24 批 用户第 4 条)
*
* ⚠️ 这是全站**唯一不需要登录**的页面 —— 用户明确要求,而且理由很实在:
* 这个页面就是要"发给别人看 / 自己随手点开"的,要登录就没意义了。
*
* 用户原话:
* 「现在的系统,每一个功能模块都是需要登录的。但是导航展示页面却不需要登录。
* 这就意味着我们需要给他一个非常特殊的地址。地址后面可以跟着用户添加的一个短语,
* 就跟类似于一个密钥一样,他可以给自己起一个短语。然后这个地址后面跟着问号
* 带上这个短语的形式去进行展示。这样的话我们是为了区分不同的用户,他的不同的
* 导航页面,用户选了一个短语之后,通过这个短语,我们就能知道要访问的是哪一个
* 用户的面板,这个页面是不需要登录的。在这个页面里面要增加一个功能,右上角
* 增加一个功能,就是登录,点击这个页面之后再进入到我们现在这个系统就是家书的
* 这个系统的登录页面。否则的话,那个导航的展示的页面是跟之前的一样,是不需要
* 进行登录的。这样的话,导航的展示页面,导航页的展示页面上面的那个头像就
* 变成用户自己的头像就可以了。」
*
* ⇒ 地址形式:`https://<站点>/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');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>没有这个导航页</title>
<style>
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;
background:#F7F4EF;color:#2C231D;
font:15px/1.7 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif}
.b{max-width:420px;padding:28px 30px;background:#fff;border:1px solid rgba(44,35,29,.10);
border-radius:16px;box-shadow:0 4px 16px rgba(44,35,29,.08);text-align:center}
h1{margin:0 0 8px;font-size:18px}
p{margin:0 0 16px;color:#7E6B5A;font-size:14px}
a{display:inline-block;padding:8px 18px;border-radius:10px;background:#A84A28;color:#fff;
text-decoration:none;font-size:14px}
</style>
</head>
<body>
<div class="b">
<h1>没有这个导航页</h1>
<p>地址里的短语不对,或者主人换过短语了。<br>确认一下地址,或者找主人要新的。</p>
<a href="<?= e(url('auth/login')) ?>">去家书登录</a>
</div>
</body>
</html>
<?php
exit;
}
// ---- 3. 取这个人的导航数据(**只这一份**)----
//
// ⚠️ 这里开始的所有查询都只认 $personId,不再接任何请求参数。
$gridGroups = Nav::groups($personId, 'grid', true);
$tabGroups = Nav::groups($personId, 'tabs', true);
$engines = Nav::engines($personId, true);
$settings = Nav::settings($personId);
$sitesOf = static function (int $gid) use ($personId): array {
return Nav::sites($personId, $gid);
};
$mePerson = \App\Person::find($personId);
$meAvatar = avatar_url($mePerson['avatar'] ?? null);
// 展示页自己的标题(用设置里的站点名与副标题;副标题允许为空 → 不显示)
$navTitle = trim((string)($settings['site_name'] ?? '')) !== '' ? (string)$settings['site_name'] : '我的导航';
$navSubtitle = (string)($settings['subtitle'] ?? '');
$primary = Nav::safeColor((string)($settings['primary_color'] ?? ''), '#2F6BF7');
$collLayout = (string)($settings['collections_layout'] ?? 'tabs') === 'flat' ? 'flat' : 'tabs';
$openTarget = Nav::openTarget($personId);
$openMode = $openTarget === '_blank' ? 'blank' : 'self';
$suggestKeys = Nav::suggestKeys($personId);
$default = Nav::defaultEngine($personId, $engines);
/** 渲染一个站点格(与 SimpleNav 的 site_tile 同构) */
$siteTile = static function (array $s) use ($openTarget): void {
$label = Nav::siteLabel($s);
$src = Nav::iconUrl((string)($s['icon'] ?? ''));
?>
<li class="site">
<a href="<?= e((string)$s['url']) ?>" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
title="<?= e(trim((string)($s['name'] ?? '')) !== '' ? (string)$s['name'] : $label) ?>">
<?php if ($src !== ''): ?>
<img class="icon" src="<?= e($src) ?>" alt="" loading="lazy">
<?php else: ?>
<span class="ico-letter" style="background:<?= e(Nav::letterColor($label)) ?>"><?= e(mb_substr($label, 0, 1, 'UTF-8')) ?></span>
<?php endif; ?>
<span class="name"><?= e($label) ?></span>
</a>
</li>
<?php
};
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($navTitle) ?><?= $navSubtitle !== '' ? ' · ' . e($navSubtitle) : '' ?></title>
<meta name="robots" content="noindex,nofollow">
<meta name="theme-color" content="<?= e($primary) ?>">
<link rel="stylesheet" href="<?= e(asset('/assets/css/nav-show.css')) ?>">
<?php if ($primary !== '#2F6BF7'): ?>
<style>:root { --n-primary: <?= e($primary) ?>; }</style>
<?php endif; ?>
</head>
<body data-open="<?= e($openMode) ?>">
<div class="container">
<?php /* ⚠️ 右上角恒有「登录」入口(用户明确要求)。
它进的是**家书**的登录页,不是这个导航系统的登录页 ——
因为导航展示页本来就不需要登录,这个按钮是给"主人自己"用的便利入口。 */ ?>
<div class="topline">
<div class="who">
<?php if ($meAvatar !== ''): ?>
<img class="who-av" src="<?= e($meAvatar) ?>" alt="">
<?php endif; ?>
<span class="who-name"><?= e($navTitle) ?></span>
</div>
<a class="login-btn" href="<?= e(url('auth/login')) ?>">
<svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12.5 6.5V5a1.5 1.5 0 0 0-1.5-1.5H5A1.5 1.5 0 0 0 3.5 5v10A1.5 1.5 0 0 0 5 16.5h6a1.5 1.5 0 0 0 1.5-1.5v-1.5"/>
<path d="M8 10h9m0 0-2.5-2.5M17 10l-2.5 2.5"/>
</svg>
登录
</a>
</div>
<header class="hero">
<?php if ($meAvatar !== ''): ?>
<img class="logo" src="<?= e($meAvatar) ?>" alt="">
<?php endif; ?>
<h1><?= e($navTitle) ?></h1>
<?php if ($navSubtitle !== ''): ?>
<p><?= e($navSubtitle) ?></p>
<?php endif; ?>
</header>
<?php if ($engines): ?>
<div class="s-search">
<div class="search-type">
<?php foreach ($engines as $en): ?>
<label class="s-engine<?= $default && $en['slug'] === $default['slug'] ? ' active' : '' ?>"
data-search="<?= e((string)$en['search_url']) ?>"
data-param="<?= e((string)$en['param']) ?>"
data-slug="<?= e((string)$en['slug']) ?>"
data-placeholder="<?= e((string)$en['name']) ?>搜索"
style="--ec:<?= e(Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
<span><?= e((string)$en['name']) ?></span>
</label>
<?php endforeach; ?>
</div>
<form id="search-form" action="<?= e((string)($default['search_url'] ?? 'https://cn.bing.com/search')) ?>"
method="get" target="<?= e($openTarget) ?>"<?= $openTarget === '_blank' ? ' rel="noopener"' : '' ?>
data-slug="<?= e((string)($default['slug'] ?? '')) ?>" data-keys="<?= e($suggestKeys) ?>">
<input type="text" id="search-text" name="<?= e((string)($default['param'] ?? 'q')) ?>"
placeholder="<?= e((string)($default['name'] ?? '搜索')) ?>搜索" autocomplete="off">
<button class="submit" type="submit" aria-label="搜索">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle><path d="M20 20l-3.6-3.6"></path>
</svg>
</button>
<ul id="word"></ul>
</form>
</div>
<?php endif; ?>
<?php foreach ($gridGroups as $g): $items = $sitesOf((int)$g['id']); if (!$items) { continue; } ?>
<ul class="mylist">
<li class="title"><?= e((string)$g['name']) ?></li>
<?php foreach ($items as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php
$collGroups = [];
foreach ($tabGroups as $g) {
$items = $sitesOf((int)$g['id']);
if ($items) { $collGroups[] = ['g' => $g, 'items' => $items]; }
}
?>
<?php if ($collGroups): ?>
<div class="collections<?= $collLayout === 'flat' ? ' flat' : '' ?>" data-layout="<?= e($collLayout) ?>">
<div class="collections-title">合集</div>
<?php if ($collLayout === 'flat'): ?>
<?php foreach ($collGroups as $c): ?>
<ul class="mylist">
<li class="title"><?= e((string)$c['g']['name']) ?></li>
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
<?php else: ?>
<div class="tabs" role="tablist">
<?php foreach ($collGroups as $i => $c): ?>
<button type="button" class="tab<?= $i === 0 ? ' active' : '' ?>" role="tab"
aria-selected="<?= $i === 0 ? 'true' : 'false' ?>"
data-target="coll-<?= (int)$c['g']['id'] ?>"><?= e((string)$c['g']['name']) ?></button>
<?php endforeach; ?>
</div>
<div class="tab-panels">
<?php foreach ($collGroups as $i => $c): ?>
<ul class="mylist tab-panel<?= $i === 0 ? ' active' : '' ?>" id="coll-<?= (int)$c['g']['id'] ?>" role="tabpanel">
<?php foreach ($c['items'] as $s) { $siteTile($s); } ?>
</ul>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ⚠️ 空态:主人**还没在导航管理页里加过东西**时(`Nav::ensureSeed` 只在管理页跑,
展示页故意**不写数据** —— 它是一个免登录页面,不该被任何拿到短语的人触发出写操作),
这里会一块内容都没有。空白的公开页看起来像"坏了",所以要给一句话。 */ ?>
<?php if (!$gridGroups && !$collGroups): ?>
<div class="nav-empty">
<p>这个导航页还是空的 —— 主人还没往里加东西。</p>
<p class="small muted">(去「导航」页加几个常用网站,这里就有了。)</p>
</div>
<?php endif; ?>
<footer class="foot">
<a href="<?= e(url('auth/login')) ?>">登录家书</a>
<span>·</span>
<span><?= e($navTitle) ?></span>
</footer>
</div>
<script>
/* 联想词来源:走家书自己的代理(不直接把第三方接口暴露给浏览器 ——
SimpleNav 原来也是走自己的 suggest.php,同一个理由:跨域 + 不必要的直连)。 */
window.SIMPLENAV = {
suggest: <?= json_encode(url('nav/suggest') . '?p=' . rawurlencode($phrase), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
suggestKeys: <?= json_encode($suggestKeys) ?>
};
</script>
<script src="<?= e(asset('/assets/js/nav-show.js')) ?>"></script>
</body>
</html>
+213
View File
@@ -0,0 +1,213 @@
<?php
/**
* 家书 FamilyBook · 导航搜索联想代理(v24 第 24 批 用户第 4 条,从 SimpleNav 搬来)
*
* ⚠️ 浏览器直接调各家联想接口会撞跨域 / JSONP 限制,所以由服务端代理并统一输出 JSON。
* SimpleNav 原文这里有一段**线上实测结论**,照抄保留(它是最贵的部分):
*
* 百度 sugrec?prod=pc&wd= 200 / 0.12s / 1137B ✅ 最快最稳 → 默认来源
* 360 sug.so.360.cn/suggest 200 / 0.20s / 714B ✅
* 必应 api.bing.com/osjson 200 / 0.5~1.6s / 408B ✅ 可用但明显慢
* 搜狗 suggnew/ajajjson 200 / **0 字节** ❌ 联想域名已不解析
* 谷歌 suggestqueries.google 连接超时 ❌ 国内不可达
* DuckDuckGo 连接超时 ❌ 国内不可达
*
* 所以默认策略是「**联想词统一取百度,搜索仍走各自引擎**」——
* 点哪个引擎就用哪个引擎搜,只是下拉建议来自百度,任何一个引擎都不会出现"下拉是空的"。
*
* 用法:`/nav/suggest.php?p=<短语>&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 : [];
}
+15
View File
@@ -30,6 +30,21 @@ if (preg_match('#^/carddav(/.*)?$#', $path)) {
return true; 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 等)→ 交给内置服务器自己处理 // 真实存在的文件(静态资源、media.php 等)→ 交给内置服务器自己处理
$file = __DIR__ . $path; $file = __DIR__ . $path;
if ($path !== '/' && is_file($file)) { if ($path !== '/' && is_file($file)) {
+17
View File
@@ -144,6 +144,23 @@ $ppHasSel = count(array_filter($ppSelected)) > 0;
<div class="pp-empty" hidden>没有匹配的人。</div> <div class="pp-empty" hidden>没有匹配的人。</div>
</div> </div>
<?php /* 🔴🔴 **承运字段**(v24 第 24 批 用户第 1 条修的真 bug)—— 别删!
未在弹框里用过时,提交表单**必须**把"当前已选"一起带上。 */ ?>
<?php /* ⚠️ 我第一版在这里凭空写了 `<?= $ppName ?>_ver` / `_nickname` 两个"旁路字段",
以为 chips 会把它们一起送上去 —— 那是**猜的**,实际 chips 只渲染
`<span class="pp-chip" data-id>`,**一个 input 都不产生**。
已核实后才写成现在这样:按 $ppName 原样把已选 id 各写一个 hidden。
(教训:写代码前先把"实际的渲染产物"读出来,别照着自己脑补的结构写。) */ ?>
<?php if ($ppSelected): ?>
<?php foreach ($ppSelected as $ppSelId): ?>
<?php if ((int)$ppSelId > 0): ?>
<input type="hidden" class="pp-carry"
name="<?= e($ppName) ?>" value="<?= (int)$ppSelId ?>">
<?php endif; ?>
<?php endforeach; ?>
<?php unset($ppSelId); ?>
<?php endif; ?>
<?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定), <?php /* 无 JS 兜底:启用脚本时这段只是**纯文本**(HTML5 规定),
不会变成 DOM 节点、也就不会造成上面的膨胀; 不会变成 DOM 节点、也就不会造成上面的膨胀;
禁用脚本时才渲染出来,选人照样能用。 */ ?> 禁用脚本时才渲染出来,选人照样能用。 */ ?>
+5
View File
@@ -21,6 +21,9 @@ $bkSetIcon = static function (string $name): string {
'stats' => '<path d="M3 13V9M6.5 13V5M10 13V7.5M13.5 13V3"/>', 'stats' => '<path d="M3 13V9M6.5 13V5M10 13V7.5M13.5 13V3"/>',
// 导入笔记:一支笔写在纸上 // 导入笔记:一支笔写在纸上
'import' => '<path d="M9.5 2.5l4 4L5 15H2v-3z"/><path d="M8 4l4 4"/>', 'import' => '<path d="M9.5 2.5l4 4L5 15H2v-3z"/><path d="M8 4l4 4"/>',
// 微信读书授权(v24 第 24 批):钥匙
'weread' => '<path d="M6.8 9.2a2.6 2.6 0 1 0 0-.1z"/><path d="M8.6 7.4l5-5"/>'
. '<path d="M11.4 4.6l1.5 1.5M12.9 3.1l1.5 1.5"/>',
]; ];
$d = $paths[$name] ?? ''; $d = $paths[$name] ?? '';
if ($d === '') { if ($d === '') {
@@ -36,6 +39,8 @@ $bkSetIcon = static function (string $name): string {
<div class="menu-list"> <div class="menu-list">
<a href="<?= e(url('books/stats')) ?>"<?= $bkSetOn('stats') ?>><?= $bkSetIcon('stats') ?><span>读书统计</span></a> <a href="<?= e(url('books/stats')) ?>"<?= $bkSetOn('stats') ?>><?= $bkSetIcon('stats') ?><span>读书统计</span></a>
<a href="<?= e(url('books/notes_import')) ?>"<?= $bkSetOn('import') ?>><?= $bkSetIcon('import') ?><span>导入读书笔记</span></a> <a href="<?= e(url('books/notes_import')) ?>"<?= $bkSetOn('import') ?>><?= $bkSetIcon('import') ?><span>导入读书笔记</span></a>
<?php /* v24 第 24 批 用户第 5 条:微信读书(官方 API Key 授权,能直接读划线与想法) */ ?>
<a href="<?= e(url('books/weread')) ?>"<?= $bkSetOn('weread') ?>><?= $bkSetIcon('weread') ?><span>微信读书导入</span></a>
</div> </div>
</details> </details>
<?php <?php
+167
View File
@@ -0,0 +1,167 @@
<?php
/**
* 微信读书导入(v24 第 24 批 用户第 5 条)。
*
* 两种导入方式,**这里管的是"官方 API Key"那一种**:
* ① 手动粘贴 —— 见 `books/notes_import`(早已有,App 里「导出笔记」复制出来即可);
* ② 官方授权 —— 本页:填一次微信读书开放平台的 API Key(`wrk-` 开头),
* 之后系统就能直接读你的书架笔记,选中要导的书一键导入。
*
* 用户原话:「你看一下能不能导入微信的读书笔记,就是每一个人设置一下,比如说扫一下
* 微信的这个码呀,或者什么之类的授权一下」→ 后续明确「用微信读书 api key」。
* ⚠️ 官方在 2026 年开了开放平台,走**扫码登录后一键生成 API Key**(`wrk-` 前缀),
* 不再需要早期那种"复制 cookie"的野路子。Key 的获取页就在本页给出。
*
* ⚠️ Key 是**账号凭据**(官方原话:等价于账号凭证,泄露等于账号泄露):
* 所以界面上**只回显掩码**(`wrk-********abcd`),完整值只在提交那一刻存在。
*
* @param array|null $preview 「拉取预览」的结果(每本书 + 前几条笔记)
* @param int $nbCount 预览时一共发现多少本有笔记的书
*/
$preview = $preview ?? null;
$nbCount = (int)($nbCount ?? 0);
$me = (int)\App\Auth::personId();
$hasKey = \App\WeRead::hasKey($me);
$masked = \App\WeRead::maskedKey($me);
$canWrite = \App\Auth::canWrite();
?>
<div class="page-head">
<h1>微信读书导入</h1>
<span class="sub">填一次 API Key,之后就能直接把你微信读书里的划线与想法导进来</span>
<div class="actions">
<?php $bkSetCurrent = 'import'; require __DIR__ . '/_settings_menu.php'; ?>
</div>
</div>
<?php /* ==================== ① 授权(填 API Key)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
授权
<?php if ($hasKey): ?>
<span class="node-badge">已授权</span>
<?php else: ?>
<span class="node-badge">还没授权</span>
<?php endif; ?>
</h2>
<p class="small muted" style="margin:0 0 12px">
去 <a href="https://weread.qq.com/r/weread-skills" target="_blank" rel="noopener">微信读书开放平台</a>
扫码登录,页面上点「获取 API Key」,会得到一串 <code class="mono">wrk-</code> 开头的字符串,
复制到下面就行。
</p>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>这串 Key 等于你的账号凭据</b> —— 我们会<b>加密存放</b>,页面上也只显示掩码;
不想再用时,把它清空保存即可(或去开放平台重置那串 Key)。
</p>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="key_save">
<div class="field">
<label>微信读书 API Key</label>
<input class="input mono" name="api_key" autocomplete="off"
placeholder="<?= $hasKey ? e($masked) . '(已保存,留空并保存 = 清除)' : 'wrk-xxxxxxxxxxxx' ?>">
<span class="tip">
<?php if ($hasKey): ?>
当前已保存:<b class="mono"><?= e($masked) ?></b>。要换就把新的整串贴进来(会覆盖)。
<?php else: ?>
形如 <code class="mono">wrk-NVbX…dgAA</code>,只在你自己的账号里可见。
<?php endif; ?>
</span>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn" type="submit">保存</button>
</div>
</form>
<?php else: ?>
<p class="empty-hint">只读账号不能配置导入。</p>
<?php endif; ?>
</div>
<?php /* ==================== ② 拉取预览(先看再导)====================
⚠️ 必须有这一步:直接"一键全导"会把别人(或自己多年前)的书一次性灌进书架,
想撤回只能一本本删。先给个清单、让用户自己勾,是最省事也最不容易后悔的做法。 */ ?>
<?php if ($canWrite && $hasKey): ?>
<div class="card card-pad">
<h2 class="cinema-h2">从微信读书取数据</h2>
<form method="post" action="<?= e(url('books/weread')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="act" value="fetch">
<div class="btn-row" style="margin-top:0">
<button class="btn btn-sm" type="submit">拉取我的笔记清单</button>
</div>
<p class="tip" style="margin-top:6px">
会去微信读书取一次数据(你有笔记的书 + 每本书的划线/想法),可能要十几秒。
</p>
</form>
</div>
<?php endif; ?>
<?php if ($preview !== null): ?>
<div class="card card-pad">
<h2 class="cinema-h2">
发现 <?= (int)$nbCount ?> 本有笔记的书
<span class="hint">勾选要导入的,然后点下面的「导入选中」</span>
</h2>
<?php if (!$preview): ?>
<p class="empty-hint">微信读书里还没有带笔记的书。</p>
<?php else: ?>
<form method="post" action="<?= e(url('books/weread')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="import">
<div class="wr-list">
<?php foreach ($preview as $p): ?>
<label class="wr-book">
<input type="checkbox" name="pick[]" value="<?= e((string)$p['bookId']) ?>"
<?= (int)$p['count'] > 0 ? 'checked' : '' ?>>
<span class="wr-main">
<span class="wr-title"><?= e((string)$p['title']) ?></span>
<?php if ((string)$p['author'] !== ''): ?>
<span class="small muted"><?= e((string)$p['author']) ?></span>
<?php endif; ?>
<span class="node-badge"><?= (int)$p['count'] ?> 条</span>
<?php if ((int)$p['local'] > 0): ?>
<span class="node-badge">书架里已有</span>
<?php else: ?>
<span class="node-badge">会新建</span>
<?php endif; ?>
</span>
<?php if (!empty($p['items'])): ?>
<span class="wr-sample">
<?php foreach ($p['items'] as $it): ?>
<span class="wr-q">
<?php if ((string)($it['quote'] ?? '') !== ''): ?>
<i><?= e(mb_substr((string)$it['quote'], 0, 60, 'UTF-8')) ?></i>
<?php endif; ?>
<?php if ((string)($it['content'] ?? '') !== ''): ?>
<em><?= e(mb_substr((string)$it['content'], 0, 60, 'UTF-8')) ?></em>
<?php endif; ?>
</span>
<?php endforeach; ?>
</span>
<?php endif; ?>
</label>
<?php endforeach; ?>
</div>
<div class="btn-row">
<button class="btn" type="submit">导入选中</button>
</div>
</form>
<?php endif; ?>
</div>
<?php endif; ?>
<?php /* ==================== ③ 另一条路:手动粘贴 ====================
⚠️ 两种方式都留着,不是重复:
· API Key 走"全自动",但要求用户愿意把 Key 交出来;
· 粘贴走"零授权",App 里「导出笔记」复制一段文字即可。
用户可能两个都用(比如只想导某一本书的几段)。 */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">或者:手动粘贴笔记</h2>
<p class="small muted" style="margin:0 0 10px">
不想配 Key 也行 —— 在微信读书 App 里打开一本书 → 右上角「…」→ 导出笔记,
把复制到的文字粘到那里,效果一样。
</p>
<div class="btn-row" style="margin-top:0">
<a class="btn btn-ghost btn-sm" href="<?= e(url('books/notes_import')) ?>">去粘贴导入</a>
</div>
</div>
+224 -142
View File
@@ -1,21 +1,44 @@
<?php <?php
/** /**
* 首页 —— 一块 dashboard(用户 2026-10-01 第 13 条)。 * 首页 —— 一块 dashboard。
* *
* 用户 2026-10-03 第 3 条把它重排成: * ────────────────────────────────────────────────────────────────
* · hero:标题 + 个人 slogan + 两个图标按钮(我的资料 / 我的家谱) * v24 第 24 批 用户第 2、3 条(本次重排,用户原话):
* · 统计信息:随机问候语 + 今日天气 + 近七天日程数 / 待办数
* · 未来七天的日程(自定义 + 观影 + 旅行 + 有截止时间的任务,一次汇总)
* · 手头在做(进行中 + 已逾期的任务)
* · 各模块的统计与模块内信息(**可按人在个人设置里开关与排序**)
* · 往年今日
* ⚠️ 「接下来的日子(两个月内的生日)」已去掉:生日/纪念日本来就是日程,
* 已经在「未来七天」里,两块并排是重复。
* *
* ⚠️ **家人卡片已经不在首页了**(用户明确要求):看人去「名册」、看关系去「家谱」, * 第 2 条:「首页上的功能…一个就是上面的简要信息,这样去写。早上好,中午好,
* 首页留给"今天该关心什么"。统计数字仍在,因为那是唯一的全局概览。 * 或者晚上好,某某某今天是几月几日?星期几农历几月几日?如果有节气的
* 话,或者有重要的节日的话,在这里显示。比如说今天是这个国庆节啊,
* 今天是端午节,或者是今天是中秋节,或者是今天是芒种,今天是大寒,
* 今天是小寒,今天是什么纪念日,今天是谁的生日等等。显示在这里
* 紧接着它的下面显示今天一共有多少条日程,今天有几件代办正在进行。
* 把天气放在这个信息面板的最右侧,以图标的形式,彩色图标的形式去进行
* 展示。比如说晴天多云下雨,然后下面显示呃温度、风力等等,在最下面
* 再显示呃地址。就是比如说他选中的是他在个人设置里面设置的是青岛,
* 那么就显示山东青岛就是让它放在这个信息面板的最右侧,靠右放。」
* *
* ⚠️ 标题下的那句个人口号已按用户要求撤掉(v8):hero 区只留标题与三个统计。 * 第 3 条:「家里的镜框这里呃全部不要了…紧接着直接显示日程和任务日程和任务在
* 整个页面里面左右,并排放各占一半,日程就显示今天的日程,也就是
* 日视图那样形式,为了好看高度需要固定。这样的话,鼠标放上去之后,
* 向下可以滚动,暂时不要出现滚动条太丑了。为了节省上下滚动的这个空间,
* 我们可以把没有日程的那些时间掐头去尾给它去掉。…同样的和日视图一样,
* 需要在当前的时间或者是时刻那里显示一条红线。右侧显示任务模块里面的
* 数据显示当前正在进行当中的所有的任务,除了显示任务的标题之外,
* 还要显示他的状态。比如说正在进行中待确认等等。除了这之外,
* 还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面,
* 就后最下面显示的是往年的今天这个模块。」
*
* ⇒ 所以现在首页自上而下只有三块:
* ① 信息面板(问候 + 日子标记 + 今日数字 | 最右:彩色天气图标 + 温度风力 + 地址)
* ② 日程 / 任务 **左右各半**(各自固定高度、内部滚动、不显滚动条)
* ③ 往年的今天
*
* ⚠️ 被删掉的(用户明确说"全部不要了"):
* · 「家里的近况」模块卡片墙(moduleCards)—— 那张一片一片的统计卡
* · 「未来七天」清单 —— 与左侧「今天日程」重复
* 两块都不再渲染。服务端仍照旧算 moduleCards(个人设置里那个开关还留着),
* 但首页不再用 —— 留着它是为了"以后要恢复"时不用动后端。
*
* ⚠️ 「家人卡片」「两个月内的生日」早先就已按用户要求撤掉,本批不变。
*/ */
// 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出 // 生日事件上用简称,卡片才不会一行塞不下;长名字在详情页里出
$today = date('Y-m-d'); $today = date('Y-m-d');
@@ -24,6 +47,7 @@ $homeMods = array_values(array_filter(
(array)($homeModules ?? []), (array)($homeModules ?? []),
static fn($k) => \App\Module::exists((string)$k) static fn($k) => \App\Module::exists((string)$k)
)); ));
$todayMarks = $todayMarks ?? ['tags' => [], 'line' => ''];
?> ?>
<?php /* 用户 2026-10-03 第 3 条: <?php /* 用户 2026-10-03 第 3 条:
① 标题下显示**个人 slogan**(原来撤掉了,现在要回来); ① 标题下显示**个人 slogan**(原来撤掉了,现在要回来);
@@ -56,7 +80,7 @@ $homeMods = array_values(array_filter(
<a class="icon-btn" href="<?= e(url('kin')) ?>" <a class="icon-btn" href="<?= e(url('kin')) ?>"
title="我的家谱" aria-label="我的家谱"> title="我的家谱" aria-label="我的家谱">
<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none" <svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/> <rect x="7.4" y="2.2" width="5.2" height="4.2" rx="1"/>
<rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/> <rect x="2.2" y="13.6" width="5.2" height="4.2" rx="1"/>
<rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/> <rect x="12.6" y="13.6" width="5.2" height="4.2" rx="1"/>
@@ -66,151 +90,209 @@ $homeMods = array_values(array_filter(
</div> </div>
</section> </section>
<?php /* 用户 2026-10-03 第 3 条:「接下来的日子(两个月内)」整块**去掉** —— <?php /* ==================== ① 信息面板(用户第 2 条)====================
它列的是生日,而生日/纪念日本来就是日程,已经在下面的「未来七天」里出现过, 左:问候 + 姓名 → 日期/星期/农历 → 日子标记(节气/节日/纪念日/生日)→ 今日数字
两块并排是重复信息。生日的"提前提醒"由日历与纪念日模块负责。 */ 右:**彩色**天气图标 + 温度 + 风力 → 地址
?> ⚠️ 天气靠右:用户原话「把天气放在这个信息面板的最右侧…靠右放」。
用 flex + margin-left:auto 把它顶到右端(窄屏会自动折到下一行,见 CSS)。 */ ?>
<?php /* ==================== 统计信息(用户第 3 条) ====================
一句话问候 + 今天的天气 + 几个数字。放在最上面,因为它回答的是
"今天是个什么日子、我该关心什么"——比下面任何一个列表都更该先看到。 */ ?>
<section class="section"> <section class="section">
<div class="card card-pad home-brief"> <div class="card card-pad home-brief">
<div class="home-brief-hello"> <div class="hb-left">
<b><?= e((string)($greeting ?? '')) ?></b> <?php /* ① 问候(早上好/中午好…)+ 我的称呼 —— 问候语里已经带了名字,
<span class="muted small"><?= e(date('n 月 j 日')) ?> <?= e((string)($weekday ?? '')) ?></span> 但用户明确要求「早上好,某某某」这样的形式,所以名字单独再强调一次。
(Home::greeting 本身会拼 "早上好,老杨,今天有 3 件事",
这里只用它的"时段"部分,尾巴换成面板里更该看的信息。) */ ?>
<?php
$hbPart = '你好';
$hbH = (int)date('G');
if ($hbH < 5) { $hbPart = '夜深了'; }
elseif ($hbH < 11) { $hbPart = '早上好'; }
elseif ($hbH < 13) { $hbPart = '中午好'; }
elseif ($hbH < 18) { $hbPart = '下午好'; }
else { $hbPart = '晚上好'; }
?>
<div class="hb-hello">
<b><?= e($hbPart) ?></b><?php if (trim((string)($meName ?? '')) !== ''): ?>,<?= e((string)$meName) ?><?php endif; ?>
</div>
<div class="hb-date">
<span class="hb-d"><?= e((string)($todayLabel ?? date('n 月 j 日'))) ?></span>
<span class="hb-w"><?= e((string)($weekday ?? '')) ?></span>
<?php if (($lunarShort ?? '') !== ''): ?>
<span class="hb-lunar" title="<?= e((string)($lunarLine ?? '')) ?>"><?= e((string)$lunarShort) ?></span>
<?php endif; ?>
</div>
<?php if (!empty($todayMarks['tags'])): ?>
<div class="hb-marks">
<?php foreach ($todayMarks['tags'] as $mk): ?>
<span class="hb-mark is-<?= e((string)$mk['kind']) ?>"><?= e((string)$mk['word']) ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="hb-stats">
<a class="hbs" href="<?= e(url('calendar')) ?>">
<b><?= count($todayEvents ?? []) ?></b> 条日程(今天)
</a>
<a class="hbs" href="<?= e(url('tasks')) ?>">
<b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办
</a>
<?php if ((int)($stat7['overdue'] ?? 0) > 0): ?>
<a class="hbs is-late" href="<?= e(url('tasks')) ?>">
<b><?= (int)$stat7['overdue'] ?></b> 件逾期
</a>
<?php endif; ?>
</div>
</div> </div>
<?php if (!empty($weather)): ?> <?php if (!empty($weather)): ?>
<div class="home-brief-weather"> <div class="hb-weather is-<?= e((string)($weather['tone'] ?? 'cloud')) ?>">
<span class="w-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span> <span class="hbw-icon" aria-hidden="true"><?= e((string)($weather['icon'] ?? '')) ?></span>
<span class="w-temp"><?= e((string)($weather['temp'] ?? '')) ?></span> <div class="hbw-main">
<span class="w-desc"><?= e((string)($weather['text'] ?? '')) ?></span> <div class="hbw-temp"><?= e((string)($weather['temp'] ?? '')) ?></div>
<div class="hbw-text"><?= e((string)($weather['text'] ?? '')) ?></div>
<?php if (($weather['wind'] ?? '') !== ''): ?>
<div class="hbw-wind">
<?= $weather['wdir'] !== '' ? e((string)$weather['wdir']) . ' ' : '' ?><?= e((string)$weather['wind']) ?>
</div>
<?php endif; ?>
</div>
<?php if (($weather['place'] ?? '') !== ''): ?> <?php if (($weather['place'] ?? '') !== ''): ?>
<span class="muted small"><?= e((string)$weather['place']) ?></span> <div class="hbw-place"><?= e((string)$weather['place']) ?></div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="home-brief-stats">
<span class="hbs"><b><?= (int)($stat7['events'] ?? 0) ?></b> 条日程(近七天)</span>
<span class="hbs"><b><?= (int)($stat7['todos'] ?? 0) ?></b> 件待办</span>
<span class="hbs"><b><?= (int)($stat7['overdue'] ?? 0) ?></b> 件逾期</span>
</div>
</div> </div>
</section> </section>
<?php /* ==================== 各模块统计(用户第 3 条) ==================== <?php /* ==================== ② 日程 / 任务 左右各半(用户第 3 条)====================
每个模块给一个"一眼看得懂的小结"。用户在个人设置里能挑显示哪些、以及顺序。 */ ?> ⚠️ 高度**固定**(CSS 里给 .hd-pane-body 一个 max-height):
<?php if ($homeMods && !empty($moduleCards)): ?> 用户原话「为了好看高度需要固定。这样的话,鼠标放上去之后,向下可以滚动,
<section class="section"> 暂时不要出现滚动条太丑了」——
<div class="page-head" style="margin-bottom:12px"> 所以是 `overflow-y:auto` + 隐藏滚动条(`scrollbar-width:none` 等三套写法)。
<h1 style="font-size:15px">家里的近况</h1> ⚠️ 日程掐头去尾:见下面 $hr 的算法 —— 只画"第一个日程前一小时"到"最后一个日程后一小时",
<div class="actions"> 中间空档不裁(裁了会让人以为日程连在一起)。全天日程永远在顶上。 */ ?>
<a class="btn btn-ghost btn-sm" href="<?= e(url('settings/me')) ?>#home-mods">调整显示</a> <?php
// ---- 日程时间轴:掐头去尾(用户第 3 条"没有日程的那些时间掐头去尾给它去掉")----
$allDay = [];
$timed = [];
foreach (($todayEvents ?? []) as $ev) {
if ((string)($ev['time'] ?? '') === '') { $allDay[] = $ev; }
else { $timed[] = $ev; }
}
$startH = 0; $endH = 24;
if ($timed) {
$mins = array_map(static function (array $e): int {
$p = explode(':', (string)$e['time']);
return ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
}, $timed);
// 前后各留一小时:紧了会让人以为"到点就结束了",松了又浪费高度
$startH = max(0, intdiv(min($mins), 60) - 1);
$endH = min(24, intdiv(max($mins), 60) + 2); // +2 = 跨过那一小时再收尾
if ($endH <= $startH) { $endH = min(24, $startH + 2); }
}
$nowH = (int)date('G');
$nowPos = ($nowH - $startH) / max(1, ($endH - $startH)); // 红线位置(0~1)
$showNow = $nowPos >= 0 && $nowPos <= 1;
?>
<section class="section">
<div class="home-duo">
<?php /* ---- 左:今天的日程(日视图形式)---- */ ?>
<div class="card card-pad hd-pane" id="homeAgenda">
<div class="hd-pane-head">
<h2 class="cinema-h2">今天的日程</h2>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a>
</div>
<div class="hd-pane-body">
<?php if (!$todayEvents): ?>
<p class="empty-hint">今天没有安排。</p>
<?php else: ?>
<?php if ($allDay): ?>
<div class="ag-allday">
<?php foreach ($allDay as $ev): ?>
<a class="ag-allday-item" href="<?= e((string)$ev['href']) ?>">
<span class="dash-dot" style="background:<?= e((string)$ev['color']) ?>;"></span>
<span class="ag-ad-t">全天</span>
<span class="ag-ad-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div class="ag-grid">
<?php for ($h = $startH; $h < $endH; $h++): ?>
<div class="ag-row">
<span class="ag-hour"><?= sprintf('%02d:00', $h) ?></span>
<span class="ag-line"></span>
</div>
<?php endfor; ?>
<?php /* 当前时刻红线:用户原话「需要在当前的时间或者是时刻那里显示一条红线」 */ ?>
<?php if ($showNow): ?>
<div class="ag-now" style="top: <?= number_format($nowPos * 100, 2) ?>%">
<span class="ag-now-dot"></span>
</div>
<?php endif; ?>
<?php /* 日程块:按"相对第一个刻度的小时数"定位(与日视图同一套比例)---- */ ?>
<?php foreach ($timed as $ev): ?>
<?php
$p = explode(':', (string)$ev['time']);
$evMin = ((int)($p[0] ?? 0)) * 60 + (int)($p[1] ?? 0);
$topPct = (($evMin / 60) - $startH) / max(1, ($endH - $startH)) * 100;
$topPct = max(0, min(96, $topPct));
?>
<a class="ag-ev" href="<?= e((string)$ev['href']) ?>"
style="top: <?= number_format($topPct, 2) ?>%; border-left-color: <?= e((string)$ev['color']) ?>;">
<span class="ag-ev-t"><?= e((string)$ev['time']) ?></span>
<span class="ag-ev-w"><?= e((string)$ev['title']) ?></span>
<span class="dash-cal" style="background:<?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</a>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div> </div>
</div> </div>
<div class="mod-cards">
<?php foreach ($homeMods as $mk): ?>
<?php $mc = $moduleCards[$mk] ?? null; if (!$mc) { continue; } ?>
<a class="mod-card" href="<?= e((string)$mc['href']) ?>"
style="--mc: <?= e((string)($mc['color'] ?? '#888')) ?>">
<div class="mod-card-head">
<span class="mod-card-icon"><?= $mc['icon'] /* 行内 SVG,自己人写的,不转义 */ ?></span>
<span class="mod-card-name"><?= e((string)$mc['name']) ?></span>
</div>
<?php if (($mc['big'] ?? '') !== ''): ?>
<div class="mod-card-big"><?= e((string)$mc['big']) ?></div>
<?php endif; ?>
<?php if (!empty($mc['lines'])): ?>
<ul class="mod-card-lines">
<?php foreach ($mc['lines'] as $ln): ?>
<li><?= e((string)$ln) ?></li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</a>
<?php endforeach; ?>
</div>
</section>
<?php endif; ?>
<?php /* ==================== 日程(未来七天) ==================== */ ?> <?php /* ---- 右:进行中的任务(标题 + 状态 + 看板名)---- */ ?>
<section class="section"> <div class="card card-pad hd-pane" id="homeTasks">
<div class="page-head" style="margin-bottom:12px"> <div class="hd-pane-head">
<h1 style="font-size:15px">未来七天 <span class="sub">日程 · 观影 · 旅行 · 到期任务</span></h1> <h2 class="cinema-h2">手头在做</h2>
<div class="actions"> <a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
<a class="btn btn-ghost btn-sm" href="<?= e(url('calendar')) ?>">看整月</a> </div>
<div class="hd-pane-body">
<?php if (!$tasks): ?>
<p class="empty-hint">没有进行中的任务。</p>
<?php else: ?>
<ul class="dash-list tk-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<li class="dash-row tk-row">
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php /* 用户第 3 条:「除了显示任务的标题之外,还要显示他的状态。
比如说正在进行中待确认等等。」 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php /* 用户第 3 条:「还要显示他所处的看板的名称,就是他现在被放在哪一个看板里面」 */ ?>
<?php if (trim((string)($t['board_name'] ?? '')) !== ''): ?>
<span class="tk-board"><?= e((string)$t['board_name']) ?></span>
<?php endif; ?>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
<?php if (($t['due_at'] ?? '') !== ''): ?>
<span class="tk-due"><?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</div> </div>
</div> </div>
<?php if (!$soon): ?>
<div class="card card-pad empty"><b>这七天还没有安排</b>有事先记一笔,日后再翻才有得看。</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($soon as $ev): ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e((string)$ev['color']) ?>;"></span>
<span class="dash-when">
<?= e((string)$ev['date'] === $today ? '今天' : substr((string)$ev['date'], 5)) ?>
<?php if (($ev['time'] ?? '') !== ''): ?><b><?= e((string)$ev['time']) ?></b><?php endif; ?>
</span>
<a class="dash-what" href="<?= e((string)$ev['href']) ?>"><?= e((string)$ev['title']) ?></a>
<?php /* 用户 2026-10-04 第 11 批 #1:「右侧的日历本,现在看不清,
应该用一个色块,颜色就用日历的颜色,文字是日历本的文字,用白色。」
原来是 .dash-tag(--faint 灰色 11px 小字),在浅色纸上几乎看不见 ——
现在做成实心色块:底色 = 这本日历的颜色,字 = 白,一眼能对上左侧那个小圆点。 */ ?>
<span class="dash-cal"
style="background: <?= e((string)$ev['color']) ?>;"><?= e((string)$ev['calname']) ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section> </section>
<?php /* ==================== 进行中的任务 ==================== */ ?> <?php /* ==================== ③ 往年的今天 ==================== */ ?>
<section class="section">
<div class="page-head" style="margin-bottom:12px">
<h1 style="font-size:15px">手头在做 <span class="sub">进行中 · 已逾期</span></h1>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('tasks')) ?>">任务看板</a>
</div>
</div>
<?php if (!$tasks): ?>
<div class="card card-pad empty"><b>没有进行中的任务</b>也没有逾期的事,挺好。</div>
<?php else: ?>
<div class="card card-pad">
<ul class="dash-list">
<?php foreach ($tasks as $t): ?>
<?php $late = \App\Task::isOverdue($t['due_at'] ?? null); ?>
<li class="dash-row">
<span class="dash-dot" style="background: <?= e($late ? 'var(--red)' : 'var(--brand)') ?>;"></span>
<span class="dash-when">
<?php if (($t['due_at'] ?? '') !== ''): ?>
<?= e(\App\Task::dueWord($t['due_at'] ?? null)) ?>
<?php else: ?>
进行中
<?php endif; ?>
</span>
<a class="dash-what" href="<?= e(url('tasks/show', ['id' => (int)$t['id']])) ?>">
<?= e((string)$t['title']) ?>
</a>
<?php /* v24 第 14 批 用户第 2 条:首页这一块是**按状态**捞出来的
(进行中 + 已逾期的待办,跨看板),所以把状态标出来 ——
否则"待办为什么出现在这里"看不出来。 */ ?>
<span class="kb-st is-<?= e((string)($t['status'] ?? 'todo')) ?> is-static">
<?= e(\App\Task::statusWord((string)($t['status'] ?? 'todo'))) ?>
</span>
<?php if ((int)($t['child_total'] ?? 0) > 0): ?>
<span class="dash-tag"><?= (int)$t['child_done'] ?>/<?= (int)$t['child_total'] ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</section>
<?php /* ==================== 往年今日 ==================== */ ?>
<section class="section"> <section class="section">
<div class="page-head" style="margin-bottom:12px"> <div class="page-head" style="margin-bottom:12px">
<?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?> <?php /* v24 第 19 条:只认「往年的今天」,不再退回同月 —— 见 home_memories() 的说明。 */ ?>
@@ -240,4 +322,4 @@ $homeMods = array_values(array_filter(
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
</section> </section>
+21 -3
View File
@@ -602,16 +602,34 @@ $ledMe = (int)($me ?? 0);
} }
// ③ 多线图:按类别显隐线条(几何不动,只切可见性) // ③ 多线图:按类别显隐线条(几何不动,只切可见性)
//
// 🔴🔴 v24 第 24 批 用户第 6 条修的真 bug(我上一批引入的):
// 这里原来写的是 `g.hidden = ...` —— **对 SVG 元素完全无效**。
// `hidden` 是 **HTML 元素**的属性(映射到 `[hidden]{display:none}`);
// `<g>` 是 SVG 元素,它身上根本没有 `hidden` 这个 IDL 属性,
// 赋值只是往对象上挂了一个**谁都不看的 JS 属性** → 线条一条都没被藏起来。
// 用户原话:「选了存款,它本来有五条线,但是这还是显示的所有的线。」
// 症状最迷惑的地方:第一张图(用 `w.hidden` 藏 **div**)**真的会换**,
// 所以看起来"筛选基本是好的,只是第二张图有点不太对"。
// ⇒ 正确的隐藏方式是 **`style.display`**(SVG 认这个)。
// 必须同时给 `g` 与 `li`/`tr` 用同一种写法:
// · `<g>` 是 SVG → 只能 display
// · `.ck-lg-item` / `<tr>` 是 HTML,两样都行;统一用 display 少一个特例。
// ⚠️ 反向也要对:`style.display = ''` 是"恢复默认",不是 `'block'` ——
// 给 `<g>` 写 display:block 会把 SVG 的显示模型搞乱(不同浏览器表现不一致)。
Array.prototype.forEach.call(document.querySelectorAll('[data-ck-line]'), function (g) { 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) { 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) { 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' : '';
}); });
// ⑤ 概览卡里的高亮条 // ⑤ 概览卡里的高亮条
+545
View File
@@ -0,0 +1,545 @@
<?php
/**
* 个人导航 · 管理页(v24 第 24 批 用户第 4 条,从 SimpleNav 的 admin 搬过来)
*
* ⚠️ 与 SimpleNav 的 admin 的区别(只有一处,但很要紧):
* 原项目是**单用户**系统,admin 直接改全局数据;
* 这里**每人一份**,所有操作都带 `person_id`,且**只能动自己的**。
* 路由层(index.php 的 nav/save)已经把 person_id 焊死在每个动作里 ——
* 本模板只负责把"我自己的"数据摆出来。
*
* ⚠️ 拖拽排序与开关走 AJAX(fetch + X-Requested-With),与 SimpleNav 的 admin.js 同一套;
* 其余表单是普通 POST(保存后 302 回本页,刷一下就看到结果,比 AJAX 简单可靠)。
*
* @param array $gridGroups 我的网格板块
* @param array $tabGroups 我的合集板块
* @param array $allGroups 我的全部板块(下拉用)
* @param array $sitesBy group_id => 站点列表
* @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);
?>
<div class="page-head">
<h1>导航</h1>
<span class="sub">你个人用的网址导航 · 每人一份</span>
<div class="actions">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav')) ?>">刷新</a>
</div>
</div>
<?php /* ==================== ① 展示页地址与短语(用户第 4 条的"特殊地址")====================
用户原话:「给他一个非常特殊的地址…地址后面可以跟着用户添加的一个短语,就跟类似
于一个密钥一样,他可以给自己起一个短语…这个页面是不需要登录的。」
⚠️ 这卡片放在**最上面**:它是这个模块与其它模块最不一样的地方(免登录),
也最容易让人困惑("我的导航页在哪?怎么发给别人?")。 */ ?>
<div class="card card-pad" id="navLink">
<h2 class="cinema-h2">
我的导航页地址
<span class="hint">这个地址<b>不需要登录</b>,可以直接发给别人看</span>
</h2>
<?php if ($phrase !== ''): ?>
<div class="field">
<label>展示页地址</label>
<input class="input mono" readonly value="<?= e($showUrl) ?>">
<span class="tip">
短语 <b><?= e($phrase) ?></b> 就是这页的"钥匙"—— 换一个短语,旧地址立刻失效。
<?php /* 两种地址写法都支持:?q=xxx 与 ?xxx(见 nav_show.php 的说明) */ ?>
也支持写成 <code class="mono"><?= e(rtrim((string)fb_config('base_url', ''), '/')) ?>/nav/?<?= e($phrase) ?></code>。
</span>
</div>
<?php else: ?>
<p class="tip" style="background:#FFF6E5;border-color:#E8C98A;padding:8px 12px;border-radius:8px">
<b>还没设短语</b> —— 设一个短语之后,才能通过地址把导航页分享出去(那页不需要登录)。
</p>
<?php endif; ?>
<?php if ($canWrite): ?>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form">
<?= csrf_field() ?>
<input type="hidden" name="act" value="phrase_save">
<div class="field">
<label>我的短语</label>
<input class="input mono" name="phrase" value="<?= e($phrase) ?>"
placeholder="如 yangyq(4~32 位字母/数字/下划线/连字符)" autocomplete="off">
<span class="tip">要 <b>4~32 位</b>,只能用字母、数字、下划线、连字符;不能与别人重复。</span>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-sm" type="submit">保存短语</button>
</div>
</form>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form"
onsubmit="return confirm('换成一串随机短语?之前的地址会立刻失效。')">
<?= csrf_field() ?>
<input type="hidden" name="act" value="phrase_random">
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
</form>
<?php endif; ?>
</div>
<?php /* ==================== ② 设置(照搬 SimpleNav 的 settings)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">导航设置</h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="settings_save">
<div class="form-grid">
<div class="field">
<label>站点名</label>
<input class="input" name="site_name" value="<?= e($sc('site_name', '我的导航')) ?>">
<span class="tip">展示页最上方的大标题。</span>
</div>
<div class="field">
<label>副标题</label>
<input class="input" name="subtitle" value="<?= e($sc('subtitle')) ?>" placeholder="留空就不显示">
<?php /* ⚠️ SimpleNav 原文把"副标题清空后仍然显示"单列为 bug 现场,
这里保留它的行为:**空就是空**,不回退成默认文案。 */ ?>
<span class="tip">留空就<b>不显示</b>(不会自动填回默认值)。</span>
</div>
<div class="field">
<label>主题色</label>
<input class="input" type="color" name="primary_color"
value="<?= e(\App\Nav::safeColor($sc('primary_color', '#2F6BF7'), '#2F6BF7')) ?>"
style="height:38px;padding:3px">
</div>
<div class="field">
<label>默认搜索引擎</label>
<select class="select" name="default_engine">
<?php foreach ($engines as $en): ?>
<option value="<?= e((string)$en['slug']) ?>"
<?= (string)$en['slug'] === $sc('default_engine') ? 'selected' : '' ?>>
<?= e((string)$en['name']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>链接打开方式</label>
<select class="select" name="open_blank">
<option value="1" <?= $sc('open_blank', '1') === '1' ? 'selected' : '' ?>>新标签页</option>
<option value="0" <?= $sc('open_blank', '1') === '0' ? 'selected' : '' ?>>当前页面</option>
</select>
</div>
<div class="field">
<label>联想词按键</label>
<select class="select" name="suggest_keys">
<option value="both" <?= $sc('suggest_keys', 'both') === 'both' ? 'selected' : '' ?>>↓/↑ 与 , . 都认(推荐)</option>
<option value="arrows" <?= $sc('suggest_keys', 'both') === 'arrows' ? 'selected' : '' ?>>只认 ↓/↑</option>
<option value="punct" <?= $sc('suggest_keys', 'both') === 'punct' ? 'selected' : '' ?>>只认 , 和 .</option>
</select>
<span class="tip">在展示页的搜索框里,用哪组键选中联想词。</span>
</div>
<div class="field">
<label>合集显示方式</label>
<select class="select" name="collections_layout">
<option value="tabs" <?= $sc('collections_layout', 'tabs') === 'tabs' ? 'selected' : '' ?>>标签页折叠(一次看一个)</option>
<option value="flat" <?= $sc('collections_layout', 'tabs') === 'flat' ? 'selected' : '' ?>>全部平铺(一眼看全)</option>
</select>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit">保存设置</button>
<?php if ($canWrite): ?>
<button class="btn btn-ghost" type="submit" formaction="<?= e(url('nav/save')) ?>"
name="act" value="fetch_all"
onclick="return confirm('会把所有站点的名称和图标重新抓一遍,可能比较慢。自己传过图标的不会被覆盖。继续?')">
全部更新(抓名称与图标)
</button>
<?php endif; ?>
</div>
</form>
</div>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */ ?>
<?php if ($canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="site_save">
<?php if ($editSite): ?>
<input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>网址 *</label>
<input class="input mono" name="url" required
value="<?= e((string)($editSite['url'] ?? '')) ?>" placeholder="https://example.com">
</div>
<div class="field">
<label>所属板块 *</label>
<select class="select" name="group_id" required>
<?php foreach ($allGroups as $g): ?>
<option value="<?= (int)$g['id'] ?>"
<?= (int)($editSite['group_id'] ?? 0) === (int)$g['id'] ? 'selected' : '' ?>>
<?= e((string)$g['name']) ?><?= (string)$g['display'] === 'tabs' ? '(合集)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="field">
<label>显示名</label>
<input class="input" name="name" value="<?= e((string)($editSite['name'] ?? '')) ?>"
placeholder="留空会自动抓">
</div>
<div class="field">
<label>简称</label>
<input class="input" name="short_name" value="<?= e((string)($editSite['short_name'] ?? '')) ?>"
placeholder="页面上显示的字(优先于显示名)">
</div>
<div class="field full">
<label class="chk">
<input type="checkbox" name="is_visible" value="1" checked> 在展示页上显示
</label>
<?php if (!$editSite): ?>
<label class="chk">
<input type="checkbox" name="do_fetch" value="1" checked>
顺便抓取网站名与图标(联网,稍慢)
</label>
<?php endif; ?>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editSite ? '保存' : '添加' ?></button>
<?php if ($editSite): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<?php /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2">
站点列表
<span class="hint">按住左边的 ⋮⋮ 可以拖动排序</span>
</h2>
<?php if (!$sitesBy): ?>
<p class="empty-hint">还没有站点。上面「新增站点」加一个吧。</p>
<?php else: ?>
<?php foreach ($allGroups as $g): ?>
<?php $rows = $sitesBy[(int)$g['id']] ?? []; if (!$rows) { continue; } ?>
<div class="nav-grp-block">
<h3 class="nav-sub"><?= e((string)$g['name']) ?>
<span class="small muted">(<?= count($rows) ?>)</span>
</h3>
<ul class="nav-sortlist" data-sort-list
data-reorder-url="<?= e(url('nav/save')) ?>"
data-group-id="<?= (int)$g['id'] ?>">
<?php foreach ($rows as $r): ?>
<li class="nav-sortrow<?= empty($r['is_visible']) ? ' is-off' : '' ?>"
data-id="<?= (int)$r['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<?php $icon = \App\Nav::iconUrl((string)$r['icon']); ?>
<?php if ($icon !== ''): ?>
<img class="nav-ico" src="<?= e($icon) ?>" alt="">
<?php else: ?>
<span class="nav-ico nav-ico-letter"
style="background:<?= e(\App\Nav::letterColor(\App\Nav::siteLabel($r))) ?>">
<?= e(mb_substr(\App\Nav::siteLabel($r), 0, 1, 'UTF-8')) ?>
</span>
<?php endif; ?>
<span class="nav-sname"><?= e(\App\Nav::siteLabel($r)) ?></span>
<span class="nav-surl small muted"><?= e(\App\Nav::domainOf((string)$r['url'])) ?></span>
<?php if (empty($r['is_visible'])): ?><span class="node-badge">已隐藏</span><?php endif; ?>
<?php if (!empty($r['icon_custom'])): ?><span class="node-badge">自定义图标</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_site' => (int)$r['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="site" data-nav-id="<?= (int)$r['id'] ?>">
<?= empty($r['is_visible']) ? '显示' : '隐藏' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="site" data-nav-id="<?= (int)$r['id'] ?>"
data-nav-name="<?= e(\App\Nav::siteLabel($r)) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<?php /* ==================== ⑤ 板块 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editGroup ? '编辑板块' : '新增板块' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="group_save">
<?php if ($editGroup): ?>
<input type="hidden" name="id" value="<?= (int)$editGroup['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>板块名 *</label>
<input class="input" name="name" required value="<?= e((string)($editGroup['name'] ?? '')) ?>"
placeholder="如 常用 / 工具">
</div>
<div class="field">
<label>显示方式</label>
<select class="select" name="display">
<option value="grid" <?= (string)($editGroup['display'] ?? 'grid') === 'grid' ? 'selected' : '' ?>>普通网格(一直平铺)</option>
<option value="tabs" <?= (string)($editGroup['display'] ?? '') === 'tabs' ? 'selected' : '' ?>>合集标签页(折叠,点开才看)</option>
</select>
</div>
<div class="field full">
<label class="chk"><input type="checkbox" name="is_visible" value="1" checked> 在展示页上显示</label>
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editGroup ? '保存' : '添加' ?></button>
<?php if ($editGroup): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">板块列表 <span class="hint">拖动排序</span></h2>
<ul class="nav-sortlist" data-sort-list
data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($allGroups as $g): ?>
<li class="nav-sortrow<?= empty($g['is_visible']) ? ' is-off' : '' ?>" data-id="<?= (int)$g['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<span class="nav-sname"><?= e((string)$g['name']) ?></span>
<span class="node-badge"><?= (string)$g['display'] === 'tabs' ? '合集' : '网格' ?></span>
<span class="small muted"><?= count($sitesBy[(int)$g['id']] ?? []) ?> 个站点</span>
<?php if (empty($g['is_visible'])): ?><span class="node-badge">已隐藏</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_group' => (int)$g['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="group" data-nav-id="<?= (int)$g['id'] ?>">
<?= empty($g['is_visible']) ? '显示' : '隐藏' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="group" data-nav-id="<?= (int)$g['id'] ?>"
data-nav-name="<?= e((string)$g['name']) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php /* ==================== ⑥ 搜索引擎 ==================== */ ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editEngine ? '编辑搜索引擎' : '新增搜索引擎' ?></h2>
<form method="post" action="<?= e(url('nav/save')) ?>">
<?= csrf_field() ?>
<input type="hidden" name="act" value="engine_save">
<?php if ($editEngine): ?>
<input type="hidden" name="id" value="<?= (int)$editEngine['id'] ?>">
<?php endif; ?>
<div class="form-grid">
<div class="field">
<label>名称 *</label>
<input class="input" name="name" required value="<?= e((string)($editEngine['name'] ?? '')) ?>">
</div>
<div class="field">
<label>标识</label>
<input class="input mono" name="slug" value="<?= e((string)($editEngine['slug'] ?? '')) ?>"
placeholder="留空自动生成">
</div>
<div class="field">
<label>搜索地址 *</label>
<input class="input mono" name="search_url" required
value="<?= e((string)($editEngine['search_url'] ?? '')) ?>"
placeholder="https://cn.bing.com/search">
</div>
<div class="field">
<label>关键词参数 *</label>
<input class="input mono" name="param"
value="<?= e((string)($editEngine['param'] ?? 'q')) ?>" placeholder="q">
<span class="tip">百度是 wd,搜狗是 query,多数是 q。</span>
</div>
<div class="field">
<label>联想接口(可选)</label>
<input class="input mono" name="suggest_url"
value="<?= e((string)($editEngine['suggest_url'] ?? '')) ?>"
placeholder="https://api.bing.com/osjson.aspx?query={q}">
<span class="tip">用 <code class="mono">{q}</code> 占位关键词;留空 = 这个引擎没有联想。</span>
</div>
<div class="field">
<label>联想类型</label>
<input class="input mono" name="suggest_type"
value="<?= e((string)($editEngine['suggest_type'] ?? '')) ?>"
placeholder="bing_osjson / baidu_sugrec / so360">
</div>
<div class="field">
<label>颜色</label>
<input class="input" type="color" name="color"
value="<?= e(\App\Nav::safeColor((string)($editEngine['color'] ?? ''), '#2F6BF7')) ?>"
style="height:38px;padding:3px">
</div>
</div>
<div class="btn-row">
<button class="btn" type="submit"><?= $editEngine ? '保存' : '添加' ?></button>
<?php if ($editEngine): ?>
<a class="btn btn-ghost" href="<?= e(url('nav')) ?>">取消</a>
<?php endif; ?>
</div>
</form>
</div>
<div class="card card-pad">
<h2 class="cinema-h2">搜索引擎列表</h2>
<ul class="nav-sortlist" data-sort-list data-reorder-url="<?= e(url('nav/save')) ?>">
<?php foreach ($engines as $en): ?>
<li class="nav-sortrow<?= empty($en['is_active']) ? ' is-off' : '' ?>" data-id="<?= (int)$en['id'] ?>">
<span class="nav-drag" title="拖动排序">⋮⋮</span>
<span class="nav-ico nav-ico-letter"
style="background:<?= e(\App\Nav::safeColor((string)$en['color'], '#2F6BF7')) ?>">
<?= e(mb_substr((string)$en['name'], 0, 1, 'UTF-8')) ?>
</span>
<span class="nav-sname"><?= e((string)$en['name']) ?></span>
<span class="small muted mono"><?= e((string)$en['slug']) ?></span>
<?php if (empty($en['is_active'])): ?><span class="node-badge">未启用</span><?php endif; ?>
<span class="nav-acts">
<a class="btn btn-ghost btn-sm" href="<?= e(url('nav', ['edit_engine' => (int)$en['id']])) ?>">编辑</a>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-toggle="engine" data-nav-id="<?= (int)$en['id'] ?>">
<?= empty($en['is_active']) ? '启用' : '停用' ?>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-nav-del="engine" data-nav-id="<?= (int)$en['id'] ?>"
data-nav-name="<?= e((string)$en['name']) ?>">删除</button>
</span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<script>
/* 导航管理页的交互(v24 第 24 批)。
⚠️ 拖拽排序与开关走 AJAX(fetch + X-Requested-With),服务端据此回 JSON;
其余表单是普通 POST —— 保存后整页刷新,结果一眼可见,比 AJAX 可靠。
⚠️ 排序提交的是**整组 id 顺序**(SimpleNav 的做法:整组归一化,
每次拖完间隙都恢复整齐,之后新增仍天然排最后)。 */
(function () {
var TOKEN = (function () {
var el = document.querySelector('input[name="_csrf"]');
return el ? el.value : '';
})();
function post(data) {
var fd = new FormData();
fd.append('_csrf', TOKEN);
Object.keys(data).forEach(function (k) {
if (Array.isArray(data[k])) {
data[k].forEach(function (v) { fd.append(k + '[]', v); });
} else {
fd.append(k, data[k]);
}
});
return fetch(NAV_SAVE_URL, {
method: 'POST', body: fd, credentials: 'same-origin',
headers: { 'X-Requested-With': 'XMLHttpRequest' }
}).then(function (r) { return r.json().catch(function () { return { ok: false, error: '服务器返回的不是 JSON' }; }); });
}
var NAV_SAVE_URL = <?= json_encode(url('nav/save'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>;
/* ---- 拖拽排序(原生 HTML5 DnD,不引库)---- */
Array.prototype.forEach.call(document.querySelectorAll('[data-sort-list]'), function (list) {
var dragEl = null;
list.addEventListener('dragstart', function (e) {
var row = e.target.closest ? e.target.closest('.nav-sortrow') : null;
if (!row) { return; }
dragEl = row;
row.classList.add('is-dragging');
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; }
});
list.addEventListener('dragover', function (e) {
if (!dragEl) { return; }
e.preventDefault();
var row = e.target.closest ? e.target.closest('.nav-sortrow') : null;
if (!row || row === dragEl) { return; }
var r = row.getBoundingClientRect();
var after = (e.clientY - r.top) > r.height / 2;
list.insertBefore(dragEl, after ? row.nextSibling : row);
});
list.addEventListener('dragend', function () {
if (!dragEl) { return; }
dragEl.classList.remove('is-dragging');
dragEl = null;
var ids = Array.prototype.map.call(list.querySelectorAll('.nav-sortrow'),
function (r) { return r.getAttribute('data-id'); });
post({
act: 'site_reorder', ids: ids,
group_id: list.getAttribute('data-group-id') || 0
}).then(function () { window.location.reload(); });
});
/* 让行可拖:整行 draggable,但只有按住 ⋮⋮ 才起拖(避免误拖) */
Array.prototype.forEach.call(list.querySelectorAll('.nav-sortrow'), function (row) {
var handle = row.querySelector('.nav-drag');
if (!handle) { return; }
handle.addEventListener('mousedown', function () { row.draggable = true; });
row.addEventListener('mouseup', function () { row.draggable = false; });
row.addEventListener('mouseleave', function () { row.draggable = false; });
});
});
/* ⚠️ 上面的排序对"方块列表"和"站点列表"是同一套,但两者提交的 act 不同:
站点那组带 data-group-id(属于某个板块),板块/引擎那组不带。
服务端据此区分 —— 这里补上"没有 group-id 时用 group_reorder / engine_reorder"。 */
Array.prototype.forEach.call(document.querySelectorAll('[data-sort-list]'), function (list) {
if (list.getAttribute('data-group-id') !== null) { return; } // 站点组,已在上面处理
// 用列表里第一行的线索判断是"板块"还是"引擎"
var isEngine = list.closest('.card') && /搜索引擎/.test(list.closest('.card').textContent);
var act = isEngine ? 'engine_reorder' : 'group_reorder';
list.addEventListener('dragend', function () {
var ids = Array.prototype.map.call(list.querySelectorAll('.nav-sortrow'),
function (r) { return r.getAttribute('data-id'); });
post({ act: act, ids: ids }).then(function () { window.location.reload(); });
});
});
/* ---- 显示/隐藏 与 删除(都走 AJAX)---- */
document.addEventListener('click', function (e) {
var t = e.target;
if (!t || !t.closest) { return; }
var tg = t.closest('[data-nav-toggle]');
if (tg) {
e.preventDefault();
post({ act: tg.getAttribute('data-nav-toggle') + '_toggle',
id: tg.getAttribute('data-nav-id') })
.then(function () { window.location.reload(); });
return;
}
var del = t.closest('[data-nav-del]');
if (del) {
e.preventDefault();
var kind = del.getAttribute('data-nav-del');
var nm = del.getAttribute('data-nav-name') || '';
var warn = kind === 'group'
? '删掉板块「' + nm + '」?里面的站点会一起删掉。'
: '删掉「' + nm + '」?';
if (!window.confirm(warn)) { return; }
post({ act: kind + '_delete', id: del.getAttribute('data-nav-id') })
.then(function () { window.location.reload(); });
}
});
})();
</script>