Files
FamilyBook/templates/nav/index.php
T
laoyang 37ba86321b v29 第 29 批(9 条):日程定位/复选框提示/名册错乱三处真因修复 + 读书落盘 + 导航四设置 + 笔记阅读态与共享
前 4 条是"上批改了但用户仍报"的,这次先拿真机证据再动手,三处真因如下:

1. 首页没定位到红线 —— 真根因:定位脚本的 PHP 条件是
   `if (!$todayEvents || empty($timed))`,**今天只有全天日程时整段脚本不渲染**
   (实测 hasScript=False、scrollTop=0),而 .ag-grid(0–24h) 与红线都在。
   有分时段日程时定位其实完全正确(偏差 0px)。已改成 `if (!$todayEvents)`。
2. 任务复选框提示还在 —— 真根因:`_task_check.php` 里那句无障碍文字
   `<span class="visually-hidden">` 用的类名**在 app.css 里从来没有定义过**
   (项目一直只用 .sr-only)→ 没有规则 = 普通 span = 直接可见。
   已把两个类名合并到同一条规则(clip 隐藏,仍可读屏)。
3. 名册完全错乱 —— 真根因:v29 重写平铺列表时**漏了一个 `</div>`**(闭合 .person),
   浏览器把下一张卡嵌进上一张里,实测 .person-main 只剩 24~151px(本该 ~800px)。
   `php -l` 与全部静态断言都抓不到,只有真机量 DOM 才现形。已补上闭合。
   另把 v28 引入的多列 grid 改回单列(它与 content-visibility 冲突)+窄屏动作区折行。
4. 读书慢 —— 按用户给的思路「做一个同步,然后落盘」:
   新增 weread_snapshots 表 + App\WeReadCache(按人+数据集存 JSON);
   渲染路径改走 notebooksCached()/readStatsCached()(永不联网);
   同步从"渲染之前"挪到"**响应之后**"(queueBackgroundSync + fastcgi_finish_request)。
5. 导航设置改成右上角**下拉菜单 + 四视图**(addr/general/board/engine),
   表单带 back 域 → 保存后回原来那一项。
   🔴 顺带修掉一个真 bug:`if (!$inSettings && $canWrite)` 的 endif 漂到了文件末尾,
   把 board/engine 两个视图也包进了"非设置态" → 打开时标题对、**内容是空的**。
6. 笔记列表缩略图:cover → contain + 固定高度(按比例完整显示,不裁切)。
7. 笔记段落编辑器:加 Markdown 工具条 + 斜线命令(行首打 /co → 回车补全代码块),
   两者共用同一份命令表。
8. 笔记点进来默认**阅读态**(图文混排、附件在最下方、图片四档宽度、点「编辑」才可改);
   清单块并入 Markdown —— 但**历史 list 块转写成 `- 项` 文本,老数据不丢**。
9. 笔记共享改成**逐人 + 权限**(note_shares,与 resource_shares 同构);
   可见性收敛成全站唯一的 Note::VIS_COND;迁移用一次性哨兵。
2026-10-08 10:54:43 +08:00

840 lines
44 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);
/* 🔴 v28 用户第 11 条:「导航模块,右上角增加设置按钮,和其他模块一样。
把导航展示地址,放进去,把导航设置,放进去,把板块设置,放进去,
把搜索引擎的设置,也放进去,你参考一下之前的导航系统 simplenav,
把之前的那些设置,都放在设置中,进来之后,默认就是添加网址,和所有网址的列表。」
⇒ 两种视图,用 `?set=1` 切(服务端渲染,无 JS 也能切;刷新/回退都不丢状态):
· 默认(`?set` 空):**只显示「新增站点」+「站点列表」** —— 日常真正要用的两件事;
· 设置(`?set=1`):展示地址 / 导航设置 / 板块 / 搜索引擎 四块。
⚠️ 为什么用查询参数而不是 JS 折叠:设置项里有表单(保存后会 302 回本页),
用 JS 折叠的话每次保存回来都会**自动摊开全部设置**,用户还要再收一次。
用参数就能让"保存后回到设置页"(下面的表单里保留了 `set=1` 的隐藏域)。
⚠️ 保留 `$editSite` 等编辑态:正在编辑某个站点/板块/引擎时,**强制显示设置视图**,
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
/* 🔴🔴 v29 用户第 5 条:「导航的设置,做成设置按钮,点击后,拉下来设置选项,
分别是地址设置、导航设置、板块设置、搜索引擎设置,把现在的设置页面,拆开,
放在不同的设置选项下。」
⇒ 从"一个大的设置页"改成**下拉菜单 + 四个独立视图**:
· `?set=addr` 地址设置(展示页地址 + 短语)
· `?set=general` 导航设置(标题/搜索引擎/布局等,原 SimpleNav 的 settings)
· `?set=board` 板块设置
· `?set=engine` 搜索引擎设置
· 不带 `set` 默认:新增网址 + 网址列表
⚠️ 为什么拆开(而不只是把四块堆在一个?"设置页"里):
用户点的原话就是"拆开"。而且四块各自是**独立表单**,堆在一页里
每次保存后 302 回来都要重新找自己在哪一块。分开之后
"你在哪一块"由 URL 决定,保存后回到原地,位置感清楚。
⚠️ 用**链接**(服务端渲染)而不是 JS 下拉切换内容:
与 v28 的 `?set=1` 同一理由 —— 设置里有表单,保存会 302 回来,
纯 JS 的展开状态在刷新后就丢了。`<details>` 只负责"菜单展开"这一个动效,
点条目是**真实导航**(URL 变、视图变、刷新/回退都不丢)。
⚠️ 编辑态(正在编辑某板块/某引擎)要落到对应的那个视图里,
否则用户点"编辑板块"会看到一个没有板块表单的页面(看起来像功能坏了)。 */
$setKey = (string)($_GET['set'] ?? '');
if ($editGroup !== null) { $setKey = 'board'; }
if ($editEngine !== null) { $setKey = 'engine'; }
$inSettings = in_array($setKey, ['addr', 'general', 'board', 'engine'], true);
/* 设置项的登记表(菜单与"当前在哪一项"的标题都读它,只有一份) */
$setItems = [
'addr' => ['地址设置', 'M2.5 4h11M2.5 8h7M2.5 12h9'],
'general' => ['导航设置', 'M8 5.5a2.5 2.5 0 100 5 2.5 2.5 0 000-5z M8 1.5v1.6M8 12.9v1.6M1.5 8h1.6M12.9 8h1.6M3.4 3.4l1.1 1.1M11.5 11.5l1.1 1.1M12.6 3.4l-1.1 1.1M4.5 11.5l-1.1 1.1'],
'board' => ['板块设置', 'M2 3.5h4v4H2zM10 3.5h4v4h-4zM2 9.5h4v4H2zM10 9.5h4v4h-4z'],
'engine' => ['搜索引擎设置', 'M7 2.5a4.5 4.5 0 100 9 4.5 4.5 0 000-9z M10.4 10.4L14 14'],
];
?>
<div class="page-head">
<h1><?= $inSettings ? e($setItems[$setKey][0]) : '导航' ?></h1>
<span class="sub">
<?php if ($inSettings): ?>
<a href="<?= e(url('nav')) ?>">← 回到网址列表</a>
<?php else: ?>
你个人用的网址导航 · 每人一份
<?php endif; ?>
</span>
<div class="actions">
<?php /* 🔴 v29 用户第 5 条:右上角「设置」点开是**下拉菜单**,四项分设。
⚠️ 用项目已有的 `.movie-set.menu`(任务/观影模块同款):
外观、点击外部收起、键盘可达全都是现成的,
这里再手写一套 `<details>` 样式,迟早跟那边不一致。 */ ?>
<details class="movie-set menu">
<summary class="btn btn-ghost btn-sm" title="导航设置" aria-label="导航设置">
设置
<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
stroke-linejoin="round" style="margin-left:2px;vertical-align:-1px">
<path d="M4 6.5l4 4 4-4"/>
</svg>
</summary>
<div class="menu-list">
<?php foreach ($setItems as $k => $it): ?>
<a href="<?= e(url('nav', ['set' => $k])) ?>"<?= $setKey === $k ? ' class="on"' : '' ?>>
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="<?= e($it[1]) ?>"/>
</svg>
<span><?= e($it[0]) ?></span>
</a>
<?php endforeach; ?>
<a href="<?= e(url('nav')) ?>">
<svg class="mi" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"
fill="none" stroke="currentColor" stroke-width="1.5"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8l3.5 3.5L13 5"/>
</svg>
<span>我的网址</span>
</a>
</div>
</details>
</div>
</div>
<?php /* 设置视图里的四块(展示地址 / 导航设置 / 板块 / 搜索引擎)。
只在设置态渲染 —— 默认进来看到的是"添加网址 + 网址列表"(用户原话)。 */ ?>
<?php /* ==== 地址设置(?set=addr)==== */ ?>
<?php if ($setKey === 'addr'): ?>
<?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="back" value="addr">
<input type="hidden" name="act" value="phrase_random">
<button class="btn btn-ghost btn-sm" type="submit">换一串随机的</button>
</form>
<?php endif; ?>
</div>
<?php endif; ?><?php /* ==== /地址设置 ==== */ ?>
<?php /* ==== 导航设置(?set=general)==== */ ?>
<?php if ($setKey === 'general'): ?>
<?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="back" value="general">
<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 endif; ?><?php /* ==== /导航设置 ==== */ ?>
<?php /* ==================== ③ 站点:新增/编辑 ==================== */
/* 🔴 v29 用户第 5 条:这两块(新增站点 + 站点列表)是**默认视图**的内容,
设置视图(addr/general/board/engine)里不该出现 —— 用户原话是
「进来之后,默认就是添加网址,和所有网址的列表」,反过来说
进了设置就不该再看到"添加网址"。 */
?>
<?php if (!$inSettings && $canWrite): ?>
<div class="card card-pad">
<h2 class="cinema-h2"><?= $editSite ? '编辑站点' : '新增站点' ?></h2>
<?php /* 🔴 v24 第 26 批 用户第 4 条:「输入 url 之后,如果其他用户添加了,
他可以直接复用」。做法是**输入框失焦时问一次**服务端,
把别人加过的同款站点列出来让用户挑一个 —— 挑中就带上 reuse_key 提交。
⚠️ 复用只在**新增**时给(编辑已有站点时再抄别人语义很乱):
所以这段只在 `!$editSite` 时输出,并带 `is_new=1` 让服务端也认这个前提。 */ ?>
<?php if (!$editSite): ?>
<div class="nav-reuse" id="navReuse" hidden>
<div class="nav-reuse-h">有人加过这个站点,可以直接复用它的图标与名称:</div>
<div class="nav-reuse-list" id="navReuseList"></div>
<div class="nav-reuse-f" id="navReuseFoot"></div>
</div>
<?php endif; ?>
<form method="post" action="<?= e(url('nav/save')) ?>" id="navSiteForm">
<?= csrf_field() ?>
<input type="hidden" name="act" value="site_save">
<?php if ($editSite): ?>
<input type="hidden" name="id" value="<?= (int)$editSite['id'] ?>">
<?php endif; ?>
<?php if (!$editSite): ?>
<?php /* 服务端凭这个标记判断"这是新建、可以复用"(见 index.php 的 site_save 段) */ ?>
<input type="hidden" name="is_new" value="1">
<?php endif; ?>
<?php /* 用户选定的可复用条目(点一下列表里的卡片就填进来) */ ?>
<input type="hidden" name="reuse_key" id="navReuseKey" value="">
<div class="form-grid">
<div class="field">
<label>网址 *</label>
<input class="input mono" name="url" id="navSiteUrl" 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" id="navSiteName" value="<?= e((string)($editSite['name'] ?? '')) ?>"
placeholder="留空会自动抓">
</div>
<div class="field">
<label>简称</label>
<input class="input" name="short_name" id="navSiteShort"
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 /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
<?php if (!$inSettings): ?>
<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 endif; ?><?php /* ④ 站点列表(仅默认视图) */ ?>
<?php /* ⚠️ 这里必须补上 277 行那个 `if (!$inSettings && $canWrite)` 的收尾 ——
它原本一路开到了文件末尾(607 行),把**板块设置 / 搜索引擎设置**两个视图
也一起包进了"非设置态"里 → 打开 `?set=board` 时整段被跳过,
页面标题显示"板块设置"、内容却是空的(本轮实测抓到的真 bug:
`<!--DBG-->` 探针证明执行根本没走到 409 行)。
⇒ 「非设置态才显示」只该管**站点编辑 + 站点列表**这两块(见 v29 第 5 条),
设置类视图(addr/general/board/engine)各自独立、不受它管。 */ ?>
<?php endif; ?>
<?php /* ==== 板块设置(?set=board)==== */ ?>
<?php if ($setKey === 'board'): ?>
<?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="back" value="board">
<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 endif; ?><?php /* ==== /板块设置 ==== */ ?>
<?php /* ==== 搜索引擎设置(?set=engine)==== */ ?>
<?php if ($setKey === 'engine'): ?>
<?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="back" value="engine">
<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>
<?php /* 🔴 v28 用户第 10 条:「联想接口还是不能用啊,需要你补充几个进去,
让用户选择。搜索引擎列表,联想接口,是**对所有用户可见**的。」
⇒ 这里列出内置的联想接口库(App\Nav::SUGGEST_LIBRARY,全局共享一份),
**点一下就把地址与类型填进上面两个框** —— 用户不必去记
"sugrec 还是 osjson"这种东西,也不必手抄 URL(抄错一位就永远空下拉)。
⚠️ 这些只是"填入表单",仍要用户点保存才生效 ——
静默改数据会在用户没注意时改掉他自己的配置。 */ ?>
<div class="field">
<label>内置联想接口 <span class="hint">点一下即可填入上面两项</span></label>
<div class="sug-lib">
<?php foreach (\App\Nav::suggestLibrary() as $sl): ?>
<button type="button" class="sug-lib-item"
data-sug-url="<?= e((string)$sl['url']) ?>"
data-sug-type="<?= e((string)$sl['type']) ?>">
<b><?= e((string)$sl['name']) ?></b>
<span><?= e((string)$sl['note']) ?></span>
</button>
<?php endforeach; ?>
</div>
<span class="tip">
这些接口对**所有用户**都可见、可直接选用。国内网络下不可达的
(谷歌 / DuckDuckGo)没有列进来 —— 选了也只会永远是空下拉。
</span>
</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>
<?php /* 🔴 v24 第 26 批 用户第 4 条:「可以内置几个,让用户选择」。
默认已经内置 6 个(必应/百度/360 启用,谷歌/搜狗/DuckGo 未启用 —— 后三个
在国内网络下不可用,见 nav_suggest.php 头部的线上实测)。
⚠️ 但**老用户**库里只有当年那几条(ensureSeed 只在"一条都没有"时才灌),
所以要给一个"补齐内置"的入口,否则"内置几个"对他们等于 0 个。
⚠️ 用独立表单(不是 data-nav-* 那种 AJAX 按钮):它是一次性动作、
执行完要整页刷新看结果,走普通 POST 最省事也最不容易出错。 */ ?>
<p class="small muted" style="margin-top:0">
内置了必应 / 百度 / 360 三档(国内可用)。谷歌 / 搜狗 / DuckGo 也列在下面,
但它们的联想或搜索在国内网络下不可用,所以默认<strong>未启用</b>。
展示页顶部那排标签,就是这里<strong>已启用</strong>的引擎。
</p>
<form method="post" action="<?= e(url('nav/save')) ?>" class="inline-form" style="margin:0 0 10px">
<?= csrf_field() ?>
<input type="hidden" name="act" value="engine_seed_add">
<button class="btn btn-sm" type="submit">补齐内置搜索引擎</button>
<span class="small muted">已有的不会被改动(你改过的地址、颜色都保留)。</span>
</form>
<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; ?><?php /* ==== /搜索引擎设置 ==== */ ?>
<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(); });
}
});
/* ==================================================================
跨用户复用站点(v24 第 26 批 用户第 4 条)
用户原话:「输入 url 之后,如果其他用户添加了,他可以直接复用,
复用的时候,自定义图标和简称,他可以自己再设置,
设置好以后就固定了。」
交互设计(三拍,对应他这句话的三段):
① **输入 URL 之后** → 输入框失焦(或粘贴后 600ms)自动问一次服务端;
② 有人加过 → 列出可复用的卡片(图标 + 显示名),**点一下即选中**;
③ 选中后 → 把它的 名字/图标 展示出来,并把 reuse_key 填进隐藏域;
用户可以在上面的「显示名 / 简称」里改成自己喜欢的("自己再设置"),
提交时服务端会把图标**复制一份并标成自定义**("设置好以后就固定了")。
⚠️ 输入框再变 → 立刻清空已选(URL 换了,之前选的复用目标就不成立了)。
这一条最容易漏:不清的话用户改了网址、却还带着上一个站点的图标提交。
⚠️ 全程不阻塞:查询失败(离线/超时)就当"没人加过",不弹错、不挡提交。
================================================================== */
var urlIn = document.getElementById('navSiteUrl');
var reuse = document.getElementById('navReuse');
var reuseL = document.getElementById('navReuseList');
var reuseF = document.getElementById('navReuseFoot');
var reuseK = document.getElementById('navReuseKey');
var nameIn = document.getElementById('navSiteName');
var shrtIn = document.getElementById('navSiteShort');
if (urlIn && reuse && reuseL && reuseK) {
var lookupTimer = null;
var lastLookup = '';
function clearReuse() {
reuseK.value = '';
reuse.hidden = true;
reuseL.innerHTML = '';
if (reuseF) { reuseF.textContent = ''; }
}
function doLookup() {
var u = (urlIn.value || '').trim();
if (u === '' || !/^https?:\/\//i.test(u)) { clearReuse(); return; }
if (u === lastLookup) { return; } // 同一个地址不重复问
lastLookup = u;
post({ act: 'site_lookup', url: u }).then(function (d) {
if (!d || !d.items || !d.items.length) { clearReuse(); return; }
reuseL.innerHTML = '';
d.items.forEach(function (it) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'nav-reuse-item';
b.setAttribute('data-key', it.key);
b.setAttribute('data-name', it.name || '');
b.setAttribute('data-short', it.short_name || '');
var img = document.createElement('img');
img.className = 'nav-ico';
img.alt = '';
if (it.icon) { img.src = it.icon; }
var sp = document.createElement('span');
sp.className = 'nav-reuse-nm';
sp.textContent = it.label || '';
b.appendChild(img);
b.appendChild(sp);
b.addEventListener('click', function () {
Array.prototype.forEach.call(reuseL.querySelectorAll('.nav-reuse-item'),
function (x) { x.classList.remove('is-on'); });
b.classList.add('is-on');
reuseK.value = it.key;
/* "他可以自己再设置":把别人的名字/简称**预填**进输入框,
用户想改就直接改(不填也不会错 —— 服务端会用别人的补上)。
⚠️ 只填**空的**字段:用户已经手打过名字时不要覆盖他(那是抢用户的输入)。 */
if (nameIn && nameIn.value.trim() === '' && it.name) { nameIn.value = it.name; }
if (shrtIn && shrtIn.value.trim() === '' && it.short_name) { shrtIn.value = it.short_name; }
if (reuseF) {
reuseF.textContent = '已选「' + (it.label || '') + '」:'
+ '图标会复制一份到你名下并固定(以后「全部更新」不会改它)。'
+ '上面两项可自行修改。';
}
});
reuseL.appendChild(b);
});
reuse.hidden = false;
}).catch(function () { clearReuse(); });
}
// 失焦后问(不用 input 事件:边打边问会把半截域名也拿去查)
urlIn.addEventListener('blur', function () { setTimeout(doLookup, 120); });
// 粘贴之后通常不会失焦,补一拍
urlIn.addEventListener('paste', function () {
clearTimeout(lookupTimer);
lookupTimer = setTimeout(doLookup, 600);
});
// URL 一变 → 之前选的复用目标失效,必须清掉
urlIn.addEventListener('input', function () {
if (reuseK.value !== '') { clearReuse(); }
lastLookup = '';
});
}
/* 🔴 v28 用户第 10 条:内置联想接口库 —— 点一下把地址+类型填进**同一个表单**里。
⚠️ 用 closest('form') 定位,而不是 document.querySelector:
页面上可能同时有"新增引擎"和"编辑引擎"两个表单(后者是 ?edit_engine=… 带出来的),
用全局查找会把内容填到**另一个**表单里 —— 用户点了没反应、或更糟:填错地方。 */
Array.prototype.forEach.call(document.querySelectorAll('.sug-lib-item'), function (b) {
b.addEventListener('click', function () {
var form = b.closest('form');
if (!form) { return; }
var u = form.querySelector('input[name="suggest_url"]');
var t = form.querySelector('input[name="suggest_type"]');
if (u) { u.value = b.getAttribute('data-sug-url') || ''; }
if (t) { t.value = b.getAttribute('data-sug-type') || ''; }
// 反馈:让用户看到"填进去了"(否则一片安静,会以为没点上)
b.classList.add('is-picked');
Array.prototype.forEach.call(b.parentNode.children, function (sib) {
if (sib !== b) { sib.classList.remove('is-picked'); }
});
});
});
})();
</script>