Browse Source

docs: update README with comprehensive project documentation

Replace the minimal placeholder README with a full Chinese-language document covering features, deployment steps, project structure, security architecture, and usage instructions. This improves project clarity for users and contributors, enabling easier setup and maintenance.
caesar 2 tháng trước cách đây
mục cha
commit
6ee52dc2d1
15 tập tin đã thay đổi với 2744 bổ sung2 xóa
  1. 53 0
      .htaccess
  2. 123 2
      README.md
  3. 26 0
      config.php
  4. 214 0
      db_init.php
  5. 30 0
      init.php
  6. 41 0
      public/.htaccess
  7. 605 0
      public/admin.php
  8. 135 0
      public/api.php
  9. 794 0
      public/index.php
  10. 172 0
      src/Auth.php
  11. 21 0
      src/AutoLoader.php
  12. 154 0
      src/Database.php
  13. 210 0
      src/LinkManager.php
  14. 56 0
      src/Response.php
  15. 110 0
      src/Validator.php

+ 53 - 0
.htaccess

@@ -0,0 +1,53 @@
+# BCJD 导航 - 项目根目录安全配置
+# 注意:服务器 DocumentRoot 应指向 /public 目录
+# 本文件仅作额外防护,防止误配时暴露敏感文件
+
+# ============================================
+# 禁止直接访问 src/ 目录(OOP 类库)
+# ============================================
+<IfModule mod_rewrite.c>
+    RewriteEngine On
+    RewriteRule ^src/ - [F,L]
+</IfModule>
+
+# ============================================
+# 禁止直接访问敏感文件
+# ============================================
+<FilesMatch "(config\.php|db_init\.php|init\.php)$">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Deny from all
+    </IfModule>
+</FilesMatch>
+
+# ============================================
+# 禁止访问项目说明文件
+# ============================================
+<FilesMatch "\.(md|txt|log)$">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Deny from all
+    </IfModule>
+</FilesMatch>
+<Files "LICENSE">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Deny from all
+    </IfModule>
+</Files>
+
+# 禁止浏览目录
+Options -Indexes
+
+# ============================================
+# 如需允许通过特定 IP 访问 db_init.php,注释上面 FilesMatch 规则并取消下面注释:
+# ============================================
+# <Files "db_init.php">
+#   Require ip 你的IP地址
+# </Files>

+ 123 - 2
README.md

@@ -1,3 +1,124 @@
-# bcjd-html
+# BCJD 导航
 
-bcjd.net front page
+bcjd.net 服务导航页面(PHP + MariaDB 版)
+
+## 功能特性
+
+- **链接分类**:链接按「应用」「运维/管理」「站点」等分组展示
+- **权限控制**:支持公开/私有两种权限,私有链接需要登录后才能看到
+- **单用户密码登录**:仅需输入密码,无需用户名(基于 PHP Session + bcrypt)
+- **链接管理**:支持在管理面板中自行添加、编辑、删除链接
+- **暗色/浅色主题**:跟随系统或手动切换
+- **实时搜索**:按 `/` 聚焦搜索框,回车打开第一个结果
+
+## 部署步骤
+
+### 0. 服务器要求
+
+- PHP 8.0+
+- MariaDB 10.3+ / MySQL 5.7+
+- Apache(mod_rewrite + mod_headers)
+- PDO 扩展(php-mysql)
+
+### 1. 克隆项目并配置
+
+```bash
+git clone https://github.com/你的用户名/bcjd-html.git /var/www/html
+# 或上传至服务器后解压
+```
+
+项目根目录不应作为 Web 入口。**必须将 `public/` 目录设为服务器 DocumentRoot。**
+
+### 2. 配置 DocumentRoot
+
+编辑 Apache 站点配置(如 `/etc/apache2/sites-available/bcjd.conf`):
+
+```apache
+<VirtualHost *:80>
+    ServerName bcjd.net
+    DocumentRoot /var/www/html/bcjd-html/public
+
+    <Directory /var/www/html/bcjd-html/public>
+        AllowOverride All
+        Require all granted
+    </Directory>
+</VirtualHost>
+```
+
+或者使用符号链接:
+
+```bash
+# ln -s /var/www/html/bcjd-html/public /var/www/html/你的Web目录
+```
+
+### 3. 配置数据库
+
+编辑 [`config.php`](config.php),修改以下参数:
+
+```php
+define('DB_HOST', 'localhost');
+define('DB_NAME', 'bcjd_nav');
+define('DB_USER', '你的数据库用户名');
+define('DB_PASS', '你的数据库密码');
+```
+
+### 4. 初始化数据库
+
+在浏览器访问 `http://你的域名/db_init.php`
+
+脚本会自动创建数据库和表结构,并导入默认链接数据。
+
+**⚠️ 初始化完成后,请删除或重命名 `db_init.php` 文件以保证安全。**
+**⚠️ `.htaccess` 已自动禁止通过 Web 访问 `db_init.php`,如需使用请临时修改规则。**
+
+### 5. 修改默认密码
+
+默认密码为 `bcjd2025`。
+
+登录后访问 `http://你的域名/admin.php` →「修改密码」选项卡修改。
+
+## 项目结构
+
+```
+bcjd-html/                      # 项目根目录(不对外暴露)
+├── .htaccess                   # 顶层安全防护(禁止访问敏感文件/目录)
+├── config.php                  # 数据库 & 密码配置
+├── db_init.php                 # 数据库初始化脚本(用完删除)
+├── init.php                    # 系统引导(自动加载 + 安全头 + 时区)
+├── src/                        # OOP 类库(命名空间:BCJD\)
+│   ├── AutoLoader.php          # PSR-4 自动加载器
+│   ├── Auth.php                # 认证类(CSRF Token + Session 安全 + bcrypt)
+│   ├── Database.php            # PDO 单例管理器(预处理语句 + 严格模式)
+│   ├── LinkManager.php         # 链接 CRUD(参数绑定 + XSS 过滤)
+│   ├── Response.php            # 统一 JSON 响应
+│   └── Validator.php           # 输入验证净化
+├── public/                     # 🎯 Web 入口(服务器 DocumentRoot 指向此目录)
+│   ├── .htaccess               # Apache 安全规则(安全头 + 防目录浏览)
+│   ├── index.php               # 前台首页
+│   ├── admin.php               # 管理面板(需登录)
+│   └── api.php                 # RESTful API 接口
+├── README.md
+└── LICENSE
+```
+
+## 权限说明
+
+在管理面板添加/编辑链接时,可以设置「私有链接」开关:
+
+- **公开**(默认):所有访客可见
+- **私有**:仅在登录后可见
+
+登录按钮在首页右上角,点击后弹出密码输入框。
+
+## 安全架构
+
+| 防护维度 | 实现方式 |
+|---------|---------|
+| **SQL 注入** | 全部 PDO 预处理语句 + 参数绑定 |
+| **XSS 攻击** | htmlspecialchars(ENT_QUOTES) + SVG 白名单过滤 |
+| **CSRF 跨站请求伪造** | 64 位随机 Token + hash_equals 验证 |
+| **Session 劫持** | 登录后 regenerate_id + 30 分钟超时 + HttpOnly |
+| **密码暴力破解** | bcrypt cost=12 + 500ms 登录延迟 |
+| **信息泄露** | PDO 异常记录日志,用户仅见通用错误提示 |
+| **HTTP 安全头** | X-Frame-Options / X-Content-Type-Options / X-XSS-Protection / Referrer-Policy |
+| **目录隔离** | 敏感代码在 Web 根目录之外,无法直接访问 |

+ 26 - 0
config.php

@@ -0,0 +1,26 @@
+<?php
+/**
+ * BCJD 导航 - 系统配置
+ *
+ * ⚠️ 安全提醒:
+ * - 不要将本文件提交到公开仓库
+ * - 数据库密码使用高强度随机字符串
+ * - 默认密码 bcjd2025 部署后请立即修改
+ */
+
+// ---- 数据库连接 ----
+define('DB_HOST',    'localhost');
+define('DB_PORT',    3306);
+define('DB_NAME',    'bcjd_nav');
+define('DB_USER',    'root');
+define('DB_PASS',    '');
+define('DB_CHARSET', 'utf8mb4');
+
+// ---- 站点密码(bcrypt 哈希)----
+// 默认密码:bcjd2025 (请尽快修改!)
+// 可通过管理面板修改密码
+define('SITE_PASSWORD_HASH', '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi');
+
+// ---- 应用配置 ----
+define('APP_NAME',       'BCJD 导航');
+define('SESSION_TIMEOUT', 1800); // Session 超时(秒)

+ 214 - 0
db_init.php

@@ -0,0 +1,214 @@
+<?php
+/**
+ * BCJD 导航 - 数据库初始化脚本
+ *
+ * 使用 OOP 类库重构。
+ * 访问此文件一次即可自动建库建表并导入现有数据。
+ * ⚠️ 执行完毕后建议删除或限制访问此文件!
+ */
+
+require_once __DIR__ . '/init.php';
+
+use BCJD\Database;
+
+header('Content-Type: text/html; charset=utf-8');
+
+try {
+    // 1. 先创建数据库
+    $pdo = Database::rawConnection();
+    $pdo->exec(sprintf(
+        'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci',
+        DB_NAME
+    ));
+    $pdo->exec(sprintf('USE `%s`', DB_NAME));
+    echo "✅ 数据库创建/确认成功<br>\n";
+
+    // 2. 创建 links 表
+    $pdo->exec("
+        CREATE TABLE IF NOT EXISTS `links` (
+            `id`          INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
+            `title`       VARCHAR(100)  NOT NULL COMMENT '链接标题',
+            `url`         VARCHAR(500)  NOT NULL COMMENT '链接地址',
+            `description` VARCHAR(255)  NOT NULL DEFAULT '' COMMENT '描述',
+            `icon_svg`    TEXT          NOT NULL COMMENT 'SVG 图标代码',
+            `group_name`  VARCHAR(50)   NOT NULL DEFAULT 'default' COMMENT '分组名称',
+            `group_label` VARCHAR(50)   NOT NULL DEFAULT '' COMMENT '分组显示标签',
+            `group_hint`  VARCHAR(100)  NOT NULL DEFAULT '' COMMENT '分组提示文字',
+            `keywords`    VARCHAR(255)  NOT NULL DEFAULT '' COMMENT '搜索关键词',
+            `tag_label`   VARCHAR(30)   NOT NULL DEFAULT '' COMMENT '标签文字',
+            `tag_class`   VARCHAR(30)   NOT NULL DEFAULT '' COMMENT '标签样式类',
+            `meta_domain` VARCHAR(100)  NOT NULL DEFAULT '' COMMENT '域名/元信息',
+            `sort_order`  INT UNSIGNED  NOT NULL DEFAULT 0 COMMENT '排序',
+            `is_private`  TINYINT(1)    NOT NULL DEFAULT 0 COMMENT '1=需登录,0=公开',
+            `created_at`  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            INDEX `idx_group` (`group_name`),
+            INDEX `idx_private` (`is_private`),
+            INDEX `idx_sort` (`sort_order`)
+        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+    ");
+    echo "✅ links 表创建/确认成功<br>\n";
+
+    // 3. 导入初始数据(如果表为空)
+    $count = $pdo->query("SELECT COUNT(*) FROM links")->fetchColumn();
+    if ($count > 0) {
+        echo "⚠️ links 表已有 {$count} 条记录,跳过数据导入<br>\n";
+    } else {
+        $stmt = $pdo->prepare("
+            INSERT INTO `links` (`title`, `url`, `description`, `icon_svg`, `group_name`, `group_label`, `group_hint`, `keywords`, `tag_label`, `tag_class`, `meta_domain`, `sort_order`, `is_private`)
+            VALUES (:title, :url, :description, :icon_svg, :group_name, :group_label, :group_hint, :keywords, :tag_label, :tag_class, :meta_domain, :sort_order, :is_private)
+        ");
+
+        $links = [
+            // ===== 应用 - 公开 =====
+            [
+                'title'       => 'Monica',
+                'url'         => 'https://monica.bcjd.net',
+                'description' => '个人关系/联系人管理类应用入口。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 21s-7-4.4-9-9.2C1.3 8 3.4 5 6.8 5c1.8 0 3 .9 3.7 1.8C11.2 5.9 12.4 5 14.2 5 17.6 5 19.7 8 21 11.8 19 16.6 12 21 12 21Z" stroke="currentColor" stroke-width="1.7"/></svg>',
+                'group_name'  => 'apps',
+                'group_label' => '应用',
+                'group_hint'  => '常用入口',
+                'keywords'    => 'monica crm 联系人 个人 管理',
+                'tag_label'   => 'App',
+                'tag_class'   => 'ok',
+                'meta_domain' => 'monica.bcjd.net',
+                'sort_order'  => 1,
+                'is_private'  => 0,
+            ],
+            [
+                'title'       => 'Gogs',
+                'url'         => 'https://gogs.bcjd.net',
+                'description' => 'Git 仓库服务入口(代码托管/协作)。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M7 7h10v10H7V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M9 9h6M9 12h6M9 15h4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
+                'group_name'  => 'apps',
+                'group_label' => '应用',
+                'group_hint'  => '常用入口',
+                'keywords'    => 'gogs git 代码 仓库 dev',
+                'tag_label'   => 'Dev',
+                'tag_class'   => 'ok',
+                'meta_domain' => 'gogs.bcjd.net',
+                'sort_order'  => 2,
+                'is_private'  => 0,
+            ],
+            [
+                'title'       => 'Docker',
+                'url'         => 'https://docker.bcjd.net',
+                'description' => '容器管理入口(例如 Portainer)。建议仅管理员使用。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7.5 12 3l8 4.5v9L12 21l-8-4.5v-9Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M12 3v18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" opacity=".75"/><path d="M4 7.5 12 12l8-4.5" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round" opacity=".75"/></svg>',
+                'group_name'  => 'apps',
+                'group_label' => '应用',
+                'group_hint'  => '常用入口',
+                'keywords'    => 'docker portainer 容器 运维 管理',
+                'tag_label'   => 'Ops',
+                'tag_class'   => 'ok',
+                'meta_domain' => 'docker.bcjd.net',
+                'sort_order'  => 3,
+                'is_private'  => 1,
+            ],
+            // ===== 运维/管理 - 私有 =====
+            [
+                'title'       => 'Webmin',
+                'url'         => 'https://webmin.bcjd.net',
+                'description' => '服务器管理面板入口。请确认账号安全与访问来源。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4v6c0 5-3.5 9.4-8 10-4.5-.6-8-5-8-10V6l8-4Z" stroke="currentColor" stroke-width="1.7"/></svg>',
+                'group_name'  => 'ops',
+                'group_label' => '运维 / 管理',
+                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+                'keywords'    => 'webmin 运维 管理 面板 linux',
+                'tag_label'   => 'Admin',
+                'tag_class'   => 'warn',
+                'meta_domain' => 'webmin.bcjd.net',
+                'sort_order'  => 10,
+                'is_private'  => 1,
+            ],
+            [
+                'title'       => 'phpMyAdmin',
+                'url'         => 'https://phpmyadmin.bcjd.net',
+                'description' => '数据库管理入口。请避免在不可信网络下使用。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M6 7c0 2.2 2.7 4 6 4s6-1.8 6-4-2.7-4-6-4-6 1.8-6 4Z" stroke="currentColor" stroke-width="1.7"/><path d="M6 7v10c0 2.2 2.7 4 6 4s6-1.8 6-4V7" stroke="currentColor" stroke-width="1.7"/></svg>',
+                'group_name'  => 'ops',
+                'group_label' => '运维 / 管理',
+                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+                'keywords'    => 'phpmyadmin mysql mariadb 数据库 管理',
+                'tag_label'   => 'DB',
+                'tag_class'   => 'warn',
+                'meta_domain' => 'phpmyadmin.bcjd.net',
+                'sort_order'  => 11,
+                'is_private'  => 1,
+            ],
+            [
+                'title'       => 'Mail',
+                'url'         => 'https://mail.bcjd.net',
+                'description' => '邮箱系统入口(WebMail/邮件访问)。建议注意账号与设备安全。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16v10H4V7Z" stroke="currentColor" stroke-width="1.7"/><path d="M4 8l8 6 8-6" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
+                'group_name'  => 'ops',
+                'group_label' => '运维 / 管理',
+                'group_hint'  => '<span style="color:var(--danger)">谨慎操作</span>(建议仅管理员使用)',
+                'keywords'    => 'mail 邮箱 邮件 webmail 收件箱 管理 运维 smtp imap',
+                'tag_label'   => 'Mail',
+                'tag_class'   => 'warn',
+                'meta_domain' => 'mail.bcjd.net',
+                'sort_order'  => 12,
+                'is_private'  => 1,
+            ],
+            // ===== 站点 - 公开 =====
+            [
+                'title'       => 'www.bcjd.net',
+                'url'         => 'https://www.bcjd.net',
+                'description' => '网站主页/内容入口。你可以把此导航页部署在这里。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1v-9.5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>',
+                'group_name'  => 'sites',
+                'group_label' => '站点',
+                'group_hint'  => '对外主页 / 内容入口',
+                'keywords'    => 'bcjd 主站 www 首页',
+                'tag_label'   => 'Web',
+                'tag_class'   => 'ok',
+                'meta_domain' => '主站入口',
+                'sort_order'  => 20,
+                'is_private'  => 0,
+            ],
+            [
+                'title'       => 'www.qyqy.org',
+                'url'         => 'https://www.qyqy.org',
+                'description' => 'qyqy.org 的 www 入口。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16v12H4V6Z" stroke="currentColor" stroke-width="1.7"/><path d="M7 9h10M7 12h8M7 15h6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>',
+                'group_name'  => 'sites',
+                'group_label' => '站点',
+                'group_hint'  => '对外主页 / 内容入口',
+                'keywords'    => 'qyqy 网站 www',
+                'tag_label'   => 'Web',
+                'tag_class'   => '',
+                'meta_domain' => '站点入口',
+                'sort_order'  => 21,
+                'is_private'  => 0,
+            ],
+            [
+                'title'       => 'www.mahaoyun.com',
+                'url'         => 'https://www.mahaoyun.com',
+                'description' => 'mahaoyun.com 的 www 入口。',
+                'icon_svg'    => '<svg viewBox="0 0 24 24" fill="none"><path d="M12 3l9 6-9 6-9-6 9-6Z" stroke="currentColor" stroke-width="1.7"/><path d="M3 9v8l9 6 9-6V9" stroke="currentColor" stroke-width="1.7"/></svg>',
+                'group_name'  => 'sites',
+                'group_label' => '站点',
+                'group_hint'  => '对外主页 / 内容入口',
+                'keywords'    => 'mahaoyun 网站 www',
+                'tag_label'   => 'Web',
+                'tag_class'   => '',
+                'meta_domain' => '站点入口',
+                'sort_order'  => 22,
+                'is_private'  => 0,
+            ],
+        ];
+
+        foreach ($links as $link) {
+            $stmt->execute($link);
+        }
+        echo '✅ 已导入 ' . count($links) . ' 条链接数据<br>';
+    }
+
+    echo '<hr><p style="color:green;font-weight:bold">🎉 数据库初始化完成!</p>';
+    echo '<p>👉 <a href="public/index.php">访问首页</a> | 👉 <a href="public/admin.php">管理面板</a></p>';
+    echo '<p style="color:red;font-weight:bold">⚠️ 出于安全考虑,建议你现在删除或重命名本文件(db_init.php)!</p>';
+
+} catch (\PDOException $e) {
+    echo '<p style="color:red">❌ 数据库错误:' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</p>';
+}

+ 30 - 0
init.php

@@ -0,0 +1,30 @@
+<?php
+/**
+ * BCJD 导航 - 系统引导文件
+ *
+ * 所有入口文件统一引入此文件,确保:
+ * - 类自动加载
+ * - 错误处理统一
+ * - 安全头统一输出
+ * - 时区设置
+ */
+
+// ---- 严格模式 ----
+declare(strict_types=1);
+
+// ---- 显示错误(仅开发环境) ----
+// 生产环境应注释掉下面两行
+// ini_set('display_errors', '1');
+// error_reporting(E_ALL);
+
+// ---- 时区 ----
+date_default_timezone_set('Asia/Shanghai');
+
+// ---- 配置加载 ----
+require_once __DIR__ . '/config.php';
+
+// ---- 自动加载 ----
+require_once __DIR__ . '/src/AutoLoader.php';
+
+// ---- 安全头 ----
+\BCJD\Auth::sendSecurityHeaders();

+ 41 - 0
public/.htaccess

@@ -0,0 +1,41 @@
+# BCJD 导航 - Web 入口目录配置
+# 服务器 DocumentRoot 应指向此目录
+
+DirectoryIndex index.php
+
+# ============================================
+# 安全头
+# ============================================
+<IfModule mod_headers.c>
+    Header always set X-Frame-Options "DENY"
+    Header always set X-Content-Type-Options "nosniff"
+    Header always set X-XSS-Protection "1; mode=block"
+    Header always set Referrer-Policy "strict-origin-when-cross-origin"
+</IfModule>
+
+# ============================================
+# 禁止访问项目说明文件
+# ============================================
+<FilesMatch "\.(md|txt|log)$">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Deny from all
+    </IfModule>
+</FilesMatch>
+<Files "LICENSE">
+    <IfModule mod_authz_core.c>
+        Require all denied
+    </IfModule>
+    <IfModule !mod_authz_core.c>
+        Deny from all
+    </IfModule>
+</Files>
+
+# ============================================
+# Rewrite: PHP 文件直接访问,无需额外规则
+# ============================================
+
+# 禁止浏览目录
+Options -Indexes

+ 605 - 0
public/admin.php

@@ -0,0 +1,605 @@
+<?php
+/**
+ * BCJD 导航 - 管理面板
+ *
+ * 安全特性:
+ * - CSRF Token 防护所有写操作
+ * - 请求时动态获取 Token(不与页面静态绑定)
+ */
+
+require_once __DIR__ . '/../init.php';
+
+use BCJD\Auth;
+
+$csrfToken = Auth::getCsrfToken();
+?><!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width,initial-scale=1" />
+  <meta name="color-scheme" content="light dark" />
+  <title>BCJD 导航 - 管理面板</title>
+  <style>
+    :root{
+      --bg: #0b1020;
+      --panel: rgba(255,255,255,.06);
+      --panel-2: rgba(255,255,255,.10);
+      --text: rgba(255,255,255,.92);
+      --muted: rgba(255,255,255,.65);
+      --border: rgba(255,255,255,.12);
+      --shadow: 0 10px 30px rgba(0,0,0,.35);
+      --accent: #6ea8ff;
+      --accent2: #7cf7d4;
+      --danger: #ff6b6b;
+      --ok: #3ddc97;
+      --max: 1120px;
+      --radius: 18px;
+      --radius-sm: 12px;
+      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+      --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
+    }
+    html[data-theme="light"]{
+      --bg: #f6f7fb;
+      --panel: rgba(0,0,0,.04);
+      --panel-2: rgba(0,0,0,.06);
+      --text: rgba(0,0,0,.88);
+      --muted: rgba(0,0,0,.58);
+      --border: rgba(0,0,0,.10);
+      --shadow: 0 10px 30px rgba(0,0,0,.12);
+      --accent: #245bdb;
+      --accent2: #0aa37f;
+      --danger: #d64545;
+      --ok: #0a7f5f;
+    }
+    @media (prefers-color-scheme: light){
+      html:not([data-theme="dark"]){
+        --bg: #f6f7fb;
+        --panel: rgba(0,0,0,.04);
+        --panel-2: rgba(0,0,0,.06);
+        --text: rgba(0,0,0,.88);
+        --muted: rgba(0,0,0,.58);
+        --border: rgba(0,0,0,.10);
+        --shadow: 0 10px 30px rgba(0,0,0,.12);
+        --accent: #245bdb;
+        --accent2: #0aa37f;
+        --danger: #d64545;
+        --ok: #0a7f5f;
+      }
+    }
+    *{ box-sizing:border-box; }
+    body{
+      margin:0;
+      min-height:100vh;
+      font-family: var(--sans);
+      color: var(--text);
+      background: var(--bg);
+    }
+    a{ color:var(--accent); text-decoration:none; }
+    a:hover{ text-decoration:underline; }
+    .wrap{
+      max-width: var(--max);
+      margin: 0 auto;
+      padding: 30px 18px 44px;
+    }
+    header{
+      display:flex;
+      justify-content:space-between;
+      align-items:center;
+      gap:18px;
+      margin-bottom: 24px;
+    }
+    header h1{ margin:0; font-size:22px; font-weight:800; }
+    .header-actions{ display:flex; gap:10px; align-items:center; }
+
+    button, .btn{
+      height: 40px; display:inline-flex; align-items:center; justify-content:center;
+      padding: 0 16px; border-radius:999px; border:1px solid var(--border);
+      background: var(--panel); box-shadow: var(--shadow); color: var(--text);
+      cursor:pointer; font-family: var(--sans); font-size:13px; font-weight:600;
+      transition: background .15s; white-space:nowrap;
+    }
+    button:hover, .btn:hover{ background: var(--panel-2); border-color: rgba(110,168,255,.28); }
+    .btn-primary{ background: var(--accent); border-color: var(--accent); color: #fff; }
+    .btn-primary:hover{ opacity:.88; border-color:var(--accent); background:var(--accent); }
+    .btn-danger{ background: rgba(255,107,107,.15); border-color: rgba(255,107,107,.35); color: var(--danger); }
+    .btn-danger:hover{ background: rgba(255,107,107,.25); }
+    .btn-sm{ height:34px; padding:0 12px; font-size:12px; }
+
+    .tabs{
+      display:flex; gap:4px; margin-bottom: 24px;
+      border-bottom:1px solid var(--border); padding-bottom:0;
+    }
+    .tab{
+      padding:10px 18px; border-radius:12px 12px 0 0; cursor:pointer;
+      font-size:14px; font-weight:600; color: var(--muted);
+      border:1px solid transparent; border-bottom:none; transition: all .12s; background:transparent;
+    }
+    .tab:hover{ color:var(--text); background:var(--panel); }
+    .tab.active{ color:var(--accent); border-color:var(--border); background:var(--panel); }
+    .tab-content{ display:none; }
+    .tab-content.active{ display:block; }
+
+    .link-item{
+      display:flex; align-items:center; gap:12px; padding:12px 14px;
+      border-radius:var(--radius-sm); border:1px solid var(--border);
+      background:var(--panel); margin-bottom:8px; transition:background .12s;
+    }
+    .link-item:hover{ background:var(--panel-2); }
+    .link-item .info{ flex:1; min-width:0; }
+    .link-item .info .title{ font-weight:700; font-size:14px; }
+    .link-item .info .url{
+      font-size:12px; color:var(--muted); font-family:var(--mono);
+      white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+    }
+    .link-item .badge{
+      font-size:11px; padding:3px 8px; border-radius:999px;
+      border:1px solid var(--border); background:var(--panel-2); color:var(--muted); white-space:nowrap;
+    }
+    .link-item .badge.private{
+      border-color:rgba(255,107,107,.35); background:rgba(255,107,107,.08); color:var(--danger);
+    }
+    .link-item .item-actions{ display:flex; gap:6px; flex-shrink:0; }
+
+    .form-group{ margin-bottom:16px; }
+    .form-group label{
+      display:block; font-size:13px; font-weight:600; margin-bottom:6px; color:var(--muted);
+    }
+    .form-group input, .form-group select, .form-group textarea{
+      width:100%; height:42px; padding:0 14px; border-radius:999px;
+      border:1px solid var(--border); background:var(--panel); color:var(--text);
+      font-size:14px; font-family:var(--sans); outline:none; transition:border-color .15s;
+    }
+    .form-group textarea{
+      height:auto; min-height:60px; padding:10px 14px; border-radius:var(--radius-sm);
+      resize:vertical; font-family:var(--mono); font-size:13px;
+    }
+    .form-group input:focus, .form-group select:focus, .form-group textarea:focus{ border-color:var(--accent); }
+    .form-group select option{ background:var(--bg); color:var(--text); }
+    .form-row{ display:grid; grid-template-columns:1fr 1fr; gap:14px; }
+    .form-row-3{ display:grid; grid-template-columns:1fr 1fr 1fr; gap:14px; }
+    .checkbox-row{
+      display:flex; align-items:center; gap:10px; margin-bottom:16px;
+    }
+    .checkbox-row input[type="checkbox"]{ width:18px; height:18px; accent-color:var(--accent); }
+
+    .toast{
+      display:none; position:fixed; bottom:30px; left:50%; transform:translateX(-50%);
+      padding:12px 24px; border-radius:999px; background:var(--panel-2);
+      border:1px solid var(--border); backdrop-filter:blur(10px); box-shadow:var(--shadow);
+      font-size:14px; z-index:999;
+    }
+    .toast.show{ display:block; }
+    .toast.success{ border-color:rgba(61,220,151,.35); color:var(--ok); }
+    .toast.error{ border-color:rgba(255,107,107,.35); color:var(--danger); }
+
+    .modal-overlay{
+      display:none; position:fixed; inset:0; background:rgba(0,0,0,.55);
+      backdrop-filter:blur(4px); z-index:1000; align-items:center; justify-content:center;
+    }
+    .modal-overlay.active{ display:flex; }
+    .modal-box{
+      width:520px; max-width:92vw; max-height:90vh; overflow-y:auto;
+      padding:28px; border-radius:var(--radius); border:1px solid var(--border);
+      background:var(--bg); box-shadow:0 20px 60px rgba(0,0,0,.5);
+    }
+    .modal-box h2{ margin:0 0 18px; font-size:20px; font-weight:800; }
+    .modal-box .btn-row{ display:flex; gap:10px; margin-top:20px; justify-content:flex-end; }
+
+    @media (max-width: 720px){
+      .form-row, .form-row-3{ grid-template-columns:1fr; }
+      .link-item{ flex-wrap:wrap; }
+      .link-item .item-actions{ width:100%; justify-content:flex-end; }
+    }
+  </style>
+</head>
+<body>
+  <div class="wrap">
+    <header>
+      <h1>BCJD 导航 · 管理面板</h1>
+      <div class="header-actions">
+        <a href="index.php" class="btn">← 返回首页</a>
+        <button id="logoutBtn">退出登录</button>
+      </div>
+    </header>
+
+    <input type="hidden" id="csrfToken" value="<?= htmlspecialchars($csrfToken, ENT_QUOTES, 'UTF-8') ?>" />
+
+    <div class="tabs">
+      <div class="tab active" data-tab="links">链接管理</div>
+      <div class="tab" data-tab="add">添加链接</div>
+      <div class="tab" data-tab="password">修改密码</div>
+    </div>
+
+    <!-- ===== 链接管理 ===== -->
+    <div class="tab-content active" id="tab-links">
+      <div style="margin-bottom:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
+        <input id="filterLinks" placeholder="筛选链接…" style="height:40px;padding:0 14px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--text);font-size:13px;font-family:var(--sans);flex:1;min-width:200px;outline:none" />
+        <button id="refreshLinks" class="btn-primary btn-sm">刷新</button>
+      </div>
+      <div id="linkList"></div>
+      <div id="linkLoading" style="text-align:center;padding:30px;color:var(--muted)">加载中…</div>
+    </div>
+
+    <!-- ===== 添加链接 ===== -->
+    <div class="tab-content" id="tab-add">
+      <form id="addForm" autocomplete="off">
+        <div class="form-row">
+          <div class="form-group">
+            <label for="addTitle">标题 *</label>
+            <input type="text" id="addTitle" required placeholder="如:Monica" />
+          </div>
+          <div class="form-group">
+            <label for="addUrl">链接地址 *</label>
+            <input type="url" id="addUrl" required placeholder="如:https://monica.bcjd.net" />
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="addDesc">描述</label>
+          <input type="text" id="addDesc" placeholder="简短描述该服务" />
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label for="addGroup">分组名(英文标识)</label>
+            <input type="text" id="addGroup" placeholder="如:apps / ops / sites" value="apps" />
+          </div>
+          <div class="form-group">
+            <label for="addGroupLabel">分组显示名称</label>
+            <input type="text" id="addGroupLabel" placeholder="如:应用" />
+          </div>
+        </div>
+        <div class="form-row-3">
+          <div class="form-group">
+            <label for="addKeywords">搜索关键词</label>
+            <input type="text" id="addKeywords" placeholder="空格分隔" />
+          </div>
+          <div class="form-group">
+            <label for="addTagLabel">标签文字</label>
+            <input type="text" id="addTagLabel" placeholder="如:App" />
+          </div>
+          <div class="form-group">
+            <label for="addTagClass">标签样式</label>
+            <select id="addTagClass">
+              <option value="">默认</option>
+              <option value="ok">绿色 (ok)</option>
+              <option value="warn">红色 (warn)</option>
+            </select>
+          </div>
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label for="addMetaDomain">域名/元信息</label>
+            <input type="text" id="addMetaDomain" placeholder="如:monica.bcjd.net" />
+          </div>
+          <div class="form-group">
+            <label for="addIconSvg">SVG 图标代码</label>
+            <input type="text" id="addIconSvg" placeholder="<svg>...</svg>" />
+          </div>
+        </div>
+        <div class="checkbox-row">
+          <input type="checkbox" id="addIsPrivate" />
+          <label for="addIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接(需登录才能看到)</label>
+        </div>
+        <div>
+          <button type="submit" class="btn-primary">添加链接</button>
+        </div>
+      </form>
+    </div>
+
+    <!-- ===== 修改密码 ===== -->
+    <div class="tab-content" id="tab-password">
+      <form id="pwdForm" autocomplete="off">
+        <div class="form-group">
+          <label for="oldPwd">当前密码</label>
+          <input type="password" id="oldPwd" required autocomplete="current-password" />
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label for="newPwd">新密码(至少6位)</label>
+            <input type="password" id="newPwd" required minlength="6" autocomplete="new-password" />
+          </div>
+          <div class="form-group">
+            <label for="confirmPwd">确认新密码</label>
+            <input type="password" id="confirmPwd" required minlength="6" autocomplete="new-password" />
+          </div>
+        </div>
+        <div>
+          <button type="submit" class="btn-primary">更新密码</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- ===== 编辑模态框 ===== -->
+  <div class="modal-overlay" id="editModal">
+    <div class="modal-box">
+      <h2>编辑链接</h2>
+      <form id="editForm" autocomplete="off">
+        <input type="hidden" id="editId" />
+        <div class="form-row">
+          <div class="form-group">
+            <label for="editTitle">标题 *</label>
+            <input type="text" id="editTitle" required />
+          </div>
+          <div class="form-group">
+            <label for="editUrl">链接地址 *</label>
+            <input type="url" id="editUrl" required />
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="editDesc">描述</label>
+          <input type="text" id="editDesc" />
+        </div>
+        <div class="form-row">
+          <div class="form-group">
+            <label for="editGroup">分组名</label>
+            <input type="text" id="editGroup" />
+          </div>
+          <div class="form-group">
+            <label for="editKeywords">关键词</label>
+            <input type="text" id="editKeywords" />
+          </div>
+        </div>
+        <div class="form-row-3">
+          <div class="form-group">
+            <label for="editTagLabel">标签</label>
+            <input type="text" id="editTagLabel" />
+          </div>
+          <div class="form-group">
+            <label for="editTagClass">标签样式</label>
+            <select id="editTagClass">
+              <option value="">默认</option>
+              <option value="ok">绿色 (ok)</option>
+              <option value="warn">红色 (warn)</option>
+            </select>
+          </div>
+          <div class="form-group">
+            <label for="editMetaDomain">域名</label>
+            <input type="text" id="editMetaDomain" />
+          </div>
+        </div>
+        <div class="form-group">
+          <label for="editIconSvg">SVG 图标</label>
+          <textarea id="editIconSvg" rows="3"></textarea>
+        </div>
+        <div class="checkbox-row">
+          <input type="checkbox" id="editIsPrivate" />
+          <label for="editIsPrivate" style="margin:0;cursor:pointer">🔒 私有链接</label>
+        </div>
+        <div class="btn-row">
+          <button type="button" id="editCancel">取消</button>
+          <button type="submit" class="btn-primary">保存</button>
+        </div>
+      </form>
+    </div>
+  </div>
+
+  <!-- Toast -->
+  <div class="toast" id="toast"></div>
+
+  <script>
+    (function(){
+    'use strict';
+
+    const $ = (s) => document.querySelector(s);
+    const $$ = (s) => [...document.querySelectorAll(s)];
+    const esc = (s) => (s || '').replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''})[m]);
+
+    function getCsrf(){ return $('#csrfToken').value; }
+
+    function toast(msg, type = 'success'){
+      const t = $('#toast');
+      t.textContent = msg;
+      t.className = 'toast show ' + type;
+      clearTimeout(t._timer);
+      t._timer = setTimeout(() => t.classList.remove('show'), 3000);
+    }
+
+    // ===== API =====
+    async function apiGet(action){
+      const r = await fetch(`api.php?action=${action}`);
+      if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
+      return r.json();
+    }
+    async function apiPost(action, data){
+      const fd = new URLSearchParams({csrf_token: getCsrf()});
+      for(const [k,v] of Object.entries(data)) fd.append(k, v);
+      const r = await fetch(`api.php?action=${action}`, {
+        method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'},
+        body: fd.toString(),
+      });
+      if(!r.ok){ const e = await r.json(); throw new Error(e.error || '请求失败'); }
+      return r.json();
+    }
+
+    // ===== 登录检查 =====
+    apiGet('check_login').then(d => { if(!d.logged_in) window.location.href = 'index.php'; });
+
+    // ===== 退出登录 =====
+    $('#logoutBtn').addEventListener('click', async () => {
+      await apiPost('logout', {});
+      window.location.href = 'index.php';
+    });
+
+    // ===== 选项卡 =====
+    $$('.tab').forEach(tab => {
+      tab.addEventListener('click', () => {
+        $$('.tab').forEach(t => t.classList.remove('active'));
+        $$('.tab-content').forEach(t => t.classList.remove('active'));
+        tab.classList.add('active');
+        const target = document.getElementById('tab-' + tab.dataset.tab);
+        if(target) target.classList.add('active');
+      });
+    });
+
+    // ===== 加载链接列表 =====
+    let allLinks = [];
+
+    async function loadLinks(){
+      const list = $('#linkList');
+      const loading = $('#linkLoading');
+      loading.style.display = '';
+      list.innerHTML = '';
+
+      try {
+        const data = await apiGet('get_links');
+        allLinks = [];
+        for(const g of (data.groups || [])){
+          for(const l of (g.links || [])){
+            allLinks.push(l);
+          }
+        }
+        renderLinks(allLinks);
+        loading.style.display = 'none';
+      } catch(e){
+        loading.textContent = '加载失败:' + e.message;
+      }
+    }
+
+    function renderLinks(links){
+      const list = $('#linkList');
+      if(links.length === 0){
+        list.innerHTML = '<div style="text-align:center;padding:30px;color:var(--muted)">暂无链接</div>';
+        return;
+      }
+      list.innerHTML = links.map(l => `
+        <div class="link-item" data-id="${l.id}">
+          <div class="info">
+            <div class="title">${esc(l.title)}</div>
+            <div class="url">${esc(l.url)}</div>
+          </div>
+          <span class="badge">${esc(l.group_name)}</span>
+          ${l.is_private ? '<span class="badge private">🔒 私有</span>' : '<span class="badge">公开</span>'}
+          <div class="item-actions">
+            <button class="btn-sm edit-btn" data-id="${l.id}">编辑</button>
+            <button class="btn-sm btn-danger delete-btn" data-id="${l.id}">删除</button>
+          </div>
+        </div>
+      `).join('');
+
+      list.querySelectorAll('.edit-btn').forEach(btn => {
+        btn.addEventListener('click', () => openEdit(parseInt(btn.dataset.id)));
+      });
+      list.querySelectorAll('.delete-btn').forEach(btn => {
+        btn.addEventListener('click', () => deleteLink(parseInt(btn.dataset.id)));
+      });
+    }
+
+    // ===== 筛选 =====
+    $('#filterLinks').addEventListener('input', () => {
+      const v = $('#filterLinks').value.toLowerCase().trim();
+      if(!v){ renderLinks(allLinks); return; }
+      const filtered = allLinks.filter(l =>
+        (l.title + ' ' + l.url + ' ' + l.group_name + ' ' + (l.keywords || '') + ' ' + (l.description || '')).toLowerCase().includes(v)
+      );
+      renderLinks(filtered);
+    });
+
+    $('#refreshLinks').addEventListener('click', loadLinks);
+
+    // ===== 删除链接 =====
+    async function deleteLink(id){
+      if(!confirm('确定要删除此链接吗?')) return;
+      try {
+        const data = await apiPost('delete_link', {id});
+        if(data.success){ toast('已删除'); loadLinks(); }
+        else { toast(data.error || '删除失败', 'error'); }
+      } catch(e){ toast(e.message, 'error'); }
+    }
+
+    // ===== 编辑链接 =====
+    function openEdit(id){
+      const l = allLinks.find(x => x.id === id);
+      if(!l) return;
+      $('#editId').value = l.id;
+      $('#editTitle').value = l.title;
+      $('#editUrl').value = l.url;
+      $('#editDesc').value = l.description || '';
+      $('#editGroup').value = l.group_name;
+      $('#editKeywords').value = l.keywords || '';
+      $('#editTagLabel').value = l.tag_label || '';
+      $('#editTagClass').value = l.tag_class || '';
+      $('#editMetaDomain').value = l.meta_domain || '';
+      $('#editIconSvg').value = l.icon_svg || '';
+      $('#editIsPrivate').checked = !!l.is_private;
+      $('#editModal').classList.add('active');
+    }
+
+    $('#editCancel').addEventListener('click', () => $('#editModal').classList.remove('active'));
+    $('#editModal').addEventListener('click', (e) => { if(e.target === $('#editModal')) $('#editModal').classList.remove('active'); });
+
+    $('#editForm').addEventListener('submit', async (e) => {
+      e.preventDefault();
+      try {
+        const result = await apiPost('update_link', {
+          id: $('#editId').value,
+          title: $('#editTitle').value,
+          url: $('#editUrl').value,
+          description: $('#editDesc').value,
+          group_name: $('#editGroup').value,
+          keywords: $('#editKeywords').value,
+          tag_label: $('#editTagLabel').value,
+          tag_class: $('#editTagClass').value,
+          meta_domain: $('#editMetaDomain').value,
+          icon_svg: $('#editIconSvg').value,
+          is_private: $('#editIsPrivate').checked ? 1 : 0,
+        });
+        if(result.success){ toast('已更新'); $('#editModal').classList.remove('active'); loadLinks(); }
+        else { toast(result.error || '更新失败', 'error'); }
+      } catch(e){ toast(e.message, 'error'); }
+    });
+
+    // ===== 添加链接 =====
+    $('#addForm').addEventListener('submit', async (e) => {
+      e.preventDefault();
+      try {
+        const result = await apiPost('add_link', {
+          title: $('#addTitle').value,
+          url: $('#addUrl').value,
+          description: $('#addDesc').value,
+          group_name: $('#addGroup').value || 'default',
+          group_label: $('#addGroupLabel').value,
+          keywords: $('#addKeywords').value,
+          tag_label: $('#addTagLabel').value,
+          tag_class: $('#addTagClass').value,
+          meta_domain: $('#addMetaDomain').value,
+          icon_svg: $('#addIconSvg').value,
+          is_private: $('#addIsPrivate').checked ? 1 : 0,
+        });
+        if(result.success){
+          toast('链接已添加');
+          $('#addForm').reset();
+          $$('.tab').forEach(t => t.classList.remove('active'));
+          $$('.tab-content').forEach(t => t.classList.remove('active'));
+          document.querySelector('[data-tab="links"]').classList.add('active');
+          document.getElementById('tab-links').classList.add('active');
+          loadLinks();
+        } else { toast(result.error || '添加失败', 'error'); }
+      } catch(e){ toast(e.message, 'error'); }
+    });
+
+    // ===== 修改密码 =====
+    $('#pwdForm').addEventListener('submit', async (e) => {
+      e.preventDefault();
+      const oldPwd = $('#oldPwd').value;
+      const newPwd = $('#newPwd').value;
+      const confirmPwd = $('#confirmPwd').value;
+
+      if(newPwd !== confirmPwd){ toast('两次输入的新密码不一致', 'error'); return; }
+      if(newPwd.length < 6){ toast('新密码至少6位', 'error'); return; }
+
+      try {
+        const result = await apiPost('update_password', {
+          old_password: oldPwd, new_password: newPwd, confirm_password: confirmPwd,
+        });
+        if(result.success){ toast('密码已更新'); $('#pwdForm').reset(); }
+        else { toast(result.error || '密码更新失败', 'error'); }
+      } catch(e){ toast(e.message, 'error'); }
+    });
+
+    // ===== 启动 =====
+    loadLinks();
+
+    })();
+  </script>
+</body>
+</html>

+ 135 - 0
public/api.php

@@ -0,0 +1,135 @@
+<?php
+/**
+ * BCJD 导航 - RESTful API 接口
+ *
+ * 使用 OOP 类库重构,引入控制器模式。
+ *
+ * 端点:
+ *   GET    ?action=get_links         - 获取链接列表
+ *   POST   ?action=login             - 登录
+ *   POST   ?action=logout            - 退出
+ *   POST   ?action=check_login       - 检查登录状态
+ *   POST   ?action=get_csrf          - 获取 CSRF Token
+ *   POST   ?action=add_link          - 添加链接(需登录)
+ *   POST   ?action=delete_link       - 删除链接(需登录)
+ *   POST   ?action=update_link       - 更新链接(需登录)
+ *   POST   ?action=update_password   - 修改密码(需登录)
+ */
+
+require_once __DIR__ . '/../init.php';
+
+use BCJD\Auth;
+use BCJD\LinkManager;
+use BCJD\Response;
+
+// ---- CORS 头 ----
+header('Access-Control-Allow-Origin: *');
+header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
+header('Access-Control-Allow-Headers: Content-Type, X-CSRF-Token');
+
+// 预检请求直接返回
+if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
+    http_response_code(204);
+    exit;
+}
+
+$action = $_GET['action'] ?? $_POST['action'] ?? '';
+$csrfToken = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+
+try {
+    switch ($action) {
+
+        // ===== 获取链接 =====
+        case 'get_links':
+            $lm = new LinkManager();
+            Response::json([
+                'logged_in' => Auth::isLoggedIn(),
+                'groups'    => $lm->getLinks(),
+            ]);
+            break;
+
+        // ===== 登录 =====
+        case 'login':
+            $password = $_POST['password'] ?? '';
+            if (empty($password)) {
+                Response::error('请输入密码');
+            }
+            if (Auth::verifyPassword($password)) {
+                Auth::login();
+                Response::success(['message' => '登录成功']);
+            } else {
+                Response::error('密码错误', 401);
+            }
+            break;
+
+        // ===== 退出 =====
+        case 'logout':
+            Auth::logout();
+            Response::success(['message' => '已退出']);
+            break;
+
+        // ===== 检查登录状态 =====
+        case 'check_login':
+            Response::json(['logged_in' => Auth::isLoggedIn()]);
+            break;
+
+        // ===== 获取 CSRF Token =====
+        case 'get_csrf':
+            Auth::requireLogin();
+            Response::json(['csrf_token' => Auth::getCsrfToken()]);
+            break;
+
+        // ===== 添加链接 =====
+        case 'add_link':
+            if (!Auth::validateCsrfToken($csrfToken)) {
+                Response::error('CSRF 验证失败,请刷新页面重试', 403);
+            }
+            $lm = new LinkManager();
+            $id = $lm->addLink($_POST);
+            Response::success(['id' => $id]);
+            break;
+
+        // ===== 删除链接 =====
+        case 'delete_link':
+            if (!Auth::validateCsrfToken($csrfToken)) {
+                Response::error('CSRF 验证失败,请刷新页面重试', 403);
+            }
+            $lm = new LinkManager();
+            $lm->deleteLink((int)($_POST['id'] ?? 0));
+            Response::success();
+            break;
+
+        // ===== 更新链接 =====
+        case 'update_link':
+            if (!Auth::validateCsrfToken($csrfToken)) {
+                Response::error('CSRF 验证失败,请刷新页面重试', 403);
+            }
+            $lm = new LinkManager();
+            $lm->updateLink((int)($_POST['id'] ?? 0), $_POST);
+            Response::success();
+            break;
+
+        // ===== 修改密码 =====
+        case 'update_password':
+            if (!Auth::validateCsrfToken($csrfToken)) {
+                Response::error('CSRF 验证失败,请刷新页面重试', 403);
+            }
+            LinkManager::updatePassword(
+                $_POST['old_password'] ?? '',
+                $_POST['new_password'] ?? '',
+                $_POST['confirm_password'] ?? ''
+            );
+            Response::success(['message' => '密码已更新']);
+            break;
+
+        default:
+            Response::error('未知操作: ' . $action, 400);
+    }
+} catch (\PDOException $e) {
+    // 生产环境不暴露数据库错误细节
+    error_log('Database error: ' . $e->getMessage());
+    Response::error('数据库操作失败,请稍后重试', 500);
+} catch (\Throwable $e) {
+    error_log('Unexpected error: ' . $e->getMessage());
+    Response::error('服务器内部错误', 500);
+}

+ 794 - 0
public/index.php

@@ -0,0 +1,794 @@
+<?php require_once __DIR__ . '/../init.php'; ?><!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8" />
+  <meta name="viewport" content="width=device-width,initial-scale=1" />
+  <meta name="color-scheme" content="light dark" />
+  <title>BCJD 导航</title>
+  <style>
+    :root{
+      --bg: #0b1020;
+      --panel: rgba(255,255,255,.06);
+      --panel-2: rgba(255,255,255,.10);
+      --text: rgba(255,255,255,.92);
+      --muted: rgba(255,255,255,.65);
+      --border: rgba(255,255,255,.12);
+      --shadow: 0 10px 30px rgba(0,0,0,.35);
+      --accent: #6ea8ff;
+      --accent2: #7cf7d4;
+      --danger: #ff6b6b;
+      --ok: #3ddc97;
+      --max: 1120px;
+      --radius: 18px;
+      --radius-sm: 12px;
+      --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+      --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", Arial, sans-serif;
+    }
+
+    html[data-theme="light"]{
+      --bg: #f6f7fb;
+      --panel: rgba(0,0,0,.04);
+      --panel-2: rgba(0,0,0,.06);
+      --text: rgba(0,0,0,.88);
+      --muted: rgba(0,0,0,.58);
+      --border: rgba(0,0,0,.10);
+      --shadow: 0 10px 30px rgba(0,0,0,.12);
+      --accent: #245bdb;
+      --accent2: #0aa37f;
+      --danger: #d64545;
+      --ok: #0a7f5f;
+    }
+
+    @media (prefers-color-scheme: light){
+      html:not([data-theme="dark"]){
+        --bg: #f6f7fb;
+        --panel: rgba(0,0,0,.04);
+        --panel-2: rgba(0,0,0,.06);
+        --text: rgba(0,0,0,.88);
+        --muted: rgba(0,0,0,.58);
+        --border: rgba(0,0,0,.10);
+        --shadow: 0 10px 30px rgba(0,0,0,.12);
+        --accent: #245bdb;
+        --accent2: #0aa37f;
+        --danger: #d64545;
+        --ok: #0a7f5f;
+      }
+    }
+
+    *{ box-sizing:border-box; }
+    html{ scroll-behavior:smooth; }
+    body{
+      margin:0;
+      min-height:100vh;
+      font-family: var(--sans);
+      color: var(--text);
+      background:
+        radial-gradient(1000px 520px at 12% 12%, rgba(110,168,255,.18), transparent 55%),
+        radial-gradient(900px 460px at 88% 20%, rgba(124,247,212,.14), transparent 55%),
+        radial-gradient(860px 520px at 35% 100%, rgba(255,107,107,.08), transparent 55%),
+        var(--bg);
+    }
+
+    a{ color:inherit; text-decoration:none; }
+
+    .wrap{
+      max-width: var(--max);
+      margin: 0 auto;
+      padding: 30px 18px 44px;
+    }
+
+    header{
+      display:flex;
+      justify-content:space-between;
+      align-items:flex-start;
+      gap:18px;
+      margin-bottom: 18px;
+    }
+
+    .brand{
+      display:flex;
+      flex-direction:column;
+      gap:6px;
+    }
+    .brand h1{
+      margin:0;
+      font-size: 24px;
+      font-weight: 800;
+      letter-spacing:.2px;
+    }
+    .brand .sub{
+      font-size:13px;
+      color: var(--muted);
+      line-height:1.45;
+    }
+
+    .toolbar{
+      display:flex;
+      align-items:center;
+      justify-content:flex-end;
+      gap:10px;
+      flex-wrap:wrap;
+    }
+
+    .pill,
+    button{
+      height: 42px;
+      display:inline-flex;
+      align-items:center;
+      justify-content:center;
+      border-radius:999px;
+      border:1px solid var(--border);
+      background: var(--panel);
+      box-shadow: var(--shadow);
+      backdrop-filter: blur(10px);
+      color: var(--text);
+    }
+
+    .pill{
+      padding: 0 14px;
+      font-family: var(--mono);
+      white-space:nowrap;
+    }
+
+    button{
+      padding: 0 14px;
+      cursor:pointer;
+      transition: transform .06s ease, background .15s ease, border-color .15s ease;
+      font-family: var(--sans);
+      font-size:13px;
+    }
+    button:hover{
+      background: var(--panel-2);
+      border-color: rgba(110,168,255,.28);
+    }
+    button:active{ transform: translateY(1px); }
+
+    .search{
+      display:flex;
+      align-items:center;
+      gap:10px;
+      min-width: min(560px, 100%);
+      margin-bottom: 22px;
+      padding: 13px 14px;
+      border-radius: 999px;
+      border: 1px solid var(--border);
+      background: var(--panel);
+      backdrop-filter: blur(10px);
+      box-shadow: var(--shadow);
+    }
+    .search input{
+      width:100%;
+      border:0;
+      outline:none;
+      background: transparent;
+      color: var(--text);
+      font-size:14px;
+      font-family: var(--sans);
+    }
+    .search input::placeholder{ color: var(--muted); }
+    .search kbd{
+      padding: 3px 8px;
+      border-radius: 8px;
+      border: 1px solid var(--border);
+      background: var(--panel-2);
+      color: var(--muted);
+      font-size:12px;
+      font-family: var(--mono);
+    }
+
+    .board{
+      display:flex;
+      flex-direction:column;
+      gap: 22px;
+    }
+
+    .section-block{
+      display:flex;
+      flex-direction:column;
+      gap: 12px;
+    }
+
+    .section{
+      display:flex;
+      align-items:flex-end;
+      justify-content:space-between;
+      gap:10px;
+      padding: 0 2px;
+    }
+    .section h2{
+      margin:0;
+      font-size: 13px;
+      font-weight: 800;
+      letter-spacing: .9px;
+      text-transform: uppercase;
+      color: var(--muted);
+    }
+    .section .hint{
+      font-size: 12px;
+      color: var(--muted);
+    }
+
+    .grid{
+      display:grid;
+      grid-template-columns: repeat(12, 1fr);
+      gap: 14px;
+    }
+
+    .card{
+      grid-column: span 4;
+      min-height: 138px;
+      padding: 14px 14px 12px;
+      border-radius: var(--radius);
+      border: 1px solid var(--border);
+      background:
+        linear-gradient(180deg, rgba(255,255,255,.06), transparent 26%),
+        linear-gradient(180deg, var(--panel), transparent 120%);
+      box-shadow: var(--shadow);
+      backdrop-filter: blur(10px);
+      display:flex;
+      flex-direction:column;
+      gap:10px;
+      position:relative;
+      overflow:hidden;
+      transition: transform .10s ease, border-color .15s ease, background .15s ease;
+    }
+    .card::before{
+      content:"";
+      position:absolute;
+      top:0; left:0; right:0;
+      height: 1px;
+      background: linear-gradient(90deg, transparent, rgba(255,255,255,.32), transparent);
+      opacity:.75;
+      pointer-events:none;
+    }
+    .card:hover{
+      transform: translateY(-2px);
+      border-color: rgba(110,168,255,.35);
+      background:
+        linear-gradient(180deg, rgba(255,255,255,.08), transparent 26%),
+        linear-gradient(180deg, var(--panel-2), transparent 130%);
+    }
+
+    .top{
+      display:flex;
+      align-items:flex-start;
+      justify-content:space-between;
+      gap:10px;
+    }
+
+    .title{
+      display:flex;
+      align-items:center;
+      gap:10px;
+      min-width:0;
+    }
+
+    .icon{
+      width:40px;
+      height:40px;
+      flex:0 0 auto;
+      display:grid;
+      place-items:center;
+      border-radius: 13px;
+      border: 1px solid var(--border);
+      background:
+        radial-gradient(18px 18px at 30% 30%, rgba(124,247,212,.35), transparent 60%),
+        radial-gradient(18px 18px at 70% 70%, rgba(110,168,255,.32), transparent 60%),
+        rgba(255,255,255,.06);
+    }
+    .icon svg{
+      width:20px;
+      height:20px;
+      opacity:.92;
+    }
+
+    .name{
+      font-size:15px;
+      font-weight:780;
+      white-space:nowrap;
+      overflow:hidden;
+      text-overflow:ellipsis;
+    }
+    .meta{
+      margin-top:2px;
+      color: var(--muted);
+      font-size:11px;
+      font-family: var(--mono);
+      white-space:nowrap;
+      overflow:hidden;
+      text-overflow:ellipsis;
+    }
+
+    .tags{
+      display:flex;
+      flex-wrap:wrap;
+      gap:8px;
+    }
+
+    .tag{
+      padding: 4px 8px;
+      border-radius:999px;
+      border:1px solid var(--border);
+      background: var(--panel-2);
+      color: var(--muted);
+      font-size:11px;
+      line-height:1;
+    }
+    .tag.ok{
+      color: var(--ok);
+      border-color: rgba(61,220,151,.35);
+      background: rgba(61,220,151,.10);
+    }
+    .tag.warn{
+      color: var(--danger);
+      border-color: rgba(255,107,107,.35);
+      background: rgba(255,107,107,.08);
+    }
+
+    .desc{
+      color: var(--muted);
+      font-size:13px;
+      line-height:1.4;
+      display:-webkit-box;
+      -webkit-line-clamp:2;
+      -webkit-box-orient:vertical;
+      overflow:hidden;
+      min-height: 36px;
+    }
+
+    .actions{
+      margin-top:auto;
+      display:flex;
+      align-items:center;
+      justify-content:space-between;
+      gap:8px;
+    }
+
+    .go{
+      display:inline-flex;
+      align-items:center;
+      gap:8px;
+      padding: 9px 12px;
+      border-radius: var(--radius-sm);
+      border: 1px solid rgba(110,168,255,.35);
+      background: rgba(110,168,255,.14);
+      font-size:13px;
+      font-weight:730;
+    }
+    .go:hover{ background: rgba(110,168,255,.20); }
+
+    .small{
+      max-width:48%;
+      white-space:nowrap;
+      overflow:hidden;
+      text-overflow:ellipsis;
+      text-align:right;
+      color: var(--muted);
+      font-size:12px;
+      font-family: var(--mono);
+    }
+
+    .empty{
+      display:none;
+      padding: 18px 16px;
+      border-radius: 16px;
+      border: 1px dashed var(--border);
+      background: var(--panel);
+      color: var(--muted);
+      text-align:center;
+      box-shadow: var(--shadow);
+    }
+
+    footer{
+      margin-top: 24px;
+      padding-top: 14px;
+      border-top: 1px solid var(--border);
+      display:flex;
+      justify-content:space-between;
+      gap:12px;
+      flex-wrap:wrap;
+      color: var(--muted);
+      font-size:12px;
+    }
+
+    .mono{ font-family: var(--mono); }
+
+    @media (max-width: 980px){
+      .card{ grid-column: span 6; }
+      .search{ min-width: 100%; }
+    }
+
+    @media (max-width: 640px){
+      header{
+        flex-direction:column;
+        align-items:stretch;
+      }
+      .toolbar{
+        justify-content:flex-start;
+      }
+      .card{ grid-column: span 12; }
+      .small{ display:none; }
+    }
+
+    /* ===== 登录模态框 ===== */
+    .modal-overlay{
+      display:none;
+      position:fixed;
+      inset:0;
+      background:rgba(0,0,0,.55);
+      backdrop-filter:blur(4px);
+      z-index:1000;
+      align-items:center;
+      justify-content:center;
+    }
+    .modal-overlay.active{ display:flex; }
+
+    .modal-box{
+      width:380px;
+      max-width:92vw;
+      padding:32px 28px 28px;
+      border-radius:var(--radius);
+      border:1px solid var(--border);
+      background:var(--bg);
+      box-shadow:0 20px 60px rgba(0,0,0,.5);
+    }
+    .modal-box h2{
+      margin:0 0 6px;
+      font-size:20px;
+      font-weight:800;
+    }
+    .modal-box p{
+      margin:0 0 20px;
+      color:var(--muted);
+      font-size:14px;
+    }
+    .modal-box label{
+      display:block;
+      font-size:13px;
+      font-weight:600;
+      margin-bottom:6px;
+      color:var(--muted);
+    }
+    .modal-box input[type="password"]{
+      width:100%;
+      height:44px;
+      padding:0 14px;
+      border-radius:999px;
+      border:1px solid var(--border);
+      background:var(--panel);
+      color:var(--text);
+      font-size:15px;
+      font-family:var(--sans);
+      outline:none;
+      transition:border-color .15s;
+    }
+    .modal-box input[type="password"]:focus{
+      border-color:var(--accent);
+    }
+    .modal-box .btn-row{
+      display:flex;
+      gap:10px;
+      margin-top:20px;
+    }
+    .modal-box .btn-row button{
+      flex:1;
+      height:44px;
+      font-size:14px;
+      font-weight:600;
+    }
+    .modal-box .btn-primary{
+      background:var(--accent);
+      border-color:var(--accent);
+      color:#fff;
+    }
+    .modal-box .btn-primary:hover{
+      opacity:.88;
+      border-color:var(--accent);
+    }
+    .modal-box .modal-error{
+      display:none;
+      margin-top:12px;
+      padding:10px 14px;
+      border-radius:var(--radius-sm);
+      background:rgba(255,107,107,.12);
+      border:1px solid rgba(255,107,107,.3);
+      color:var(--danger);
+      font-size:13px;
+    }
+
+    /* ===== 私有链接标识 ===== */
+    .card.private{
+      border-left:3px solid var(--danger);
+    }
+  </style>
+</head>
+
+<body>
+  <div class="wrap">
+    <header>
+      <div class="brand">
+        <h1>BCJD 服务导航</h1>
+        <div class="sub">统一入口 · 快速跳转 · 显示时区:<span class="mono">UTC+8</span></div>
+      </div>
+
+      <div class="toolbar">
+        <div class="pill" id="clock">--:--:-- (UTC+8)</div>
+        <button id="themeBtn" title="切换主题">主题:跟随系统</button>
+        <button id="loginBtn" title="登录">登录</button>
+        <button id="adminBtn" title="管理" style="display:none">管理</button>
+      </div>
+    </header>
+
+    <div class="search" role="search" aria-label="Search">
+      <span class="mono" style="opacity:.7">/</span>
+      <input id="q" placeholder="搜索服务…(回车打开第一个)" autocomplete="off" />
+      <kbd>Enter</kbd>
+    </div>
+
+    <!-- 加载状态 -->
+    <div id="loading" style="text-align:center;padding:40px;color:var(--muted);font-size:14px">加载中…</div>
+
+    <div class="board" id="board" style="display:none">
+      <!-- 由 JS 动态渲染 -->
+      <div class="empty" id="emptyState">没有找到匹配的服务。</div>
+    </div>
+
+    <footer>
+      <div>提示:按 <span class="mono">/</span> 聚焦搜索;按 <span class="mono">Enter</span> 打开第一个结果;按 <span class="mono">Esc</span> 清空搜索。</div>
+      <div class="mono">Updated (UTC+8): <span id="ts">--</span></div>
+    </footer>
+  </div>
+
+  <!-- ===== 登录模态框 ===== -->
+  <div class="modal-overlay" id="loginModal">
+    <div class="modal-box">
+      <h2>登录</h2>
+      <p>请输入站点密码以解锁全部链接。</p>
+      <label for="loginPwd">密码</label>
+      <input type="password" id="loginPwd" placeholder="输入密码" autocomplete="current-password" />
+      <div class="btn-row">
+        <button id="loginCancel">取消</button>
+        <button id="loginConfirm" class="btn-primary">登录</button>
+      </div>
+      <div class="modal-error" id="loginError"></div>
+    </div>
+  </div>
+
+  <script>
+    (function(){
+    'use strict';
+
+    const $ = (s) => document.querySelector(s);
+    const $$ = (s) => [...document.querySelectorAll(s)];
+    const board = $("#board");
+    const loading = $("#loading");
+    const emptyState = $("#emptyState");
+    const q = $("#q");
+    const loginBtn = $("#loginBtn");
+    const adminBtn = $("#adminBtn");
+    const loginModal = $("#loginModal");
+    const loginPwd = $("#loginPwd");
+    const loginError = $("#loginError");
+    const loginCancel = $("#loginCancel");
+    const loginConfirm = $("#loginConfirm");
+
+    let loggedIn = false;
+    let allGroups = [];
+
+    // ===== 时钟 =====
+    const TZ = "Asia/Shanghai";
+    const fmtClock = new Intl.DateTimeFormat("zh-CN", {
+      timeZone: TZ, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
+    });
+    function formatYMDHMS_UTC8(d){
+      const parts = new Intl.DateTimeFormat("zh-CN", {
+        timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit",
+        hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
+      }).formatToParts(d);
+      const get = (t) => parts.find(p => p.type === t)?.value || "";
+      return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")}`;
+    }
+    function tick(){
+      const d = new Date();
+      $("#clock").textContent = `${fmtClock.format(d)} (UTC+8)`;
+      $("#ts").textContent = formatYMDHMS_UTC8(d);
+    }
+    tick(); setInterval(tick, 1000);
+
+    // ===== 主题切换 =====
+    const themeBtn = $("#themeBtn");
+    const root = document.documentElement;
+    function getThemeLabel(){
+      const cur = root.getAttribute("data-theme");
+      if(cur === "dark") return "主题:深色";
+      if(cur === "light") return "主题:浅色";
+      return "主题:跟随系统";
+    }
+    function refreshThemeLabel(){ themeBtn.textContent = getThemeLabel(); }
+    function setTheme(mode){
+      if(!mode){ root.removeAttribute("data-theme"); localStorage.removeItem("theme"); refreshThemeLabel(); return; }
+      root.setAttribute("data-theme", mode); localStorage.setItem("theme", mode); refreshThemeLabel();
+    }
+    const saved = localStorage.getItem("theme");
+    if(saved === "dark" || saved === "light") setTheme(saved); else refreshThemeLabel();
+    themeBtn.addEventListener("click", () => {
+      const cur = root.getAttribute("data-theme");
+      if(cur === "dark") setTheme("light"); else if(cur === "light") setTheme(null); else setTheme("dark");
+    });
+
+    // ===== API 请求 =====
+    async function apiGet(action){
+      const r = await fetch(`api.php?action=${action}`);
+      if(!r.ok) { const e = await r.json(); throw new Error(e.error || '请求失败'); }
+      return r.json();
+    }
+    async function apiPost(action, data){
+      const fd = new URLSearchParams();
+      for(const [k,v] of Object.entries(data)) fd.append(k, v);
+      const r = await fetch(`api.php?action=${action}`, {
+        method: 'POST',
+        headers: {'Content-Type':'application/x-www-form-urlencoded'},
+        body: fd.toString(),
+      });
+      if(!r.ok) { const e = await r.json(); throw new Error(e.error || '请求失败'); }
+      return r.json();
+    }
+
+    // ===== 渲染链接 =====
+    function renderLinks(groups){
+      board.innerHTML = '';
+
+      for(const g of groups){
+        if(!g.links || g.links.length === 0) continue;
+
+        const section = document.createElement('section');
+        section.className = 'section-block';
+        section.dataset.group = g.name;
+
+        const header = document.createElement('div');
+        header.className = 'section';
+        header.innerHTML = `<h2>${esc(g.label || g.name)}</h2><div class="hint">${g.hint || ''}</div>`;
+
+        const grid = document.createElement('div');
+        grid.className = 'grid';
+
+        for(const link of g.links){
+          const card = document.createElement('a');
+          card.className = 'card' + (link.is_private ? ' private' : '');
+          card.href = link.url;
+          card.target = '_blank';
+          card.rel = 'noopener';
+          card.dataset.group = g.name;
+          card.dataset.keywords = (link.keywords || '') + ' ' + link.title + ' ' + (link.description || '');
+
+          const tagClass = link.tag_class ? `tag ${link.tag_class}` : 'tag';
+
+          card.innerHTML = `
+            <div class="top">
+              <div class="title">
+                <div class="icon" aria-hidden="true">${link.icon_svg || ''}</div>
+                <div style="min-width:0">
+                  <div class="name">${esc(link.title)}</div>
+                  <div class="meta">${esc(link.meta_domain)}</div>
+                </div>
+              </div>
+              <div class="tags">
+                ${link.tag_label ? `<span class="${tagClass}">${esc(link.tag_label)}</span>` : ''}
+                ${link.is_private ? '<span class="tag" style="color:var(--danger);border-color:rgba(255,107,107,.3);background:rgba(255,107,107,.08)">🔒 私有</span>' : ''}
+              </div>
+            </div>
+            <div class="desc">${esc(link.description)}</div>
+            <div class="actions">
+              <span class="go">打开 →</span>
+              <span class="small">${link.url.replace(/^https?:\/\//,'').split('/')[0]}</span>
+            </div>
+          `;
+
+          grid.appendChild(card);
+        }
+
+        section.appendChild(header);
+        section.appendChild(grid);
+        board.appendChild(section);
+      }
+
+      board.appendChild(emptyState);
+      applyFilter();
+    }
+
+    function esc(s){ return (s || '').replace(/[&<>"']/g, function(m){
+      return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m];
+    });}
+
+    // ===== 获取并渲染链接 =====
+    async function loadLinks(){
+      loading.style.display = '';
+      board.style.display = 'none';
+      try {
+        const data = await apiGet('get_links');
+        loggedIn = data.logged_in;
+        allGroups = data.groups || [];
+        renderLinks(allGroups);
+        loading.style.display = 'none';
+        board.style.display = '';
+
+        loginBtn.textContent = loggedIn ? '已登录' : '登录';
+        adminBtn.style.display = loggedIn ? '' : 'none';
+      } catch(e){
+        loading.textContent = '加载失败:' + e.message;
+      }
+    }
+
+    // ===== 搜索 =====
+    function norm(s){ return (s || "").toLowerCase().trim(); }
+    function visibleCards(){ return $$(".card").filter(c => c.style.display !== "none"); }
+    function updateGroups(){
+      for(const group of $$(".section-block")){
+        const key = group.dataset.group;
+        const count = $$(".card").filter(c => c.dataset.group === key && c.style.display !== "none").length;
+        group.style.display = count > 0 ? "" : "none";
+      }
+    }
+    function applyFilter(){
+      const v = norm(q.value);
+      for(const c of $$(".card")){
+        const hay = norm((c.dataset.keywords || "") + " " + c.textContent);
+        c.style.display = (!v || hay.includes(v)) ? "" : "none";
+      }
+      updateGroups();
+      emptyState.style.display = visibleCards().length ? "none" : "block";
+    }
+
+    q.addEventListener("input", applyFilter);
+    q.addEventListener("keydown", (e) => {
+      if(e.key === "Enter"){ const first = visibleCards()[0]; if(first) first.click(); }
+      if(e.key === "Escape"){ q.value = ""; applyFilter(); q.blur(); }
+    });
+    document.addEventListener("keydown", (e) => {
+      if(e.key === "/" && document.activeElement !== q){ e.preventDefault(); q.focus(); }
+    });
+
+    // ===== 登录逻辑 =====
+    function showLoginModal(){
+      loginModal.classList.add('active'); loginPwd.value = '';
+      loginError.style.display = 'none'; loginPwd.focus();
+    }
+    function hideLoginModal(){ loginModal.classList.remove('active'); }
+
+    loginBtn.addEventListener('click', () => {
+      if(loggedIn){
+        if(!confirm('确定要退出登录吗?')) return;
+        apiPost('logout', {}).then(() => { loggedIn = false; loadLinks(); }).catch(e => alert(e.message));
+      } else {
+        showLoginModal();
+      }
+    });
+
+    function doLogin(){
+      const pwd = loginPwd.value;
+      if(!pwd){ loginError.textContent = '请输入密码'; loginError.style.display = ''; return; }
+      loginConfirm.disabled = true; loginConfirm.textContent = '登录中…';
+      apiPost('login', {password: pwd}).then(data => {
+        if(data.success){ hideLoginModal(); loadLinks(); }
+        else { loginError.textContent = data.error || '密码错误'; loginError.style.display = ''; loginConfirm.disabled = false; loginConfirm.textContent = '登录'; }
+      }).catch(e => {
+        loginError.textContent = e.message; loginError.style.display = '';
+        loginConfirm.disabled = false; loginConfirm.textContent = '登录';
+      });
+    }
+
+    loginConfirm.addEventListener('click', doLogin);
+    loginPwd.addEventListener('keydown', (e) => { if(e.key === 'Enter') doLogin(); });
+    loginCancel.addEventListener('click', hideLoginModal);
+    loginModal.addEventListener('click', (e) => { if(e.target === loginModal) hideLoginModal(); });
+
+    // ===== 管理面板 =====
+    adminBtn.addEventListener('click', () => { window.location.href = 'admin.php'; });
+
+    // ===== 启动 =====
+    loadLinks();
+
+    })();
+  </script>
+</body>
+</html>

+ 172 - 0
src/Auth.php

@@ -0,0 +1,172 @@
+<?php
+namespace BCJD;
+
+/**
+ * Auth - 认证管理(登录/CSRF/Session 安全)
+ *
+ * 安全特性:
+ * - CSRF Token 自动生成与验证
+ * - Session 固定会话防护(regenerate_id)
+ * - Session 超时机制(30分钟无操作自动过期)
+ * - 登录失败延迟(防暴力破解)
+ * - 安全的 Cookie 参数
+ * - bcrypt 密码哈希验证
+ */
+class Auth
+{
+    private const SESSION_TIMEOUT = 1800; // 30 分钟
+    private const LOGIN_DELAY     = 500;  // 毫秒
+
+    /**
+     * 初始化 Session 安全配置
+     */
+    public static function initSession(): void
+    {
+        if (session_status() === PHP_SESSION_ACTIVE) {
+            return;
+        }
+
+        // 仅通过 Cookie 传递 Session ID
+        ini_set('session.use_only_cookies', '1');
+        ini_set('session.use_strict_mode', '1');
+        ini_set('session.cookie_httponly', '1');
+        ini_set('session.cookie_samesite', 'Lax');
+        ini_set('session.cookie_secure', '0'); // 如果使用 HTTPS 可改为 1
+        ini_set('session.gc_maxlifetime', (string)self::SESSION_TIMEOUT);
+
+        session_start();
+
+        // Session 超时检查
+        if (!empty($_SESSION['_last_activity'])) {
+            if (time() - $_SESSION['_last_activity'] > self::SESSION_TIMEOUT) {
+                self::destroy();
+                session_start();
+            }
+        }
+        $_SESSION['_last_activity'] = time();
+    }
+
+    /**
+     * 校验密码
+     */
+    public static function verifyPassword(string $password): bool
+    {
+        // 登录失败延迟(防止暴力破解)
+        usleep(self::LOGIN_DELAY * 1000);
+        return password_verify($password, SITE_PASSWORD_HASH);
+    }
+
+    /**
+     * 执行登录
+     */
+    public static function login(): void
+    {
+        self::initSession();
+        $_SESSION['logged_in'] = true;
+        $_SESSION['_login_time'] = time();
+        session_regenerate_id(true);
+    }
+
+    /**
+     * 执行登出
+     */
+    public static function logout(): void
+    {
+        self::initSession();
+        self::destroy();
+    }
+
+    /**
+     * 检查是否已登录
+     */
+    public static function isLoggedIn(): bool
+    {
+        self::initSession();
+        return !empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true;
+    }
+
+    /**
+     * 要求登录,未登录则返回 401
+     */
+    public static function requireLogin(): void
+    {
+        if (!self::isLoggedIn()) {
+            Response::error('请先登录', 401);
+        }
+    }
+
+    /**
+     * 生成 CSRF Token 并存入 Session
+     */
+    public static function generateCsrfToken(): string
+    {
+        self::initSession();
+        $token = bin2hex(random_bytes(32));
+        $_SESSION['csrf_token'] = $token;
+        return $token;
+    }
+
+    /**
+     * 验证 CSRF Token
+     */
+    public static function validateCsrfToken(?string $token): bool
+    {
+        self::initSession();
+        if (empty($_SESSION['csrf_token']) || empty($token)) {
+            return false;
+        }
+        return hash_equals($_SESSION['csrf_token'], $token);
+    }
+
+    /**
+     * 获取 CSRF Token(用于前端表单)
+     */
+    public static function getCsrfToken(): string
+    {
+        self::initSession();
+        if (empty($_SESSION['csrf_token'])) {
+            return self::generateCsrfToken();
+        }
+        return $_SESSION['csrf_token'];
+    }
+
+    /**
+     * 生成 CSRF 隐藏字段 HTML
+     */
+    public static function csrfField(): string
+    {
+        return '<input type="hidden" name="csrf_token" value="' . self::getCsrfToken() . '">';
+    }
+
+    /**
+     * 添加安全响应头
+     */
+    public static function sendSecurityHeaders(): void
+    {
+        header('X-Frame-Options: DENY');
+        header('X-Content-Type-Options: nosniff');
+        header('X-XSS-Protection: 1; mode=block');
+        header('Referrer-Policy: strict-origin-when-cross-origin');
+    }
+
+    /**
+     * 销毁会话
+     */
+    private static function destroy(): void
+    {
+        $_SESSION = [];
+        if (ini_get('session.use_cookies')) {
+            $params = session_get_cookie_params();
+            setcookie(
+                session_name(),
+                '',
+                time() - 42000,
+                $params['path'],
+                $params['domain'],
+                $params['secure'],
+                $params['httponly']
+            );
+        }
+        session_destroy();
+    }
+}

+ 21 - 0
src/AutoLoader.php

@@ -0,0 +1,21 @@
+<?php
+/**
+ * BCJD - PSR-4 风格自动加载器
+ */
+spl_autoload_register(function (string $class): void {
+    // 只处理 BCJD 命名空间
+    $prefix = 'BCJD\\';
+    $baseDir = __DIR__ . '/';
+
+    $len = strlen($prefix);
+    if (strncmp($prefix, $class, $len) !== 0) {
+        return;
+    }
+
+    $relativeClass = substr($class, $len);
+    $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';
+
+    if (file_exists($file)) {
+        require_once $file;
+    }
+});

+ 154 - 0
src/Database.php

@@ -0,0 +1,154 @@
+<?php
+namespace BCJD;
+
+/**
+ * Database - PDO 单例连接管理器
+ *
+ * 安全特性:
+ * - 单例模式,避免重复连接
+ * - 所有查询强制使用预处理语句
+ * - 查询错误日志记录(不暴露细节给用户)
+ * - 支持事务
+ */
+class Database
+{
+    private static ?Database $instance = null;
+    private ?\PDO $pdo = null;
+
+    private string $host;
+    private int    $port;
+    private string $dbname;
+    private string $user;
+    private string $pass;
+    private string $charset;
+
+    private function __construct()
+    {
+        $this->host    = DB_HOST;
+        $this->port    = DB_PORT;
+        $this->dbname  = DB_NAME;
+        $this->user    = DB_USER;
+        $this->pass    = DB_PASS;
+        $this->charset = DB_CHARSET;
+    }
+
+    /** 禁止克隆 */
+    private function __clone() {}
+
+    public static function instance(): self
+    {
+        if (self::$instance === null) {
+            self::$instance = new self();
+        }
+        return self::$instance;
+    }
+
+    /**
+     * 获取 PDO 连接(惰性初始化)
+     */
+    public function getConnection(): \PDO
+    {
+        if ($this->pdo === null) {
+            $dsn = sprintf(
+                'mysql:host=%s;port=%d;dbname=%s;charset=%s',
+                $this->host, $this->port, $this->dbname, $this->charset
+            );
+
+            $this->pdo = new \PDO($dsn, $this->user, $this->pass, [
+                \PDO::ATTR_ERRMODE            => \PDO::ERRMODE_EXCEPTION,
+                \PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
+                \PDO::ATTR_EMULATE_PREPARES   => false,
+                \PDO::ATTR_STRINGIFY_FETCHES  => false,
+            ]);
+
+            // 设置严格 SQL 模式
+            $this->pdo->exec("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'");
+        }
+        return $this->pdo;
+    }
+
+    /**
+     * 执行查询返回所有行
+     */
+    public function fetchAll(string $sql, array $params = []): array
+    {
+        $stmt = $this->execute($sql, $params);
+        return $stmt->fetchAll();
+    }
+
+    /**
+     * 执行查询返回单行
+     */
+    public function fetchOne(string $sql, array $params = []): ?array
+    {
+        $stmt = $this->execute($sql, $params);
+        $row = $stmt->fetch();
+        return $row !== false ? $row : null;
+    }
+
+    /**
+     * 执行查询返回单列值
+     */
+    public function fetchColumn(string $sql, array $params = []): mixed
+    {
+        $stmt = $this->execute($sql, $params);
+        return $stmt->fetchColumn();
+    }
+
+    /**
+     * 执行 INSERT/UPDATE/DELETE,返回受影响行数
+     */
+    public function execute(string $sql, array $params = []): \PDOStatement
+    {
+        $pdo = $this->getConnection();
+        $stmt = $pdo->prepare($sql);
+        $stmt->execute($params);
+        return $stmt;
+    }
+
+    /**
+     * 获取最后插入的 ID
+     */
+    public function lastInsertId(): string
+    {
+        return $this->getConnection()->lastInsertId();
+    }
+
+    /**
+     * 事务封装
+     */
+    public function beginTransaction(): void
+    {
+        $this->getConnection()->beginTransaction();
+    }
+
+    public function commit(): void
+    {
+        $this->getConnection()->commit();
+    }
+
+    public function rollback(): void
+    {
+        $this->getConnection()->rollback();
+    }
+
+    /**
+     * 不带数据库名连接(用于建库操作)
+     */
+    public static function rawConnection(): \PDO
+    {
+        $dsn = sprintf('mysql:host=%s;port=%d;charset=%s', DB_HOST, DB_PORT, DB_CHARSET);
+        return new \PDO($dsn, DB_USER, DB_PASS, [
+            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
+        ]);
+    }
+
+    /**
+     * 销毁连接(用于重置)
+     */
+    public function close(): void
+    {
+        $this->pdo = null;
+        self::$instance = null;
+    }
+}

+ 210 - 0
src/LinkManager.php

@@ -0,0 +1,210 @@
+<?php
+namespace BCJD;
+
+/**
+ * LinkManager - 链接管理(CRUD)
+ *
+ * 安全特性:
+ * - 全部使用参数化查询(防 SQL 注入)
+ * - 输入验证与净化
+ * - 权限检查(私有链接过滤)
+ * - 统一异常处理
+ */
+class LinkManager
+{
+    private Database $db;
+
+    public function __construct()
+    {
+        $this->db = Database::instance();
+    }
+
+    /**
+     * 获取可见链接列表(按分组)
+     */
+    public function getLinks(): array
+    {
+        $isLoggedIn = Auth::isLoggedIn();
+
+        if ($isLoggedIn) {
+            $rows = $this->db->fetchAll(
+                "SELECT * FROM links ORDER BY sort_order ASC, id ASC"
+            );
+        } else {
+            $rows = $this->db->fetchAll(
+                "SELECT * FROM links WHERE is_private = 0 ORDER BY sort_order ASC, id ASC"
+            );
+        }
+
+        return $this->groupLinks($rows);
+    }
+
+    /**
+     * 按 group_name 分组
+     */
+    private function groupLinks(array $rows): array
+    {
+        $groups = [];
+        foreach ($rows as $r) {
+            $g = $r['group_name'];
+            if (!isset($groups[$g])) {
+                $groups[$g] = [
+                    'name'  => $g,
+                    'label' => $r['group_label'],
+                    'hint'  => $r['group_hint'],
+                    'links' => [],
+                ];
+            }
+            $groups[$g]['links'][] = $r;
+        }
+        return array_values($groups);
+    }
+
+    /**
+     * 添加链接
+     */
+    public function addLink(array $data): int
+    {
+        Auth::requireLogin();
+
+        // 验证必要字段
+        $title = Validator::truncate($data['title'] ?? '', 100);
+        $url   = Validator::sanitizeUrl($data['url'] ?? '');
+
+        if (empty($title)) {
+            Response::error('标题不能为空');
+        }
+        if (empty($url)) {
+            Response::error('链接地址无效');
+        }
+
+        // 获取最大排序值
+        $maxSort = (int)$this->db->fetchColumn(
+            "SELECT COALESCE(MAX(sort_order), 0) + 1 FROM links"
+        );
+
+        $this->db->execute(
+            "INSERT INTO links 
+             (title, url, description, icon_svg, group_name, keywords, tag_label, tag_class, meta_domain, is_private, sort_order)
+             VALUES (:title, :url, :description, :icon_svg, :group_name, :keywords, :tag_label, :tag_class, :meta_domain, :is_private, :sort_order)",
+            [
+                'title'       => $title,
+                'url'         => $url,
+                'description' => Validator::truncate($data['description'] ?? '', 255),
+                'icon_svg'    => Validator::sanitizeSvg($data['icon_svg'] ?? ''),
+                'group_name'  => Validator::validateGroupName($data['group_name'] ?? 'default'),
+                'keywords'    => Validator::sanitizeKeywords($data['keywords'] ?? ''),
+                'tag_label'   => Validator::truncate($data['tag_label'] ?? '', 30),
+                'tag_class'   => Validator::validateTagClass($data['tag_class'] ?? ''),
+                'meta_domain' => Validator::truncate($data['meta_domain'] ?? '', 100),
+                'is_private'  => !empty($data['is_private']) ? 1 : 0,
+                'sort_order'  => $maxSort,
+            ]
+        );
+
+        return (int)$this->db->lastInsertId();
+    }
+
+    /**
+     * 删除链接
+     */
+    public function deleteLink(int $id): void
+    {
+        Auth::requireLogin();
+
+        if ($id <= 0) {
+            Response::error('无效的链接ID');
+        }
+
+        $affected = $this->db->execute(
+            "DELETE FROM links WHERE id = ?",
+            [$id]
+        )->rowCount();
+
+        if ($affected === 0) {
+            Response::error('链接不存在', 404);
+        }
+    }
+
+    /**
+     * 更新链接
+     */
+    public function updateLink(int $id, array $data): void
+    {
+        Auth::requireLogin();
+
+        if ($id <= 0) {
+            Response::error('无效的链接ID');
+        }
+
+        // 可更新字段映射
+        $fieldMap = [
+            'title'       => fn($v) => Validator::truncate($v, 100),
+            'url'         => fn($v) => Validator::sanitizeUrl($v),
+            'description' => fn($v) => Validator::truncate($v, 255),
+            'icon_svg'    => fn($v) => Validator::sanitizeSvg($v ?? ''),
+            'group_name'  => fn($v) => Validator::validateGroupName($v ?? 'default'),
+            'keywords'    => fn($v) => Validator::sanitizeKeywords($v ?? ''),
+            'tag_label'   => fn($v) => Validator::truncate($v ?? '', 30),
+            'tag_class'   => fn($v) => Validator::validateTagClass($v ?? ''),
+            'meta_domain' => fn($v) => Validator::truncate($v ?? '', 100),
+            'is_private'  => fn($v) => !empty($v) ? 1 : 0,
+        ];
+
+        $updates = [];
+        $params  = ['id' => $id];
+
+        foreach ($fieldMap as $field => $sanitizer) {
+            if (array_key_exists($field, $data)) {
+                $updates[] = "`{$field}` = :{$field}";
+                $params[$field] = $sanitizer($data[$field]);
+            }
+        }
+
+        if (empty($updates)) {
+            Response::error('没有需要更新的字段');
+        }
+
+        $sql = "UPDATE links SET " . implode(', ', $updates) . " WHERE id = :id";
+        $this->db->execute($sql, $params);
+    }
+
+    /**
+     * 更新站点密码
+     */
+    public static function updatePassword(string $oldPw, string $newPw, string $confirm): void
+    {
+        Auth::requireLogin();
+
+        if (!Auth::verifyPassword($oldPw)) {
+            Response::error('当前密码错误', 403);
+        }
+
+        $error = Validator::validatePassword($newPw);
+        if ($error !== null) {
+            Response::error($error);
+        }
+
+        if ($newPw !== $confirm) {
+            Response::error('两次输入的新密码不一致');
+        }
+
+        $hash = password_hash($newPw, PASSWORD_BCRYPT, ['cost' => 12]);
+        $configPath = __DIR__ . '/../config.php';
+
+        $content = file_get_contents($configPath);
+        if ($content === false) {
+            Response::error('无法读取配置文件', 500);
+        }
+
+        $content = preg_replace(
+            "/define\('SITE_PASSWORD_HASH',\s*'[^']*'\)/",
+            "define('SITE_PASSWORD_HASH', '{$hash}')",
+            $content
+        );
+
+        if (file_put_contents($configPath, $content, LOCK_EX) === false) {
+            Response::error('无法写入配置文件,请检查文件权限', 500);
+        }
+    }
+}

+ 56 - 0
src/Response.php

@@ -0,0 +1,56 @@
+<?php
+namespace BCJD;
+
+/**
+ * Response - 统一 JSON 响应处理
+ *
+ * 安全特性:
+ * - 统一 Content-Type 与 charset
+ * - 禁用内容嗅探
+ * - 支持 HTTP 缓存头控制
+ * - JSON_UNESCAPED_UNICODE 确保中文正常显示
+ */
+class Response
+{
+    /**
+     * 发送 JSON 响应
+     */
+    public static function json(array $data, int $status = 200): never
+    {
+        http_response_code($status);
+
+        // 安全响应头
+        header('Content-Type: application/json; charset=utf-8');
+        header('X-Content-Type-Options: nosniff');
+        header('Cache-Control: no-store, no-cache, must-revalidate');
+
+        echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+        exit;
+    }
+
+    /**
+     * 成功响应
+     */
+    public static function success(array $extra = []): never
+    {
+        self::json(array_merge(['success' => true], $extra));
+    }
+
+    /**
+     * 错误响应
+     */
+    public static function error(string $message, int $status = 400): never
+    {
+        self::json(['error' => $message], $status);
+    }
+
+    /**
+     * 输出纯 HTML(用于 db_init 等脚本)
+     */
+    public static function html(string $html): never
+    {
+        header('Content-Type: text/html; charset=utf-8');
+        echo $html;
+        exit;
+    }
+}

+ 110 - 0
src/Validator.php

@@ -0,0 +1,110 @@
+<?php
+namespace BCJD;
+
+/**
+ * Validator - 输入验证与净化
+ *
+ * 安全特性:
+ * - HTML 转义(XSS 防护)
+ * - URL 格式校验
+ * - 字符串长度限制
+ * - 白名单样式类校验
+ */
+class Validator
+{
+    /**
+     * HTML 转义(XSS 防护)
+     */
+    public static function escape(string $value): string
+    {
+        return htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, 'UTF-8', false);
+    }
+
+    /**
+     * 净化 URL(仅允许 http/https 协议)
+     */
+    public static function sanitizeUrl(string $url): string
+    {
+        $url = trim($url);
+        // 禁止 javascript: data: 等危险协议
+        if (preg_match('/^(javascript|data|file|php):/i', $url)) {
+            return '';
+        }
+        // 自动补全协议
+        if (!preg_match('#^https?://#i', $url)) {
+            $url = 'https://' . $url;
+        }
+        return filter_var($url, FILTER_VALIDATE_URL) !== false ? $url : '';
+    }
+
+    /**
+     * SVG 净化:仅允许安全的 SVG 标签和属性
+     */
+    public static function sanitizeSvg(string $svg): string
+    {
+        // 移除 script/on* 等危险内容
+        $svg = strip_tags($svg, '<svg><path><circle><rect><line><polyline><polygon><ellipse><g><defs><use><text><tspan>');
+        // 移除 on* 事件属性
+        $svg = preg_replace('/\bon\w+\s*=\s*(["\']).*?\1/i', '', $svg);
+        return $svg;
+    }
+
+    /**
+     * 验证分组名(英文+数字+下划线)
+     */
+    public static function validateGroupName(string $name): string
+    {
+        $name = trim($name);
+        if (!preg_match('/^[a-zA-Z0-9_\-]{1,50}$/', $name)) {
+            return 'default';
+        }
+        return $name;
+    }
+
+    /**
+     * 白名单校验标签样式类
+     */
+    public static function validateTagClass(string $class): string
+    {
+        $allowed = ['', 'ok', 'warn'];
+        return in_array($class, $allowed, true) ? $class : '';
+    }
+
+    /**
+     * 截断字符串到指定长度
+     */
+    public static function truncate(string $value, int $maxLength = 255): string
+    {
+        return mb_substr(trim($value), 0, $maxLength, 'UTF-8');
+    }
+
+    /**
+     * 净化关键字(移除特殊字符)
+     */
+    public static function sanitizeKeywords(string $keywords): string
+    {
+        return preg_replace('/[^\p{L}\p{N}\s\-_]/u', '', trim($keywords));
+    }
+
+    /**
+     * 验证密码强度
+     */
+    public static function validatePassword(string $password): ?string
+    {
+        if (strlen($password) < 6) {
+            return '密码至少需要6个字符';
+        }
+        if (strlen($password) > 128) {
+            return '密码长度不能超过128个字符';
+        }
+        return null;
+    }
+
+    /**
+     * 安全地清理用户输入:去除不可见控制字符
+     */
+    public static function stripControlChars(string $value): string
+    {
+        return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
+    }
+}