Files
SimpleNav/db.php
T
laoyang 717a8877bb 后台排序改造:按板块标签页 + 拖拽排序 + 顺序完全固定
- 后台网站列表按板块分标签页,每个板块单独调顺序;用 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 会落进已有间隙、插到用户排好的中间)
- 改变所属板块时,该条目自动排到新板块的最后
- 后台页面 + 拖拽手柄做了配套样式,触屏设备降级为上下移按钮
2026-09-29 09:21:51 +08:00

374 lines
18 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
/**
* 简航 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);
}