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:
@@ -29,14 +29,18 @@ use App\Family;
|
||||
use App\Geo;
|
||||
use App\Home;
|
||||
use App\HomeMods;
|
||||
use App\Holiday;
|
||||
use App\Anniversary;
|
||||
use App\Ics;
|
||||
use App\Kinship;
|
||||
use App\Ledger;
|
||||
use App\Lunar;
|
||||
use App\Movie;
|
||||
use App\MovieReview;
|
||||
use App\Module;
|
||||
use App\MovieStats;
|
||||
use App\Nav;
|
||||
use App\NavFetch;
|
||||
use App\NodeType;
|
||||
use App\Nickname;
|
||||
use App\Person;
|
||||
@@ -57,6 +61,7 @@ use App\Upload;
|
||||
use App\UserSetting;
|
||||
use App\Vehicle;
|
||||
use App\View;
|
||||
use App\WeRead;
|
||||
|
||||
if (!Database::hasTables()) {
|
||||
header('Location: install.php');
|
||||
@@ -796,6 +801,33 @@ switch ($r) {
|
||||
$meName = Nickname::display($me, $people[$me] ?? null) ?: (string)($people[$me]['name'] ?? '');
|
||||
$greeting = Home::greeting($meName, $today, count($soon), $lateN);
|
||||
|
||||
// ---- v24 第 24 批 用户第 2 条:信息面板要的那几样 ----
|
||||
// 「今天是几月几日?星期几?农历几月几日?如果有节气/重要节日/纪念日/生日,
|
||||
// 在这里显示;紧接着下面显示今天一共有多少条日程、几件待办正在进行。」
|
||||
$lunarToday = Lunar::solarToLunar((int)date('Y'), (int)date('n'), (int)date('j'));
|
||||
$lunarLine = $lunarToday === null ? '' : Lunar::label(
|
||||
(int)$lunarToday['year'], (int)$lunarToday['month'], (int)$lunarToday['day'],
|
||||
!empty($lunarToday['is_leap']));
|
||||
// 抬头要的是「农历几月几日」,不要"农历二〇二六年"那一段(面板上塞不下)
|
||||
$lunarShort = $lunarToday === null ? '' : (
|
||||
Lunar::monthName((int)$lunarToday['month'], !empty($lunarToday['is_leap']))
|
||||
. Lunar::dayName((int)$lunarToday['day']));
|
||||
$todayMarks = Home::todayMarks($familyId, $me, $today);
|
||||
|
||||
// 今天的日程(v24 第 24 批 用户第 3 条:左侧那块要"今天的日程,日视图那样")
|
||||
// ⚠️ 与「未来七天」不同:这里**只要今天**,而且要按时间排好(日视图从上往下就是时间序)。
|
||||
$todayEvents = array_values(array_filter($soon, static function (array $e) use ($today): bool {
|
||||
return (string)($e['date'] ?? '') === $today;
|
||||
}));
|
||||
usort($todayEvents, static function (array $a, array $b): int {
|
||||
// 全天事件(无时间)排最前 —— 日视图的习惯就是这样
|
||||
$ta = (string)($a['time'] ?? '');
|
||||
$tb = (string)($b['time'] ?? '');
|
||||
if ($ta === '' && $tb !== '') { return -1; }
|
||||
if ($tb === '' && $ta !== '') { return 1; }
|
||||
return strcmp($ta, $tb);
|
||||
});
|
||||
|
||||
// 天气:地点在**个人设置**里(v24 用户 2026-10-03 第 1 条),每人一份。
|
||||
// 三级回落都在这一个调用里:自定义经纬度 → 内置城市表 → 不显示。
|
||||
$weatherLoc = Home::weatherLocation($me, $familyId);
|
||||
@@ -807,11 +839,13 @@ switch ($r) {
|
||||
'family' => Family::find($familyId),
|
||||
'me' => $people[$me] ?? null,
|
||||
'meId' => $me,
|
||||
'meName' => $meName,
|
||||
'kin' => $kin,
|
||||
'allCount' => count($people),
|
||||
'relCount' => Relations::count($familyId),
|
||||
'birthdays' => $birthdays,
|
||||
'soon' => $soon,
|
||||
'todayEvents'=> $todayEvents,
|
||||
'tasks' => $tasks,
|
||||
'memories' => $memories,
|
||||
// 个人 slogan 优先,没填才回落到家庭统一的那句(见 settings 页)
|
||||
@@ -822,6 +856,12 @@ switch ($r) {
|
||||
'weekday' => Home::weekday($today),
|
||||
'weather' => $weather,
|
||||
'stat7' => ['events' => $ev7, 'todos' => $todoN, 'overdue' => $lateN],
|
||||
// v24 第 24 批:信息面板
|
||||
'todayLabel' => date('n 月 j 日'),
|
||||
'lunarShort' => $lunarShort,
|
||||
'lunarLine' => $lunarLine,
|
||||
'todayMarks' => $todayMarks,
|
||||
'today' => $today,
|
||||
'homeModules'=> $homeMods,
|
||||
'moduleCards'=> $moduleCards,
|
||||
], 'home');
|
||||
@@ -5475,6 +5515,101 @@ switch ($r) {
|
||||
], 'books');
|
||||
break;
|
||||
|
||||
// ---- 微信读书导入(v24 第 24 批 用户第 5 条)----
|
||||
//
|
||||
// 用户原话:「能不能导入微信的读书笔记,就是每一个人设置一下…授权一下」
|
||||
// → 后续明确「用微信读书 api key」(官方 2026 已开放,见 App\WeRead 头部)
|
||||
//
|
||||
// ⚠️ Key 是**账号凭据**:加密落库、界面只回显掩码(同 DAV 口令那套思路)。
|
||||
case 'books/weread':
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
$imported = null;
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
|
||||
csrf_check();
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
if ($act === 'key_save') {
|
||||
$r = WeRead::saveKey($me, (string)($_POST['api_key'] ?? ''));
|
||||
flash($r['ok'] ? 'ok' : 'err', $r['ok']
|
||||
? 'API Key 已保存(加密存放,页面只显示掩码)。'
|
||||
: (string)$r['error']);
|
||||
redirect('books/weread');
|
||||
}
|
||||
if ($act === 'fetch') {
|
||||
// 「拉取预览」:只取数据、**不落库**,让用户先看一眼再决定
|
||||
$nb = WeRead::notebooks($me);
|
||||
if (!$nb['ok']) {
|
||||
flash('err', (string)$nb['error']);
|
||||
redirect('books/weread');
|
||||
}
|
||||
$preview = [];
|
||||
foreach (array_slice($nb['books'], 0, 40) as $b) {
|
||||
$nt = WeRead::notes($me, (string)$b['bookId']);
|
||||
$preview[] = [
|
||||
'title' => (string)$b['title'],
|
||||
'author' => (string)$b['author'],
|
||||
'bookId' => (string)$b['bookId'],
|
||||
'count' => count((array)($nt['items'] ?? [])),
|
||||
'items' => array_slice((array)($nt['items'] ?? []), 0, 3),
|
||||
'local' => WeRead::matchLocalBook((int)Auth::familyId(), (string)$b['title']),
|
||||
];
|
||||
}
|
||||
View::render('books/weread', [
|
||||
'title' => '微信读书导入',
|
||||
'preview' => $preview,
|
||||
'nbCount' => count($nb['books']),
|
||||
], 'books');
|
||||
break;
|
||||
}
|
||||
if ($act === 'import') {
|
||||
// 「真的导入」:只导**勾选的那些书**(用户在预览页里挑)
|
||||
$want = array_map('strval', (array)($_POST['pick'] ?? []));
|
||||
$nb = WeRead::notebooks($me);
|
||||
if (!$nb['ok']) {
|
||||
flash('err', (string)$nb['error']);
|
||||
redirect('books/weread');
|
||||
}
|
||||
$familyId = (int)Auth::familyId();
|
||||
$sumBooks = $sumNotes = 0;
|
||||
foreach ($nb['books'] as $b) {
|
||||
if (!in_array((string)$b['bookId'], $want, true)) {
|
||||
continue;
|
||||
}
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') {
|
||||
continue;
|
||||
}
|
||||
$bid = WeRead::matchLocalBook($familyId, $title);
|
||||
if ($bid <= 0) {
|
||||
$bid = Book::create($familyId, $me, [
|
||||
'title' => $title,
|
||||
'author' => (string)$b['author'],
|
||||
'status' => 'reading',
|
||||
]);
|
||||
if ($bid <= 0) {
|
||||
continue;
|
||||
}
|
||||
$sumBooks++;
|
||||
}
|
||||
$nt = WeRead::notes($me, (string)$b['bookId']);
|
||||
if (!$nt['ok']) {
|
||||
continue;
|
||||
}
|
||||
$res = WeRead::store($bid, $me, (array)$nt['items']);
|
||||
$sumNotes += (int)$res['added'];
|
||||
}
|
||||
flash('ok', '导入完成:新建 ' . $sumBooks . ' 本书、'
|
||||
. $sumNotes . ' 条笔记(重复的已自动跳过)。');
|
||||
redirect('books');
|
||||
}
|
||||
}
|
||||
View::render('books/weread', [
|
||||
'title' => '微信读书导入',
|
||||
'preview' => $imported,
|
||||
'nbCount' => 0,
|
||||
], 'books');
|
||||
break;
|
||||
|
||||
case 'books/notes_import':
|
||||
Auth::requireWrite();
|
||||
// 同一条路由干两件事:GET 给粘贴框,POST 先解析出**预览**。
|
||||
@@ -5518,6 +5653,256 @@ switch ($r) {
|
||||
redirect('books');
|
||||
break;
|
||||
|
||||
// ==================== 个人导航(v24 第 24 批 用户第 4 条,从 SimpleNav 搬进来)====================
|
||||
//
|
||||
// ⚠️ 这一块与其它模块最大的不同:**导航展示页不需要登录**(用户明确要求),
|
||||
// 它的入口在 index.php 之外(`nav_show.php`),因为它必须在 bootstrap
|
||||
// 的"要求登录"之前就放行。这里的两条路由都是**管理端**(要登录)。
|
||||
|
||||
case 'nav':
|
||||
$me = (int)Auth::personId();
|
||||
Nav::ensureSeed($me); // 第一次进来给一份默认导航(只在他一条板块都没有时)
|
||||
$gridGroups = Nav::groups($me, 'grid', true);
|
||||
$tabGroups = Nav::groups($me, 'tabs', true);
|
||||
$allGroups = Nav::groups($me);
|
||||
$sitesBy = Nav::sitesByGroup($me);
|
||||
$engines = Nav::engines($me);
|
||||
$settings = Nav::settings($me);
|
||||
$phrase = Nav::phrase($me);
|
||||
View::render('nav/index', [
|
||||
'title' => '导航',
|
||||
'meId' => $me,
|
||||
'gridGroups' => $gridGroups,
|
||||
'tabGroups' => $tabGroups,
|
||||
'allGroups' => $allGroups,
|
||||
'sitesBy' => $sitesBy,
|
||||
'engines' => $engines,
|
||||
'navSet' => $settings,
|
||||
'phrase' => $phrase,
|
||||
// 展示页地址(短语为空时模板里会提示去设置)
|
||||
'showUrl' => $phrase !== ''
|
||||
? rtrim((string)fb_config('base_url', ''), '/') . '/nav/?q=' . rawurlencode($phrase)
|
||||
: '',
|
||||
// 我自己的头像 —— 展示页右上角要用(用户原话「头像就变成用户自己的头像」)
|
||||
'meAvatar' => avatar_url(Person::find($me)['avatar'] ?? null),
|
||||
], 'nav');
|
||||
break;
|
||||
|
||||
case 'nav/save':
|
||||
$me = (int)Auth::personId();
|
||||
Auth::requireWrite();
|
||||
csrf_check();
|
||||
// 一个路由干多件事(与 SimpleNav 的 admin/actions.php 同一个思路:
|
||||
// 那个文件就是按 ?action= 分派的)。这里用 act 区分,避免开十几条路由。
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
$ok = static function (string $msg): void {
|
||||
// AJAX(导航管理页的拖拽排序/开关都是 fetch)→ 回 JSON;否则回 302
|
||||
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => true, 'msg' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('ok', $msg);
|
||||
redirect('nav');
|
||||
};
|
||||
$bad = static function (string $msg): void {
|
||||
if (strtolower((string)($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '')) === 'xmlhttprequest') {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['ok' => false, 'error' => $msg], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
flash('err', $msg);
|
||||
redirect('nav');
|
||||
};
|
||||
|
||||
$gid = (int)($_POST['id'] ?? 0);
|
||||
switch ($act) {
|
||||
case 'group_save':
|
||||
$id = Nav::saveGroup($me, [
|
||||
'name' => (string)($_POST['name'] ?? ''),
|
||||
'display' => (string)($_POST['display'] ?? 'grid'),
|
||||
'is_visible' => !empty($_POST['is_visible']),
|
||||
], $gid);
|
||||
$id > 0 ? $ok('板块已保存。') : $bad('板块得有个名字。');
|
||||
break;
|
||||
|
||||
case 'group_delete':
|
||||
Nav::removeGroup($me, $gid);
|
||||
$ok('板块已删除(里面的站点一起删了)。');
|
||||
break;
|
||||
|
||||
case 'group_toggle':
|
||||
Nav::toggleGroup($me, $gid);
|
||||
$ok('已切换显示。');
|
||||
break;
|
||||
|
||||
case 'group_reorder':
|
||||
// 拖拽后整组顺序一次提交(SimpleNav 的做法:整组归一化,间隙恢复整齐)
|
||||
// ⚠️ 同 site_reorder:不是数组就拒绝,别静默只改一项(见那边的说明)。
|
||||
if (!is_array($_POST['ids'] ?? null)) {
|
||||
$bad('排序数据格式不对(应是一组 id)。');
|
||||
}
|
||||
$ids = array_map('intval', (array)$_POST['ids']);
|
||||
$n = Nav::applySort('nav_groups', $me, $ids);
|
||||
$ok('顺序已保存(' . $n . ' 项)。');
|
||||
break;
|
||||
|
||||
case 'site_save':
|
||||
$url = Nav::normUrl((string)($_POST['url'] ?? ''));
|
||||
$id = Nav::saveSite($me, [
|
||||
'group_id' => (int)($_POST['group_id'] ?? 0),
|
||||
'url' => $url,
|
||||
'name' => (string)($_POST['name'] ?? ''),
|
||||
'short_name' => (string)($_POST['short_name'] ?? ''),
|
||||
'is_visible' => !empty($_POST['is_visible']),
|
||||
], $gid);
|
||||
if ($id <= 0) {
|
||||
$bad('站点地址和所属板块都不能空。');
|
||||
}
|
||||
// 抓元信息(名称 + 图标):用户勾了"顺便抓取"才做(联网、慢)
|
||||
if (!empty($_POST['do_fetch'])) {
|
||||
$meta = NavFetch::meta($url);
|
||||
$db = Database::get();
|
||||
$newName = trim((string)($meta['name'] ?? ''));
|
||||
$file = NavFetch::fetchIcon($meta, $url);
|
||||
if ($file !== '') {
|
||||
// ⚠️ 抓来的图标**不算**自定义(icon_custom=0),
|
||||
// 下次「全部更新」可以覆盖它 —— 这正是 SimpleNav 的语义。
|
||||
$db->prepare('UPDATE nav_sites SET icon = ?, icon_custom = 0,
|
||||
name = CASE WHEN name = \'\' OR name IS NULL THEN ? ELSE name END
|
||||
WHERE id = ? AND person_id = ?')
|
||||
->execute([$file, $newName !== '' ? $newName : Nav::domainOf($url), $id, $me]);
|
||||
}
|
||||
}
|
||||
$ok('站点已保存。');
|
||||
break;
|
||||
|
||||
case 'site_delete':
|
||||
Nav::removeSite($me, $gid);
|
||||
$ok('站点已删除。');
|
||||
break;
|
||||
|
||||
case 'site_toggle':
|
||||
Nav::toggleSite($me, $gid);
|
||||
$ok('已切换显示。');
|
||||
break;
|
||||
|
||||
case 'site_reorder':
|
||||
// ⚠️ 此处**必须**把「不是数组」当成拒绝,不能静默将就:
|
||||
// 如果前端漏了 `ids[]` 的方括号(或有人手搓请求),
|
||||
// `$_POST['ids']` 会是**一个字符串**而不是数组。
|
||||
// 直接 `(array)` 强转的话它变成 `['8,9,10']` → applySort 里
|
||||
// `(int)'8,9,10'` = 8 → **只改了第一项却回「成功」**。
|
||||
// (本轮实测:探针正好踩到这个组合,报了"排序成功但没落库"。)
|
||||
// 宁可回一句明确的话,也不要"看起来成功"。
|
||||
$rawIds = $_POST['ids'] ?? null;
|
||||
if (!is_array($rawIds)) {
|
||||
$bad('排序数据格式不对(应是一组 id)。');
|
||||
}
|
||||
$ids = array_map('intval', $rawIds);
|
||||
$to = (int)($_POST['group_id'] ?? 0);
|
||||
$n = Nav::applySort('nav_sites', $me, $ids, 'group_id', $to);
|
||||
$ok('顺序已保存(' . $n . ' 项)。');
|
||||
break;
|
||||
|
||||
case 'site_move':
|
||||
Nav::moveSite($me, $gid, (int)($_POST['group_id'] ?? 0))
|
||||
? $ok('已移动到别的板块。') : $bad('目标板块不存在。');
|
||||
break;
|
||||
|
||||
case 'engine_save':
|
||||
$id = Nav::saveEngine($me, $_POST, $gid);
|
||||
$id > 0 ? $ok('搜索引擎已保存。') : $bad('名称和搜索地址都不能空。');
|
||||
break;
|
||||
|
||||
case 'engine_delete':
|
||||
Nav::removeEngine($me, $gid);
|
||||
$ok('搜索引擎已删除。');
|
||||
break;
|
||||
|
||||
case 'engine_toggle':
|
||||
Nav::toggleEngine($me, $gid);
|
||||
$ok('已切换启用状态。');
|
||||
break;
|
||||
|
||||
case 'engine_reorder':
|
||||
if (!is_array($_POST['ids'] ?? null)) {
|
||||
$bad('排序数据格式不对(应是一组 id)。');
|
||||
}
|
||||
$ids = array_map('intval', (array)$_POST['ids']);
|
||||
$n = Nav::applySort('nav_engines', $me, $ids);
|
||||
$ok('顺序已保存(' . $n . ' 项)。');
|
||||
break;
|
||||
|
||||
case 'settings_save':
|
||||
Nav::setSetting($me, 'site_name', trim((string)($_POST['site_name'] ?? '我的导航')));
|
||||
// ⚠️ 副标题**允许为空**,而且不能把空串当"未设置"回退成默认值 ——
|
||||
// SimpleNav 原文把这一条单列为 bug 现场("删了照样显示")。
|
||||
Nav::setSetting($me, 'subtitle', trim((string)($_POST['subtitle'] ?? '')));
|
||||
Nav::setSetting($me, 'primary_color',
|
||||
Nav::safeColor((string)($_POST['primary_color'] ?? ''), '#2F6BF7'));
|
||||
Nav::setSetting($me, 'default_engine', trim((string)($_POST['default_engine'] ?? 'bing')));
|
||||
Nav::setSetting($me, 'open_blank', !empty($_POST['open_blank']) ? '1' : '0');
|
||||
$sk = (string)($_POST['suggest_keys'] ?? 'both');
|
||||
Nav::setSetting($me, 'suggest_keys',
|
||||
in_array($sk, ['arrows', 'punct', 'both'], true) ? $sk : 'both');
|
||||
Nav::setSetting($me, 'collections_layout',
|
||||
((string)($_POST['collections_layout'] ?? 'tabs')) === 'flat' ? 'flat' : 'tabs');
|
||||
$ok('设置已保存。');
|
||||
break;
|
||||
|
||||
case 'phrase_save':
|
||||
$r = Nav::setPhrase($me, (string)($_POST['phrase'] ?? ''));
|
||||
$r['ok'] ? $ok('短语已保存,展示页地址已更新。') : $bad((string)$r['error']);
|
||||
break;
|
||||
|
||||
case 'phrase_random':
|
||||
$r = Nav::setPhrase($me, Nav::randomPhrase());
|
||||
$r['ok'] ? $ok('已换成随机短语。') : $bad((string)$r['error']);
|
||||
break;
|
||||
|
||||
case 'fetch_all':
|
||||
// 「全部更新」:把每条站点的名称与图标重新抓一遍。
|
||||
// ⚠️ 跳过 icon_custom=1 的(用户自己传的图标不许被覆盖)—— SimpleNav 的明确语义。
|
||||
$n = 0;
|
||||
$db = Database::get();
|
||||
foreach (Nav::sitesByGroup($me) as $sites) {
|
||||
foreach ($sites as $s) {
|
||||
$meta = NavFetch::meta((string)$s['url']);
|
||||
$name = trim((string)($meta['name'] ?? ''));
|
||||
$sets = [];
|
||||
$args = [];
|
||||
// 只补**空**的名字,不覆盖用户已经起好的(含自定义简称)
|
||||
if ($name !== '' && trim((string)$s['name']) === '') {
|
||||
$sets[] = 'name = ?';
|
||||
$args[] = $name;
|
||||
}
|
||||
if (empty($s['icon_custom'])) {
|
||||
$file = NavFetch::fetchIcon($meta, (string)$s['url']);
|
||||
if ($file !== '') {
|
||||
$sets[] = 'icon = ?';
|
||||
$args[] = $file;
|
||||
}
|
||||
}
|
||||
if ($sets) {
|
||||
$args[] = date('Y-m-d H:i:s');
|
||||
$args[] = (int)$s['id'];
|
||||
$args[] = $me;
|
||||
$db->prepare('UPDATE nav_sites SET ' . implode(', ', $sets)
|
||||
. ', updated_at = ? WHERE id = ? AND person_id = ?')
|
||||
->execute($args);
|
||||
}
|
||||
$n++;
|
||||
}
|
||||
}
|
||||
$ok('已更新 ' . $n . ' 条站点的名称与图标。');
|
||||
break;
|
||||
|
||||
default:
|
||||
$bad('未知操作。');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
http_response_code(404);
|
||||
View::render('error', [
|
||||
|
||||
Reference in New Issue
Block a user