diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..31bb55a
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+# 运行时数据(数据库与抓取的图标不纳入版本库)
+/data/*
+!/data/.htaccess
+!/data/index.html
+/uploads/icons/*
+!/uploads/icons/.gitkeep
+
+# 杂项
+*.db
+*.log
+.DS_Store
+Thumbs.db
diff --git a/.htaccess b/.htaccess
new file mode 100644
index 0000000..bb5d694
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,8 @@
+# SimpleNav —— 根目录安全规则
+
+ RewriteEngine On
+ RewriteRule ^data/ - [F,L]
+ RewriteRule ^\.git/ - [F,L]
+
+# 关闭目录浏览
+Options -Indexes
diff --git a/README.md b/README.md
index 8f77418..627be07 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,68 @@
-# SimpleNav
+# SimpleNav · 简航
-简航,一个面向个人的轻量级导航系统
\ No newline at end of file
+简航(SimpleNav),一个面向个人的**轻量级网站导航系统**。PHP + SQLite,零外部依赖、无需 Composer,上传即用。
+
+## 特点
+
+- **轻量**:纯 PHP + SQLite,不依赖 MySQL / 框架 / Composer;前端无 Bootstrap / jQuery,原生 CSS + JS。
+- **个人向**:单管理员,页面就是「你自己常用网站的起始页」。
+- **搜索引擎可切换 + 联想下拉**:后台勾选喜欢的搜索引擎即可显示在顶部;输入时下拉联想词由服务端代理(规避跨域)。
+- **添加即补全**:填网址自动抓取网站名称、图标(图标下载并规范化后存到服务器本地)。
+- **显示简称**:每个网站有「名称(自动)」和「简称(手动)」两个字段,页面展示你自定义的简称。
+- **自定义排序**:网站 / 板块 / 搜索引擎都可上移下移排序。
+- **一键全部更新**:批量刷新所有网站的图标与名称,**不会改动你自定义的简称**。
+- **合集标签页**:把「合集」做成标签页,点标签平铺该合集下的全部链接。
+
+## 页面结构(复刻 n.yangyq.net 风格)
+
+1. 顶部:搜索引擎切换 + 搜索框(带联想下拉)
+2. 中部:网格板块(如「常用」「工具」)
+3. 下部:「合集」标签页
+
+## 目录结构
+
+```
+SimpleNav/
+├─ index.php 前台首页
+├─ suggest.php 搜索联想代理(多引擎,服务端请求)
+├─ login.php / logout.php 管理员登录 / 首次初始化
+├─ db.php 数据库初始化(建表 + 默认数据)
+├─ config.php 基础配置
+├─ functions.php 公共函数(抓取元信息、下载图标等)
+├─ admin/ 后台(网站 / 板块 / 搜索引擎 / 设置)
+│ ├─ index.php 后台页面
+│ ├─ actions.php 增删改 / 排序处理
+│ ├─ fetch_meta.php 添加网站时自动抓取名称与图标
+│ └─ update_all.php 一键“全部更新”(分批)
+├─ assets/ CSS / JS / 图标
+├─ uploads/icons/ 抓取到的网站图标(需可写)
+└─ data/ SQLite 数据库(需可写,禁止 Web 访问)
+```
+
+## 部署
+
+1. 上传全部文件到站点目录(如 `/www/wwwroot/n.yangyq.net/simplenav`)。
+2. 确保 web 用户对 `data/` 与 `uploads/icons/` 有写权限。
+3. 浏览器打开站点 → 首次访问进入「初始化管理员」,创建账号后即可在后台管理。
+4. 建议在 nginx server{} 中加入 `deploy/nginx-simplenav-deny-data.conf` 里的规则(禁止访问 `data/`)。
+
+服务器上创建最小权限部署账号:以 root 运行 `deploy/setup-simplenav-user.sh`。
+
+## 搜索引擎联想接口说明
+
+联想接口需服务端代理(浏览器直接调用会跨域)。内置解析类型:
+
+| 类型 | 平台 | 联想接口 |
+|---|---|---|
+| `bing_osjson` | 必应 | `https://api.bing.com/osjson.aspx?query={q}` |
+| `baidu_sugrec` | 百度 | `https://www.baidu.com/sugrec?prod=pc&wd={q}` |
+| `google_firefox` | 谷歌 | `https://suggestqueries.google.com/complete/search?client=firefox&q={q}` |
+| `sogou_ajaj` | 搜狗 | `https://www.sogou.com/suggnew/ajajjson?type=web&key={q}` |
+| `so360` | 360 | `https://sug.so.360.cn/suggest?encodein=utf-8&encodeout=utf-8&format=json&word={q}` |
+| `ddg` | DuckDuckGo | `https://duckduckgo.com/ac/?type=list&q={q}` |
+
+> 注:国内服务器访问 Google / DuckDuckGo 可能不通,联想会优雅降级为空(不影响搜索跳转)。
+
+## 许可
+
+Apache License 2.0
diff --git a/admin/_auth.php b/admin/_auth.php
new file mode 100644
index 0000000..20c788e
--- /dev/null
+++ b/admin/_auth.php
@@ -0,0 +1,93 @@
+query('SELECT COUNT(*) FROM users')->fetchColumn();
+}
+
+function current_user(): ?array
+{
+ if (empty($_SESSION['uid'])) {
+ return null;
+ }
+ $st = db()->prepare('SELECT id, username FROM users WHERE id=?');
+ $st->execute([$_SESSION['uid']]);
+ $u = $st->fetch();
+ return $u ?: null;
+}
+
+function require_login(): void
+{
+ if (!current_user()) {
+ header('Location: ' . url('login.php'));
+ exit;
+ }
+}
+
+function csrf_token(): string
+{
+ if (empty($_SESSION['csrf'])) {
+ $_SESSION['csrf'] = bin2hex(random_bytes(16));
+ }
+ return $_SESSION['csrf'];
+}
+
+function csrf_field(): string
+{
+ return '';
+}
+
+function csrf_check(): void
+{
+ $t = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
+ if (!hash_equals(csrf_token(), $t)) {
+ http_response_code(419);
+ exit('CSRF 校验失败,请刷新页面重试。');
+ }
+}
+
+/** 统一的后台页面外壳 */
+function admin_layout(string $active, string $title, string $body): void
+{
+ $nav = [
+ 'sites' => ['网站管理', 'admin/'],
+ 'groups' => ['板块管理', 'admin/?p=groups'],
+ 'engines' => ['搜索引擎', 'admin/?p=engines'],
+ 'settings' => ['系统设置', 'admin/?p=settings'],
+ ];
+ ?>
+
+
+
+
+= e($title) ?> · = e(site_name()) ?> 后台
+
+
+
+
+
+
+
+
+
= e(current_user()['username'] ?? '') ?>
+
退出
+
+
+= $body ?>
+
+
+ $m, 't' => $t]; }
+function post_id(): int { return (int)($_POST['id'] ?? 0); }
+
+try {
+ switch ($action) {
+
+ /* ---------- 网站 ---------- */
+ case 'site_save': {
+ $id = post_id();
+ $gid = (int)($_POST['group_id'] ?? 0);
+ $url = norm_url((string)($_POST['url'] ?? ''));
+ $name = trim((string)($_POST['name'] ?? ''));
+ $short = trim((string)($_POST['short_name'] ?? ''));
+ $icon = trim((string)($_POST['icon'] ?? ''));
+ $visible = isset($_POST['is_visible']) ? 1 : 0;
+ if ($url === '' || $gid <= 0) {
+ flash('网址和板块不能为空', 'err');
+ break;
+ }
+ $now = date('Y-m-d H:i:s');
+ if ($id > 0) {
+ $st = $pdo->prepare('UPDATE sites SET group_id=?, url=?, name=?, short_name=?, icon=?, is_visible=?, updated_at=? WHERE id=?');
+ $st->execute([$gid, $url, $name, $short, $icon, $visible, $now, $id]);
+ flash('已保存网站');
+ } else {
+ $max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM sites WHERE group_id=' . $gid)->fetchColumn();
+ $st = $pdo->prepare('INSERT INTO sites (group_id,url,name,short_name,icon,sort_order,is_visible,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)');
+ $st->execute([$gid, $url, $name, $short, $icon, $max, $visible, $now, $now]);
+ flash('已添加网站');
+ }
+ break;
+ }
+ case 'site_delete': {
+ $pdo->prepare('DELETE FROM sites WHERE id=?')->execute([post_id()]);
+ flash('已删除网站');
+ break;
+ }
+ case 'site_toggle': {
+ $pdo->prepare('UPDATE sites SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]);
+ flash('已切换显示状态');
+ break;
+ }
+ case 'site_move': {
+ move_sort('sites', post_id(), (string)($_POST['dir'] ?? 'up'), 'group_id');
+ break;
+ }
+
+ /* ---------- 板块 ---------- */
+ case 'group_save': {
+ $id = post_id();
+ $name = trim((string)($_POST['name'] ?? ''));
+ $display = ($_POST['display'] ?? 'grid') === 'tabs' ? 'tabs' : 'grid';
+ $visible = isset($_POST['is_visible']) ? 1 : 0;
+ if ($name === '') { flash('板块名称不能为空', 'err'); break; }
+ if ($id > 0) {
+ $pdo->prepare('UPDATE groups SET name=?, display=?, is_visible=? WHERE id=?')
+ ->execute([$name, $display, $visible, $id]);
+ flash('已保存板块');
+ } else {
+ $max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM groups')->fetchColumn();
+ $pdo->prepare('INSERT INTO groups (name,slug,display,sort_order,is_visible) VALUES (?,?,?,?,?)')
+ ->execute([$name, slugify($name), $display, $max, $visible]);
+ flash('已添加板块');
+ }
+ break;
+ }
+ case 'group_delete': {
+ $pdo->prepare('DELETE FROM groups WHERE id=?')->execute([post_id()]);
+ flash('已删除板块(含其下网站)');
+ break;
+ }
+ case 'group_toggle': {
+ $pdo->prepare('UPDATE groups SET is_visible = 1 - is_visible WHERE id=?')->execute([post_id()]);
+ flash('已切换显示状态');
+ break;
+ }
+ case 'group_move': {
+ move_sort('groups', post_id(), (string)($_POST['dir'] ?? 'up'), null);
+ break;
+ }
+
+ /* ---------- 搜索引擎 ---------- */
+ case 'engine_save': {
+ $id = post_id();
+ $name = trim((string)($_POST['name'] ?? ''));
+ $slug = preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['slug'] ?? ''));
+ $search = trim((string)($_POST['search_url'] ?? ''));
+ $param = trim((string)($_POST['param'] ?? 'q'));
+ $sugUrl = trim((string)($_POST['suggest_url'] ?? ''));
+ $sugType = preg_replace('/[^a-z0-9_]/i', '', (string)($_POST['suggest_type'] ?? ''));
+ $color = preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['color'] ?? '')) ? $_POST['color'] : '#2F6BF7';
+ $active = isset($_POST['is_active']) ? 1 : 0;
+ if ($name === '' || $slug === '' || $search === '') { flash('名称/标识/搜索地址不能为空', 'err'); break; }
+ if ($id > 0) {
+ $pdo->prepare('UPDATE engines SET name=?,slug=?,search_url=?,param=?,suggest_url=?,suggest_type=?,color=?,is_active=? WHERE id=?')
+ ->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $id]);
+ flash('已保存搜索引擎');
+ } else {
+ $max = (int)$pdo->query('SELECT COALESCE(MAX(sort_order),0)+1 FROM engines')->fetchColumn();
+ $pdo->prepare('INSERT INTO engines (name,slug,search_url,param,suggest_url,suggest_type,color,is_active,sort_order) VALUES (?,?,?,?,?,?,?,?,?)')
+ ->execute([$name, $slug, $search, $param, $sugUrl, $sugType, $color, $active, $max]);
+ flash('已添加搜索引擎');
+ }
+ break;
+ }
+ case 'engine_delete': {
+ $pdo->prepare('DELETE FROM engines WHERE id=?')->execute([post_id()]);
+ flash('已删除搜索引擎');
+ break;
+ }
+ case 'engine_toggle': {
+ $pdo->prepare('UPDATE engines SET is_active = 1 - is_active WHERE id=?')->execute([post_id()]);
+ flash('已切换启用状态');
+ break;
+ }
+ case 'engine_move': {
+ move_sort('engines', post_id(), (string)($_POST['dir'] ?? 'up'), null);
+ break;
+ }
+
+ /* ---------- 设置 ---------- */
+ case 'settings_save': {
+ $map = [
+ 'site_name' => trim((string)($_POST['site_name'] ?? '')),
+ 'subtitle' => trim((string)($_POST['subtitle'] ?? '')),
+ 'default_engine' => preg_replace('/[^a-z0-9\-_]/i', '', (string)($_POST['default_engine'] ?? '')),
+ 'primary_color' => preg_match('/^#[0-9a-fA-F]{3,8}$/', (string)($_POST['primary_color'] ?? '')) ? $_POST['primary_color'] : '#2F6BF7',
+ 'open_blank' => isset($_POST['open_blank']) ? '1' : '0',
+ ];
+ $st = $pdo->prepare('INSERT INTO settings (k,v) VALUES (?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v');
+ foreach ($map as $k => $v) { $st->execute([$k, $v]); }
+ flash('设置已保存');
+ $back = 'admin/?p=settings';
+ break;
+ }
+ case 'password_save': {
+ $old = (string)($_POST['old_password'] ?? '');
+ $new = (string)($_POST['new_password'] ?? '');
+ $u = current_user();
+ $st = $pdo->prepare('SELECT password_hash FROM users WHERE id=?');
+ $st->execute([$u['id']]);
+ $hash = (string)$st->fetchColumn();
+ if (!password_verify($old, $hash)) { flash('原密码不正确', 'err'); $back = 'admin/?p=settings'; break; }
+ if (mb_strlen($new) < 4) { flash('新密码至少 4 位', 'err'); $back = 'admin/?p=settings'; break; }
+ $pdo->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([password_hash($new, PASSWORD_DEFAULT), $u['id']]);
+ flash('密码已更新');
+ $back = 'admin/?p=settings';
+ break;
+ }
+
+ default:
+ flash('未知操作', 'err');
+ }
+} catch (Throwable $e) {
+ flash('操作失败:' . $e->getMessage(), 'err');
+}
+
+header('Location: ' . url($back));
+exit;
+
+/** 上/下移动排序(同组内交换 sort_order) */
+function move_sort(string $table, int $id, string $dir, ?string $groupCol): void
+{
+ if ($id <= 0) return;
+ $pdo = db();
+ $st = $pdo->prepare("SELECT * FROM {$table} WHERE id=?");
+ $st->execute([$id]);
+ $cur = $st->fetch();
+ if (!$cur) return;
+
+ $where = $groupCol ? " AND {$groupCol}=" . (int)$cur[$groupCol] : '';
+ if ($dir === 'up') {
+ $sql = "SELECT * FROM {$table} WHERE sort_order < ?{$where} ORDER BY sort_order DESC LIMIT 1";
+ } else {
+ $sql = "SELECT * FROM {$table} WHERE sort_order > ?{$where} ORDER BY sort_order ASC LIMIT 1";
+ }
+ $st2 = $pdo->prepare($sql);
+ $st2->execute([$cur['sort_order']]);
+ $nb = $st2->fetch();
+ if (!$nb) return;
+
+ $upd = $pdo->prepare("UPDATE {$table} SET sort_order=? WHERE id=?");
+ $upd->execute([$nb['sort_order'], $cur['id']]);
+ $upd->execute([$cur['sort_order'], $nb['id']]);
+}
diff --git a/admin/fetch_meta.php b/admin/fetch_meta.php
new file mode 100644
index 0000000..c7ac78f
--- /dev/null
+++ b/admin/fetch_meta.php
@@ -0,0 +1,36 @@
+ false, 'msg' => '缺少网址'], JSON_UNESCAPED_UNICODE);
+ exit;
+}
+
+$meta = fetch_site_meta($url);
+$domain = domain_of($meta['final_url'] ?: $url);
+$iconFile = '';
+if (!empty($meta['icon'])) {
+ $iconFile = download_icon($meta['icon'], $domain);
+}
+if ($iconFile === '' && $domain !== '') {
+ // 兜底再试一次根目录 favicon.ico
+ $iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain);
+}
+
+echo json_encode([
+ 'ok' => true,
+ 'url' => $url,
+ 'name' => $meta['name'] !== '' ? $meta['name'] : ($meta['title'] !== '' ? $meta['title'] : $domain),
+ 'short' => $meta['name'] !== '' ? $meta['name'] : $domain,
+ 'icon' => $iconFile,
+ 'icon_url' => $iconFile !== '' ? icon_src($iconFile) : '',
+], JSON_UNESCAPED_UNICODE);
diff --git a/admin/index.php b/admin/index.php
new file mode 100644
index 0000000..ebfe52e
--- /dev/null
+++ b/admin/index.php
@@ -0,0 +1,258 @@
+query('SELECT * FROM groups ORDER BY sort_order, id')->fetchAll();
+$engines = $pdo->query('SELECT * FROM engines ORDER BY sort_order, id')->fetchAll();
+
+$SUGGEST_TYPES = ['bing_osjson', 'baidu_sugrec', 'google_firefox', 'sogou_ajaj', 'so360', 'ddg'];
+
+function flash_html(?array $f): string
+{
+ if (!$f) return '';
+ $cls = ($f['t'] ?? 'ok') === 'err' ? 'alert err' : 'alert ok';
+ return '' . e($f['m']) . '
';
+}
+
+function toggle_form(string $action, int $id): string
+{
+ return '';
+}
+
+function move_form(string $action, int $id, string $dir, string $label, string $p): string
+{
+ return '';
+}
+
+ob_start();
+
+if ($p === 'sites'):
+ $rows = $pdo->query('SELECT s.*, g.name AS gname FROM sites s JOIN groups g ON g.id=s.group_id ORDER BY g.sort_order, g.id, s.sort_order, s.id')->fetchAll();
+ ?>
+ = flash_html($flash) ?>
+
+
+
+
新增 / 编辑网站
+
+
+
+
+
+
+ 网站列表(= count($rows) ?>)
+
+ | 图标 | 简称(展示) | 名称 | 网址 | 板块 | 显示 | 排序 | 操作 |
+
+
+
+ = e(mb_substr($label, 0, 1)) ?> |
+ = e($label) ?> |
+ = e($r['name']) ?> |
+ = e($r['url']) ?> |
+ = e($r['gname']) ?> |
+ = toggle_form('site_toggle', (int)$r['id']) ?> |
+ = move_form('site_move', (int)$r['id'], 'up', '上移', '') . move_form('site_move', (int)$r['id'], 'down', '下移', '') ?> |
+
+
+
+ |
+
+
+
+
+
+
+ = flash_html($flash) ?>
+
+
+
+ | 名称 | 展示方式 | 网站数 | 显示 | 排序 | 操作 |
+
+ query('SELECT COUNT(*) FROM sites WHERE group_id=' . (int)$g['id'])->fetchColumn(); ?>
+
+ | = e($g['name']) ?> |
+ = $g['display'] === 'tabs' ? '合集标签页' : '普通网格' ?> |
+ = $cnt ?> |
+ = toggle_form('group_toggle', (int)$g['id']) ?> |
+ = move_form('group_move', (int)$g['id'], 'up', '上移', 'groups') . move_form('group_move', (int)$g['id'], 'down', '下移', 'groups') ?> |
+
+
+
+ |
+
+
+
+
+
+
+ = flash_html($flash) ?>
+
+
+
+ | 名称 | 搜索地址 | 联想类型 | 启用 | 排序 | 操作 |
+
+
+
+ | ■ = e($en['name']) ?> (= e($en['slug']) ?>) |
+ = e($en['search_url']) ?> |
+ = e($en['suggest_type']) ?> |
+ = $en['is_active'] ? '已启用' : '未启用' ?> = toggle_form('engine_toggle', (int)$en['id']) ?> |
+ = move_form('engine_move', (int)$en['id'], 'up', '上移', 'engines') . move_form('engine_move', (int)$en['id'], 'down', '下移', 'engines') ?> |
+
+
+
+ |
+
+
+
+
+ 提示:联想接口需填写对应平台的地址,解析类型可选 bing_osjson / baidu_sugrec / google_firefox / sogou_ajaj / so360 / ddg。
+
+
+ = flash_html($flash) ?>
+
+
+ query('SELECT COUNT(*) FROM sites')->fetchColumn();
+$rows = $pdo->query('SELECT id, url FROM sites ORDER BY id LIMIT ' . $limit . ' OFFSET ' . $offset)->fetchAll();
+
+$updated = 0;
+$failed = 0;
+$now = date('Y-m-d H:i:s');
+
+foreach ($rows as $row) {
+ $url = norm_url((string)$row['url']);
+ $meta = fetch_site_meta($url);
+ $domain = domain_of($meta['final_url'] ?: $url);
+ $name = $meta['name'] !== '' ? $meta['name'] : $meta['title'];
+ $iconFile = '';
+ if (!empty($meta['icon'])) {
+ $iconFile = download_icon($meta['icon'], $domain);
+ }
+ if ($iconFile === '' && $domain !== '') {
+ $iconFile = download_icon('http://' . $domain . '/favicon.ico', $domain);
+ }
+
+ $sets = ['updated_at=?'];
+ $args = [$now];
+ if ($name !== '') { $sets[] = 'name=?'; $args[] = $name; }
+ if ($iconFile !== '') { $sets[] = 'icon=?'; $args[] = $iconFile; }
+ // 注意:这里绝不修改 short_name(用户自定义简称)
+
+ $args[] = (int)$row['id'];
+ try {
+ $pdo->prepare('UPDATE sites SET ' . implode(',', $sets) . ' WHERE id=?')->execute($args);
+ if ($iconFile !== '' || $name !== '') { $updated++; } else { $failed++; }
+ } catch (Throwable $e) {
+ $failed++;
+ }
+}
+
+$next = $offset + count($rows);
+echo json_encode([
+ 'done' => $next >= $total || count($rows) === 0,
+ 'total' => $total,
+ 'offset' => $next,
+ 'updated' => $updated,
+ 'failed' => $failed,
+], JSON_UNESCAPED_UNICODE);
diff --git a/apple-touch-icon.png b/apple-touch-icon.png
new file mode 100644
index 0000000..84a91bc
Binary files /dev/null and b/apple-touch-icon.png differ
diff --git a/assets/css/admin.css b/assets/css/admin.css
new file mode 100644
index 0000000..9c24339
--- /dev/null
+++ b/assets/css/admin.css
@@ -0,0 +1,74 @@
+/* 简航 SimpleNav —— 后台样式 */
+body.admin { background: #eef0f3; color: #333; font-size: 14px; }
+.topbar {
+ display: flex; align-items: center; gap: 16px; padding: 0 20px; height: 56px;
+ background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,.06); position: sticky; top: 0; z-index: 50;
+}
+.topbar .brand { font-size: 17px; font-weight: 700; }
+.topbar .brand a { color: #2F6BF7; }
+.topbar .brand span { color: #999; font-weight: 400; font-size: 13px; margin-left: 6px; }
+.topnav { display: flex; gap: 4px; flex: 1; }
+.topnav a { padding: 8px 14px; border-radius: 8px; color: #555; }
+.topnav a:hover { background: #f2f4f7; }
+.topnav a.active { background: #2F6BF7; color: #fff; }
+.topright { display: flex; align-items: center; gap: 10px; color: #666; }
+.wrap { max-width: 1140px; margin: 20px auto 60px; padding: 0 16px; }
+
+.card { background: #fff; border-radius: 12px; padding: 18px; margin-bottom: 18px; box-shadow: 0 2px 10px rgba(0,0,0,.04); }
+.card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
+.card-head h2 { font-size: 16px; margin: 0; }
+
+.grid-form { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
+.grid-form label { display: flex; flex-direction: column; font-size: 13px; color: #555; gap: 5px; }
+.grid-form label.chk { flex-direction: row; align-items: center; gap: 6px; }
+.grid-form .span2 { grid-column: 1 / -1; }
+.grid-form .row { display: flex; gap: 8px; }
+.grid-form .row input { flex: 1; }
+.grid-form input[type=text], .grid-form input[type=password], .grid-form select {
+ height: 38px; padding: 0 10px; border: 1px solid #d9dde3; border-radius: 8px; font-size: 14px; width: 100%;
+}
+.grid-form input:focus, .grid-form select:focus { outline: none; border-color: #2F6BF7; }
+.form-actions { display: flex; align-items: center; gap: 10px; }
+.hint { color: #999; font-size: 12px; }
+
+.btn { display: inline-flex; align-items: center; gap: 6px; height: 38px; padding: 0 16px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
+.btn.primary { background: #2F6BF7; color: #fff; }
+.btn.primary:hover { background: #2456cc; }
+.btn.ghost { background: #f2f4f7; color: #444; }
+.btn.ghost:hover { background: #e6e9ee; }
+.btn.block { width: 100%; justify-content: center; }
+
+.alert { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; }
+.alert.ok { background: #e8f6ee; color: #1a7f45; }
+.alert.err { background: #fdecec; color: #c0392b; }
+
+.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
+.tbl th, .tbl td { padding: 9px 8px; text-align: left; border-bottom: 1px solid #f0f1f3; vertical-align: middle; }
+.tbl th { color: #888; font-weight: 500; }
+.tbl tr:hover td { background: #fafbfc; }
+.tbl .muted { color: #999; }
+.tbl .ell { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.tbl .nowrap { white-space: nowrap; }
+.mini { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #f4f5f7; }
+.mini.letter { display: inline-flex; align-items: center; justify-content: center; color: #fff; font-size: 13px; }
+.inline { display: inline; }
+.ico-btn { border: none; background: #f2f4f7; width: 28px; height: 28px; border-radius: 6px; cursor: pointer; color: #555; margin: 0 1px; }
+.ico-btn:hover { background: #e6e9ee; }
+.ico-btn.danger { color: #c0392b; }
+.ok-dot { color: #1a7f45; font-size: 12px; }
+
+/* 登录页 */
+.login-wrap { min-height: 100vh; display: flex; align-items: center; justify-content: center; }
+.login-card { width: 340px; background: #fff; border-radius: 14px; padding: 30px 26px; box-shadow: 0 10px 30px rgba(0,0,0,.08); text-align: center; }
+.login-card h1 { font-size: 20px; margin: 8px 0 2px; }
+.login-card .sub { color: #999; font-size: 13px; margin: 0 0 18px; }
+.login-card input { width: 100%; height: 42px; margin-bottom: 12px; padding: 0 12px; border: 1px solid #d9dde3; border-radius: 8px; font-size: 14px; }
+.login-card input:focus { outline: none; border-color: #2F6BF7; }
+.login-card .btn { height: 42px; }
+.login-logo { width: 56px; height: 56px; }
+
+@media (max-width: 720px) {
+ .grid-form { grid-template-columns: 1fr; }
+ .topbar { flex-wrap: wrap; height: auto; padding: 10px 14px; }
+ .tbl .ell { max-width: 120px; }
+}
diff --git a/assets/css/style.css b/assets/css/style.css
new file mode 100644
index 0000000..2173971
--- /dev/null
+++ b/assets/css/style.css
@@ -0,0 +1,116 @@
+/* 简航 SimpleNav —— 样式(复刻 n.yangyq.net 的灰底白卡片风格,去 Bootstrap 依赖) */
+:root { --primary: #2F6BF7; }
+* { box-sizing: border-box; }
+body {
+ margin: 0;
+ font-size: 14px;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
+ background: #d8d9da;
+ color: #333;
+ overflow-x: hidden;
+}
+a { text-decoration: none; color: inherit; }
+.container { max-width: 1140px; margin: 50px auto 40px; padding: 0 15px; }
+
+/* ---------- 搜索 ---------- */
+.s-search { position: relative; z-index: 2000; margin-bottom: 10px; }
+.search-type { margin-bottom: 0; }
+.s-engine {
+ display: inline-block; padding: 6px 14px; margin: 0 2px 0 0;
+ background: rgba(255,255,255,.45); border-radius: 5px 5px 0 0; cursor: pointer;
+ font-size: 14px; line-height: 20px; transition: background .2s;
+}
+.s-engine span { color: var(--ec, #555); }
+.s-engine:hover { background: rgba(255,255,255,.75); }
+.s-engine.active { background: #fff; }
+#search-form { position: relative; }
+#search-text {
+ width: 100%; height: 50px; line-height: 50px; text-indent: 14px; font-size: 16px;
+ border: none; border-radius: 5px; background: #fff; outline: 0;
+ box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3); transition: box-shadow .3s;
+}
+#search-text:focus { box-shadow: 0 0 24px 0 rgba(50,50,50,.08); }
+#search button.submit {
+ position: absolute; top: 7px; right: 7px; width: 60px; height: 36px;
+ background: none; border: 0; border-radius: 3px; cursor: pointer; outline: none;
+}
+#search button.submit::after {
+ content: ""; display: block; width: 16px; height: 16px; margin: 0 auto;
+ border: 2px solid #c9c9c9; border-radius: 50%; position: relative;
+}
+#search button.submit::before {
+ content: ""; position: absolute; right: 20px; bottom: 9px; width: 7px; height: 2px;
+ background: #c9c9c9; transform: rotate(45deg);
+}
+#word {
+ position: absolute; list-style: none; top: 55px; left: 0; width: 100%; margin: 0;
+ background: #fff; border-radius: 5px; z-index: 20000; padding: 10px;
+ box-shadow: 0 0 10px #bbb; display: none;
+}
+#word li { height: 35px; padding: 0 8px; line-height: 35px; cursor: pointer; font-size: 16px; border-radius: 5px; }
+#word li:hover { background: #eee; }
+
+/* ---------- 板块卡片 ---------- */
+.mylist {
+ list-style: none; margin: 16px 0; padding: 12px 20px;
+ background: #fff; border-radius: 15px; box-shadow: 0 0.5rem 0.625rem rgba(212,212,212,.3);
+ display: flex; flex-wrap: wrap;
+}
+.mylist > li.title {
+ width: 100%; margin: 0 0 10px; padding: 0 0 8px 10px; height: auto;
+ font-size: 16px; font-weight: 600; line-height: 24px;
+ border-bottom: 1px solid #eee; position: relative;
+}
+.mylist > li.title::before {
+ content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px;
+ background: var(--primary); border-radius: 2px;
+}
+.mylist > li.site { margin: 2px; }
+.mylist li.site a {
+ display: flex; flex-direction: column; align-items: center; width: 100px; padding: 10px 4px;
+ border-radius: 10px; transition: background .2s;
+}
+.mylist li.site a:hover { background: #f0f1f2; }
+.mylist li.site .icon { width: 45px; height: 45px; border-radius: 10px; background: #fff; object-fit: contain; }
+.mylist li.site .ico-letter {
+ width: 45px; height: 45px; border-radius: 10px; color: #fff; font-size: 20px; font-weight: 600;
+ display: flex; align-items: center; justify-content: center;
+}
+.mylist li.site .name {
+ width: 100%; margin-top: 6px; text-align: center; font-size: 13px; color: #555;
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+
+/* ---------- 合集标签页 ---------- */
+.collections { margin: 16px 0; }
+.collections-title {
+ font-size: 16px; font-weight: 600; padding: 0 0 8px 10px; margin: 0 0 10px;
+ position: relative;
+}
+.collections-title::before {
+ content: ""; position: absolute; left: 0; top: 3px; width: 4px; height: 18px;
+ background: var(--primary); border-radius: 2px;
+}
+.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 10px; }
+.tabs .tab {
+ border: none; background: rgba(255,255,255,.65); color: #444; cursor: pointer;
+ padding: 7px 16px; border-radius: 18px; font-size: 14px; transition: all .2s;
+}
+.tabs .tab:hover { background: #fff; }
+.tabs .tab.active { background: var(--primary); color: #fff; }
+.tab-panel { display: none; }
+.tab-panel.active { display: flex; }
+
+/* ---------- 页脚 ---------- */
+.foot { text-align: center; color: #8a8a8a; font-size: 12px; margin: 24px 0 0; }
+.foot a { color: #8a8a8a; }
+.foot a:hover { color: var(--primary); }
+
+/* ---------- 响应式 ---------- */
+@media (max-width: 767px) {
+ .container { margin-top: 24px; }
+ .mylist { border-radius: 10px; padding: 10px 12px; }
+ .mylist li.site a { width: 25%; }
+ .mylist li.site .icon, .mylist li.site .ico-letter { width: 40px; height: 40px; }
+ .mylist li.site .name { font-size: 12px; }
+}
diff --git a/assets/icons/icon-128.png b/assets/icons/icon-128.png
new file mode 100644
index 0000000..7040d6e
Binary files /dev/null and b/assets/icons/icon-128.png differ
diff --git a/assets/icons/icon-144.png b/assets/icons/icon-144.png
new file mode 100644
index 0000000..178f521
Binary files /dev/null and b/assets/icons/icon-144.png differ
diff --git a/assets/icons/icon-150.png b/assets/icons/icon-150.png
new file mode 100644
index 0000000..aa0f3b4
Binary files /dev/null and b/assets/icons/icon-150.png differ
diff --git a/assets/icons/icon-16.png b/assets/icons/icon-16.png
new file mode 100644
index 0000000..811624c
Binary files /dev/null and b/assets/icons/icon-16.png differ
diff --git a/assets/icons/icon-180.png b/assets/icons/icon-180.png
new file mode 100644
index 0000000..bd2ac31
Binary files /dev/null and b/assets/icons/icon-180.png differ
diff --git a/assets/icons/icon-192-maskable.png b/assets/icons/icon-192-maskable.png
new file mode 100644
index 0000000..c515e69
Binary files /dev/null and b/assets/icons/icon-192-maskable.png differ
diff --git a/assets/icons/icon-192.png b/assets/icons/icon-192.png
new file mode 100644
index 0000000..3ca3014
Binary files /dev/null and b/assets/icons/icon-192.png differ
diff --git a/assets/icons/icon-256.png b/assets/icons/icon-256.png
new file mode 100644
index 0000000..a4dea95
Binary files /dev/null and b/assets/icons/icon-256.png differ
diff --git a/assets/icons/icon-32.png b/assets/icons/icon-32.png
new file mode 100644
index 0000000..87b2c29
Binary files /dev/null and b/assets/icons/icon-32.png differ
diff --git a/assets/icons/icon-384.png b/assets/icons/icon-384.png
new file mode 100644
index 0000000..e9fa8d4
Binary files /dev/null and b/assets/icons/icon-384.png differ
diff --git a/assets/icons/icon-48.png b/assets/icons/icon-48.png
new file mode 100644
index 0000000..95e7b7f
Binary files /dev/null and b/assets/icons/icon-48.png differ
diff --git a/assets/icons/icon-512-maskable.png b/assets/icons/icon-512-maskable.png
new file mode 100644
index 0000000..0736449
Binary files /dev/null and b/assets/icons/icon-512-maskable.png differ
diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png
new file mode 100644
index 0000000..56d7e9c
Binary files /dev/null and b/assets/icons/icon-512.png differ
diff --git a/assets/icons/icon-64.png b/assets/icons/icon-64.png
new file mode 100644
index 0000000..eda8143
Binary files /dev/null and b/assets/icons/icon-64.png differ
diff --git a/assets/icons/icon-96.png b/assets/icons/icon-96.png
new file mode 100644
index 0000000..cdc260e
Binary files /dev/null and b/assets/icons/icon-96.png differ
diff --git a/assets/img/logo.svg b/assets/img/logo.svg
new file mode 100644
index 0000000..3e7ebc8
--- /dev/null
+++ b/assets/img/logo.svg
@@ -0,0 +1,14 @@
+
diff --git a/assets/js/admin.js b/assets/js/admin.js
new file mode 100644
index 0000000..de155b1
--- /dev/null
+++ b/assets/js/admin.js
@@ -0,0 +1,106 @@
+/* 简航 SimpleNav —— 后台交互 */
+(function () {
+ 'use strict';
+ var $ = function (s) { return document.querySelector(s); };
+
+ /* ---------- 自动获取网站信息 ---------- */
+ var fetchBtn = $('#btn-fetch');
+ if (fetchBtn) {
+ fetchBtn.addEventListener('click', function () {
+ var url = ($('#f-url') || {}).value || '';
+ var hint = $('#fetch-hint');
+ if (!url) { if (hint) hint.textContent = '请先填写网址'; return; }
+ if (hint) hint.textContent = '正在获取…';
+ fetchBtn.disabled = true;
+ fetch(fetchBtn.getAttribute('data-url') + '?url=' + encodeURIComponent(url))
+ .then(function (r) { return r.json(); })
+ .then(function (d) {
+ fetchBtn.disabled = false;
+ if (!d.ok) { if (hint) hint.textContent = d.msg || '获取失败'; return; }
+ if ($('#f-name')) $('#f-name').value = d.name || '';
+ if ($('#f-short') && !$('#f-short').value) $('#f-short').value = d.short || d.name || '';
+ if ($('#f-icon')) $('#f-icon').value = d.icon || '';
+ if (hint) hint.textContent = (d.icon ? '已获取名称与图标' : '已获取名称(未找到图标)') + (d.url ? ':' + d.url : '');
+ })
+ .catch(function () { fetchBtn.disabled = false; if (hint) hint.textContent = '网络错误'; });
+ });
+ }
+ var resetBtn = $('#btn-reset');
+ if (resetBtn) {
+ resetBtn.addEventListener('click', function () {
+ ['f-id', 'f-url', 'f-name', 'f-short', 'f-icon'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'f-id' ? '0' : ''; });
+ var v = $('#f-visible'); if (v) v.checked = true;
+ var h = $('#fetch-hint'); if (h) h.textContent = '';
+ });
+ }
+
+ /* ---------- 编辑网站:回填表单 ---------- */
+ document.querySelectorAll('.edit-site').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var d = btn.dataset;
+ $('#f-id').value = d.id;
+ $('#f-group').value = d.group;
+ $('#f-url').value = d.url;
+ $('#f-name').value = d.name;
+ $('#f-short').value = d.short;
+ $('#f-icon').value = d.icon;
+ $('#f-visible').checked = d.visible === '1';
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+ });
+
+ /* ---------- 编辑板块 / 引擎 ---------- */
+ document.querySelectorAll('.edit-group').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var d = btn.dataset;
+ $('#g-id').value = d.id; $('#g-name').value = d.name;
+ $('#g-display').value = d.display; $('#g-visible').checked = d.visible === '1';
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+ });
+ var gReset = $('#g-reset');
+ if (gReset) gReset.addEventListener('click', function () { $('#g-id').value = '0'; $('#g-name').value = ''; $('#g-display').value = 'grid'; $('#g-visible').checked = true; });
+
+ document.querySelectorAll('.edit-engine').forEach(function (btn) {
+ btn.addEventListener('click', function () {
+ var d = btn.dataset;
+ $('#e-id').value = d.id; $('#e-name').value = d.name; $('#e-slug').value = d.slug;
+ $('#e-search').value = d.search; $('#e-param').value = d.param; $('#e-sug').value = d.sug;
+ $('#e-sugtype').value = d.sugtype; $('#e-color').value = d.color; $('#e-active').checked = d.active === '1';
+ window.scrollTo({ top: 0, behavior: 'smooth' });
+ });
+ });
+ var eReset = $('#e-reset');
+ if (eReset) eReset.addEventListener('click', function () {
+ ['e-id', 'e-name', 'e-slug', 'e-search', 'e-sug', 'e-color'].forEach(function (id) { var el = $('#' + id); if (el) el.value = id === 'e-id' ? '0' : ''; });
+ $('#e-param').value = 'q'; $('#e-active').checked = false;
+ });
+
+ /* ---------- 一键“全部更新”(分批循环) ---------- */
+ var upBtn = $('#btn-update-all');
+ if (upBtn) {
+ upBtn.addEventListener('click', function () {
+ if (!confirm('将批量刷新所有网站的图标与名称(用户自定义简称不受影响),确定继续?')) return;
+ var base = upBtn.getAttribute('data-url');
+ var total = 0, updated = 0, failed = 0, offset = 0;
+ upBtn.disabled = true;
+ function step() {
+ fetch(base + '?offset=' + offset + '&limit=6')
+ .then(function (r) { return r.json(); })
+ .then(function (d) {
+ total = d.total; updated += d.updated; failed += d.failed; offset = d.offset;
+ upBtn.textContent = '⟳ 更新中 ' + Math.min(offset, total) + '/' + total;
+ if (!d.done) { step(); }
+ else {
+ upBtn.disabled = false;
+ upBtn.textContent = '⟳ 全部更新(图标+名称)';
+ alert('更新完成:成功 ' + updated + ',失败/无变化 ' + failed + ',共 ' + total + ' 个网站。');
+ location.reload();
+ }
+ })
+ .catch(function () { upBtn.disabled = false; upBtn.textContent = '⟳ 全部更新(图标+名称)'; alert('更新中断,请重试。'); });
+ }
+ step();
+ });
+ }
+})();
diff --git a/assets/js/app.js b/assets/js/app.js
new file mode 100644
index 0000000..a1f8428
--- /dev/null
+++ b/assets/js/app.js
@@ -0,0 +1,90 @@
+/* 简航 SimpleNav —— 前台交互:搜索引擎切换 + 联想下拉 + 合集标签页 */
+(function () {
+ 'use strict';
+ var cfg = window.SIMPLENAV || {};
+
+ /* ---------- 搜索引擎切换 ---------- */
+ var tabs = document.querySelectorAll('#engine-tabs .s-engine');
+ var form = document.getElementById('search-form');
+ var input = document.getElementById('search-text');
+
+ function activateEngine(el) {
+ tabs.forEach(function (t) { t.classList.remove('active'); });
+ el.classList.add('active');
+ var search = el.getAttribute('data-search');
+ var param = el.getAttribute('data-param');
+ var slug = el.getAttribute('data-slug');
+ var ph = el.getAttribute('data-placeholder') || '搜索';
+ if (form) { form.setAttribute('action', search); }
+ if (input) { input.setAttribute('name', param); input.setAttribute('placeholder', ph); }
+ form.setAttribute('data-slug', slug);
+ hideWord();
+ }
+ tabs.forEach(function (el) {
+ el.addEventListener('click', function () { activateEngine(el); input && input.focus(); });
+ });
+
+ /* ---------- 联想下拉 ---------- */
+ var word = document.getElementById('word');
+ var timer = null;
+ var lastQ = '';
+
+ function hideWord() { if (word) { word.innerHTML = ''; word.style.display = 'none'; } }
+ function showWord(items) {
+ if (!word) return;
+ if (!items || !items.length) { hideWord(); return; }
+ word.innerHTML = items.map(function (t) {
+ var d = document.createElement('div');
+ d.textContent = t;
+ return '' + d.innerHTML + '';
+ }).join('');
+ word.style.display = 'block';
+ }
+
+ if (input) {
+ input.addEventListener('input', function () {
+ var q = input.value.trim();
+ if (q === '') { hideWord(); return; }
+ if (q === lastQ) return;
+ lastQ = q;
+ clearTimeout(timer);
+ timer = setTimeout(function () {
+ var slug = form ? (form.getAttribute('data-slug') || '') : '';
+ fetch(cfg.suggest + '?slug=' + encodeURIComponent(slug) + '&q=' + encodeURIComponent(q))
+ .then(function (r) { return r.json(); })
+ .then(function (data) { showWord(data && data.items); })
+ .catch(function () { hideWord(); });
+ }, 180);
+ });
+
+ input.addEventListener('keydown', function (e) {
+ if (e.key === 'Enter' && word && word.style.display === 'block' && word.firstChild) {
+ // 默认走第一条建议
+ input.value = word.firstChild.textContent;
+ hideWord();
+ }
+ });
+ }
+
+ document.addEventListener('click', function (e) {
+ if (word && !word.contains(e.target) && e.target !== input) { hideWord(); }
+ var li = e.target.closest ? e.target.closest('#word li') : null;
+ if (li) {
+ input.value = li.textContent;
+ hideWord();
+ if (form) { form.submit(); }
+ }
+ });
+
+ /* ---------- 合集标签页 ---------- */
+ var collTabs = document.querySelectorAll('#coll-tabs .tab');
+ collTabs.forEach(function (tab) {
+ tab.addEventListener('click', function () {
+ collTabs.forEach(function (t) { t.classList.remove('active'); });
+ tab.classList.add('active');
+ document.querySelectorAll('.tab-panel').forEach(function (p) { p.classList.remove('active'); });
+ var panel = document.getElementById(tab.getAttribute('data-target'));
+ if (panel) { panel.classList.add('active'); }
+ });
+ });
+})();
diff --git a/config.php b/config.php
new file mode 100644
index 0000000..9133982
--- /dev/null
+++ b/config.php
@@ -0,0 +1,60 @@
+query('SELECT k, v FROM settings') as $row) {
+ $cache[$row['k']] = $row['v'];
+ }
+ } catch (Throwable $e) {
+ $cache = [];
+ }
+ }
+ return array_key_exists($key, $cache) && $cache[$key] !== '' ? $cache[$key] : $default;
+}
diff --git a/data/.htaccess b/data/.htaccess
new file mode 100644
index 0000000..4628e4a
--- /dev/null
+++ b/data/.htaccess
@@ -0,0 +1,8 @@
+# 禁止 Web 访问 data 目录(SQLite 数据库等敏感文件)
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
diff --git a/data/index.html b/data/index.html
new file mode 100644
index 0000000..7d6a4be
--- /dev/null
+++ b/data/index.html
@@ -0,0 +1 @@
+
diff --git a/db.php b/db.php
new file mode 100644
index 0000000..8518f17
--- /dev/null
+++ b/db.php
@@ -0,0 +1,226 @@
+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 '', -- 本地图标文件名
+ 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);
+
+ // 是否为空库(用于判断是否写入默认数据)
+ $isEmpty = (int)$pdo->query('SELECT COUNT(*) FROM groups')->fetchColumn() === 0;
+
+ seed_settings($pdo);
+ seed_engines($pdo);
+ if ($isEmpty) {
+ seed_groups_and_sites($pdo);
+ }
+}
+
+function seed_settings(PDO $pdo): void
+{
+ $defaults = [
+ 'site_name' => '简航',
+ 'subtitle' => '个人网站导航',
+ 'default_engine' => 'bing',
+ 'primary_color' => '#2F6BF7',
+ 'open_blank' => '1',
+ ];
+ $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
+ $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', 'https://suggestqueries.google.com/complete/search?client=firefox&q={q}', 'google_firefox','#3B83FA', 0, 3],
+ ['搜狗', 'sogou', 'https://www.sogou.com/web', 'query', 'https://www.sogou.com/suggnew/ajajjson?type=web&key={q}', 'sogou_ajaj', '#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', 'https://duckduckgo.com/ac/?type=list&q={q}', 'ddg', '#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, [
+ ['海大', 'http://www.ouc.edu.cn/'],
+ ['阿里云', 'https://www.aliyun.com/'],
+ ['知乎', 'https://www.zhihu.com/'],
+ ['天翼云盘', 'https://cloud.189.cn/'],
+ ['快科技', 'http://www.mydrivers.com/'],
+ ['轻舟', 'https://yangjh.net/'],
+ ['老杨', 'https://yangyq.net/'],
+ ['红围脖', 'https://hongbowei.com/'],
+ ]],
+ ['工具', 'grid', 2, [
+ ['宝塔', 'https://bt.yangyq.net:8868/44d5b346'],
+ ['海大VPN', 'https://otrust.ouc.edu.cn'],
+ ['BB系统', 'https://wlkc.ouc.edu.cn/'],
+ ['教务系统', 'http://jwgl.ouc.edu.cn/'],
+ ['信息门户', 'http://my.ouc.edu.cn/'],
+ ['信息学部', 'http://it.ouc.edu.cn/'],
+ ['淘宝', 'https://www.taobao.com/'],
+ ['京东', 'https://www.jd.com/'],
+ ['日历', 'https://nas.yangyq.net/calendar'],
+ ['办公Nas', 'https://nas.yangyq.net/'],
+ ['家庭Nas', 'https://home.yangyq.net/'],
+ ['Git', 'https://git.yangyq.net/'],
+ ['网盘', 'https://nas.yangyq.net/drive/'],
+ ['电影', 'https://movie.yangyq.net/'],
+ ['相册', 'https://home.yangyq.net/photo'],
+ ['文件', 'https://nas.yangyq.net/file'],
+ ['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://draw.yangyq.net'],
+ ['稿定PS', 'https://ps.gaoding.com/'],
+ ['密码生成', 'https://n.yangyq.net/password/'],
+ ['PDF工具', 'https://pdf.wps.cn/'],
+ ['微信读书', 'https://weread.qq.com/'],
+ ['高德地图', 'https://gaode.com/'],
+ ['百度地图', 'https://map.baidu.com/'],
+ ['建设银行', 'http://ccb.com/cn/home/index.html'],
+ ['海大财务', 'http://hdcw.ouc.edu.cn'],
+ ['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);
+}
diff --git a/deploy/nginx-simplenav-deny-data.conf b/deploy/nginx-simplenav-deny-data.conf
new file mode 100644
index 0000000..e990f2e
--- /dev/null
+++ b/deploy/nginx-simplenav-deny-data.conf
@@ -0,0 +1,14 @@
+# SimpleNav 安全规则:禁止通过 Web 访问数据库等敏感目录
+# 用法:把下面几行加入宝塔站点 n.yangyq.net 的 nginx server{} 块中,然后 reload nginx。
+# (放在 /simplenav 子目录部署时用第一条;以后放根目录时用第二组。)
+
+# —— 子目录部署(当前测试阶段)——
+location ~ ^/simplenav/data/ { deny all; return 404; }
+location ~ ^/simplenav/\.git/ { deny all; return 404; }
+
+# —— 以后放到根目录时启用 ——
+# location ~ ^/data/ { deny all; return 404; }
+# location ~ ^/\.git/ { deny all; return 404; }
+
+# 可选:禁止 uploads 目录执行脚本(防上传木马)
+location ~* ^/simplenav/uploads/.*\.(php|phtml|phar)$ { deny all; return 404; }
diff --git a/deploy/setup-simplenav-user.sh b/deploy/setup-simplenav-user.sh
new file mode 100644
index 0000000..2548081
--- /dev/null
+++ b/deploy/setup-simplenav-user.sh
@@ -0,0 +1,122 @@
+#!/bin/bash
+#
+# SimpleNav(简航)部署账号初始化脚本
+# 在服务器上以 root 身份运行一次即可(可重复运行,幂等)
+#
+# 作用:
+# 1. 创建 simplenav 部署用户(无密码,只能密钥登录,无任何特权组)
+# 2. 安装本地 ssh 公钥
+# 3. 只给 /www/wwwroot/n.yangyq.net 的读写权限(POSIX ACL,不改原有属主)
+# 4. 预建应用目录 /simplenav/{data,uploads/icons} 并让部署用户与 web 用户都可读写
+#
+set -euo pipefail
+
+# ==================== 配置 ====================
+DEPLOY_USER="simplenav"
+SITE="/www/wwwroot/n.yangyq.net"
+APP_SUBDIR="simplenav" # 先部署到子目录测试,通过后再放根目录
+PUBKEY="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOCvCGMMkyf9CHaedSdZe4XfXrS0MCW15REibldEF6U4 deploy@n.yangyq.net"
+# ==============================================
+
+[ "$(id -u)" -eq 0 ] || { echo "错误:请用 root 运行"; exit 1; }
+[ -d "$SITE" ] || { echo "错误:目录不存在 $SITE"; exit 1; }
+command -v setfacl >/dev/null 2>&1 || {
+ echo "错误:缺少 setfacl,请先安装 acl"
+ echo " CentOS/RHEL/统信: yum install -y acl"
+ echo " Debian/Ubuntu: apt-get install -y acl"
+ exit 1
+}
+
+WEB_USER=$(stat -c '%U' "$SITE")
+echo "站点目录属主(web 用户):$WEB_USER"
+echo
+
+# ---------- 1. 创建用户 ----------
+if id "$DEPLOY_USER" >/dev/null 2>&1; then
+ echo "[=] 用户 $DEPLOY_USER 已存在,跳过创建"
+else
+ useradd -m -s /bin/bash "$DEPLOY_USER"
+ passwd -l "$DEPLOY_USER" >/dev/null 2>&1 || true
+ echo "[+] 已创建用户 $DEPLOY_USER(密码锁定,仅允许密钥登录)"
+fi
+for g in wheel sudo adm root; do
+ if id -nG "$DEPLOY_USER" | tr ' ' '\n' | grep -qx "$g"; then
+ gpasswd -d "$DEPLOY_USER" "$g" >/dev/null 2>&1 || true
+ echo "[!] 已从特权组 $g 中移除 $DEPLOY_USER"
+ fi
+done
+echo "[i] 当前所属组:$(id -nG "$DEPLOY_USER")"
+
+# ---------- 2. 安装公钥 ----------
+install -d -m 700 -o "$DEPLOY_USER" -g "$DEPLOY_USER" "/home/$DEPLOY_USER/.ssh"
+AUTH="/home/$DEPLOY_USER/.ssh/authorized_keys"
+touch "$AUTH"
+if grep -qF "$PUBKEY" "$AUTH"; then
+ echo "[=] 公钥已存在,跳过"
+else
+ echo "$PUBKEY" >> "$AUTH"
+ echo "[+] 公钥已写入 authorized_keys"
+fi
+chmod 600 "$AUTH"
+chown "$DEPLOY_USER:$DEPLOY_USER" "$AUTH"
+chmod 700 "/home/$DEPLOY_USER"
+echo
+
+# ---------- 3. 上级目录穿透 ----------
+for d in /www /www/wwwroot; do
+ if [ -d "$d" ]; then
+ other_bit=$(stat -c '%a' "$d"); other_bit=${other_bit: -1}
+ if [ "$other_bit" -lt 1 ]; then
+ setfacl -m "u:$DEPLOY_USER:x" "$d"
+ echo "[+] 已为 $d 补充 $DEPLOY_USER 的穿透(x)权限"
+ fi
+ fi
+done
+
+# ---------- 4. 目录授权(ACL,不改属主) ----------
+# 站点根下可能有 root 属主文件(宝塔 .user.ini),setfacl -R 会报 EPERM,需要容错
+acl_denied=""
+setfacl -R -m "u:$DEPLOY_USER:rwX" "$SITE" 2>/dev/null || acl_denied="yes"
+setfacl -R -d -m "u:$DEPLOY_USER:rwX" "$SITE" 2>/dev/null || acl_denied="yes"
+if [ "$WEB_USER" != "root" ] && [ "$WEB_USER" != "UNKNOWN" ]; then
+ setfacl -R -m "u:$WEB_USER:rwX" "$SITE" 2>/dev/null || true
+ setfacl -R -d -m "u:$WEB_USER:rwX" "$SITE" 2>/dev/null || true
+fi
+if [ -n "$acl_denied" ]; then
+ find "$SITE" ! -user "$WEB_USER" -print0 2>/dev/null | while IFS= read -r -d '' p; do
+ printf " [跳过·属主非 %s] %s\n" "$WEB_USER" "$p"
+ done
+ echo "[!] 有部分条目未能设置 ACL(通常是 root 属主的 .user.ini,不影响部署)"
+fi
+echo "[+] 已对 $SITE 授予 $DEPLOY_USER 与 $WEB_USER 读写权限(属主未改动)"
+echo
+
+# ---------- 5. 预建应用可写目录 ----------
+APP="$SITE/$APP_SUBDIR"
+install -d -m 775 "$APP/data" "$APP/uploads/icons"
+setfacl -R -m "u:$DEPLOY_USER:rwX,u:$WEB_USER:rwX" "$APP" 2>/dev/null || true
+setfacl -R -d -m "u:$DEPLOY_USER:rwX,u:$WEB_USER:rwX" "$APP" 2>/dev/null || true
+echo "[+] 已预建 $APP/{data,uploads/icons},并让 $DEPLOY_USER 与 $WEB_USER 可读写"
+echo
+
+# ---------- 6. 验收 ----------
+echo "==================== 验收 ===================="
+echo "用户信息: $(id "$DEPLOY_USER")"
+echo "密钥文件: $(stat -c '%a %U:%G' "$AUTH")"
+echo
+echo -n "以 $DEPLOY_USER 身份在 $SITE 写测试: "
+if su -s /bin/sh "$DEPLOY_USER" -c "touch '$SITE/.deploy_write_test' && rm -f '$SITE/.deploy_write_test'"; then
+ echo "通过"
+else
+ echo "失败(请检查上面的 ACL 输出)"
+fi
+echo -n "以 $DEPLOY_USER 身份读 /etc/shadow: "
+if su -s /bin/sh "$DEPLOY_USER" -c "cat /etc/shadow" >/dev/null 2>&1; then
+ echo "竟然可读(异常)"
+else
+ echo "被拒绝(正常)"
+fi
+echo "=============================================="
+echo
+echo "完成后请把下面这行结果发我:"
+echo " sudo -l -U $DEPLOY_USER"
diff --git a/favicon.ico b/favicon.ico
new file mode 100644
index 0000000..4f2641f
Binary files /dev/null and b/favicon.ico differ
diff --git a/functions.php b/functions.php
new file mode 100644
index 0000000..67305e9
--- /dev/null
+++ b/functions.php
@@ -0,0 +1,237 @@
+ [
+ 'timeout' => $timeout,
+ 'header' => "User-Agent: Mozilla/5.0 SimpleNav\r\n",
+ 'follow_location' => 1,
+ ]]);
+ $body = @file_get_contents($url, false, $ctx, 0, $maxBytes);
+ if ($body === false) {
+ return null;
+ }
+ return ['body' => $body, 'url' => $url, 'type' => ''];
+ }
+ $ch = curl_init($url);
+ curl_setopt_array($ch, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 5,
+ CURLOPT_TIMEOUT => $timeout,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible; SimpleNav/1.0)',
+ CURLOPT_SSL_VERIFYPEER => false,
+ CURLOPT_SSL_VERIFYHOST => 0,
+ CURLOPT_ENCODING => '',
+ ]);
+ $body = curl_exec($ch);
+ $type = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
+ $eff = (string)curl_getinfo($ch, CURLINFO_EFFECTIVE_URL);
+ $err = curl_errno($ch);
+ curl_close($ch);
+ if ($err || $body === false) {
+ return null;
+ }
+ return ['body' => $body, 'url' => $eff ?: $url, 'type' => $type];
+}
+
+/**
+ * 抓取网站元信息:标题、名称、图标地址。
+ * @return array{title:string,name:string,icon:string,final_url:string}
+ */
+function fetch_site_meta(string $url): array
+{
+ $url = norm_url($url);
+ $out = ['title' => '', 'name' => '', 'icon' => '', 'final_url' => $url];
+ $resp = http_get($url);
+ if ($resp === null) {
+ return $out;
+ }
+ $html = $resp['body'];
+ $final = $resp['url'];
+ $out['final_url'] = $final;
+
+ // 编码:优先按 转换到 UTF-8
+ $html = ensure_utf8($html);
+
+ //
+ if (preg_match('#]*>(.*?)#is', $html, $m)) {
+ $out['title'] = trim(preg_replace('/\s+/', ' ', html_entity_decode($m[1], ENT_QUOTES, 'UTF-8')));
+ }
+ // og:site_name 优先作为"名称"
+ if (preg_match('#]+property=["\']og:site_name["\'][^>]+content=["\']([^"\']+)#i', $html, $m)) {
+ $out['name'] = trim(html_entity_decode($m[1], ENT_QUOTES, 'UTF-8'));
+ } elseif ($out['title'] !== '') {
+ // 用标题的首段(按分隔符切分)作为名称
+ $t = preg_split('#[\|\-_—·::]#u', $out['title']);
+ $out['name'] = trim($t[0]);
+ }
+
+ // 图标:
+ $icon = '';
+ if (preg_match_all('#]+>#i', $html, $links)) {
+ foreach ($links[0] as $tag) {
+ if (preg_match('#rel=["\'][^"\']*icon[^"\']*["\']#i', $tag)) {
+ if (preg_match('#href=["\']([^"\']+)["\']#i', $tag, $hm)) {
+ $cand = html_entity_decode($hm[1], ENT_QUOTES, 'UTF-8');
+ // 优先 apple-touch-icon(通常更大更清晰)
+ if (stripos($tag, 'apple-touch') !== false) {
+ $icon = $cand;
+ break;
+ }
+ if ($icon === '') {
+ $icon = $cand;
+ }
+ }
+ }
+ }
+ }
+ if ($icon !== '') {
+ $out['icon'] = absolutize($icon, $final);
+ } else {
+ $p = parse_url($final);
+ if (!empty($p['scheme']) && !empty($p['host'])) {
+ $out['icon'] = $p['scheme'] . '://' . $p['host'] . '/favicon.ico';
+ }
+ }
+ return $out;
+}
+
+/** 把可能为相对路径的 URL 补全为绝对 URL */
+function absolutize(string $href, string $base): string
+{
+ if (preg_match('#^https?://#i', $href)) {
+ return $href;
+ }
+ $p = parse_url($base);
+ $root = ($p['scheme'] ?? 'http') . '://' . ($p['host'] ?? '');
+ if (strpos($href, '//') === 0) {
+ return ($p['scheme'] ?? 'http') . ':' . $href;
+ }
+ if (strpos($href, '/') === 0) {
+ return $root . $href;
+ }
+ $dir = rtrim(dirname($p['path'] ?? '/'), '/');
+ return $root . $dir . '/' . $href;
+}
+
+/** 简单编码归一:识别 GBK/GB18030 并转 UTF-8 */
+function ensure_utf8(string $s): string
+{
+ if (preg_match('#]+charset=["\']?\s*(gbk|gb2312|gb18030)#i', $s)) {
+ if (function_exists('mb_convert_encoding')) {
+ return mb_convert_encoding($s, 'UTF-8', 'GB18030');
+ }
+ }
+ if (function_exists('mb_check_encoding') && !mb_check_encoding($s, 'UTF-8')) {
+ return mb_convert_encoding($s, 'UTF-8', 'GB18030');
+ }
+ return $s;
+}
+
+/**
+ * 下载图标并保存到本地(PNG,64x64,透明背景)。
+ * @return string 本地文件名(如 ab12cd.png),失败返回 ''
+ */
+function download_icon(string $iconUrl, string $key, int $size = 64): string
+{
+ if ($iconUrl === '') {
+ return '';
+ }
+ if (!is_dir(ICON_PATH)) {
+ @mkdir(ICON_PATH, 0755, true);
+ }
+ $resp = http_get($iconUrl, 500000, 8);
+ if ($resp === null || $resp['body'] === '') {
+ return '';
+ }
+ $bin = $resp['body'];
+ $name = md5($key) . '.png';
+ $dest = ICON_PATH . '/' . $name;
+
+ // 用 GD 统一转成 PNG(顺带裁成正方形、缩放)
+ if (function_exists('imagecreatefromstring')) {
+ $im = @imagecreatefromstring($bin);
+ if ($im !== false) {
+ $w = imagesx($im);
+ $h = imagesy($im);
+ $side = min($w, $h);
+ $square = imagecreatetruecolor($size, $size);
+ imagealphablending($square, false);
+ imagesavealpha($square, true);
+ $trans = imagecolorallocatealpha($square, 0, 0, 0, 127);
+ imagefill($square, 0, 0, $trans);
+ $sx = (int)(($w - $side) / 2);
+ $sy = (int)(($h - $side) / 2);
+ imagecopyresampled($square, $im, 0, 0, $sx, $sy, $size, $size, $side, $side);
+ imagepng($square, $dest);
+ imagedestroy($square);
+ imagedestroy($im);
+ return $name;
+ }
+ }
+ // 无 GD:原样保存(按 content-type 猜扩展名)
+ $ext = 'ico';
+ if (stripos($resp['type'], 'png') !== false) {
+ $ext = 'png';
+ } elseif (stripos($resp['type'], 'svg') !== false) {
+ $ext = 'svg';
+ } elseif (stripos($resp['type'], 'jpeg') !== false || stripos($resp['type'], 'jpg') !== false) {
+ $ext = 'jpg';
+ } elseif (stripos($resp['type'], 'gif') !== false) {
+ $ext = 'gif';
+ }
+ $name = md5($key) . '.' . $ext;
+ @file_put_contents(ICON_PATH . '/' . $name, $bin);
+ return is_file(ICON_PATH . '/' . $name) ? $name : '';
+}
+
+/** 图标显示地址(为空时返回 '',前端回退到首字母块) */
+function icon_src(string $file): string
+{
+ return $file === '' ? '' : url(ICON_URL . '/' . $file);
+}
+
+/** 名称首字配色(无图标时的回退头像底色) */
+function letter_color(string $s): string
+{
+ $palette = ['#2F6BF7', '#5B8DEF', '#0EA5E9', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
+ return $palette[abs(crc32($s)) % count($palette)];
+}
diff --git a/index.php b/index.php
new file mode 100644
index 0000000..7d5318a
--- /dev/null
+++ b/index.php
@@ -0,0 +1,140 @@
+query("SELECT * FROM groups WHERE is_visible=1 AND display='grid' ORDER BY sort_order, id")->fetchAll();
+$tabGroups = $pdo->query("SELECT * FROM groups WHERE is_visible=1 AND display='tabs' ORDER BY sort_order, id")->fetchAll();
+$engines = $pdo->query("SELECT * FROM engines WHERE is_active=1 ORDER BY sort_order, id")->fetchAll();
+
+// 默认引擎
+$defSlug = setting('default_engine', 'bing');
+$default = null;
+foreach ($engines as $en) {
+ if ($en['slug'] === $defSlug) { $default = $en; break; }
+}
+if ($default === null) { $default = $engines[0] ?? null; }
+
+function sites_of(int $gid): array
+{
+ $st = db()->prepare("SELECT * FROM sites WHERE group_id=? AND is_visible=1 ORDER BY sort_order, id");
+ $st->execute([$gid]);
+ return $st->fetchAll();
+}
+
+function site_label(array $s): string
+{
+ return $s['short_name'] !== '' ? $s['short_name'] : ($s['name'] !== '' ? $s['name'] : domain_of($s['url']));
+}
+?>
+
+
+
+
+
+= e(site_name()) ?> · = e(setting('subtitle', '个人网站导航')) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ $en): ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
合集
+
+ $g): ?>
+
+
+
+
+ $g): $items = sites_of((int)$g['id']); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/login.php b/login.php
new file mode 100644
index 0000000..9b19bb9
--- /dev/null
+++ b/login.php
@@ -0,0 +1,64 @@
+query('SELECT COUNT(*) FROM users')->fetchColumn() > 0;
+$err = '';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $u = trim((string)($_POST['username'] ?? ''));
+ $p = (string)($_POST['password'] ?? '');
+ if ($u === '' || $p === '') {
+ $err = '请输入用户名和密码';
+ } elseif (mb_strlen($p) < 4) {
+ $err = '密码至少 4 位';
+ } elseif (!$hasAdmin) {
+ // 首次创建管理员
+ $st = db()->prepare('INSERT INTO users (username, password_hash, created_at) VALUES (?,?,?)');
+ $st->execute([$u, password_hash($p, PASSWORD_DEFAULT), date('Y-m-d H:i:s')]);
+ $_SESSION['uid'] = (int)db()->lastInsertId();
+ header('Location: ' . url('admin/'));
+ exit;
+ } else {
+ // 登录校验
+ $st = db()->prepare('SELECT * FROM users WHERE username=? LIMIT 1');
+ $st->execute([$u]);
+ $row = $st->fetch();
+ if ($row && password_verify($p, $row['password_hash'])) {
+ $_SESSION['uid'] = (int)$row['id'];
+ header('Location: ' . url('admin/'));
+ exit;
+ }
+ $err = '用户名或密码错误';
+ }
+}
+$title = $hasAdmin ? '登录' : '初始化管理员';
+?>
+
+
+
+
+= e($title) ?> · = e(site_name()) ?>
+
+
+
+
+
+
+
diff --git a/logout.php b/logout.php
new file mode 100644
index 0000000..e028230
--- /dev/null
+++ b/logout.php
@@ -0,0 +1,14 @@
+ '', 'items' => []]);
+ exit;
+}
+
+$items = [];
+try {
+ $st = db()->prepare('SELECT * FROM engines WHERE slug=? AND is_active=1 LIMIT 1');
+ $st->execute([$slug]);
+ $engine = $st->fetch();
+ if ($engine && $engine['suggest_url'] !== '') {
+ $u = str_replace('{q}', rawurlencode($q), $engine['suggest_url']);
+ $resp = http_get($u, 100000, 5);
+ if ($resp !== null) {
+ $items = parse_suggest($engine['suggest_type'], ensure_utf8($resp['body']), $q);
+ }
+ }
+} catch (Throwable $e) {
+ $items = [];
+}
+
+// 去重、限长、裁剪
+$items = array_values(array_unique(array_filter(array_map('trim', $items))));
+$items = array_slice($items, 0, 10);
+
+echo json_encode(['q' => $q, 'items' => $items], JSON_UNESCAPED_UNICODE);
+exit;
+
+/** 按引擎类型解析联想结果 */
+function parse_suggest(string $type, string $body, string $q): array
+{
+ $body = trim($body);
+ $out = [];
+
+ switch ($type) {
+ case 'bing_osjson':
+ case 'google_firefox':
+ // ["query", ["s1","s2",...]]
+ $j = json_decode($body, true);
+ if (is_array($j) && isset($j[1]) && is_array($j[1])) {
+ $out = $j[1];
+ }
+ break;
+
+ case 'baidu_sugrec':
+ // {"q":"x","g":[{"type":"..","q":".."}], ...}
+ $j = json_decode($body, true);
+ if (is_array($j)) {
+ if (!empty($j['g']) && is_array($j['g'])) {
+ foreach ($j['g'] as $it) { if (isset($it['q'])) $out[] = $it['q']; }
+ } elseif (!empty($j['s']) && is_array($j['s'])) {
+ $out = $j['s'];
+ }
+ }
+ break;
+
+ case 'so360':
+ // {"s":[".."]} 或 {"result":[{"word":".."}]}
+ $j = json_decode($body, true);
+ if (is_array($j)) {
+ if (!empty($j['s']) && is_array($j['s'])) { $out = $j['s']; }
+ elseif (!empty($j['result']) && is_array($j['result'])) {
+ foreach ($j['result'] as $it) { if (isset($it['word'])) $out[] = $it['word']; }
+ }
+ }
+ break;
+
+ case 'sogou_ajaj':
+ // {"query":"x","items":["a","b"]}(可能是 GBK,已在上面转码)
+ $j = json_decode($body, true);
+ if (is_array($j) && !empty($j['items']) && is_array($j['items'])) {
+ $out = $j['items'];
+ } else {
+ // 兜底:正则抓引号字符串
+ if (preg_match_all('#"([^"]{1,40})"#u', $body, $m)) {
+ $out = array_slice($m[1], 1); // 跳过 query 自身
+ }
+ }
+ break;
+
+ case 'ddg':
+ // [{"phrase":".."},...]
+ $j = json_decode($body, true);
+ if (is_array($j)) {
+ foreach ($j as $it) {
+ if (is_array($it) && isset($it['phrase'])) { $out[] = $it['phrase']; }
+ elseif (is_string($it)) { $out[] = $it; }
+ }
+ }
+ break;
+
+ default:
+ // 通用兜底:尝试 JSON,再退化正则
+ $j = json_decode($body, true);
+ if (is_array($j)) {
+ if (isset($j[1]) && is_array($j[1])) { $out = $j[1]; }
+ elseif (!empty($j['s']) && is_array($j['s'])) { $out = $j['s']; }
+ }
+ }
+ return $out;
+}
diff --git a/uploads/.htaccess b/uploads/.htaccess
new file mode 100644
index 0000000..7f135f3
--- /dev/null
+++ b/uploads/.htaccess
@@ -0,0 +1,12 @@
+# 禁止在 uploads 目录执行脚本(只允许作为静态资源访问)
+
+
+ Require all denied
+
+
+ Order allow,deny
+ Deny from all
+
+
+Options -ExecCGI
+AddType text/plain .php .phtml .phar
diff --git a/uploads/icons/.gitkeep b/uploads/icons/.gitkeep
new file mode 100644
index 0000000..e69de29