- 后台网站列表按板块分标签页,每个板块单独调顺序;用 URL hash (#sgroup-<gid>) 定位,上移/下移提交后回跳到原来的板块 - 支持鼠标拖拽排序(原生 HTML5 拖拽,零库依赖):拖动时实时重排预览, 松手后整组落库(Ajax 动作 site_reorder,带 CSRF 校验与板块归属校验, 防止跨板块搬条目) - 稀疏排序:新增链接的顺序号 = 同板块 MAX + SORT_STEP(1000),天然排最后; 拖拽落库、历史数据迁移时整组归一化为 index * SORT_STEP - 修复真根因:旧机制靠「交换两条 sort_order」上移/下移,一旦历史数据里 出现重复值,交换即无效、位置退化为按 id 排序 —— 这正是「排了顺序又被 打乱」的来源。新增一次性归一化迁移,用 settings.mig_sort_sparse 作哨兵, 避免每次请求重复覆盖用户设置 - 新增/修改链接不再用 MAX+1(MAX+1 会落进已有间隙、插到用户排好的中间) - 改变所属板块时,该条目自动排到新板块的最后 - 后台页面 + 拖拽手柄做了配套样式,触屏设备降级为上下移按钮
374 lines
18 KiB
PHP
374 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* 简航 SimpleNav —— 数据库(PDO SQLite)
|
||
* 首次访问自动建表并写入一份通用的默认数据(示例链接,可在后台随意增删)。
|
||
*/
|
||
declare(strict_types=1);
|
||
|
||
require_once __DIR__ . '/config.php';
|
||
|
||
function db(): PDO
|
||
{
|
||
static $pdo = null;
|
||
if ($pdo === null) {
|
||
if (!is_dir(DATA_PATH)) {
|
||
@mkdir(DATA_PATH, 0755, true);
|
||
}
|
||
$pdo = new PDO('sqlite:' . DB_PATH);
|
||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||
$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
|
||
$pdo->exec('PRAGMA foreign_keys = ON');
|
||
db_init($pdo);
|
||
}
|
||
return $pdo;
|
||
}
|
||
|
||
function db_init(PDO $pdo): void
|
||
{
|
||
$pdo->exec(<<<'SQL'
|
||
CREATE TABLE IF NOT EXISTS groups (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
slug TEXT DEFAULT '',
|
||
display TEXT NOT NULL DEFAULT 'grid', -- grid=普通网格板块 tabs=合集标签页
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
is_visible INTEGER NOT NULL DEFAULT 1
|
||
);
|
||
CREATE TABLE IF NOT EXISTS sites (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
group_id INTEGER NOT NULL,
|
||
url TEXT NOT NULL,
|
||
name TEXT NOT NULL DEFAULT '', -- 自动抓取的网站名称
|
||
short_name TEXT NOT NULL DEFAULT '', -- 用户自定义简称(页面展示用)
|
||
icon TEXT NOT NULL DEFAULT '', -- 本地图标文件名
|
||
icon_custom INTEGER NOT NULL DEFAULT 0, -- 1=用户自定义图标("全部更新"不再覆盖)
|
||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||
is_visible INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT DEFAULT '',
|
||
updated_at TEXT DEFAULT '',
|
||
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||
);
|
||
CREATE TABLE IF NOT EXISTS engines (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
slug TEXT NOT NULL,
|
||
search_url TEXT NOT NULL,
|
||
param TEXT NOT NULL DEFAULT 'q',
|
||
suggest_url TEXT NOT NULL DEFAULT '',
|
||
suggest_type TEXT NOT NULL DEFAULT '',
|
||
color TEXT NOT NULL DEFAULT '#2F6BF7',
|
||
is_active INTEGER NOT NULL DEFAULT 0,
|
||
sort_order INTEGER NOT NULL DEFAULT 0
|
||
);
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
k TEXT PRIMARY KEY,
|
||
v TEXT NOT NULL DEFAULT ''
|
||
);
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
username TEXT UNIQUE NOT NULL,
|
||
password_hash TEXT NOT NULL,
|
||
created_at TEXT NOT NULL DEFAULT ''
|
||
);
|
||
SQL);
|
||
|
||
// 老库升级:为已存在的 sites 表补 icon_custom 列
|
||
$cols = [];
|
||
foreach ($pdo->query('PRAGMA table_info(sites)') as $c) {
|
||
$cols[(string)$c['name']] = true;
|
||
}
|
||
if (!isset($cols['icon_custom'])) {
|
||
$pdo->exec('ALTER TABLE sites ADD COLUMN icon_custom INTEGER NOT NULL DEFAULT 0');
|
||
}
|
||
|
||
// 老库升级:清掉「搜狗联想」这个已经彻底失效的默认值
|
||
// (2026-09-28 实测:suggnew/ajajjson 返回 200 但 0 字节,联想域名 w.sugg.sogou.com 已不解析)
|
||
// 只匹配默认种子里的那个 URL —— 用户自己改过的地址绝不动。
|
||
$pdo->prepare('UPDATE engines SET suggest_url=?, suggest_type=? WHERE slug=? AND suggest_url=?')
|
||
->execute(['', '', 'sogou', 'https://www.sogou.com/suggnew/ajajjson?type=web&key={q}']);
|
||
|
||
// 老库升级:把「联想词按键」的单组设置(arrows / punct)并成 'both'(两组都认)。
|
||
// 为什么:用户反馈「上下键和逗号句号都选不了词」,根因之一就是线上停在单组 'punct'
|
||
// —— 单组模式会让另一组键**静默失效**,用户不知道那是设置项在起作用,只当成坏了。
|
||
// 'both' 是 'arrows' 与 'punct' 各自行为的**严格超集**:升级不会让任何人的习惯失效
|
||
// (原来能用的键,升级后照样能用,只是多了一组)。
|
||
// ⚠️ 只改这两个已知单组值;值不在白名单里的行不动,避免误伤将来的新取值。
|
||
// ⚠️⚠️ 必须**只跑一次**:db_init() 是**每次请求**都会执行的,若无条件 UPDATE,
|
||
// 用户在后台特意选回「只认方向键」,下一个请求就被改回去 —— 设置项等于失效。
|
||
// 用 settings 里一个哨兵键把关,跑过就永不再跑。
|
||
$migDone = (int)$pdo->query("SELECT COUNT(*) FROM settings WHERE k='mig_suggest_keys_both'")->fetchColumn() > 0;
|
||
if (!$migDone) {
|
||
$pdo->exec("UPDATE settings SET v='both' WHERE k='suggest_keys' AND v IN ('arrows','punct')");
|
||
$pdo->exec("INSERT INTO settings (k,v) VALUES ('mig_suggest_keys_both','1')
|
||
ON CONFLICT(k) DO UPDATE SET v='1'");
|
||
}
|
||
|
||
// 是否为空库(用于判断是否写入默认数据)
|
||
$isEmpty = (int)$pdo->query('SELECT COUNT(*) FROM groups')->fetchColumn() === 0;
|
||
|
||
seed_settings($pdo);
|
||
seed_engines($pdo);
|
||
if ($isEmpty) {
|
||
seed_groups_and_sites($pdo);
|
||
}
|
||
|
||
// 老库升级:把排序号**归一化成稀疏值**(第 n 条 = n*SORT_STEP),只跑一次。
|
||
//
|
||
// 为什么要归一化:旧库的 sort_order 多是连续的 0,1,2…(相邻只差 1,没有空隙),
|
||
// 而「拖拽把某条插到另两条之间」需要算出**整数**中间值 —— 1 与 2 之间没有整数。
|
||
// 归一化后每两条之间留出 SORT_STEP,拖拽时任意插入点都有余量。
|
||
//
|
||
// 为什么必须**只跑一次**:db_init() 是**每次请求**都会执行的。若无条件重排,
|
||
// 用户在后台刚拖好的顺序,会在下一个请求里被按「旧顺序」覆盖回去 ——
|
||
// 与上面「联想词按键」那次是同一个坑。用 settings 里的哨兵键把关,跑过就永不再跑。
|
||
//
|
||
// 为什么这次归一化**不会打乱**用户既有顺序:重排用的排序键仍是 `sort_order, id`,
|
||
// 是**稳定排序** —— 相对次序不变,只把「差值」放大成整齐的间隙。
|
||
$migSortDone = (int)$pdo->query("SELECT COUNT(*) FROM settings WHERE k='mig_sort_sparse'")->fetchColumn() > 0;
|
||
if (!$migSortDone) {
|
||
normalize_sort_orders($pdo);
|
||
$pdo->exec("INSERT INTO settings (k,v) VALUES ('mig_sort_sparse','1')
|
||
ON CONFLICT(k) DO UPDATE SET v='1'");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 把三张表的排序号重写为 (序号 × SORT_STEP),同组内按「原 sort_order, id」稳定排序。
|
||
* 供一次性迁移调用(也是 apply_sort_order() 的整组归一化语义)。
|
||
*/
|
||
function normalize_sort_orders(PDO $pdo): void
|
||
{
|
||
// 无分组维度的两张表
|
||
foreach (['groups', 'engines'] as $tbl) {
|
||
$rows = $pdo->query("SELECT id FROM {$tbl} ORDER BY sort_order, id")->fetchAll(PDO::FETCH_COLUMN);
|
||
$upd = $pdo->prepare("UPDATE {$tbl} SET sort_order=? WHERE id=?");
|
||
foreach ($rows as $i => $rid) {
|
||
$upd->execute([($i + 1) * SORT_STEP, (int)$rid]);
|
||
}
|
||
}
|
||
// sites 按板块分别归一化(不同板块之间互不影响)
|
||
$gids = $pdo->query("SELECT id FROM groups ORDER BY sort_order, id")->fetchAll(PDO::FETCH_COLUMN);
|
||
$upd = $pdo->prepare('UPDATE sites SET sort_order=? WHERE id=?');
|
||
$sel = $pdo->prepare('SELECT id FROM sites WHERE group_id=? ORDER BY sort_order, id');
|
||
foreach ($gids as $gid) {
|
||
$sel->execute([(int)$gid]);
|
||
foreach ($sel->fetchAll(PDO::FETCH_COLUMN) as $i => $rid) {
|
||
$upd->execute([($i + 1) * SORT_STEP, (int)$rid]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取「同组最后一条的 sort_order + SORT_STEP」——**新增条目专用**。
|
||
*
|
||
* 这是用户明确要求的行为:新增的链接一律排在**最后**(得到一个很大的顺序号),
|
||
* 直到用户手动把它拖到别处,它才拥有一个「正常」的顺序。
|
||
* ⚠️ 不要改回 `MAX+1`:那只在「旧数据恰好是连续整数」时才等于「排最后」,
|
||
* 一旦 sort_order 之间有间隙(拖拽后必然如此),MAX+1 会**撞进已有间隙**里,
|
||
* 让新条目落到用户排好的中间 —— 这正是用户抱怨的「排序被打乱」。
|
||
*
|
||
* @param int|null $groupId 该表的分组值;null = 该表没有分组维度(groups / engines)
|
||
*/
|
||
function next_sort_order(string $table, ?int $groupId = null, string $groupCol = 'group_id'): int
|
||
{
|
||
$pdo = db();
|
||
if ($groupId === null) {
|
||
$max = (int)$pdo->query("SELECT COALESCE(MAX(sort_order),0) FROM {$table}")->fetchColumn();
|
||
} else {
|
||
$st = $pdo->prepare("SELECT COALESCE(MAX(sort_order),0) FROM {$table} WHERE {$groupCol}=?");
|
||
$st->execute([$groupId]);
|
||
$max = (int)$st->fetchColumn();
|
||
}
|
||
return $max + SORT_STEP;
|
||
}
|
||
|
||
/**
|
||
* 按给定 id 顺序**整组重写**排序号(第 n 条 = n*SORT_STEP),返回实际写入行数。
|
||
*
|
||
* 这是拖拽排序的落库方式:一次拖动后把整个组的顺序提交上来、整组归一化。
|
||
* 好处是每次拖完间隙都恢复整齐,之后新增仍天然排在最后。
|
||
*
|
||
* @param int[] $ids 该组按新顺序排列的 id(未列出/不属于本组的会被忽略)
|
||
* @param int|null $groupId 非 null 时做**归属校验**:不属于该组的 id 直接跳过
|
||
* (防止一个被篡改的请求把别的板块的条目拖进来)
|
||
*/
|
||
function apply_sort_order(string $table, array $ids, ?int $groupId = null, string $groupCol = 'group_id'): int
|
||
{
|
||
$pdo = db();
|
||
$upd = $pdo->prepare("UPDATE {$table} SET sort_order=? WHERE id=?");
|
||
$ck = $groupId === null ? null : $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE id=? AND {$groupCol}=?");
|
||
$n = 0;
|
||
$i = 0;
|
||
foreach ($ids as $rid) {
|
||
$rid = (int)$rid;
|
||
if ($rid <= 0) {
|
||
continue;
|
||
}
|
||
if ($ck !== null) {
|
||
$ck->execute([$rid, $groupId]);
|
||
if ((int)$ck->fetchColumn() === 0) {
|
||
continue; // 不属于本组 → 忽略
|
||
}
|
||
}
|
||
$i++;
|
||
$upd->execute([$i * SORT_STEP, $rid]);
|
||
$n++;
|
||
}
|
||
return $n;
|
||
}
|
||
|
||
function seed_settings(PDO $pdo): void
|
||
{
|
||
$defaults = [
|
||
'site_name' => '简航',
|
||
'subtitle' => '个人网站导航',
|
||
'default_engine' => 'bing',
|
||
'primary_color' => '#2F6BF7',
|
||
// '1' = 顶部搜索框与所有站点/合集链接都在**新标签页**打开;'0' = 都在**当前页面**打开。
|
||
// 默认 '1' 与引入该设置前的旧行为一致;老库若已有这一行不会被覆盖(INSERT OR IGNORE)。
|
||
'open_blank' => '1',
|
||
// 'both' = ↓/↑ 与 , . **两组都认**(默认);
|
||
// 'arrows' = 只认 ↓/↑;'punct' = 只认 , 和 .。
|
||
// ⚠️ 默认从 'arrows' 改为 'both'(2026-09-29):用户反馈「上下键和逗号句号都选不了词」,
|
||
// 线上设置恰好停在单组 'punct' —— 单组模式会让**另一组键静默失效**,极易被当成故障。
|
||
// 'both' 是两组各自行为的**严格超集**(想用哪组都行),没有任何损失,故取为默认。
|
||
'suggest_keys' => 'both',
|
||
];
|
||
$stmt = $pdo->prepare('INSERT OR IGNORE INTO settings (k, v) VALUES (?, ?)');
|
||
foreach ($defaults as $k => $v) {
|
||
$stmt->execute([$k, $v]);
|
||
}
|
||
}
|
||
|
||
function seed_engines(PDO $pdo): void
|
||
{
|
||
if ((int)$pdo->query('SELECT COUNT(*) FROM engines')->fetchColumn() > 0) {
|
||
return;
|
||
}
|
||
// name, slug, search_url, param, suggest_url, suggest_type, color, active, sort
|
||
// ⚠️ suggest_url 留空的含义:该引擎自己没有可用的联想接口。
|
||
// 反正默认「联想词来源 = 百度」,留空只会让 auto 模式下少一次无谓的超时等待。
|
||
// 搜狗(suggnew/ajajjson 已失效)、谷歌 / DuckDuckGo(国内不可达)都不填。
|
||
$rows = [
|
||
['必应', 'bing', 'https://cn.bing.com/search', 'q', 'https://api.bing.com/osjson.aspx?query={q}', 'bing_osjson', '#60A504', 1, 1],
|
||
['百度', 'baidu', 'https://www.baidu.com/s', 'wd', 'https://www.baidu.com/sugrec?prod=pc&wd={q}', 'baidu_sugrec', '#2100E0', 1, 2],
|
||
['谷歌', 'google', 'https://www.google.com/search', 'q', '', '', '#3B83FA', 0, 3],
|
||
['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', '', '', '#FE6B14', 0, 4],
|
||
['360', 'so360', 'https://www.so.com/s', 'q', 'https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}', 'so360', '#1BB04A', 0, 5],
|
||
['DuckGo','ddg', 'https://duckduckgo.com/', 'q', '', '', '#DE5833', 0, 6],
|
||
];
|
||
$stmt = $pdo->prepare('INSERT INTO engines (name,slug,search_url,param,suggest_url,suggest_type,color,is_active,sort_order) VALUES (?,?,?,?,?,?,?,?,?)');
|
||
foreach ($rows as $r) {
|
||
$stmt->execute($r);
|
||
}
|
||
}
|
||
|
||
function seed_groups_and_sites(PDO $pdo): void
|
||
{
|
||
// 板块定义: [名称, 类型, 排序, [ [名称, 网址], ... ] ]
|
||
$data = [
|
||
['常用', 'grid', 1, [
|
||
['GitHub', 'https://github.com/'],
|
||
['阿里云', 'https://www.aliyun.com/'],
|
||
['知乎', 'https://www.zhihu.com/'],
|
||
['天翼云盘', 'https://cloud.189.cn/'],
|
||
['快科技', 'http://www.mydrivers.com/'],
|
||
['豆瓣', 'https://www.douban.com/'],
|
||
['少数派', 'https://sspai.com/'],
|
||
['红围脖', 'https://hongbowei.com/'],
|
||
]],
|
||
['工具', 'grid', 2, [
|
||
['宝塔面板', 'https://www.bt.cn/'],
|
||
['阿里云盘', 'https://www.alipan.com/'],
|
||
['网易云课堂', 'https://study.163.com/'],
|
||
['学堂在线', 'https://www.xuetangx.com/'],
|
||
['CSDN', 'https://www.csdn.net/'],
|
||
['稀土掘金', 'https://juejin.cn/'],
|
||
['淘宝', 'https://www.taobao.com/'],
|
||
['京东', 'https://www.jd.com/'],
|
||
['腾讯文档', 'https://docs.qq.com/'],
|
||
['石墨文档', 'https://shimo.im/'],
|
||
['飞书', 'https://www.feishu.cn/'],
|
||
['Gitee', 'https://gitee.com/'],
|
||
['百度网盘', 'https://pan.baidu.com/'],
|
||
['豆瓣电影', 'https://movie.douban.com/'],
|
||
['花瓣网', 'https://huaban.com/'],
|
||
['蓝奏云', 'https://www.lanzou.com/'],
|
||
['bilibili', 'https://www.bilibili.com/'],
|
||
['优酷', 'https://www.youku.com/'],
|
||
['爱奇艺', 'https://www.iqiyi.com/'],
|
||
['腾讯视频', 'https://v.qq.com/'],
|
||
['芒果TV', 'https://www.mgtv.com/'],
|
||
['draw.io', 'https://app.diagrams.net/'],
|
||
['稿定PS', 'https://ps.gaoding.com/'],
|
||
['Bitwarden', 'https://bitwarden.com/'],
|
||
['PDF工具', 'https://pdf.wps.cn/'],
|
||
['微信读书', 'https://weread.qq.com/'],
|
||
['高德地图', 'https://gaode.com/'],
|
||
['百度地图', 'https://map.baidu.com/'],
|
||
['建设银行', 'http://ccb.com/cn/home/index.html'],
|
||
['支付宝', 'https://www.alipay.com/'],
|
||
['MOOC', 'https://www.icourse163.org/'],
|
||
]],
|
||
['常用软件', 'tabs', 10, [
|
||
['WPS', 'https://www.wps.cn/'],
|
||
['Chrome', 'https://www.google.cn/chrome/index.html'],
|
||
['VS Code', 'https://code.visualstudio.com/'],
|
||
['Draw.IO', 'https://github.com/jgraph/drawio-desktop/releases'],
|
||
['火绒', 'https://www.huorong.cn/'],
|
||
['7-Zip', 'https://sparanoid.com/lab/7z/'],
|
||
['Tabby', 'https://github.com/Eugeny/tabby/'],
|
||
['GitAhead', 'https://gitahead.github.io/gitahead.com/'],
|
||
['Everything', 'https://www.voidtools.com/zh-cn/'],
|
||
['Wox', 'https://github.com/Wox-launcher/Wox'],
|
||
['BCUninstaller', 'https://github.com/Klocman/Bulk-Crap-Uninstaller'],
|
||
['DBeaver', 'https://github.com/dbeaver/dbeaver'],
|
||
['Thunderbird', 'https://www.thunderbird.net/zh-CN/'],
|
||
['FDM', 'https://www.freedownloadmanager.org/zh/'],
|
||
['GIMP', 'https://www.gimp.org/'],
|
||
['VLC', 'https://www.videolan.org/vlc/'],
|
||
['Zotero', 'https://www.zotero.org/'],
|
||
['QQ音乐', 'https://y.qq.com/'],
|
||
['微信', 'https://weixin.qq.com/'],
|
||
['TIM', 'https://tim.qq.com/'],
|
||
['Shotcut', 'https://github.com/mltframework/shotcut'],
|
||
]],
|
||
['图片资源', 'tabs', 11, [
|
||
['图标库', 'https://www.iconfont.cn/'],
|
||
['插画库', 'https://undraw.co/illustrations'],
|
||
['Pixabay', 'https://pixabay.com/zh/'],
|
||
['Pexels', 'https://www.pexels.com/'],
|
||
]],
|
||
['科研论文', 'tabs', 12, [
|
||
['DeepL翻译', 'https://www.deepl.com/'],
|
||
['Linggle', 'https://linggle.com/'],
|
||
['Netspeak', 'https://netspeak.org/'],
|
||
['Semantic Scholar', 'https://www.semanticscholar.org/'],
|
||
['LaTex Table', 'https://www.tablesgenerator.com/'],
|
||
]],
|
||
];
|
||
|
||
$g = $pdo->prepare('INSERT INTO groups (name, slug, display, sort_order, is_visible) VALUES (?,?,?,?,1)');
|
||
$s = $pdo->prepare('INSERT INTO sites (group_id, url, name, short_name, icon, sort_order, is_visible, created_at, updated_at) VALUES (?,?,?,?,\'\',?,1,?,?)');
|
||
$now = date('Y-m-d H:i:s');
|
||
foreach ($data as $row) {
|
||
[$name, $display, $order, $sites] = $row;
|
||
$g->execute([$name, slugify($name), $display, $order]);
|
||
$gid = (int)$pdo->lastInsertId();
|
||
$i = 0;
|
||
foreach ($sites as $site) {
|
||
$s->execute([$gid, $site[1], $site[0], $site[0], $i++, $now, $now]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/** 生成 slug(中文退化为拼音不可用时用 md5 短串) */
|
||
function slugify(string $s): string
|
||
{
|
||
$s = trim($s);
|
||
if (preg_match('/^[a-z0-9\-]+$/i', $s)) {
|
||
return strtolower($s);
|
||
}
|
||
return 'g' . substr(md5($s), 0, 6);
|
||
}
|