- 财务:勾选多个标签时上面那张「总金额」图按**选中集合**统计(新增 ledger/total_chart 路由 + 单一容器 data-ck-total-host,按集合现取并缓存; Ledger::timeline 第二参数放宽为 string|array) - 首页日程:全天日程移出滚动容器固定置顶;进场把当前时刻红线居中 (scrollTop 计算,不用 scrollIntoView),触发条件不绑 $showNow - 任务:复选框去除 title 提示;看板可设颜色(Task::boardColor/updateBoard), 首页任务行用该颜色显示所属看板 - 导航:展示页补回 id=engine-tabs(切换搜索引擎无效的根因);补内置引擎; 新增站点输 URL 可复用他人已加过的站点(sharedByUrl/copyIconFile/adoptSite) - 任务详情页页头去掉复选框(已有「标记完成」按钮)
676 lines
34 KiB
PHP
676 lines
34 KiB
PHP
<?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>
|
||
<?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 /* ==================== ④ 站点列表(按板块分组,可拖拽排序)==================== */ ?>
|
||
<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>
|
||
<?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; ?>
|
||
|
||
<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 = '';
|
||
});
|
||
}
|
||
})();
|
||
</script>
|