
导航网站源码二次开发:添加搜索、收藏与后台管理功能
导航网站看起来只是把一组网址排成卡片,真正影响使用体验的却是几个基础能力:用户能不能快速找到目标,能不能留住常用链接,维护者能不能安全地更新内容。给现有源码增加搜索、收藏和后台管理,不一定要重写整站,但需要先弄清楚数据从哪里来、谁有权修改、用户的收藏保存在哪里。
本文以 PHP 8.1 及以上版本、PDO SQLite 和原生 JavaScript 为例,说明一套适合小型导航站的实现方式。示例可用于本地或单机部署;如果网站已有数据库、登录系统或前端框架,应将思路融入现有架构,不必照搬文件组织。示例中的收藏保存在当前浏览器,不会跨设备同步;后台使用环境变量提供管理员密码哈希,适合演示和简单站点,不替代成熟的用户与权限系统。
一、开始前先确认边界
修改源码前,先检查项目的目录结构、运行环境和现有数据模型。常见的导航站源码可能把网址写在 JSON、PHP 数组、数据库,或后台配置文件中。若同时存在多份数据源,搜索结果、首页列表和后台编辑可能互不一致。应先确定一个可信的数据来源,再决定由前端筛选还是后端查询。
- 纯静态站或条目较少:可以在浏览器中对已加载的数据做搜索,收藏放在 localStorage。
- 数据量较大或需要分页:让后端按关键词查询,并在数据库层分页;不要把全部记录塞进页面再筛选。
- 个人使用、无需同步:浏览器本地收藏足够简单。
- 需要账号间同步:收藏必须关联登录用户并保存到服务器,不能把 localStorage 当作账户数据。
- 多人维护内容:需要身份认证、权限划分、审计和备份;仅隐藏管理入口不构成访问控制。
还要检查原项目的许可协议、依赖版本和部署方式。二次开发不等于可以去掉原作者的版权声明或许可证要求。以下代码是一个最小示例,生产部署应纳入项目现有的错误处理、日志、迁移和运维流程。
二、设计数据结构:让搜索与管理使用同一份数据
为了便于演示,后端使用 SQLite 保存导航条目。每条记录包含名称、网址、分类、描述和更新时间。网址采用唯一约束,避免后台意外重复添加同一个 URL。若业务允许同一网址出现在多个分类,应改用单独的站点表和分类关联表,而不是直接依赖该约束。
<?php
// db.php:PHP 8.1+,确保 PHP 对 SQLite 的 PDO 驱动已启用。
function db(): PDO
{
$pdo = new PDO('sqlite:' . __DIR__ . '/data.sqlite');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec(
'CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
category TEXT NOT NULL DEFAULT "",
description TEXT NOT NULL DEFAULT "",
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)'
);
return $pdo;
}
SQLite 文件需要对 PHP 运行账户可写。部署时应将数据库放在不能被 Web 直接下载的位置,并配置备份。示例每次调用 db() 都执行一次幂等建表语句,方便小型示例使用;正式项目通常在部署迁移阶段创建表,而不是每个请求都执行迁移。
三、前端搜索:先明确匹配规则
本地搜索适合条目数量不多、页面已经加载完整数据的情况。下面的实现按名称、分类、描述和网址进行不区分大小写的子串匹配。它不是中文分词搜索:输入“开发工具”可以命中连续文字,但不保证“开发”能按语义匹配“工具开发平台”。数据很多、需要相关度排序、拼音或分词时,应使用后端搜索或专门的搜索组件。
假设页面中的每个导航卡片都有 data-search 属性,搜索框和空结果提示如下:
<label for="site-search">搜索导航</label>
<input id="site-search" type="search"
maxlength="100" placeholder="输入名称、分类或关键词">
<p id="search-empty" hidden>没有找到匹配的导航。</p>
<ul id="site-list">
<li class="site-card"
data-search="MDN 开发文档 文档 web">
<a href="https://developer.mozilla.org/">
MDN 开发文档
</a>
</li>
</ul>
<script>
const input = document.querySelector('#site-search');
const cards = [...document.querySelectorAll('.site-card')];
const empty = document.querySelector('#search-empty');
input.addEventListener('input', () => {
const query = input.value.trim().toLocaleLowerCase();
let visible = 0;
for (const card of cards) {
const text = (card.dataset.search || '').toLocaleLowerCase();
const match = text.includes(query);
card.hidden = !match;
if (match) visible++;
}
empty.hidden = visible !== 0;
});
</script>
maxlength="100"限制浏览器输入控件接受的文本长度,但不能代替服务器端校验:客户端限制可以被绕过。对于本地筛选而言,输入长度主要用于改善交互和避免无意义的超长查询;涉及后端查询时,仍要在服务器上限制长度,并使用参数化查询。不要用用户输入拼接 SQL,也不要把搜索词直接拼成 HTML 后写入页面。
若导航卡片由服务器输出,页面中的动态文本也必须按 HTML 文本或属性的上下文正确转义。PHP 示例可用 htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') 输出普通文本和属性值。转义规则取决于输出上下文;将值放进 JavaScript、CSS 或 URL 时,不能简单认为 HTML 转义就足够。
四、收藏:本地存储与账号同步是两种功能
下面给收藏按钮增加基于 localStorage 的保存能力。存储的数据是站点 ID 的字符串数组;示例只负责当前浏览器中的偏好,不包含服务端身份校验。用户清除浏览器数据、更换设备或使用另一个浏览器后,收藏不会自动迁移。localStorage 也不是秘密存储,不应放密码、访问令牌或敏感个人信息。
<button type="button" class="favorite" data-id="12">
收藏
</button>
<script>
const storageKey = 'nav-favorites-v1';
function readFavorites() {
try {
const value = JSON.parse(localStorage.getItem(storageKey) || '[]');
if (!Array.isArray(value)) return new Set();
return new Set(
value.filter(id => typeof id === 'string' && /^\d+$/.test(id))
);
} catch {
return new Set();
}
}
function saveFavorites(favorites) {
localStorage.setItem(storageKey, JSON.stringify([...favorites]));
}
const favorites = readFavorites();
function renderFavoriteButtons() {
document.querySelectorAll('.favorite').forEach(button => {
const active = favorites.has(button.dataset.id);
button.textContent = active ? '已收藏' : '收藏';
button.setAttribute('aria-pressed', String(active));
});
}
document.addEventListener('click', event => {
const button = event.target.closest('.favorite');
if (!button) return;
const id = button.dataset.id;
if (!/^\d+$/.test(id || '')) return;
if (favorites.has(id)) favorites.delete(id);
else favorites.add(id);
saveFavorites(favorites);
renderFavoriteButtons();
});
renderFavoriteButtons();
</script>
这里的正则校验只是在浏览器端过滤格式,不是安全边界。若收藏改为写入服务器,服务器必须从已验证的登录会话中取得用户 ID,而不是相信请求体传来的用户 ID;还要确认被收藏的导航记录存在。多用户收藏可采用 favorites(user_id, link_id) 关联表,并对两列添加外键和联合唯一约束。
五、后台管理:必须由服务器鉴权
后台管理不应依靠前端隐藏按钮、难猜的管理地址或 JavaScript 密码判断。用户可以直接构造 HTTP 请求,因此新增、修改和删除操作都必须由服务器验证会话权限。以下示例提供管理员登录、添加导航和删除导航三个最小接口,使用 PHP Session、CSRF Token、PDO 参数化查询和输出转义。它没有实现密码重置、用户管理、审计日志或速率限制,适用于说明基础边界,不应视为完整的生产后台。
先在环境变量中配置管理员密码的 password_hash 结果,不要把明文密码写进源码。可用 PHP 命令生成哈希:
<?php
echo password_hash('请替换为足够强的管理员密码', PASSWORD_DEFAULT);
将输出值放入服务器环境变量 ADMIN_PASSWORD_HASH。环境变量的设置方式取决于 Apache、Nginx、PHP-FPM 或容器配置,部署后应确认 PHP 进程确实能读取该变量。不要把包含真实凭据的配置文件提交到公开代码仓库。
以下代码可放进 admin.php。它依赖上一节的 db() 函数;示例通过 POST 表单完成操作,并在页面中显示记录。
<?php
declare(strict_types=1);
session_start();
require __DIR__ . '/db.php';
function h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function csrfToken(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function checkCsrf(): void
{
$sent = $_POST['csrf'] ?? '';
$saved = $_SESSION['csrf'] ?? '';
if (!is_string($sent) || !is_string($saved) ||
!hash_equals($saved, $sent)) {
http_response_code(400);
exit('请求无效,请刷新页面后重试。');
}
}
function requireAdmin(): void
{
if (empty($_SESSION['is_admin'])) {
http_response_code(403);
exit('无权访问。');
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
checkCsrf();
$action = $_POST['action'] ?? '';
if ($action === 'login') {
$hash = getenv('ADMIN_PASSWORD_HASH') ?: '';
$password = $_POST['password'] ?? '';
if ($hash !== '' && is_string($password) &&
password_verify($password, $hash)) {
session_regenerate_id(true);
$_SESSION['is_admin'] = true;
header('Location: admin.php');
exit;
}
$error = '登录失败。';
} elseif ($action === 'logout') {
requireAdmin();
$_SESSION = [];
session_destroy();
header('Location: admin.php');
exit;
} else {
requireAdmin();
if ($action === 'add') {
$name = trim((string)($_POST['name'] ?? ''));
$url = trim((string)($_POST['url'] ?? ''));
$category = trim((string)($_POST['category'] ?? ''));
$description = trim((string)($_POST['description'] ?? ''));
if ($name === '' || mb_strlen($name) > 100 ||
mb_strlen($category) > 60 ||
mb_strlen($description) > 500) {
http_response_code(422);
exit('名称为必填项;名称、分类或描述超过允许长度。');
}
if (strlen($url) > 2048 ||
!filter_var($url, FILTER_VALIDATE_URL)) {
http_response_code(422);
exit('网址格式无效或长度超过 2048 字节。');
}
$scheme = strtolower((string)parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
http_response_code(422);
exit('网址只允许 http 或 https 协议。');
}
$stmt = db()->prepare(
'INSERT INTO links (name, url, category, description)
VALUES (:name, :url, :category, :description)'
);
try {
$stmt->execute([
':name' => $name,
':url' => $url,
':category' => $category,
':description' => $description,
]);
} catch (PDOException $e) {
if (str_contains($e->getMessage(), 'UNIQUE')) {
http_response_code(409);
exit('该网址已存在。');
}
throw $e;
}
header('Location: admin.php');
exit;
}
if ($action === 'delete') {
$id = filter_var($_POST['id'] ?? null, FILTER_VALIDATE_INT);
if ($id === false || $id === null || $id < 1) {
http_response_code(422);
exit('记录编号无效。');
}
$stmt = db()->prepare('DELETE FROM links WHERE id = :id');
$stmt->execute([':id' => $id]);
header('Location: admin.php');
exit;
}
}
}
if (empty($_SESSION['is_admin'])) {
?>
<h1>管理员登录</h1>
<?php if (!empty($error)) echo '<p>' . h($error) . '</p>'; ?>
<form method="post">
<input type="hidden" name="csrf" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="action" value="login">
<label>密码 <input type="password" name="password"
autocomplete="current-password" required></label>
<button type="submit">登录</button>
</form>
<?php
exit;
}
$links = db()->query(
'SELECT id, name, url, category, description FROM links ORDER BY id DESC'
)->fetchAll(PDO::FETCH_ASSOC);
?>
<h1>导航管理</h1>
<form method="post">
<input type="hidden" name="csrf" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="action" value="add">
<label>名称 <input name="name" maxlength="100" required></label>
<label>网址 <input name="url" maxlength="2048" required></label>
<label>分类 <input name="category" maxlength="60"></label>
<label>描述 <textarea name="description" maxlength="500"></textarea></label>
<button type="submit">添加</button>
</form>
<form method="post">
<input type="hidden" name="csrf" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="action" value="logout">
<button type="submit">退出登录</button>
</form>
<ul>
<?php foreach ($links as $link): ?>
<li>
<?= h($link['name']) ?> —
<a href="<?= h($link['url']) ?>"><?= h($link['url']) ?></a>
<form method="post" style="display:inline">
<input type="hidden" name="csrf" value="<?= h(csrfToken()) ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?= (int)$link['id'] ?>">
<button type="submit">删除</button>
</form>
</li>
<?php endforeach; ?>
</ul>
六、代码中的限制与安全边界
示例的字段长度并非所有业务的通用标准,而是明确的应用级限制:名称最多 100 个 Unicode 字符,分类最多 60 个,描述最多 500 个;网址最多 2048 个字节。PHP 的 mb_strlen 需要启用 mbstring 扩展,且在 UTF-8 环境下适合计算多字节文本字符数。网址限制使用 strlen,计算的是字节数,不是字符数。浏览器表单中的 maxlength 主要约束交互端,服务端校验才是最终依据。
FILTER_VALIDATE_URL 能做格式校验,但不证明网址安全、可访问、属于可信域名或不会跳转到恶意站点。示例进一步只接受 HTTP 和 HTTPS,但这仍不能阻止所有钓鱼链接。对于公开提交链接的站点,可以加域名审核、人工审核、举报和安全扫描;避免服务端主动抓取用户提交的网址,否则还会引入 SSRF 风险。
CSRF Token 用于验证状态变更请求是否来自包含正确令牌的页面;它不是登录验证,也不能抵御 XSS。Session 登录状态负责鉴权,数据库参数化查询降低 SQL 注入风险,HTML 转义降低动态内容进入页面时的 XSS 风险。这些措施解决的问题不同,不能相互替代。会话 Cookie 在生产环境应启用 HTTPS,并配置合适的 Secure、HttpOnly 和 SameSite 属性;也应考虑登录失败限速、密码轮换和管理员账号保护。
示例将普通网址转义后放入 href。对可信管理员输入,这能避免部分 HTML 属性注入,但并不能代替协议白名单;代码同时限制了 http 和 https。如果后续允许其他协议,必须重新评估,不能默认允许 javascript: 等不适用于导航链接的方案。
七、接入现有导航页面
后台保存数据后,首页需要从数据库读取条目,并为卡片生成稳定 ID,供收藏逻辑使用。输出页面时,应对所有动态字段按上下文转义。例如:
<?php
$links = db()->query(
'SELECT id, name, url, category, description FROM links ORDER BY category, name'
)->fetchAll(PDO::FETCH_ASSOC);
?>
<ul id="site-list">
<?php foreach ($links as $link):
$searchText = $link['name'] . ' ' . $link['category'] . ' ' .
$link['description'] . ' ' . $link['url'];
?>
<li class="site-card" data-search="<?= h($searchText) ?>">
<a href="<?= h($link['url']) ?>"><?= h($link['name']) ?></a>
<button type="button" class="favorite"
data-id="<?= (int)$link['id'] ?>">收藏</button>
<p><?= h($link['description']) ?></p>
</li>
<?php endforeach; ?>
</ul>
此处搜索文本写入 HTML 属性,使用的转义函数包含引号处理。若现有模板引擎已有自动转义,应遵循其规则,避免重复编码导致显示异常。收藏按钮 ID 来自数据库整数,因此用整数格式输出。导航 ID 删除或更新后,浏览器旧收藏中可能仍有过期 ID;前端可以在加载时按当前有效 ID 清理,或者在展示收藏列表时忽略不存在的记录。
八、常见错误排查
- 输入搜索词后页面报错:检查选择器是否能找到输入框、卡片和空结果提示;卡片是否真的有
data-search属性。 - 中文搜索表现不符合预期:子串匹配不是分词搜索。确认预期是连续文本匹配,还是需要拼音、同义词或全文检索。
- 收藏刷新后消失:检查浏览器是否允许站点存储、是否使用了正确的站点域名和协议;隐私模式或清理网站数据可能影响本地存储。
- 后台总是登录失败:检查 PHP-FPM 或 Web 服务器进程是否读取到
ADMIN_PASSWORD_HASH,以及环境变量中是否包含完整的哈希值。 - 添加记录报数据库只读:检查 SQLite 文件及其目录的写权限。只给数据库文件写权限有时仍不够,SQLite 可能需要在同目录创建临时文件。
- 表单提示 CSRF 无效:确认页面和提交请求使用同一会话,检查 Cookie 设置、域名和 HTTPS 配置;不要为了消除报错而移除 Token 校验。
- 网址无法保存:确认输入包含完整协议,如
https://example.com。校验不通过时应向用户说明具体要求,而不是静默丢弃内容。 - 页面出现标签或奇怪转义字符:检查输出是否正确转义、是否重复转义,以及文本是否被不安全地拼接到 HTML 或脚本中。
九、从示例走向生产
小型导航站可以先用本地搜索和浏览器收藏,快速验证用户是否需要这些能力。确认需求后,再逐步加入服务端分页、分类筛选、账号收藏、编辑功能和审核流程。后台至少应考虑 HTTPS、登录限速、权限检查、CSRF 防护、输出转义、数据库备份和错误日志;多人协作时还应记录谁在何时修改了什么。
功能开发完成后,用正常输入、空输入、超长输入、重复网址、无效协议、未登录请求和缺失 CSRF Token 做基本测试。还应在目标服务器环境中验证 PHP 扩展、文件权限、Session 持久化和错误日志。把搜索、收藏和后台管理建立在一致的数据源与清晰权限边界之上,比单纯增加几个按钮更重要。





