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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

545 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 个人导航 · 管理页(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>