新增navcat解密工具、优化后台UI

This commit is contained in:
MasonLiu 2026-09-12 01:36:59 +08:00
parent 7b3d45b153
commit bf772046cc
20 changed files with 4409 additions and 2670 deletions

View File

@ -48,14 +48,14 @@
入口:站点域名 `/admin`,登录后进入;顶栏依次为:
### 1. 导航内容管理nav.php
### 1. 导航管理nav.php
- 顶部切换当前要编辑的库(科普 / 红队 / 蓝队)
- 每个块可改标题、增删链接、拖拽排序;删除后有确认弹窗
- **批量导入**:在导入面板粘贴文本,格式支持 `名称 网址``名称 | 网址`,自动拆分成导航块;解析后可核对再「保存全部修改」
- 操作结果以右下角 Toast 提示,且保持原滚动位置
### 2. 功能区管理tools.php
### 2. 内部功能tools.php
- 添加工具填写名称、图标emoji、简介、**入口地址**与**展示库范围**(未勾选=所有库通用)
- 站内页面:填 `codec.html` 等相对地址(工具本体放在 `func/`
@ -73,7 +73,7 @@
- 维护各库推荐的外部站点名称、网址、简介、分组栏目、展示范围cat_keys
- 保存后库首页「外部工具区」即时更新;同样支持批量导入
### 5. 内容与顺序content.php
### 5. 网站管理content.php
集中站点配置:
@ -85,15 +85,26 @@
- **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简
- **修改密码**:建议首次登录即修改默认 `admin/admin123`
### 6. 访问信息visits.php—— 日志与安全
### 6. 风控管理visits.php—— 日志与安全
**访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。
**访问记录**
- 完整模式逐条明细;精简模式按 IP 聚合(最近访问 / 次数)
- 支持 IP 模糊筛选、URL/方法关键词搜索、分页
- 筛选与翻页为**页面内局部刷新**(由 `visits_data.php` 查询),不会整页跳转、也不会跳回页面顶部
- 日志按行追加写于 `data/vistors.log`,无上限;可一键清空
**登录信息**
- 顶部展示当前用户、本次登录时间 / 登录 IP / 登录设备(操作系统 / 手机型号 / 浏览器 / 屏幕等,能获取到什么就记录什么,由浏览器采集 + 服务端 UA 解析)
- 「历史登录记录」列出最近成功登录(时间 / IP / 设备 / 用户名)
**登录失败日志**
- 按「IP × 日期」聚合:同一 IP 当天的失败次数、最近失败时间与设备信息
- 支持按 IP 模糊筛选(同样为页面内局部刷新);可一键清空全部登录日志(含成功与失败,存于数据库 `login_log` 表)
**自动风控规则**(阈值到达即永久封禁):
| 规则 | 默认 | 建议 |
|---|---|---|
@ -114,16 +125,16 @@
## 三、安全运维
- **改口令**:登录后台 → 内容与顺序 → 修改密码(至少 6 位)
- **改口令**:登录后台 → 网站管理 → 修改密码(至少 6 位)
- **被自动风控误封**:从其它未被封的网络登录后台移除对应规则;或直接编辑数据库 `data/homepage.db``ip_blacklist` 表删除该行(同表可删除“风控自动”记录)
- **备份与升级**:站点数据集中在 `data/`(数据库、访问日志、上传、文章图片)。升级前备份整个 `data/` 目录即可无损迁移
- **恢复出厂**:删除 `data/homepage.db`,刷新后自动重建默认内容
## 四、常见问题FAQ
1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「访问信息 → IP 黑名单」移除即可。
2. **想让某个 IP 永久无法访问**:后台「访问信息」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。
3. **工具页图标不是自己的 logo**:后台「内容与顺序」上传过 logo 后强刷Ctrl+F5工具页即可同步浏览器对旧图标有缓存可多刷新一次。
1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「风控管理 → IP 黑名单」移除即可。
2. **想让某个 IP 永久无法访问**:后台「风控管理」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。
3. **工具页图标不是自己的 logo**:后台「网站管理」上传过 logo 后强刷Ctrl+F5工具页即可同步浏览器对旧图标有缓存可多刷新一次。
4. **工具页想完全离线使用**:把第三方库放入对应位置即可(在线未放时自动回退 CDN
```
assets/js/qrcode.min.js qrcodejs 1.0.0

View File

@ -26,7 +26,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区
### 访问审计与风控
- 每次访问自动追加写入 `data/vistors.log`(完整保留、可归档)
- **访问信息页**:完整 / 精简(按 IP 聚合双模式IP 筛选与关键词搜索、分页
- **风控管理页**:完整 / 精简(按 IP 聚合双模式IP 筛选与关键词搜索、分页(页面内局部刷新,不跳回顶部)
- **登录日志**:成功登录记录(时间 / IP / 设备)与失败日志(按 IP × 日期聚合次数与设备);设备信息含系统 / 手机型号 / 屏幕等,由浏览器采集
- **IP 黑名单**:支持单 IP、CIDR 网段、星号通配;命中后**全站统一拦截**(含后台与登录页)
- **自动风控**登录失败、404 次数、请求频率三条规则,阈值可配、即时启停;自动封禁支持一键 / 批量移除
- 拦截提示文案可自定义;后台操作结果以右下角 Toast 反馈

View File

@ -1,37 +1,65 @@
<?php
/**
* admin/_guard.php —— 后台鉴权守卫 + 公共栏(所有后台页面在 $P='../' 定义后 require
* admin/_guard.php —— 后台鉴权守卫 + 公共侧边栏(所有后台页面在 $P='../' 定义后 require
*/
$P = $P ?? '../';
require_once dirname(__DIR__) . '/includes/auth.php';
require_once dirname(__DIR__) . '/includes/layout.php';
require_login('login.php');
/** 后台顶栏 */
/** 后台侧边栏(原部分区改为可收缩侧栏;含“本页功能”锚点容器) */
function admin_topbar(string $active = ''): void
{
global $P;
$site = setting_get('site_name', '知识导航站');
$admin = (string)($_SESSION['admin'] ?? '');
echo '<div class="admin-nav"><div class="admin-nav-inner">';
echo '<a class="brand brand-sm" href="../index.php" title="查看前台首页">';
layout_logo_img('logo', $site);
echo '<span class="site-name">' . he($site) . ' · 后台</span></a>';
$admin = (string) ($_SESSION['admin'] ?? '');
// 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
echo '<script>(function(){var b=document.body;if(!b)return;b.classList.add("hp-admin-side");try{if(localStorage.getItem("hp-admin-side-collapsed")==="1")b.classList.add("hp-side-collapsed");}catch(e){}})();</script>' . "\n";
// 小屏顶部条(仅移动端显示:菜单按钮 + 站名 + 退出)
echo '<div class="admin-mobilebar">';
echo '<button type="button" class="amb-btn js-admin-side-toggle" title="展开 / 收起菜单" aria-label="展开或收起菜单">☰</button>';
echo '<a class="amb-brand" href="../index.php" title="查看前台首页">';
layout_logo_img('amb-logo', $site);
echo '<span>' . he($site) . ' · 后台</span></a>';
echo '<a class="amb-out" href="logout.php">退出</a>';
echo '</div>' . "\n";
echo '<aside class="admin-side" id="adminSide">';
// 品牌区 + 折叠按钮
echo '<div class="as-head">';
echo '<a class="as-brand" href="../index.php" title="查看前台首页">';
layout_logo_img('as-logo', $site);
echo '<span class="as-brand-tx">' . he($site) . '<em>后台管理</em></span></a>';
echo '<button type="button" class="as-toggle js-admin-side-toggle" title="收起 / 展开侧栏" aria-label="收起或展开侧栏"><span class="as-toggle-ic">«</span></button>';
echo '</div>';
// 主导航
$links = [
'nav' => ['nav.php', '导航内容管理'],
'tools' => ['tools.php', '功能区管理'],
'articles' => ['articles.php', '文章管理'],
'exttools' => ['exttools.php', '外部工具'],
'content' => ['content.php', '内容与顺序'],
'visits' => ['visits.php', '访问信息'],
'nav' => ['nav.php', '导航管理', '🗂'],
'tools' => ['tools.php', '内部功能', '🧰'],
'articles' => ['articles.php', '文章管理', '📄'],
'exttools' => ['exttools.php', '外部工具', '🔗'],
'content' => ['content.php', '网站管理', '⚙'],
'visits' => ['visits.php', '风控管理', '🛡'],
];
echo '<nav class="as-nav">';
foreach ($links as $k => $lk) {
$cls = ($active === $k) ? 'active' : '';
echo '<a href="' . he($lk[0]) . '"' . ($cls ? ' class="' . $cls . '"' : '') . '>' . he($lk[1]) . '</a>';
$cls = 'as-link' . ($active === $k ? ' active' : '');
echo '<a class="' . $cls . '" href="' . he($lk[0]) . '" title="' . he($lk[1]) . '">';
echo '<span class="as-ic">' . he($lk[2]) . '</span><span class="as-tx">' . he($lk[1]) . '</span></a>';
}
echo '<span class="sp">';
echo '<span class="tip" style="color:rgba(255,255,255,.75)">管理员:' . he($admin) . '</span>';
echo '<a href="logout.php">退出登录</a>';
echo '</span>';
echo '</div></div>' . "\n";
echo '</nav>';
// 本页功能(由 common.js 依据当前页区块自动生成)
echo '<div class="as-sec" id="adminSideSections" hidden>';
echo '<div class="as-sec-t"><span class="as-tx">本页功能</span></div>';
echo '<div class="as-sec-list" id="adminSideSectionList"></div>';
echo '</div>';
// 底部:前台入口 / 退出 / 当前管理员
echo '<div class="as-foot">';
echo '<a class="as-link" href="../index.php" title="查看前台首页"><span class="as-ic">⌂</span><span class="as-tx">查看前台</span></a>';
echo '<a class="as-link" href="logout.php" title="退出登录"><span class="as-ic">⎋</span><span class="as-tx">退出登录</span></a>';
echo '<div class="as-user as-tx" title="当前登录用户">当前用户(' . he($admin) . '</div>';
echo '</div>';
echo '</aside>' . "\n";
echo '<div class="admin-side-mask" id="adminSideMask"></div>' . "\n";
}

274
admin/_visits_lib.php Normal file
View File

@ -0,0 +1,274 @@
<?php
/**
* admin/_visits_lib.php —— 风控管理页数据查询与片段渲染visits.php / visits_data.php 共用)
* 依赖includes/db.php数据层、includes/layout.phphe 转义)
*/
require_once dirname(__DIR__) . '/includes/db.php';
require_once dirname(__DIR__) . '/includes/layout.php';
/** 访问概况统计(累计访问 / 独立 IP / 今日访问 / 黑名单规则数) */
function vis_stat_all(): array
{
$entries = visitors_entries();
$ips = [];
$today = 0;
$todayStart = date('Y-m-d 00:00:00');
foreach ($entries as $e) {
$ips[$e['ip']] = 1;
if ($e['created_at'] >= $todayStart) {
$today++;
}
}
return [
'total' => count($entries),
'ips' => count($ips),
'today' => $today,
'ban' => (int) db()->query('SELECT COUNT(*) FROM ip_blacklist')->fetchColumn(),
];
}
/** 访问记录查询(完整 / 精简 + IP、关键词筛选 + 分页) */
function vis_query_access(string $mode, string $ipF, string $q, int $page, int $perPage = 30): array
{
if (!in_array($mode, ['full', 'compact'], true)) {
$mode = 'full';
}
$entries = visitors_entries();
$all = [];
if ($mode === 'compact') {
// 精简模式:按 IP 聚合(最近访问时间 / IP / 访问次数),首次出现即该 IP 最新一条
$grp = [];
$order = [];
foreach ($entries as $e) {
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) {
continue;
}
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) {
continue;
}
if (!isset($grp[$e['ip']])) {
$grp[$e['ip']] = ['ip' => $e['ip'], 'last_at' => $e['created_at'], 'cnt' => 0];
$order[] = $e['ip'];
}
$grp[$e['ip']]['cnt']++;
}
foreach ($order as $ip) {
$all[] = $grp[$ip];
}
} else {
// 完整模式:逐条明细
foreach ($entries as $e) {
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) {
continue;
}
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) {
continue;
}
$all[] = $e;
}
}
$page = max(1, $page);
$total = count($all);
$totalPages = max(1, (int) ceil($total / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
}
return [
'mode' => $mode,
'page' => $page,
'perPage' => $perPage,
'total' => $total,
'totalPages' => $totalPages,
'logs' => array_slice($all, ($page - 1) * $perPage, $perPage),
'query' => ['mode' => $mode, 'ip' => $ipF, 'q' => $q],
'hasFilter' => ($ipF !== '' || $q !== ''),
];
}
/** 生成带参数的链接($q 为基础参数,$ov 为覆盖项) */
function vis_make_url(array $q, array $ov = []): string
{
foreach ($ov as $k => $v) {
if ($v === null || $v === '') {
unset($q[$k]);
} else {
$q[$k] = $v;
}
}
return $q ? ('visits.php?' . http_build_query($q)) : 'visits.php';
}
/** 分页链接的 AJAX 查询串(供 data-vis-page 使用) */
function vis_page_query(array $q, int $page): string
{
$q['page'] = $page;
return http_build_query($q);
}
/** 渲染访问记录片段(统计行 + 表格 + 分页 / 空态) */
function vis_render_access(array $d): string
{
$logs = $d['logs'];
$mode = $d['mode'];
$q = $d['query'];
$page = (int) $d['page'];
$totalPages = (int) $d['totalPages'];
ob_start();
if ($logs) {
?>
<div class="tip" style="margin:10px 0 6px">
共命中 <?= (int) $d['total'] ?> <?= $mode === 'compact' ? '个 IP' : '条记录' ?>
当前第 <?= $page ?> / <?= $totalPages ?> 页,每页 <?= (int) $d['perPage'] ?> 条。
</div>
<div class="v-scroll">
<?php if ($mode === 'compact'): ?>
<table class="v-tbl">
<thead>
<tr>
<th>最近访问时间</th>
<th>IP 地址</th>
<th>访问次数</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string) $r['last_at']) ?></td>
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
<td><b><?= (int) $r['cnt'] ?></b> 次</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<table class="v-tbl">
<thead>
<tr>
<th>访问时间</th>
<th>IP 地址</th>
<th>请求方式</th>
<th>访问 URL</th>
<th>响应码</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string) $r['created_at']) ?></td>
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
<td><span class="v-tag"><?= he((string) $r['method']) ?></span></td>
<td><?= he((string) $r['url']) ?></td>
<td class="v-nowrap"><?php
$code = (int) $r['status'];
$cls = ($code >= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad'));
?><span class="v-tag <?= $cls ?>"><?= $code ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php if ($totalPages > 1): ?>
<div class="pager">
<span class="pg-info"> <?= (int) $d['total'] ?> 条</span>
<?php if ($page > 1): ?>
<a class="btn btn-sm" href="<?= he(vis_make_url($q, ['page' => $page - 1])) ?>" data-vis-section="access"
data-vis-page="<?= he(vis_page_query($q, $page - 1)) ?>"> 上一页</a>
<?php endif; ?>
<?php
$win = 2;
$start = max(1, $page - $win);
$end = min($totalPages, $page + $win);
if ($start > 1) {
echo '<span class="tip">…</span>';
}
for ($p = $start; $p <= $end; $p++) {
if ($p === $page) {
echo '<span class="btn btn-sm btn-primary">' . $p . '</span>';
} else {
echo '<a class="btn btn-sm" href="' . he(vis_make_url($q, ['page' => $p])) . '" data-vis-section="access" data-vis-page="' . he(vis_page_query($q, $p)) . '">' . $p . '</a>';
}
}
if ($end < $totalPages) {
echo '<span class="tip">…</span>';
}
?>
<?php if ($page < $totalPages): ?>
<a class="btn btn-sm" href="<?= he(vis_make_url($q, ['page' => $page + 1])) ?>" data-vis-section="access"
data-vis-page="<?= he(vis_page_query($q, $page + 1)) ?>">下一页 </a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php
} else {
?>
<div class="err-msg" style="margin-top:12px">
<?= $d['hasFilter'] ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?>
</div>
<?php
}
return (string) ob_get_clean();
}
/** 设备信息展示:优先客户端采集的 device回退 UA 解析 */
function vis_device_text(array $row): string
{
$dev = trim((string) ($row['device'] ?? ''));
if ($dev !== '') {
return $dev;
}
return login_ua_brief((string) ($row['ua'] ?? ''));
}
/** 登录失败日志查询IP × 日期聚合) */
function vis_query_loginfail(string $lf, int $limit = 200): array
{
return [
'filter' => $lf,
'rows' => login_fail_daily_stats($lf, $limit),
];
}
/** 登录失败日志片段渲染(表格 / 空态) */
function vis_render_loginfail(array $d): string
{
$rows = $d['rows'];
ob_start();
if ($rows) {
?>
<div class="v-scroll" style="margin-top:8px">
<table class="v-tbl">
<thead>
<tr>
<th>日期</th>
<th>IP 地址</th>
<th>失败次数</th>
<th>最近失败时间</th>
<th>设备信息</th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string) $r['day']) ?></td>
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
<td><b><?= (int) $r['cnt'] ?></b> 次</td>
<td class="v-nowrap"><?= he((string) $r['last_at']) ?></td>
<td><?= he(vis_device_text($r)) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
} else {
?>
<div class="err-msg" style="margin-top:12px">
<?= $d['filter'] !== '' ? '该 IP 暂无登录失败记录。' : '暂无登录失败记录。' ?>
</div>
<?php
}
return (string) ob_get_clean();
}

View File

@ -11,181 +11,183 @@ $pdo = db();
function settings_set(string $key, string $value): void
{
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
$st->execute([$key, $value]);
$st->execute([$key, $value]);
}
function renumber_sorts(PDO $pdo): void
{
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
$idxByPart = [];
$st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?');
foreach ($rows as $r) {
$key = (string)$r['part'];
if (!isset($idxByPart[$key])) {
$idxByPart[$key] = 1;
}
$st->execute([$idxByPart[$key], (int)$r['id']]);
$idxByPart[$key]++;
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
$idxByPart = [];
$st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?');
foreach ($rows as $r) {
$key = (string) $r['part'];
if (!isset($idxByPart[$key])) {
$idxByPart[$key] = 1;
}
$st->execute([$idxByPart[$key], (int) $r['id']]);
$idxByPart[$key]++;
}
}
function msg_block(string $kind, string $text): string
{
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
// 改为右下角 Toast注入 hpToastMsg由 common.js 在页面加载后自动弹出
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
}
$msgKind = '';
$msgText = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_verify()) {
$msgKind = 'err';
$msgText = '安全校验失败,请刷新页面重试。';
} else {
$act = (string)($_POST['act'] ?? '');
switch ($act) {
case 'note':
settings_set('article_note', trim((string)($_POST['note'] ?? '')));
touch_last_updated();
$msgKind = 'ok';
$msgText = '文章区首页注要已保存。';
break;
case 'save':
$id = max(0, (int)($_POST['id'] ?? 0));
$part = trim((string)($_POST['part'] ?? ''));
$title = trim((string)($_POST['title'] ?? ''));
$summary = trim((string)($_POST['summary'] ?? ''));
$md = (string)($_POST['markdown'] ?? '');
$sort = max(0, (int)($_POST['sort'] ?? 0));
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
// 分发到库首页文章区board_cats 逗号分隔;未勾选=不展示到库页
$bcKept = [];
foreach ((array)($_POST['board_cats'] ?? []) as $bk) {
$bk = trim((string)$bk);
if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) {
$bcKept[] = $bk;
}
}
$boardCats = implode(',', $bcKept);
if ($part === '' || $part === '__new__') {
$part = '未分类';
}
if ($title === '') {
$msgKind = 'err';
$msgText = '文章标题不能为空。';
} elseif ($id > 0) {
$st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?');
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]);
$msgKind = 'ok';
$msgText = '文章已保存。';
} else {
$st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
$now = date('Y-m-d H:i:s');
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]);
$id = (int)$pdo->lastInsertId();
$msgKind = 'ok';
$msgText = '文章已发布(可继续编辑)。';
}
if ($msgKind === 'ok') {
renumber_sorts($pdo);
touch_last_updated();
header('Location: articles.php?edit=' . $id . '&ok=1');
exit;
}
break;
case 'del':
$id = (int)($_POST['id'] ?? 0);
if ($id > 0) {
$pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]);
renumber_sorts($pdo);
touch_last_updated();
$msgKind = 'ok';
$msgText = '文章已删除。';
}
break;
case 'toggle':
$id = (int)($_POST['id'] ?? 0);
$field = (string)($_POST['field'] ?? '');
if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) {
$cur = (int)$pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
$pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
$msgKind = 'ok';
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。';
}
break;
case 'move':
$id = (int)($_POST['id'] ?? 0);
$dir = ((string)($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
$idx = -1;
foreach ($rows as $i => $r) {
if ((int)$r['id'] === $id) {
$idx = $i;
break;
}
}
if ($idx >= 0) {
$target = ($dir === 'down') ? $idx + 1 : $idx - 1;
if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) {
$tmp = $rows[$idx];
$rows[$idx] = $rows[$target];
$rows[$target] = $tmp;
renumber_sorts($pdo);
$msgKind = 'ok';
$msgText = '顺序已调整。';
}
}
break;
case 'part_order':
// 分区分类拖动排序后持久化到设置CSV 顺序
header('Content-Type: application/json; charset=utf-8');
$parts = [];
foreach (explode(',', (string)($_POST['order'] ?? '')) as $po) {
$po = trim((string)$po);
if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; }
}
settings_set('article_part_order', implode(',', $parts));
touch_last_updated();
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
exit;
case 'upload':
// 图片上传(供编辑器 XHR 使用,返回 JSON
header('Content-Type: application/json; charset=utf-8');
$up = $_FILES['file'] ?? null;
if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE);
exit;
}
if (($up['size'] ?? 0) > 2 * 1024 * 1024) {
echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE);
exit;
}
$extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg'];
$ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION));
if (!isset($extMap[$ext])) {
echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE);
exit;
}
$dir = DATA_DIR . '/articles/img';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext];
if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) {
echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE);
exit;
default:
$msgKind = 'err';
$msgText = '未知操作。';
if (!csrf_verify()) {
$msgKind = 'err';
$msgText = '安全校验失败,请刷新页面重试。';
} else {
$act = (string) ($_POST['act'] ?? '');
switch ($act) {
case 'note':
settings_set('article_note', trim((string) ($_POST['note'] ?? '')));
touch_last_updated();
$msgKind = 'ok';
$msgText = '文章区首页注要已保存。';
break;
case 'save':
$id = max(0, (int) ($_POST['id'] ?? 0));
$part = trim((string) ($_POST['part'] ?? ''));
$title = trim((string) ($_POST['title'] ?? ''));
$summary = trim((string) ($_POST['summary'] ?? ''));
$md = (string) ($_POST['markdown'] ?? '');
$sort = max(0, (int) ($_POST['sort'] ?? 0));
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
// 分发到库首页文章区board_cats 逗号分隔;未勾选=不展示到库页
$bcKept = [];
foreach ((array) ($_POST['board_cats'] ?? []) as $bk) {
$bk = trim((string) $bk);
if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) {
$bcKept[] = $bk;
}
}
$boardCats = implode(',', $bcKept);
if ($part === '' || $part === '__new__') {
$part = '未分类';
}
if ($title === '') {
$msgKind = 'err';
$msgText = '文章标题不能为空。';
} elseif ($id > 0) {
$st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?');
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]);
$msgKind = 'ok';
$msgText = '文章已保存。';
} else {
$st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
$now = date('Y-m-d H:i:s');
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]);
$id = (int) $pdo->lastInsertId();
$msgKind = 'ok';
$msgText = '文章已发布(可继续编辑)。';
}
if ($msgKind === 'ok') {
renumber_sorts($pdo);
touch_last_updated();
header('Location: articles.php?edit=' . $id . '&ok=1');
exit;
}
break;
case 'del':
$id = (int) ($_POST['id'] ?? 0);
if ($id > 0) {
$pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]);
renumber_sorts($pdo);
touch_last_updated();
$msgKind = 'ok';
$msgText = '文章已删除。';
}
break;
case 'toggle':
$id = (int) ($_POST['id'] ?? 0);
$field = (string) ($_POST['field'] ?? '');
if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) {
$cur = (int) $pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
$pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
$msgKind = 'ok';
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。';
}
break;
case 'move':
$id = (int) ($_POST['id'] ?? 0);
$dir = ((string) ($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
$idx = -1;
foreach ($rows as $i => $r) {
if ((int) $r['id'] === $id) {
$idx = $i;
break;
}
}
if ($idx >= 0) {
$target = ($dir === 'down') ? $idx + 1 : $idx - 1;
if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) {
$tmp = $rows[$idx];
$rows[$idx] = $rows[$target];
$rows[$target] = $tmp;
renumber_sorts($pdo);
$msgKind = 'ok';
$msgText = '顺序已调整。';
}
}
break;
case 'part_order':
// 分区分类拖动排序后持久化到设置CSV 顺序
header('Content-Type: application/json; charset=utf-8');
$parts = [];
foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) {
$po = trim((string) $po);
if ($po !== '' && !in_array($po, $parts, true)) {
$parts[] = $po;
}
}
settings_set('article_part_order', implode(',', $parts));
touch_last_updated();
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
exit;
case 'upload':
// 图片上传(供编辑器 XHR 使用,返回 JSON
header('Content-Type: application/json; charset=utf-8');
$up = $_FILES['file'] ?? null;
if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE);
exit;
}
if (($up['size'] ?? 0) > 2 * 1024 * 1024) {
echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE);
exit;
}
$extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg'];
$ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION));
if (!isset($extMap[$ext])) {
echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE);
exit;
}
$dir = DATA_DIR . '/articles/img';
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
$filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext];
if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) {
echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE);
exit;
default:
$msgKind = 'err';
$msgText = '未知操作。';
}
}
}
$savedFlag = isset($_GET['ok']);
@ -193,39 +195,48 @@ $list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC'
// —— 文章按分区(分类)分组;分区顺序:后台拖动保存的 article_part_order 优先,其余按各组最新更新时间降序 ——
$artGroups = [];
foreach ($list as $a) {
$p = trim((string)$a['part']);
if ($p === '') { $p = '未分类'; }
if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; }
$artGroups[$p]['rows'][] = $a;
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; }
$p = trim((string) $a['part']);
if ($p === '') {
$p = '未分类';
}
if (!isset($artGroups[$p])) {
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'rows' => []];
}
$artGroups[$p]['rows'][] = $a;
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
$artGroups[$p]['ts'] = (string) $a['updated_at'];
}
}
$poIdx = [];
$poCfg = trim((string)setting_get('article_part_order', ''));
$poCfg = trim((string) setting_get('article_part_order', ''));
if ($poCfg !== '') {
foreach (explode(',', $poCfg) as $po) {
$po = trim($po);
if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); }
foreach (explode(',', $poCfg) as $po) {
$po = trim($po);
if ($po !== '' && isset($artGroups[$po])) {
$poIdx[$po] = count($poIdx);
}
}
}
uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) {
$ia = $poIdx[$pa] ?? PHP_INT_MAX;
$ib = $poIdx[$pb] ?? PHP_INT_MAX;
if ($ia !== $ib) return $ia <=> $ib;
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
return $d !== 0 ? $d : strcmp($pa, $pb);
$ia = $poIdx[$pa] ?? PHP_INT_MAX;
$ib = $poIdx[$pb] ?? PHP_INT_MAX;
if ($ia !== $ib)
return $ia <=> $ib;
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
return $d !== 0 ? $d : strcmp($pa, $pb);
});
$noteText = setting_get('article_note', '');
$editing = false;
$row = null;
$editId = (int)($_GET['edit'] ?? 0);
$editId = (int) ($_GET['edit'] ?? 0);
if ($editId > 0) {
$st = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
$st->execute([$editId]);
$row = $st->fetch();
if ($row) {
$editing = true;
}
$st = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
$st->execute([$editId]);
$row = $st->fetch();
if ($row) {
$editing = true;
}
}
layout_head('文章管理');
@ -239,39 +250,53 @@ admin_topbar('articles');
<?php if ($savedFlag): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,文章库前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($msgText !== ''): echo msg_block($msgKind, $msgText); endif; ?>
<?php if ($msgText !== ''):
echo msg_block($msgKind, $msgText);
endif; ?>
<?php if ($editing || isset($_GET['new'])): ?>
<?php
$cur = $row ? [
'id' => (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'],
'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'],
'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'],
'board_cats' => trim((string)($row['board_cats'] ?? '')),
'id' => (int) $row['id'],
'part' => (string) $row['part'],
'title' => (string) $row['title'],
'summary' => (string) $row['summary'],
'md' => (string) $row['markdown'],
'sort' => (int) $row['sort'],
'pinned' => (int) $row['pinned'],
'enabled' => (int) $row['enabled'],
'board_cats' => trim((string) ($row['board_cats'] ?? '')),
] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => ''];
// 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中
$curPart = trim((string)$cur['part']);
if ($curPart === '') { $curPart = '未分类'; }
$curPart = trim((string) $cur['part']);
if ($curPart === '') {
$curPart = '未分类';
}
$partOptions = ['未分类'];
foreach ($list as $a2) {
$p2 = trim((string)$a2['part']);
if ($p2 === '' || $p2 === '未分类') continue;
if (!in_array($p2, $partOptions, true)) { $partOptions[] = $p2; }
$p2 = trim((string) $a2['part']);
if ($p2 === '' || $p2 === '未分类')
continue;
if (!in_array($p2, $partOptions, true)) {
$partOptions[] = $p2;
}
}
if (!in_array($curPart, $partOptions, true)) {
array_unshift($partOptions, $curPart);
}
if (!in_array($curPart, $partOptions, true)) { array_unshift($partOptions, $curPart); }
?>
<div class="fieldset-card">
<div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="act" value="save">
<input type="hidden" name="id" value="<?= (int)$cur['id'] ?>">
<input type="hidden" name="id" value="<?= (int) $cur['id'] ?>">
<div class="field-row" style="flex-wrap:wrap">
<div style="flex:1 1 200px;min-width:160px">
<label class="fl">分区 / 栏目</label>
<select name="part" id="artPartSel">
<?php foreach ($partOptions as $po): ?>
<option value="<?= he($po) ?>"<?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
<option value="<?= he($po) ?>" <?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
<?php endforeach; ?>
<option value="__new__"> 新建分区…</option>
</select>
@ -284,26 +309,28 @@ admin_topbar('articles');
</div>
<div style="flex:0 1 120px;min-width:90px">
<label class="fl">排序(组内)</label>
<input type="number" name="sort" value="<?= (int)$cur['sort'] ?>" min="0" max="999">
<input type="number" name="sort" value="<?= (int) $cur['sort'] ?>" min="0" max="999">
</div>
</div>
<label class="fl">摘要 / 一句话简介(文章区列表展示)</label>
<input type="text" name="summary" value="<?= he($cur['summary']) ?>" placeholder="可选">
<div class="field-row" style="gap:18px;margin:6px 0">
<label class="seg-check"><input type="checkbox" name="pinned" value="1"<?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
<label class="seg-check"><input type="checkbox" name="enabled" value="1"<?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
<label class="seg-check"><input type="checkbox" name="pinned" value="1" <?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
<label class="seg-check"><input type="checkbox" name="enabled" value="1" <?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
</div>
<?php $artBc = array_map('trim', explode(',', (string)$cur['board_cats'])); ?>
<?php $artBc = array_map('trim', explode(',', (string) $cur['board_cats'])); ?>
<label class="fl">分发到库首页「文章区」(不勾选=不在科普 / 红队 / 蓝队库首页展示)</label>
<div class="field-row" style="gap:18px;margin:6px 0">
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular"<?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red"<?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue"<?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular" <?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red" <?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue" <?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
</div>
<label class="fl">正文Markdown</label>
<textarea name="markdown" rows="16" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
<textarea name="markdown" rows="16"
style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
<div class="field-row" style="margin-top:8px;align-items:center">
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="flex:0 1 260px">
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
style="flex:0 1 260px">
<button type="button" class="btn btn-sm" id="artImgUpload">上传图片并插入引用</button>
<span class="tip">上传后自动在正文末尾插入 <code>![说明](data/articles/img/文件.png)</code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
</div>
@ -318,7 +345,7 @@ admin_topbar('articles');
<!-- 文章区首页注要 -->
<div class="fieldset-card">
<div class="fs-title">文章区首页注要(顶部展示给读者的说明文字)</div>
<div class="fs-title">顶部说明管理</div>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="note">
<textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea>
@ -329,7 +356,7 @@ admin_topbar('articles');
<!-- 文章列表:分类即分组(默认收起,组可拖动排序) -->
<div class="fieldset-card">
<div class="field-row" style="align-items:center;justify-content:space-between">
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表(共 <?= count($list) ?> 篇,按分类分组)</span>
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表</span>
<span style="display:inline-flex;gap:8px;align-items:center">
<button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button>
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
@ -354,43 +381,58 @@ admin_topbar('articles');
<div class="xg-b">
<div style="overflow-x:auto">
<table class="tbl">
<thead><tr><th>标题</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($g['rows'] as $a): ?>
<thead>
<tr>
<td>
<a href="articles.php?edit=<?= (int)$a['id'] ?>"><?= he((string)$a['title']) ?></a>
<?php if (trim((string)$a['summary']) !== ''): ?><div class="tip"><?= he((string)$a['summary']) ?></div><?php endif; ?>
</td>
<td style="white-space:nowrap"><?= he((string)$a['updated_at']) ?></td>
<td style="white-space:nowrap">
<?php if ((int)$a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
<?php if ((int)$a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span class="badge badge-no">隐藏</span><?php endif; ?>
</td>
<td style="white-space:nowrap">
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。">
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" title="删除"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="pinned"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="<?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="enabled"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm"><?= (int)$a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="up"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="本分类内上移"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="down"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
<button type="submit" class="btn btn-sm" title="本分类内下移"></button>
</form>
</td>
<th>标题</th>
<th>更新时间</th>
<th>状态</th>
<th>操作</th>
</tr>
<?php endforeach; ?>
</thead>
<tbody>
<?php foreach ($g['rows'] as $a): ?>
<tr>
<td>
<a href="articles.php?edit=<?= (int) $a['id'] ?>"><?= he((string) $a['title']) ?></a>
<?php if (trim((string) $a['summary']) !== ''): ?>
<div class="tip"><?= he((string) $a['summary']) ?></div><?php endif; ?>
</td>
<td style="white-space:nowrap"><?= he((string) $a['updated_at']) ?></td>
<td style="white-space:nowrap">
<?php if ((int) $a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
<?php if ((int) $a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span
class="badge badge-no">隐藏</span><?php endif; ?>
</td>
<td style="white-space:nowrap">
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。">
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id"
value="<?= (int) $a['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger" title="删除"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
name="field" value="pinned"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
<button type="submit" class="btn btn-sm"
title="<?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
name="field" value="enabled"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
<button type="submit" class="btn btn-sm"><?= (int) $a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
value="up"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
<button type="submit" class="btn btn-sm" title="本分类内上移"></button>
</form>
<form method="post" style="display:inline">
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
value="down"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
<button type="submit" class="btn btn-sm" title="本分类内下移"></button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
@ -407,43 +449,43 @@ admin_topbar('articles');
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
(function () {
'use strict';
var btn = document.getElementById('artImgUpload');
var file = document.getElementById('artImg');
var msg = document.getElementById('artUpMsg');
if (!btn || !file) return;
btn.addEventListener('click', function () {
if (!file.files || !file.files[0]) { alert('请先选择一张图片。'); return; }
var fd = new FormData();
fd.append('act', 'upload');
var csrf = document.querySelector('input[name="csrf"]');
if (csrf) fd.append(csrf.name, csrf.value);
fd.append('file', file.files[0]);
var xhr = new XMLHttpRequest();
xhr.open('POST', window.location.href);
xhr.onload = function () {
try {
var r = JSON.parse(xhr.responseText || '{}');
if (!r.ok) { msg.innerHTML = '<div class="err-msg">上传失败:' + (r.msg || '未知错误') + '</div>'; return; }
var ta = document.querySelector('textarea[name="markdown"]');
var name = r.file.replace('data/articles/img/', '');
var ins = '![图片](data/articles/img/' + name + ')';
if (ta) {
if (ta.value && ta.value.charAt(ta.value.length - 1) !== '\n') ta.value += '\n';
ta.value += '\n' + ins;
}
msg.innerHTML = '<div class="ok-msg">图片已上传data/articles/img/' + name + ',并已插入引用。</div>';
file.value = '';
} catch (e) {
msg.innerHTML = '<div class="err-msg">上传返回异常:' + e + '</div>';
}
};
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
xhr.send(fd);
if (!file.files || !file.files[0]) { alert('请先选择一张图片。'); return; }
var fd = new FormData();
fd.append('act', 'upload');
var csrf = document.querySelector('input[name="csrf"]');
if (csrf) fd.append(csrf.name, csrf.value);
fd.append('file', file.files[0]);
var xhr = new XMLHttpRequest();
xhr.open('POST', window.location.href);
xhr.onload = function () {
try {
var r = JSON.parse(xhr.responseText || '{}');
if (!r.ok) { msg.innerHTML = '<div class="err-msg">上传失败:' + (r.msg || '未知错误') + '</div>'; return; }
var ta = document.querySelector('textarea[name="markdown"]');
var name = r.file.replace('data/articles/img/', '');
var ins = '![图片](data/articles/img/' + name + ')';
if (ta) {
if (ta.value && ta.value.charAt(ta.value.length - 1) !== '\n') ta.value += '\n';
ta.value += '\n' + ins;
}
msg.innerHTML = '<div class="ok-msg">图片已上传data/articles/img/' + name + ',并已插入引用。</div>';
file.value = '';
} catch (e) {
msg.innerHTML = '<div class="err-msg">上传返回异常:' + e + '</div>';
}
};
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
xhr.send(fd);
});
})();
(function () {
})();
(function () {
'use strict';
// 分区下拉:选择「+ 新建分区…」时切换为新分区名输入框,回车/失焦确认后回落
var sel = document.getElementById('artPartSel');
@ -451,158 +493,159 @@ admin_topbar('articles');
var form = (sel && inp) ? sel.closest('form') : null;
if (!sel || !inp || !form) return;
function showSel() {
sel.style.display = '';
inp.style.display = 'none';
sel.style.display = '';
inp.style.display = 'none';
}
function acceptNew() {
var v = inp.value.replace(/^\s+|\s+$/g, '');
if (v === '') return false;
var i, found = false;
for (i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === v) { sel.selectedIndex = i; found = true; break; }
}
if (!found) {
var opt = document.createElement('option');
opt.value = v;
opt.text = v;
sel.appendChild(opt);
sel.value = v;
}
inp.value = '';
showSel();
return true;
var v = inp.value.replace(/^\s+|\s+$/g, '');
if (v === '') return false;
var i, found = false;
for (i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === v) { sel.selectedIndex = i; found = true; break; }
}
if (!found) {
var opt = document.createElement('option');
opt.value = v;
opt.text = v;
sel.appendChild(opt);
sel.value = v;
}
inp.value = '';
showSel();
return true;
}
sel.addEventListener('change', function () {
if (sel.value === '__new__') {
sel.style.display = 'none';
inp.style.display = '';
inp.focus();
}
if (sel.value === '__new__') {
sel.style.display = 'none';
inp.style.display = '';
inp.focus();
}
});
inp.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
if (!acceptNew()) showSel();
}
if (ev.key === 'Enter') {
ev.preventDefault();
if (!acceptNew()) showSel();
}
});
inp.addEventListener('blur', function () {
if (!acceptNew()) showSel();
if (!acceptNew()) showSel();
});
form.addEventListener('submit', function (ev) {
if (sel.value === '__new__' && !acceptNew()) {
ev.preventDefault();
showSel();
sel.focus();
}
if (sel.value === '__new__' && !acceptNew()) {
ev.preventDefault();
showSel();
sel.focus();
}
});
})();
(function () {
})();
(function () {
'use strict';
// 文章列表:分类即分组 —— 组头点击折叠/展开、整组拖动排序并即时持久化 part_order
var gWrap = document.getElementById('artGroups');
if (!gWrap) return;
function groups() {
return Array.prototype.filter.call(gWrap.children, function (c) { return c.classList.contains('xg'); });
return Array.prototype.filter.call(gWrap.children, function (c) { return c.classList.contains('xg'); });
}
function tip(text, isErr) {
var old = document.getElementById('artOrderTip');
if (old) old.remove();
var d = document.createElement('div');
d.id = 'artOrderTip';
d.className = isErr ? 'err-msg' : 'ok-msg';
d.style.margin = '8px 0';
d.textContent = text;
gWrap.parentNode.insertBefore(d, gWrap);
setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 3200);
var old = document.getElementById('artOrderTip');
if (old) old.remove();
var d = document.createElement('div');
d.id = 'artOrderTip';
d.className = isErr ? 'err-msg' : 'ok-msg';
d.style.margin = '8px 0';
d.textContent = text;
gWrap.parentNode.insertBefore(d, gWrap);
setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 3200);
}
function syncFoldBtn() {
var b = document.getElementById('artFoldAll');
if (!b) return;
var gs = groups();
var allFolded = gs.length > 0 && gs.every(function (g) { return g.classList.contains('is-fold'); });
var allOpen = gs.length > 0 && gs.every(function (g) { return !g.classList.contains('is-fold'); });
b.textContent = allOpen ? '收起全部' : '展开全部';
b.title = allFolded ? '展开全部分类' : (allOpen ? '收起全部分类' : '折叠 / 展开所有分类');
var b = document.getElementById('artFoldAll');
if (!b) return;
var gs = groups();
var allFolded = gs.length > 0 && gs.every(function (g) { return g.classList.contains('is-fold'); });
var allOpen = gs.length > 0 && gs.every(function (g) { return !g.classList.contains('is-fold'); });
b.textContent = allOpen ? '收起全部' : '展开全部';
b.title = allFolded ? '展开全部分类' : (allOpen ? '收起全部分类' : '折叠 / 展开所有分类');
}
gWrap.addEventListener('click', function (ev) {
if (ev.target.classList.contains('drag-h')) return;
var xgh = ev.target.closest ? ev.target.closest('.xg-h') : null;
if (!xgh || !gWrap.contains(xgh)) return;
xgh.parentNode.classList.toggle('is-fold');
syncFoldBtn();
if (ev.target.classList.contains('drag-h')) return;
var xgh = ev.target.closest ? ev.target.closest('.xg-h') : null;
if (!xgh || !gWrap.contains(xgh)) return;
xgh.parentNode.classList.toggle('is-fold');
syncFoldBtn();
});
var foldAllBtn = document.getElementById('artFoldAll');
if (foldAllBtn) {
foldAllBtn.addEventListener('click', function () {
var gs = groups();
var anyOpen = gs.some(function (g) { return !g.classList.contains('is-fold'); });
for (var i = 0; i < gs.length; i++) { gs[i].classList.toggle('is-fold', anyOpen); }
syncFoldBtn();
});
foldAllBtn.addEventListener('click', function () {
var gs = groups();
var anyOpen = gs.some(function (g) { return !g.classList.contains('is-fold'); });
for (var i = 0; i < gs.length; i++) { gs[i].classList.toggle('is-fold', anyOpen); }
syncFoldBtn();
});
}
// 拖动整个分区组排序(鼠标按住手柄拖动,其他组自适应)
var dragXg = null;
function clearDrop() {
groups().forEach(function (g) { g.classList.remove('drop-target', 'drag-src'); });
groups().forEach(function (g) { g.classList.remove('drop-target', 'drag-src'); });
}
gWrap.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !gWrap.contains(h)) return;
var xg = h.closest('.xg');
if (!xg) return;
dragXg = xg;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) {}
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !gWrap.contains(h)) return;
var xg = h.closest('.xg');
if (!xg) return;
dragXg = xg;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) { }
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
});
gWrap.addEventListener('dragover', function (ev) {
if (!dragXg) return;
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (!xg || xg === dragXg) {
clearDrop();
return;
}
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
if (!dragXg) return;
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (!xg || xg === dragXg) {
clearDrop();
xg.classList.add('drop-target');
return;
}
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearDrop();
xg.classList.add('drop-target');
});
gWrap.addEventListener('drop', function (ev) {
if (!dragXg) return;
ev.preventDefault();
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (xg && xg !== dragXg) {
var rect = xg.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
gWrap.insertBefore(dragXg, after ? xg.nextSibling : xg);
savePartOrder();
}
if (!dragXg) return;
ev.preventDefault();
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
if (xg && xg !== dragXg) {
var rect = xg.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
gWrap.insertBefore(dragXg, after ? xg.nextSibling : xg);
savePartOrder();
}
});
gWrap.addEventListener('dragend', function () {
clearDrop();
dragXg = null;
clearDrop();
dragXg = null;
});
function savePartOrder() {
var order = groups().map(function (g) { return g.getAttribute('data-part') || ''; })
.filter(function (v) { return v !== ''; }).join(',');
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) { alert('找不到安全令牌,请刷新页面重试。'); return; }
var fd = new FormData();
fd.append('act', 'part_order');
fd.append('csrf', csrfEl.value);
fd.append('order', order);
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (j && j.ok === true) tip('分类顺序已保存,前台文章区已同步更新。');
else alert('分类顺序保存失败:' + ((j && j.msg) || '未知错误'));
})
.catch(function () { alert('网络错误,分类顺序未保存。'); });
var order = groups().map(function (g) { return g.getAttribute('data-part') || ''; })
.filter(function (v) { return v !== ''; }).join(',');
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) { alert('找不到安全令牌,请刷新页面重试。'); return; }
var fd = new FormData();
fd.append('act', 'part_order');
fd.append('csrf', csrfEl.value);
fd.append('order', order);
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (j && j.ok === true) tip('分类顺序已保存,前台文章区已同步更新。');
else alert('分类顺序保存失败:' + ((j && j.msg) || '未知错误'));
})
.catch(function () { alert('网络错误,分类顺序未保存。'); });
}
})();
})();
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,26 +11,26 @@ $loggedOut = isset($_GET['out']);
$curIp = client_ip();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$username = trim((string)($_POST['username'] ?? ''));
$password = (string)($_POST['password'] ?? '');
if ($username === '' || $password === '') {
$err = '请输入用户名和密码。';
} elseif (hp_login($username, $password)) {
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
risk_clear_for_ip($curIp);
header('Location: nav.php');
exit;
$username = trim((string) ($_POST['username'] ?? ''));
$password = (string) ($_POST['password'] ?? '');
if ($username === '' || $password === '') {
$err = '请输入用户名和密码。';
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
risk_clear_for_ip($curIp);
header('Location: nav.php');
exit;
} else {
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
usleep(500000);
$risk = risk_login_fail($curIp);
if ($risk[1]) {
$err = '用户名或密码错误。';
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「风控管理 → IP 黑名单」移除该规则解除,或联系站长处理。';
} else {
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
usleep(500000);
$risk = risk_login_fail($curIp);
if ($risk[1]) {
$err = '用户名或密码错误。';
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「访问信息 → IP 黑名单」移除该规则解除,或联系站长处理。';
} else {
$err = '用户名或密码错误。';
}
$err = '用户名或密码错误。';
}
}
}
$site = setting_get('site_name', '知识导航站');
@ -52,6 +52,7 @@ layout_head('后台登录');
<input type="text" id="username" name="username" required autofocus>
<label class="fl" for="password">密码</label>
<input type="password" id="password" name="password" required>
<input type="hidden" name="dev" id="devField" value="">
<div style="margin-top:16px">
<button type="submit" class="btn btn-primary" style="width:100%">登录</button>
</div>
@ -61,4 +62,5 @@ layout_head('后台登录');
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
</body>
</html>

View File

@ -1,6 +1,6 @@
<?php
/**
* admin/nav.php —— 导航内容管理(块/链接增删改查 + 顺序)
* admin/nav.php —— 导航管理(块/链接增删改查 + 顺序)
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
*/
$P = '../';
@ -8,7 +8,7 @@ require_once __DIR__ . '/_guard.php';
$pdo = db();
$catKeys = ['popular', 'red', 'blue'];
$cat = isset($_GET['c']) ? (string)$_GET['c'] : 'popular';
$cat = isset($_GET['c']) ? (string) $_GET['c'] : 'popular';
if (!in_array($cat, $catKeys, true)) {
$cat = 'popular';
}
@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
if (!csrf_verify()) {
$errorMsg = '安全校验失败,请刷新页面重试。';
} else {
$raw = (string)($_POST['data'] ?? '');
$raw = (string) ($_POST['data'] ?? '');
$data = json_decode($raw, true);
if (!is_array($data)) {
$errorMsg = '提交数据格式错误。';
@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$pdo->beginTransaction();
// 现有块
$st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?');
$st->execute([(int)$catRow['id']]);
$st->execute([(int) $catRow['id']]);
$oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id'));
// 现有链接(按块分组)
$oldItemsByBlock = [];
@ -51,11 +51,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$keepItems = [];
foreach ($data as $b) {
if (is_array($b) && isset($b['title'])) {
$id = (int)($b['id'] ?? 0);
if ($id > 0) $keepBlocks[] = $id;
$id = (int) ($b['id'] ?? 0);
if ($id > 0)
$keepBlocks[] = $id;
foreach (($b['items'] ?? []) as $it) {
if (is_array($it) && (int)($it['id'] ?? 0) > 0) {
$keepItems[] = (int)$it['id'];
if (is_array($it) && (int) ($it['id'] ?? 0) > 0) {
$keepItems[] = (int) $it['id'];
}
}
}
@ -81,25 +82,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
$blockSort = 0;
foreach ($data as $b) {
if (!is_array($b) || !isset($b['title'])) continue;
$title = trim((string)$b['title']);
if ($title === '') continue;
$bid = (int)($b['id'] ?? 0);
if (!is_array($b) || !isset($b['title']))
continue;
$title = trim((string) $b['title']);
if ($title === '')
continue;
$bid = (int) ($b['id'] ?? 0);
$blockBelongs = in_array($bid, $oldBlocks, true);
if ($bid > 0 && $blockBelongs) {
$stUpdBlock->execute([$title, $blockSort, $bid]);
} else {
$stInsBlock->execute([(int)$catRow['id'], $title, $blockSort]);
$bid = (int)$pdo->lastInsertId();
$stInsBlock->execute([(int) $catRow['id'], $title, $blockSort]);
$bid = (int) $pdo->lastInsertId();
}
$itemSort = 0;
foreach (($b['items'] ?? []) as $it) {
if (!is_array($it)) continue;
$label = trim((string)($it['label'] ?? ''));
$url = trim((string)($it['url'] ?? ''));
$note = trim((string)($it['note'] ?? ''));
if ($label === '' && $url === '') continue;
$iid = (int)($it['id'] ?? 0);
if (!is_array($it))
continue;
$label = trim((string) ($it['label'] ?? ''));
$url = trim((string) ($it['url'] ?? ''));
$note = trim((string) ($it['note'] ?? ''));
if ($label === '' && $url === '')
continue;
$iid = (int) ($it['id'] ?? 0);
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
} else {
@ -125,7 +130,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
// ---------- 读取现有数据用于展示 ----------
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
$st->execute([(int)$catRow['id']]);
$st->execute([(int) $catRow['id']]);
$blocks = $st->fetchAll();
$itemsMap = [];
if ($blocks) {
@ -134,7 +139,7 @@ if ($blocks) {
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
$st->execute($ids);
foreach ($st->fetchAll() as $it) {
$itemsMap[(int)$it['block_id']][] = $it;
$itemsMap[(int) $it['block_id']][] = $it;
}
}
@ -146,403 +151,411 @@ foreach ($catKeys as $ck) {
$csrfVal = csrf_token();
layout_head('导航内容管理');
layout_head('导航管理');
admin_topbar('nav');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">导航内容管理</h2>
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接3 小列 × 5 行),超出部分请拆分到新块。</p>
<div class="wrap page-body">
<h2 style="margin-bottom:6px">导航管理</h2>
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接3 小列 × 5 行),超出部分请拆分到新块。</p>
<!-- 分类切换 -->
<div class="field-row" style="margin:12px 0">
<?php foreach ($catKeys as $ck): ?>
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>" href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
<?php endforeach; ?>
</div>
<!-- 分类切换 -->
<div class="field-row" style="margin:12px 0">
<?php foreach ($catKeys as $ck): ?>
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>"
href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
<?php endforeach; ?>
</div>
<?php if ($saved): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($saved): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<div class="fieldset-card" style="margin-bottom:16px">
<div class="fs-title"> 批量导入导航链接(<?= he($catNames[$cat]) ?></div>
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
每行一条,支持几种写法:
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> <code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code><code>块名 3</code> </p>
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
<div class="fieldset-card" style="margin-bottom:16px">
<div class="fs-title">批量导入</div>
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
每行一条,支持几种写法:
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code>
<code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code><code>块名 3</code>
</p>
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
## 安全研究
看雪 | https://www.kanxue.com | 逆向/安全社区
先知社区 https://xz.aliyun.com
[FreeBuf](https://www.freebuf.com)
## 资讯
https://www.ithome.com | IT之家"></textarea>
<div class="field-row" style="margin-top:8px">
<button type="button" class="btn btn-primary" id="btnParseImport">解析并导入</button>
<button type="button" class="btn" id="btnClearImport">清空输入框</button>
<span class="tip" style="align-self:center">导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。</span>
</div>
<div id="importMsg"></div>
<div class="field-row" style="margin-top:8px">
<button type="button" class="btn btn-primary" id="btnParseImport">解析并导入</button>
<button type="button" class="btn" id="btnClearImport">清空输入框</button>
<span class="tip" style="align-self:center">导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。</span>
</div>
<div id="importMsg"></div>
</div>
<form method="post" id="navForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="cat" value="<?= he($cat) ?>">
<textarea name="data" id="jsonData" hidden></textarea>
<div id="blocksWrap">
<?php foreach ($blocks as $b):
$items = $itemsMap[(int) $b['id']] ?? []; ?>
<div class="m-block is-fold" data-id="<?= (int) $b['id'] ?>">
<div class="m-block-head">
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true"></button>
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true"></button>
<input type="text" class="b-title" value="<?= he((string) $b['title']) ?>"
placeholder="导航块标题(必填)">
<span class="ops">
<button type="button" class="btn btn-sm b-up" title="上移"></button>
<button type="button" class="btn btn-sm b-down" title="下移"></button>
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
</span>
</div>
<div class="m-items">
<?php foreach (array_slice($items, 0, 15) as $it): ?>
<div class="m-item">
<input type="text" class="i-label" value="<?= he((string) $it['label']) ?>"
placeholder="文字(必填)">
<input type="text" class="i-url" value="<?= he((string) $it['url']) ?>"
placeholder="链接,如 https://…(必填)">
<input type="text" class="i-note" value="<?= he((string) $it['note']) ?>"
placeholder="备注(可选,前台悬浮提示)">
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接"></button>
</div>
<?php endforeach; ?>
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:8px 0 16px">
<button type="button" class="btn btn-sm" id="btnAddBlock">+ 新建导航块</button>
<button type="button" class="btn btn-sm" id="btnNavFoldAll" title="折叠 / 展开所有块">展开全部</button>
</div>
<div>
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
<span class="tip" id="saveTip"></span>
</div>
</form>
</div>
<form method="post" id="navForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="cat" value="<?= he($cat) ?>">
<textarea name="data" id="jsonData" hidden></textarea>
<div id="blocksWrap">
<?php foreach ($blocks as $b):
$items = $itemsMap[(int)$b['id']] ?? []; ?>
<div class="m-block is-fold" data-id="<?= (int)$b['id'] ?>">
<div class="m-block-head">
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true"></button>
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true"></button>
<input type="text" class="b-title" value="<?= he((string)$b['title']) ?>" placeholder="导航块标题(必填)">
<span class="ops">
<button type="button" class="btn btn-sm b-up" title="上移"></button>
<button type="button" class="btn btn-sm b-down" title="下移"></button>
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
</span>
</div>
<div class="m-items">
<?php foreach (array_slice($items, 0, 15) as $it): ?>
<div class="m-item">
<input type="text" class="i-label" value="<?= he((string)$it['label']) ?>" placeholder="文字(必填)">
<input type="text" class="i-url" value="<?= he((string)$it['url']) ?>" placeholder="链接,如 https://…(必填)">
<input type="text" class="i-note" value="<?= he((string)$it['note']) ?>" placeholder="备注(可选,前台悬浮提示)">
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接"></button>
</div>
<?php endforeach; ?>
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
</div>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:8px 0 16px">
<button type="button" class="btn btn-sm" id="btnAddBlock">+ 新建导航块</button>
<button type="button" class="btn btn-sm" id="btnNavFoldAll" title="折叠 / 展开所有块">展开全部</button>
</div>
<div>
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
<span class="tip" id="saveTip"></span>
</div>
</form>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var wrap = document.getElementById('blocksWrap');
var jsonData = document.getElementById('jsonData');
(function () {
'use strict';
var wrap = document.getElementById('blocksWrap');
var jsonData = document.getElementById('jsonData');
/* ---------- 模板 ---------- */
function itemHTML(label, url, note) {
return '<div class="m-item">' +
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
'</div>';
}
function blockHTML(id, title, items) {
var inner = items.map(function (it) {
return itemHTML(it.label || '', it.url || '', it.note || '');
}).join('');
return '<div class="m-block is-fold" data-id="' + (id || 0) + '">' +
'<div class="m-block-head">' +
'<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>' +
'<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>' +
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
'</span></div>' +
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
'</div>';
}
function escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* ---------- 行内事件代理 ---------- */
wrap.addEventListener('click', function (ev) {
var target = ev.target;
var block = target.closest('.m-block');
if (!block) return;
if (target.classList.contains('c-arr')) {
block.classList.toggle('is-fold');
updateNavFold();
return;
/* ---------- 模板 ---------- */
function itemHTML(label, url, note) {
return '<div class="m-item">' +
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
'</div>';
}
function blockHTML(id, title, items) {
var inner = items.map(function (it) {
return itemHTML(it.label || '', it.url || '', it.note || '');
}).join('');
return '<div class="m-block is-fold" data-id="' + (id || 0) + '">' +
'<div class="m-block-head">' +
'<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>' +
'<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>' +
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
'</span></div>' +
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
'</div>';
}
function escAttr(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
if (target.classList.contains('b-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该导航块及其全部链接?', { okText: '删除块' }) : Promise.resolve(window.confirm('确认删除该导航块及其全部链接?')))
.then(function (ok) { if (ok) block.remove(); });
return;
}
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
if (sib && sib.classList.contains('m-block')) {
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
else wrap.insertBefore(sib, block);
/* ---------- 行内事件代理 ---------- */
wrap.addEventListener('click', function (ev) {
var target = ev.target;
var block = target.closest('.m-block');
if (!block) return;
if (target.classList.contains('c-arr')) {
block.classList.toggle('is-fold');
updateNavFold();
return;
}
return;
}
if (target.classList.contains('b-add-item')) {
var list = block.querySelector('.m-items');
var count = list.querySelectorAll('.m-item').length;
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
return;
}
if (target.classList.contains('i-del')) {
target.closest('.m-item').remove();
}
});
function updateNavFold() {
var btn = document.getElementById('btnNavFoldAll');
if (!btn) return;
var bls = wrap.querySelectorAll('.m-block');
var anyOpen = false;
for (var i = 0; i < bls.length; i++) {
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
btn.textContent = anyOpen ? '收起全部' : '展开全部';
}
var foldAllBtn = document.getElementById('btnNavFoldAll');
if (foldAllBtn) {
foldAllBtn.addEventListener('click', function () {
if (target.classList.contains('b-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该导航块及其全部链接?', { okText: '删除块' }) : Promise.resolve(window.confirm('确认删除该导航块及其全部链接?')))
.then(function (ok) { if (ok) block.remove(); });
return;
}
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
if (sib && sib.classList.contains('m-block')) {
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
else wrap.insertBefore(sib, block);
}
return;
}
if (target.classList.contains('b-add-item')) {
var list = block.querySelector('.m-items');
var count = list.querySelectorAll('.m-item').length;
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
return;
}
if (target.classList.contains('i-del')) {
target.closest('.m-item').remove();
}
});
function updateNavFold() {
var btn = document.getElementById('btnNavFoldAll');
if (!btn) return;
var bls = wrap.querySelectorAll('.m-block');
var anyOpen = false;
for (var i = 0; i < bls.length; i++) {
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
for (var j = 0; j < bls.length; j++) { bls[j].classList.toggle('is-fold', anyOpen); }
updateNavFold();
});
}
// 整块拖动排序(拖动结果随「保存全部修改」一并持久化)
var dragBlock = null;
function clearBlockDrop() {
var bls = wrap.querySelectorAll('.m-block');
for (var i = 0; i < bls.length; i++) { bls[i].classList.remove('drag-src', 'drop-target'); }
}
wrap.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !wrap.contains(h)) return;
var bl = h.closest('.m-block');
if (!bl) return;
dragBlock = bl;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) {}
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
});
wrap.addEventListener('dragover', function (ev) {
if (!dragBlock) return;
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (!bl || bl === dragBlock) { clearBlockDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearBlockDrop();
bl.classList.add('drop-target');
});
wrap.addEventListener('drop', function (ev) {
if (!dragBlock) return;
ev.preventDefault();
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (bl && bl !== dragBlock) {
var rect = bl.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
wrap.insertBefore(dragBlock, after ? bl.nextSibling : bl);
btn.textContent = anyOpen ? '收起全部' : '展开全部';
}
});
wrap.addEventListener('dragend', function () {
clearBlockDrop();
dragBlock = null;
});
document.getElementById('btnAddBlock').addEventListener('click', function () {
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
var last = wrap.lastElementChild;
if (last && last.classList.contains('m-block')) { last.classList.remove('is-fold'); }
updateNavFold();
});
/* ---------- 批量导入解析 ---------- */
var MAX_ITEMS = 15;
var importTextEl = document.getElementById('importText');
var importMsg = document.getElementById('importMsg');
// 单行 -> item无法识别返回 null
function parseImportLine(raw) {
var s = String(raw).trim();
if (s === '') return null;
// 1) | 分隔:名称 | 网址 [ | 备注](也兼容 网址 | 名称)
if (s.indexOf('|') !== -1) {
var p = s.split('|').map(function (x) { return x.trim(); });
var isUrl = function (x) { return /^https?:\/\//i.test(x); };
var label1 = p[0], url1 = p[1], note1 = p[2] || '';
if (isUrl(p[0]) && p[1] !== '' && !isUrl(p[1])) {
// 网址在前、名称在后
label1 = p[1]; url1 = p[0]; note1 = p[2] || '';
}
if (url1 !== '') return { label: label1, url: url1, note: note1 };
}
// 2) Markdown[名称](网址 ["备注"])
var m = s.match(/^\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+["“]([^"]*)["”])?\s*\)$/);
if (m) return { label: m[1].trim(), url: m[2].trim(), note: (m[3] || '').trim() };
// 3) 名称 https://… 或 名称 https://… 备注
var m2 = s.match(/^(.*?)(https?:\/\/\S+)(?:\s+(.*))?$/i);
if (m2 && m2[2]) {
return { label: (m2[1] || '').trim(), url: m2[2].replace(/[),,。]+$/, ''), note: (m2[3] || '').trim() };
}
return null;
}
// host 作为缺省名称
function hostOf(url) {
try {
var u = new URL(url);
return u.hostname.replace(/^www\./, '');
} catch (e) {
return url;
}
}
// 全文 -> [{title, items}]
function importTextToBlocks(text) {
var lines = String(text).split(/\r?\n/);
var blocks = [];
var cur = null; // 当前块
var used = {}; // 已使用的标题(同名自动加序号)
var parsed = 0, skipped = 0;
function makeTitle(base) {
var t = base;
var n = 1;
while (used[t]) {
n++;
t = base + ' ' + n;
}
used[t] = true;
return t;
}
function ensureBlock() {
if (cur && cur.items.length < MAX_ITEMS) return;
var title = makeTitle(cur ? cur.title : '批量导入');
cur = { title: title, items: [] };
blocks.push(cur);
}
lines.forEach(function (ln) {
var s = String(ln).trim();
if (s === '') return;
// 块标题行
var hm = s.match(/^#{1,6}\s+(.+)$/);
if (hm) {
var t = hm[1].trim();
if (t !== '') {
var t2 = makeTitle(t);
cur = { title: t2, items: [] };
blocks.push(cur);
var foldAllBtn = document.getElementById('btnNavFoldAll');
if (foldAllBtn) {
foldAllBtn.addEventListener('click', function () {
var bls = wrap.querySelectorAll('.m-block');
var anyOpen = false;
for (var i = 0; i < bls.length; i++) {
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
}
return;
}
var it = parseImportLine(s);
if (!it) { skipped++; return; }
if (it.url === '') { skipped++; return; }
if (it.label === '') it.label = hostOf(it.url);
ensureBlock();
cur.items.push(it);
parsed++;
});
return { blocks: blocks, parsed: parsed, skipped: skipped };
}
function bindImport() {
var btn = document.getElementById('btnParseImport');
var clearBtn = document.getElementById('btnClearImport');
if (!btn || !importTextEl || !importMsg) return;
btn.addEventListener('click', function () {
var r = importTextToBlocks(importTextEl.value);
if (!r.parsed) {
importMsg.innerHTML = '<div class="err-msg">未解析到任何链接。请检查格式,每行至少包含“名称 网址”或“名称 | 网址”。</div>';
return;
}
var frag = document.createDocumentFragment();
r.blocks.forEach(function (b) {
var div = createElFromHTML(blockHTML(0, b.title, b.items));
if (div.classList) { div.classList.remove('is-fold'); }
frag.appendChild(div);
for (var j = 0; j < bls.length; j++) { bls[j].classList.toggle('is-fold', anyOpen); }
updateNavFold();
});
wrap.appendChild(frag);
}
// 整块拖动排序(拖动结果随「保存全部修改」一并持久化)
var dragBlock = null;
function clearBlockDrop() {
var bls = wrap.querySelectorAll('.m-block');
for (var i = 0; i < bls.length; i++) { bls[i].classList.remove('drag-src', 'drop-target'); }
}
wrap.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !wrap.contains(h)) return;
var bl = h.closest('.m-block');
if (!bl) return;
dragBlock = bl;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) { }
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
});
wrap.addEventListener('dragover', function (ev) {
if (!dragBlock) return;
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (!bl || bl === dragBlock) { clearBlockDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearBlockDrop();
bl.classList.add('drop-target');
});
wrap.addEventListener('drop', function (ev) {
if (!dragBlock) return;
ev.preventDefault();
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
if (bl && bl !== dragBlock) {
var rect = bl.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
wrap.insertBefore(dragBlock, after ? bl.nextSibling : bl);
}
});
wrap.addEventListener('dragend', function () {
clearBlockDrop();
dragBlock = null;
});
document.getElementById('btnAddBlock').addEventListener('click', function () {
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
var last = wrap.lastElementChild;
if (last && last.classList.contains('m-block')) { last.classList.remove('is-fold'); }
updateNavFold();
var parts = [];
parts.push('<div class="ok-msg">已解析 ' + r.parsed + ' 条链接,生成 ' + r.blocks.length + ' 个导航块并追加到下方。</div>');
if (r.skipped) parts.push('<p class="tip" style="margin-top:6px">另有 ' + r.skipped + ' 行未能识别,已跳过。</p>');
parts.push('<p class="tip">请在下方向下核对;如需调整可修改标题/链接后,统一点击「保存全部修改」生效。</p>');
importMsg.innerHTML = parts.join('');
importTextEl.value = '';
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
clearBtn.addEventListener('click', function () {
importTextEl.value = '';
importMsg.innerHTML = '';
importTextEl.focus();
});
}
bindImport();
function createElFromHTML(html) {
var t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstChild;
}
/* ---------- 批量导入解析 ---------- */
var MAX_ITEMS = 15;
var importTextEl = document.getElementById('importText');
var importMsg = document.getElementById('importMsg');
/* ---------- 收集并保存 ---------- */
document.getElementById('btnSave').addEventListener('click', function () {
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
var data = [];
var hasInvalid = false;
// 单行 -> item无法识别返回 null
function parseImportLine(raw) {
var s = String(raw).trim();
if (s === '') return null;
// 1) | 分隔:名称 | 网址 [ | 备注](也兼容 网址 | 名称)
if (s.indexOf('|') !== -1) {
var p = s.split('|').map(function (x) { return x.trim(); });
var isUrl = function (x) { return /^https?:\/\//i.test(x); };
var label1 = p[0], url1 = p[1], note1 = p[2] || '';
if (isUrl(p[0]) && p[1] !== '' && !isUrl(p[1])) {
// 网址在前、名称在后
label1 = p[1]; url1 = p[0]; note1 = p[2] || '';
}
if (url1 !== '') return { label: label1, url: url1, note: note1 };
}
// 2) Markdown[名称](网址 ["备注"])
var m = s.match(/^\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+["“]([^"]*)["”])?\s*\)$/);
if (m) return { label: m[1].trim(), url: m[2].trim(), note: (m[3] || '').trim() };
// 3) 名称 https://… 或 名称 https://… 备注
var m2 = s.match(/^(.*?)(https?:\/\/\S+)(?:\s+(.*))?$/i);
if (m2 && m2[2]) {
return { label: (m2[1] || '').trim(), url: m2[2].replace(/[),,。]+$/, ''), note: (m2[3] || '').trim() };
}
return null;
}
blocks.forEach(function (block) {
if (hasInvalid) return;
var title = block.querySelector('.b-title').value.trim();
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
var items = [];
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
var label = row.querySelector('.i-label').value.trim();
var url = row.querySelector('.i-url').value.trim();
var note = row.querySelector('.i-note').value.trim();
if (label === '' && url === '') return; // 空行忽略
if (label === '' || url === '') {
alert('链接的“文字”与“链接”不能为空。');
hasInvalid = true;
// host 作为缺省名称
function hostOf(url) {
try {
var u = new URL(url);
return u.hostname.replace(/^www\./, '');
} catch (e) {
return url;
}
}
// 全文 -> [{title, items}]
function importTextToBlocks(text) {
var lines = String(text).split(/\r?\n/);
var blocks = [];
var cur = null; // 当前块
var used = {}; // 已使用的标题(同名自动加序号)
var parsed = 0, skipped = 0;
function makeTitle(base) {
var t = base;
var n = 1;
while (used[t]) {
n++;
t = base + ' ' + n;
}
used[t] = true;
return t;
}
function ensureBlock() {
if (cur && cur.items.length < MAX_ITEMS) return;
var title = makeTitle(cur ? cur.title : '批量导入');
cur = { title: title, items: [] };
blocks.push(cur);
}
lines.forEach(function (ln) {
var s = String(ln).trim();
if (s === '') return;
// 块标题行
var hm = s.match(/^#{1,6}\s+(.+)$/);
if (hm) {
var t = hm[1].trim();
if (t !== '') {
var t2 = makeTitle(t);
cur = { title: t2, items: [] };
blocks.push(cur);
}
return;
}
items.push({ id: 0, label: label, url: url, note: note });
var it = parseImportLine(s);
if (!it) { skipped++; return; }
if (it.url === '') { skipped++; return; }
if (it.label === '') it.label = hostOf(it.url);
ensureBlock();
cur.items.push(it);
parsed++;
});
return { blocks: blocks, parsed: parsed, skipped: skipped };
}
function bindImport() {
var btn = document.getElementById('btnParseImport');
var clearBtn = document.getElementById('btnClearImport');
if (!btn || !importTextEl || !importMsg) return;
btn.addEventListener('click', function () {
var r = importTextToBlocks(importTextEl.value);
if (!r.parsed) {
importMsg.innerHTML = '<div class="err-msg">未解析到任何链接。请检查格式,每行至少包含“名称 网址”或“名称 | 网址”。</div>';
return;
}
var frag = document.createDocumentFragment();
r.blocks.forEach(function (b) {
var div = createElFromHTML(blockHTML(0, b.title, b.items));
if (div.classList) { div.classList.remove('is-fold'); }
frag.appendChild(div);
});
wrap.appendChild(frag);
updateNavFold();
var parts = [];
parts.push('<div class="ok-msg">已解析 ' + r.parsed + ' 条链接,生成 ' + r.blocks.length + ' 个导航块并追加到下方。</div>');
if (r.skipped) parts.push('<p class="tip" style="margin-top:6px">另有 ' + r.skipped + ' 行未能识别,已跳过。</p>');
parts.push('<p class="tip">请在下方向下核对;如需调整可修改标题/链接后,统一点击「保存全部修改」生效。</p>');
importMsg.innerHTML = parts.join('');
importTextEl.value = '';
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
clearBtn.addEventListener('click', function () {
importTextEl.value = '';
importMsg.innerHTML = '';
importTextEl.focus();
});
}
bindImport();
function createElFromHTML(html) {
var t = document.createElement('template');
t.innerHTML = html.trim();
return t.content.firstChild;
}
/* ---------- 收集并保存 ---------- */
document.getElementById('btnSave').addEventListener('click', function () {
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
var data = [];
var hasInvalid = false;
blocks.forEach(function (block) {
if (hasInvalid) return;
var title = block.querySelector('.b-title').value.trim();
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
var items = [];
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
var label = row.querySelector('.i-label').value.trim();
var url = row.querySelector('.i-url').value.trim();
var note = row.querySelector('.i-note').value.trim();
if (label === '' && url === '') return; // 空行忽略
if (label === '' || url === '') {
alert('链接的“文字”与“链接”不能为空。');
hasInvalid = true;
return;
}
items.push({ id: 0, label: label, url: url, note: note });
});
if (hasInvalid) return;
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
});
if (hasInvalid) return;
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
jsonData.value = JSON.stringify(data);
document.getElementById('navForm').submit();
});
if (hasInvalid) return;
jsonData.value = JSON.stringify(data);
document.getElementById('navForm').submit();
});
})();
})();
</script>
</body>
</html>

View File

@ -1,6 +1,6 @@
<?php
/**
* admin/tools.php —— 功能区管理
* admin/tools.php —— 内部功能
* 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。
* 站内工具页需先在 func/ 下开发对应 .php再到此处登记入口。
*/
@ -16,7 +16,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
if (!csrf_verify()) {
$errorMsg = '安全校验失败,请刷新页面重试。';
} else {
$raw = (string)($_POST['data'] ?? '');
$raw = (string) ($_POST['data'] ?? '');
$rows = json_decode($raw, true);
if (!is_array($rows)) {
$errorMsg = '提交数据格式错误。';
@ -24,18 +24,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
// 先整体校验
$clean = [];
foreach ($rows as $i => $r) {
if (!is_array($r)) continue;
$name = trim((string)($r['name'] ?? ''));
$url = trim((string)($r['url'] ?? ''));
$icon = trim((string)($r['icon'] ?? ''));
$desc = trim((string)($r['description'] ?? ''));
$ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0;
if ($name === '' && $url === '') continue; // 空行忽略
if (!is_array($r))
continue;
$name = trim((string) ($r['name'] ?? ''));
$url = trim((string) ($r['url'] ?? ''));
$icon = trim((string) ($r['icon'] ?? ''));
$desc = trim((string) ($r['description'] ?? ''));
$ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0;
if ($name === '' && $url === '')
continue; // 空行忽略
// 库首页“内部功能区”展示范围(空=所有库通用)
$cats = [];
if (isset($r['cats']) && is_array($r['cats'])) {
foreach ($r['cats'] as $ck) {
$s = trim((string)$ck);
$s = trim((string) $ck);
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
$cats[] = $s;
}
@ -43,32 +45,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
}
$boardCats = implode(',', $cats);
if ($name === '' || $url === '') {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。';
break;
}
if ($ext === 1) {
if (!preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部链接需以 http(s):// 开头。';
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部链接需以 http(s):// 开头。';
break;
}
} else {
if (preg_match('#^https?://#i', $url)) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php如需外链请选择“外部链接”。';
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php如需外链请选择“外部链接”。';
break;
}
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || strpos($url, '//') === 0) {
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php不允许填写协议或跨协议地址。';
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php不允许填写协议或跨协议地址。';
break;
}
}
$clean[] = [
'id' => (int)($r['id'] ?? 0),
'id' => (int) ($r['id'] ?? 0),
'icon' => ($icon !== '' ? $icon : '🧩'),
'name' => $name,
'description' => $desc,
'url' => $url,
'ext' => $ext,
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
'board_cats' => $boardCats,
];
}
@ -79,34 +81,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
try {
$pdo->beginTransaction();
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id'));
$keep = [];
foreach ($clean as $c) {
if ($c['id'] > 0) $keep[] = $c['id'];
}
$del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) {
$pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]);
}
$stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?');
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)');
$sort = 0;
foreach ($clean as $c) {
$sort++;
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
$stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]);
} else {
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]);
$keep = [];
foreach ($clean as $c) {
if ($c['id'] > 0)
$keep[] = $c['id'];
}
}
$pdo->commit();
touch_last_updated();
header('Location: tools.php?ok=1');
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$errorMsg = '保存失败:' . $e->getMessage();
$del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) {
$pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]);
}
$stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?');
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)');
$sort = 0;
foreach ($clean as $c) {
$sort++;
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
$stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]);
} else {
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]);
}
}
$pdo->commit();
touch_last_updated();
header('Location: tools.php?ok=1');
exit;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
$errorMsg = '保存失败:' . $e->getMessage();
}
}
}
@ -120,8 +123,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
if (!csrf_verify()) {
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
} else {
$tid = (int)($_POST['id'] ?? 0);
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
$tid = (int) ($_POST['id'] ?? 0);
$ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
if ($tid > 0) {
$pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
touch_last_updated();
@ -136,254 +139,261 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
// ---------- 展示现有工具 ----------
$tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll();
layout_head('功能区管理');
layout_head('内部功能');
admin_topbar('tools');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">功能区管理</h2>
<p class="tip">
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
也可登记外部网址作为外链卡片(新窗口打开)。
</p>
<div class="wrap page-body">
<h2 style="margin-bottom:6px">内部功能</h2>
<p class="tip">
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
也可登记外部网址作为外链卡片(新窗口打开)。
</p>
<?php if ($saved): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,功能区首页已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($saved): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,功能区首页已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<?php if ($errorMsg !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<div class="fieldset-card">
<form method="post" id="toolsForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<textarea name="data" id="toolsData" hidden></textarea>
<div class="fieldset-card">
<div class="fs-title">工具卡片管理</div>
<form method="post" id="toolsForm" autocomplete="off">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save">
<textarea name="data" id="toolsData" hidden></textarea>
<div id="toolsRows">
<?php foreach ($tools as $t): ?>
<div class="t-row" data-id="<?= (int)$t['id'] ?>">
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序"></button>
<span class="icon-ctl">
<span class="icon-preview"></span>
<input type="text" class="icon-input t-icon" value="<?= he((string)$t['icon']) ?>" placeholder="图标">
<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>
<input type="file" class="icon-file" accept="image/*" hidden>
</span>
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="名称(必填)">
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话说明">
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="站内如 codec.php / 外链 https://…">
<select class="t-ext">
<option value="0"<?= (int)$t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
<option value="1"<?= (int)$t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
</select>
<?php $bcT = array_map('trim', explode(',', trim((string)($t['board_cats'] ?? '')))); ?>
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
<span class="t-cats-t">库首页</span>
<label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
</span>
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="ops">
<button type="button" class="btn btn-sm t-up" title="上移"></button>
<button type="button" class="btn btn-sm t-down" title="下移"></button>
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具"></button>
</span>
</div>
<?php endforeach; ?>
<div id="toolsRows">
<?php foreach ($tools as $t): ?>
<div class="t-row" data-id="<?= (int) $t['id'] ?>">
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序"></button>
<span class="icon-ctl">
<span class="icon-preview"></span>
<input type="text" class="icon-input t-icon" value="<?= he((string) $t['icon']) ?>"
placeholder="图标">
<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>
<input type="file" class="icon-file" accept="image/*" hidden>
</span>
<input type="text" class="t-name" value="<?= he((string) $t['name']) ?>" placeholder="名称(必填)">
<input type="text" class="t-desc" value="<?= he((string) $t['description']) ?>"
placeholder="一句话说明">
<input type="text" class="t-url" value="<?= he((string) $t['url']) ?>"
placeholder="站内如 codec.php / 外链 https://…">
<select class="t-ext">
<option value="0" <?= (int) $t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
<option value="1" <?= (int) $t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
</select>
<?php $bcT = array_map('trim', explode(',', trim((string) ($t['board_cats'] ?? '')))); ?>
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
<span class="t-cats-t">库首页</span>
<label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
</span>
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"
<?= (int) $t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span
class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="ops">
<button type="button" class="btn btn-sm t-up" title="上移"></button>
<button type="button" class="btn btn-sm t-down" title="下移"></button>
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具"></button>
</span>
</div>
<?php endforeach; ?>
</div>
<div class="field-row" style="margin:12px 0">
<button type="button" class="btn btn-sm" id="toolsAdd">+ 添加工具</button>
<button type="submit" class="btn btn-primary">保存功能区</button>
<span class="tip">工具数量与展示顺序实时反映到前台功能区首页</span>
</div>
</form>
</div>
<div class="field-row" style="margin:12px 0">
<button type="button" class="btn btn-sm" id="toolsAdd">+ 添加工具</button>
<button type="submit" class="btn btn-primary">保存功能区</button>
<span class="tip">工具数量与展示顺序实时反映到前台功能区首页</span>
</div>
</form>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
'use strict';
var rows = document.getElementById('toolsRows');
(function () {
'use strict';
var rows = document.getElementById('toolsRows');
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function rowHTML() {
return '<div class="t-row" data-id="0">' +
'<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>' +
'<span class="icon-ctl">' +
'<span class="icon-preview"></span>' +
'<input type="text" class="icon-input t-icon" value="" placeholder="图标">' +
'<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>' +
'<input type="file" class="icon-file" accept="image/*" hidden>' +
'</span>' +
'<input type="text" class="t-name" value="" placeholder="名称(必填)">' +
'<input type="text" class="t-desc" value="" placeholder="一句话说明">' +
'<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' +
'<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' +
'<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' +
'<span class="t-cats-t">库首页</span>' +
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
'</span>' +
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
'</span></div>';
}
function toggleEnabled(input) {
var row = input.closest('.t-row');
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
if (!id) {
input.checked = !input.checked;
alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。');
return;
function esc(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
var fd = new FormData();
fd.append('action', 'toggle');
fd.append('csrf', csrfEl.value);
fd.append('id', String(id));
fd.append('enabled', input.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
input.checked = !input.checked;
alert('启停失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
function rowHTML() {
return '<div class="t-row" data-id="0">' +
'<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>' +
'<span class="icon-ctl">' +
'<span class="icon-preview"></span>' +
'<input type="text" class="icon-input t-icon" value="" placeholder="图标">' +
'<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>' +
'<input type="file" class="icon-file" accept="image/*" hidden>' +
'</span>' +
'<input type="text" class="t-name" value="" placeholder="名称(必填)">' +
'<input type="text" class="t-desc" value="" placeholder="一句话说明">' +
'<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' +
'<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' +
'<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' +
'<span class="t-cats-t">库首页</span>' +
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
'</span>' +
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
'<span class="ops">' +
'<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' +
'<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' +
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
'</span></div>';
}
function toggleEnabled(input) {
var row = input.closest('.t-row');
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
var csrfEl = document.querySelector('input[name="csrf"]');
if (!csrfEl) return;
if (!id) {
input.checked = !input.checked;
alert('网络错误,启停未保存。');
});
}
function placeholderHint(select, urlInput) {
urlInput.placeholder = select.value === '1'
? '外部完整网址,如 https://example.com'
: '站内工具页,如 codec.php';
}
rows.addEventListener('click', function (ev) {
var row = ev.target.closest('.t-row');
if (!row) return;
if (ev.target.classList.contains('t-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该工具入口?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该工具入口?')))
.then(function (ok) { if (ok) row.remove(); });
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
if (sib && sib.classList.contains('t-row')) {
if (ev.target.classList.contains('t-up')) rows.insertBefore(row, sib);
else rows.insertBefore(sib, row);
alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。');
return;
}
var fd = new FormData();
fd.append('action', 'toggle');
fd.append('csrf', csrfEl.value);
fd.append('id', String(id));
fd.append('enabled', input.checked ? '1' : '0');
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
.then(function (j) {
if (!j || j.ok !== true) {
input.checked = !input.checked;
alert('启停失败:' + ((j && j.msg) || '未知错误'));
}
})
.catch(function () {
input.checked = !input.checked;
alert('网络错误,启停未保存。');
});
}
});
rows.addEventListener('change', function (ev) {
if (ev.target.classList.contains('t-ext')) {
placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url'));
} else if (ev.target.classList.contains('t-enabled')) {
toggleEnabled(ev.target);
function placeholderHint(select, urlInput) {
urlInput.placeholder = select.value === '1'
? '外部完整网址,如 https://example.com'
: '站内工具页,如 codec.php';
}
});
rows.querySelectorAll('.t-ext').forEach(function (sel) {
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
});
// 行内拖动排序(拖动结果随「保存功能区」一并持久化)
var dragRow = null;
function clearRowDrop() {
var rs = rows.querySelectorAll('.t-row');
for (var i = 0; i < rs.length; i++) rs[i].classList.remove('drag-src', 'drop-target');
}
rows.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !rows.contains(h)) return;
var row = h.closest('.t-row');
if (!row) return;
dragRow = row;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) {}
setTimeout(function () { row.classList.add('drag-src'); }, 0);
});
rows.addEventListener('dragover', function (ev) {
if (!dragRow) return;
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (!row || row === dragRow) { clearRowDrop(); return; }
ev.preventDefault();
ev.dataTransfer.dropEffect = 'move';
clearRowDrop();
row.classList.add('drop-target');
});
rows.addEventListener('drop', function (ev) {
if (!dragRow) return;
ev.preventDefault();
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (row && row !== dragRow) {
var rect = row.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
rows.insertBefore(dragRow, after ? row.nextSibling : row);
}
});
rows.addEventListener('dragend', function () {
clearRowDrop();
dragRow = null;
});
document.getElementById('toolsAdd').addEventListener('click', function () {
rows.insertAdjacentHTML('beforeend', rowHTML());
});
// 保存:收集与前端校验
document.getElementById('toolsForm').addEventListener('submit', function (ev) {
var list = [], bad = false, badMsg = '';
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
if (bad) return;
var icon = r.querySelector('.t-icon').value.trim();
var name = r.querySelector('.t-name').value.trim();
var desc = r.querySelector('.t-desc').value.trim();
var url = r.querySelector('.t-url').value.trim();
var ext = r.querySelector('.t-ext').value;
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
var cats = [];
var catInputs = r.querySelectorAll('.t-catc');
for (var cc = 0; cc < catInputs.length; cc++) {
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
rows.addEventListener('click', function (ev) {
var row = ev.target.closest('.t-row');
if (!row) return;
if (ev.target.classList.contains('t-del')) {
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该工具入口?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该工具入口?')))
.then(function (ok) { if (ok) row.remove(); });
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
if (sib && sib.classList.contains('t-row')) {
if (ev.target.classList.contains('t-up')) rows.insertBefore(row, sib);
else rows.insertBefore(sib, row);
}
}
if (!name && !url) return;
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; }
if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; }
if (ext === '0' && /^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面请填相对地址(如 codec.php如需外链请选“外部链接”。'; return; }
if (ext === '0' && (/^[a-z][a-z0-9+.-]*:/i.test(url) || /^\/\//.test(url))) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面仅支持相对地址,不允许填写协议地址。'; return; }
list.push({
id: parseInt(r.getAttribute('data-id'), 10) || 0,
icon: icon, name: name, description: desc, url: url,
ext: ext, enabled: enabled, cats: cats
});
});
if (bad) {
ev.preventDefault();
alert(badMsg);
return;
rows.addEventListener('change', function (ev) {
if (ev.target.classList.contains('t-ext')) {
placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url'));
} else if (ev.target.classList.contains('t-enabled')) {
toggleEnabled(ev.target);
}
});
rows.querySelectorAll('.t-ext').forEach(function (sel) {
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
});
// 行内拖动排序(拖动结果随「保存功能区」一并持久化)
var dragRow = null;
function clearRowDrop() {
var rs = rows.querySelectorAll('.t-row');
for (var i = 0; i < rs.length; i++) rs[i].classList.remove('drag-src', 'drop-target');
}
if (!list.length) {
rows.addEventListener('dragstart', function (ev) {
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
if (!h || !rows.contains(h)) return;
var row = h.closest('.t-row');
if (!row) return;
dragRow = row;
ev.dataTransfer.effectAllowed = 'move';
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) { }
setTimeout(function () { row.classList.add('drag-src'); }, 0);
});
rows.addEventListener('dragover', function (ev) {
if (!dragRow) return;
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (!row || row === dragRow) { clearRowDrop(); return; }
ev.preventDefault();
alert('功能区至少保留一个工具。');
return;
}
document.getElementById('toolsData').value = JSON.stringify(list);
});
})();
ev.dataTransfer.dropEffect = 'move';
clearRowDrop();
row.classList.add('drop-target');
});
rows.addEventListener('drop', function (ev) {
if (!dragRow) return;
ev.preventDefault();
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
if (row && row !== dragRow) {
var rect = row.getBoundingClientRect();
var after = ev.clientY > rect.top + rect.height / 2;
rows.insertBefore(dragRow, after ? row.nextSibling : row);
}
});
rows.addEventListener('dragend', function () {
clearRowDrop();
dragRow = null;
});
document.getElementById('toolsAdd').addEventListener('click', function () {
rows.insertAdjacentHTML('beforeend', rowHTML());
});
// 保存:收集与前端校验
document.getElementById('toolsForm').addEventListener('submit', function (ev) {
var list = [], bad = false, badMsg = '';
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
if (bad) return;
var icon = r.querySelector('.t-icon').value.trim();
var name = r.querySelector('.t-name').value.trim();
var desc = r.querySelector('.t-desc').value.trim();
var url = r.querySelector('.t-url').value.trim();
var ext = r.querySelector('.t-ext').value;
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
var cats = [];
var catInputs = r.querySelectorAll('.t-catc');
for (var cc = 0; cc < catInputs.length; cc++) {
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
}
if (!name && !url) return;
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; }
if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; }
if (ext === '0' && /^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面请填相对地址(如 codec.php如需外链请选“外部链接”。'; return; }
if (ext === '0' && (/^[a-z][a-z0-9+.-]*:/i.test(url) || /^\/\//.test(url))) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面仅支持相对地址,不允许填写协议地址。'; return; }
list.push({
id: parseInt(r.getAttribute('data-id'), 10) || 0,
icon: icon, name: name, description: desc, url: url,
ext: ext, enabled: enabled, cats: cats
});
});
if (bad) {
ev.preventDefault();
alert(badMsg);
return;
}
if (!list.length) {
ev.preventDefault();
alert('功能区至少保留一个工具。');
return;
}
document.getElementById('toolsData').value = JSON.stringify(list);
});
})();
</script>
</body>
</html>

View File

@ -1,37 +1,41 @@
<?php
/**
* admin/visits.php —— 访问信息
* admin/visits.php —— 风控管理
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
require_once __DIR__ . '/_visits_lib.php';
$pdo = db();
function settings_set(string $key, string $value): void
{
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
$st->execute([$key, $value]);
$st->execute([$key, $value]);
}
/* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */
function bl_delete_ids(PDO $pdo, array $ids): int
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) { return $v > 0; })));
if (!$ids) return 0;
$ph = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
$st->execute($ids);
$rules = $st->fetchAll();
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
foreach ($rules as $r) {
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string)$r['rule']);
}
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) {
return $v > 0;
})));
if (!$ids)
return 0;
$ph = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
$st->execute($ids);
$rules = $st->fetchAll();
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
foreach ($rules as $r) {
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string) $r['rule']);
}
return count($rules);
}
return count($rules);
}
/* ---------- POST黑名单增删 / 清空日志 / 风控规则 ---------- */
@ -39,235 +43,181 @@ $msgKind = '';
$msgText = '';
$banChanged = false;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!csrf_verify()) {
$msgKind = 'err';
$msgText = '安全校验失败,请刷新页面重试。';
} else {
$act = (string)($_POST['act'] ?? '');
switch ($act) {
case 'ban_add':
$rule = trim((string)($_POST['rule'] ?? ''));
$note = trim((string)($_POST['note'] ?? ''));
if ($rule === '') {
$msgKind = 'err';
$msgText = '请填写要拦截的 IP / 网段。';
break;
}
$parsed = blacklist_parse_rule($rule);
if (!$parsed[0]) {
$msgKind = 'err';
$msgText = $parsed[3];
break;
}
$st = $pdo->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
$st->execute([$rule, $note, date('Y-m-d H:i:s'), 'manual']);
$banChanged = true;
$msgKind = 'ok';
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
break;
case 'ban_del':
$id = max(0, (int)($_POST['id'] ?? 0));
if ($id > 0) {
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
$bs->execute([$id]);
$brow = $bs->fetch();
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
if ($brow && filter_var((string)$brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string)$brow['rule']);
}
$banChanged = true;
$msgKind = 'ok';
$msgText = '黑名单规则已移除。';
} else {
$msgKind = 'err';
$msgText = '无效的黑名单记录。';
}
break;
case 'log_clear':
@file_put_contents(ACCESS_LOG_FILE, '', LOCK_EX);
$msgKind = 'ok';
$msgText = '访问日志已清空data/vistors.log。';
break;
case 'risk_save':
$clip = function ($v, $d) { $raw = trim((string)($v ?? '')); $x = ($raw === '') ? (int)$d : (int)$raw; return max(1, min(1000000, $x)); };
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
settings_set('risk_login_n', (string)$clip($_POST['r_login_n'] ?? null, 10));
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
settings_set('risk_404_n', (string)$clip($_POST['r_404_n'] ?? null, 100));
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
settings_set('risk_rate_n', (string)$clip($_POST['r_rate_n'] ?? null, 120));
settings_set('risk_tip_main', trim((string)($_POST['r_tip_main'] ?? '')));
settings_set('risk_tip_sub', trim((string)($_POST['r_tip_sub'] ?? '')));
$msgKind = 'ok';
$msgText = '风控规则已保存并即时生效。';
break;
case 'risk_clear':
risk_clear_all();
$msgKind = 'ok';
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
break;
case 'ban_batch':
$n = bl_delete_ids($pdo, (array)($_POST['bid'] ?? []));
$banChanged = true;
$msgKind = $n > 0 ? 'ok' : 'err';
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
break;
case 'ban_clear_auto':
$rows = $pdo->query("SELECT rule FROM ip_blacklist WHERE source = 'auto'")->fetchAll();
$cnt = count($rows);
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
foreach ($rows as $r) {
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string)$r['rule']);
}
}
blacklist_rules(true);
$banChanged = true;
$msgKind = 'ok';
$msgText = '已一键移除全部风控自动封禁(共 ' . $cnt . ' 条),相关 IP 风控计数已同步清除。';
break;
case 'ban_clear_all':
$pdo->exec('DELETE FROM ip_blacklist');
risk_clear_all();
blacklist_rules(true);
$banChanged = true;
$msgKind = 'ok';
$msgText = '已清空全部黑名单,风险计数一并清零。';
break;
default:
$msgKind = 'err';
$msgText = '未知操作。';
if (!csrf_verify()) {
$msgKind = 'err';
$msgText = '安全校验失败,请刷新页面重试。';
} else {
$act = (string) ($_POST['act'] ?? '');
switch ($act) {
case 'ban_add':
$rule = trim((string) ($_POST['rule'] ?? ''));
$note = trim((string) ($_POST['note'] ?? ''));
if ($rule === '') {
$msgKind = 'err';
$msgText = '请填写要拦截的 IP / 网段。';
break;
}
$parsed = blacklist_parse_rule($rule);
if (!$parsed[0]) {
$msgKind = 'err';
$msgText = $parsed[3];
break;
}
$st = $pdo->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
$st->execute([$rule, $note, date('Y-m-d H:i:s'), 'manual']);
$banChanged = true;
$msgKind = 'ok';
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
break;
case 'ban_del':
$id = max(0, (int) ($_POST['id'] ?? 0));
if ($id > 0) {
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
$bs->execute([$id]);
$brow = $bs->fetch();
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
if ($brow && filter_var((string) $brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string) $brow['rule']);
}
$banChanged = true;
$msgKind = 'ok';
$msgText = '黑名单规则已移除。';
} else {
$msgKind = 'err';
$msgText = '无效的黑名单记录。';
}
break;
case 'log_clear':
@file_put_contents(ACCESS_LOG_FILE, '', LOCK_EX);
$msgKind = 'ok';
$msgText = '访问日志已清空data/vistors.log。';
break;
case 'risk_save':
$clip = function ($v, $d) {
$raw = trim((string) ($v ?? ''));
$x = ($raw === '') ? (int) $d : (int) $raw;
return max(1, min(1000000, $x));
};
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
settings_set('risk_login_n', (string) $clip($_POST['r_login_n'] ?? null, 10));
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
settings_set('risk_404_n', (string) $clip($_POST['r_404_n'] ?? null, 100));
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
settings_set('risk_rate_n', (string) $clip($_POST['r_rate_n'] ?? null, 120));
settings_set('risk_tip_main', trim((string) ($_POST['r_tip_main'] ?? '')));
settings_set('risk_tip_sub', trim((string) ($_POST['r_tip_sub'] ?? '')));
$msgKind = 'ok';
$msgText = '风控规则已保存并即时生效。';
break;
case 'risk_clear':
risk_clear_all();
$msgKind = 'ok';
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
break;
case 'loginlog_clear':
login_log_clear();
$msgKind = 'ok';
$msgText = '登录日志已清空(含成功与失败记录)。';
break;
case 'ban_batch':
$n = bl_delete_ids($pdo, (array) ($_POST['bid'] ?? []));
$banChanged = true;
$msgKind = $n > 0 ? 'ok' : 'err';
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
break;
case 'ban_clear_auto':
$rows = $pdo->query("SELECT rule FROM ip_blacklist WHERE source = 'auto'")->fetchAll();
$cnt = count($rows);
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
foreach ($rows as $r) {
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
risk_clear_for_ip((string) $r['rule']);
}
}
blacklist_rules(true);
$banChanged = true;
$msgKind = 'ok';
$msgText = '已一键移除全部风控自动封禁(共 ' . $cnt . ' 条),相关 IP 风控计数已同步清除。';
break;
case 'ban_clear_all':
$pdo->exec('DELETE FROM ip_blacklist');
risk_clear_all();
blacklist_rules(true);
$banChanged = true;
$msgKind = 'ok';
$msgText = '已清空全部黑名单,风险计数一并清零。';
break;
default:
$msgKind = 'err';
$msgText = '未知操作。';
}
}
}
/* ---------- 概要统计数据来源data/vistors.log ---------- */
$entries = visitors_entries();
$statTotal = count($entries);
$ipSet = [];
foreach ($entries as $e) {
$ipSet[$e['ip']] = 1;
}
$statIps = count($ipSet);
$statToday = 0;
$todayStart = date('Y-m-d 00:00:00');
foreach ($entries as $e) {
if ($e['created_at'] >= $todayStart) {
$statToday++;
}
}
function vis_scalar(PDO $pdo, string $sql): int
{
$v = $pdo->query($sql)->fetchColumn();
return (int)$v;
}
$statBan = vis_scalar($pdo, 'SELECT COUNT(*) FROM ip_blacklist');
$visStat = vis_stat_all();
$statTotal = $visStat['total'];
$statIps = $visStat['ips'];
$statToday = $visStat['today'];
$statBan = $visStat['ban'];
/* ---------- 筛选与分页 ---------- */
$mode = (string)($_GET['mode'] ?? 'full');
/* ---------- 筛选参数(供表单回显)与访问记录查询 ---------- */
$mode = (string) ($_GET['mode'] ?? 'full');
if (!in_array($mode, ['full', 'compact'], true)) {
$mode = 'full';
$mode = 'full';
}
$ipF = trim((string)($_GET['ip'] ?? ''));
$q = trim((string)($_GET['q'] ?? ''));
$ipF = substr($ipF, 0, 64);
$q = substr($q, 0, 100);
$page = max(1, (int)($_GET['page'] ?? 1));
$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64);
$q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 30;
// entries 已按新→旧排列,内存中过滤 / 聚合后分页
$logs = [];
$total = 0;
$totalPages = 1;
if ($mode === 'compact') {
// 精简模式:按 IP 聚合(最近访问时间 / IP / 访问次数),首次出现即该 IP 最新一条
$grp = [];
$order = [];
foreach ($entries as $e) {
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
if (!isset($grp[$e['ip']])) {
$grp[$e['ip']] = ['ip' => $e['ip'], 'last_at' => $e['created_at'], 'cnt' => 0];
$order[] = $e['ip'];
}
$grp[$e['ip']]['cnt']++;
}
$all = [];
foreach ($order as $ip) {
$all[] = $grp[$ip];
}
$total = count($all);
$totalPages = max(1, (int)ceil($total / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
}
$off = ($page - 1) * $perPage;
$logs = array_slice($all, $off, $perPage);
} else {
// 完整模式:逐条明细
$all = [];
foreach ($entries as $e) {
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
$all[] = $e;
}
$total = count($all);
$totalPages = max(1, (int)ceil($total / $perPage));
if ($page > $totalPages) {
$page = $totalPages;
}
$off = ($page - 1) * $perPage;
$logs = array_slice($all, $off, $perPage);
}
/* ---------- 链接工具(保留当前筛选参数) ---------- */
function vis_url(array $ov): string
{
$p = $_GET;
foreach ($ov as $k => $v) {
if ($v === null || $v === '') {
unset($p[$k]);
} else {
$p[$k] = $v;
}
}
if (!$p) {
return 'visits.php';
}
return 'visits.php?' . http_build_query($p);
}
$visAccess = vis_query_access($mode, $ipF, $q, $page, $perPage);
/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
$curIp = client_ip();
if ($curIp === '') {
$curIp = '未知';
$curIp = '未知';
}
$curHit = null;
if ($curIp !== '未知') {
if ($banChanged) {
blacklist_rules(true);
}
$curHit = blacklist_ip_hit($curIp);
if ($banChanged) {
blacklist_rules(true);
}
$curHit = blacklist_ip_hit($curIp);
}
/* ---------- 自动风控规则配置与当前 IP 计数 ---------- */
$rcfg = risk_rule_cfg();
$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null;
/* ---------- 登录信息:当前会话 / 历史成功登录 / 登录失败按「IP × 日期」聚合 ---------- */
$lgStat = login_log_stat();
$lgRecent = login_log_recent_ok(15);
$lgFailF = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64);
$lgFailData = vis_query_loginfail($lgFailF, 200);
$curAdmin = (string) ($_SESSION['admin'] ?? '');
$curLoginAt = (string) ($_SESSION['login_at'] ?? '');
$curLoginIp = (string) ($_SESSION['login_ip'] ?? ($curIp !== '未知' ? $curIp : ''));
$curLoginUa = (string) ($_SESSION['login_ua'] ?? '');
$curLoginDevice = (string) ($_SESSION['login_device'] ?? '');
if ($curLoginDevice === '') {
$curLoginDevice = login_ua_brief((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
}
/* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */
$banRows = $pdo->query('SELECT id, rule, note, created_at, source FROM ip_blacklist ORDER BY id DESC')->fetchAll();
$banAutoCount = 0;
foreach ($banRows as $b) {
if (((string)($b['source'] ?? 'manual')) === 'auto') $banAutoCount++;
if (((string) ($b['source'] ?? 'manual')) === 'auto')
$banAutoCount++;
}
layout_head('访问信息');
layout_head('风控管理');
admin_topbar('visits');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">访问信息</h2>
<h2 style="margin-bottom:6px">风控管理</h2>
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
<?php if ($msgText !== ''): ?>
@ -286,122 +236,138 @@ admin_topbar('visits');
<form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px">
<?= csrf_field() ?><input type="hidden" name="act" value="log_clear">
<button type="submit" class="btn btn-sm btn-danger">清空访问日志</button>
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL完整保留不设上限可自行归档或删除该文件。</span>
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 /
URL完整保留不设上限可自行归档或删除该文件。</span>
</form>
</div>
<!-- 登录信息(当前会话 + 历史成功登录) -->
<div class="fieldset-card">
<div class="fs-title">登录信息</div>
<div class="vis-stats">
<div class="vis-stat"><b><?= he($curAdmin) ?></b><span>当前用户</span></div>
<div class="vis-stat"><b
style="font-size:15px"><?= he($curLoginAt !== '' ? $curLoginAt : '本次会话未记录') ?></b><span>本次登录时间</span></div>
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginIp !== '' ? $curLoginIp : '未知') ?></b><span>本次登录
IP</span></div>
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginDevice) ?></b><span>本次登录设备</span></div>
</div>
<p class="tip" style="margin:12px 0 4px">历史登录记录(最近 <?= count($lgRecent) ?> 条成功登录;今日成功
<?= (int) $lgStat['ok_today'] ?> 次)
</p>
<div class="v-scroll">
<table class="v-tbl">
<thead>
<tr>
<th>登录时间</th>
<th>登录 IP</th>
<th>登录设备(浏览器)</th>
<th>用户名</th>
</tr>
</thead>
<tbody>
<?php if ($lgRecent): ?>
<?php foreach ($lgRecent as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string) $r['created_at']) ?></td>
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
<td><?= he(vis_device_text($r)) ?></td>
<td class="v-nowrap"><?= he((string) $r['username']) ?></td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="4"><span class="tip">暂无成功登录记录(本次登录为首次记录)。</span></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<!-- 登录失败日志(按 IP × 日期聚合) -->
<div class="fieldset-card">
<div class="fs-title">登录失败日志</div>
<div class="vis-stats">
<div class="vis-stat"><b><?= (int) $lgStat['fail_today'] ?></b><span>今日失败次数</span></div>
<div class="vis-stat"><b><?= (int) $lgStat['fail_ips'] ?></b><span>失败涉及 IP</span></div>
<div class="vis-stat"><b><?= (int) $lgStat['ok_today'] ?></b><span>今日成功登录</span></div>
<div class="vis-stat"><b><?= (int) $lgStat['total'] ?></b><span>登录日志总数</span></div>
</div>
<form method="get" class="v-filter" style="margin-top:12px" data-vis-form="loginfail">
<input type="text" name="lf" value="<?= he($lgFailF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0">
<button type="submit" class="btn btn-primary">筛选</button>
<a class="btn" href="visits.php" data-vis-section="loginfail" data-vis-page="">重置</a>
<span class="tip" style="align-self:center">按「IP × 日期」聚合:同一 IP 当天的失败次数与最近一次设备信息</span>
</form>
<div id="lfResults"><?= vis_render_loginfail($lgFailData) ?></div>
<form method="post" data-confirm="确认清空全部登录日志(成功与失败记录)?此操作不可恢复。" style="margin-top:12px">
<?= csrf_field() ?><input type="hidden" name="act" value="loginlog_clear">
<button type="submit" class="btn btn-sm btn-danger">清空登录日志</button>
<span class="tip" style="margin-left:8px">记录存于数据库 <code>login_log</code> 表(含成功与失败、时间 / 用户名 / IP / 设备)。</span>
</form>
</div>
<!-- 筛选 + 日志列表 -->
<div class="fieldset-card">
<div class="fs-title">访问记录<?= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?></div>
<form method="get" class="v-filter">
<select name="mode" title="展示模式" onchange="this.form.submit()">
<option value="full"<?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
<option value="compact"<?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
<div class="fs-title">访问记录</div>
<form method="get" class="v-filter" data-vis-form="access">
<select name="mode" title="展示模式"
onchange="if(this.form.requestSubmit){this.form.requestSubmit();}else{this.form.submit();}">
<option value="full" <?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
<option value="compact" <?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
</select>
<input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0">
<input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式">
<button type="submit" class="btn btn-primary">筛选</button>
<a class="btn" href="visits.php">重置</a>
<a class="btn" href="visits.php" data-vis-section="access" data-vis-page="">重置</a>
</form>
<?php if ($logs): ?>
<div class="tip" style="margin:10px 0 6px">
共命中 <?= $total ?> <?= $mode === 'compact' ? '个 IP' : '条记录' ?>
当前第 <?= $page ?> / <?= $totalPages ?> 页,每页 <?= $perPage ?> 条。
</div>
<div class="v-scroll">
<?php if ($mode === 'compact'): ?>
<table class="v-tbl">
<thead>
<tr><th>最近访问时间</th><th>IP 地址</th><th>访问次数</th></tr>
</thead>
<tbody>
<?php foreach ($logs as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string)$r['last_at']) ?></td>
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
<td><b><?= (int)$r['cnt'] ?></b> 次</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<table class="v-tbl">
<thead>
<tr>
<th>访问时间</th><th>IP 地址</th><th>请求方式</th><th>访问 URL</th><th>响应码</th>
</tr>
</thead>
<tbody>
<?php foreach ($logs as $r): ?>
<tr>
<td class="v-nowrap"><?= he((string)$r['created_at']) ?></td>
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
<td><span class="v-tag"><?= he((string)$r['method']) ?></span></td>
<td><?= he((string)$r['url']) ?></td>
<td class="v-nowrap"><?php
$code = (int)$r['status'];
$cls = ($code >= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad'));
?><span class="v-tag <?= $cls ?>"><?= $code ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php if ($totalPages > 1): ?>
<div class="pager">
<span class="pg-info"> <?= $total ?> 条</span>
<?php if ($page > 1): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page - 1])) ?>">← 上一页</a><?php endif; ?>
<?php
$win = 2;
$start = max(1, $page - $win);
$end = min($totalPages, $page + $win);
if ($start > 1) echo '<span class="tip">…</span>';
for ($p = $start; $p <= $end; $p++) {
if ($p === $page) {
echo '<span class="btn btn-sm btn-primary">' . $p . '</span>';
} else {
echo '<a class="btn btn-sm" href="' . he(vis_url(['page' => $p])) . '">' . $p . '</a>';
}
}
if ($end < $totalPages) echo '<span class="tip">…</span>';
?>
<?php if ($page < $totalPages): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page + 1])) ?>">下一页 →</a><?php endif; ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="err-msg" style="margin-top:12px"><?= ($ipF !== '' || $q !== '') ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?></div>
<?php endif; ?>
<div id="visResults"><?= vis_render_access($visAccess) ?></div>
</div>
<!-- 自动风控规则 -->
<div class="fieldset-card">
<div class="fs-title">自动风控规则<span class="tip" style="font-weight:400">(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)</span></div>
<div class="fs-title">风控规则</div>
<form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="risk_save">
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
<span class="tip">失败次数 <input type="number" name="r_login_n" min="1" max="1000000" value="<?= (int)$rcfg['login_fail']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 永久封禁(建议 5-20</span>
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"
<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
<span class="tip">失败次数 <input type="number" name="r_login_n" min="1" max="1000000"
value="<?= (int) $rcfg['login_fail']['n'] ?>"
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
永久封禁(建议 5-20</span>
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
<span class="tip">累计次数 <input type="number" name="r_404_n" min="1" max="1000000" value="<?= (int)$rcfg['not_found']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 永久封禁(建议 50-300</span>
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"
<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
<span class="tip">累计次数 <input type="number" name="r_404_n" min="1" max="1000000"
value="<?= (int) $rcfg['not_found']['n'] ?>"
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
永久封禁(建议 50-300</span>
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
<span class="tip">60 秒内 <input type="number" name="r_rate_n" min="1" max="1000000" value="<?= (int)$rcfg['rate']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 永久封禁(多人共用出口 IP 时建议关闭)</span>
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"
<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
<span class="tip">60 秒内 <input type="number" name="r_rate_n" min="1" max="1000000"
value="<?= (int) $rcfg['rate']['n'] ?>"
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
永久封禁(多人共用出口 IP 时建议关闭)</span>
</div>
<p class="tip" style="margin:8px 0 6px">计数方式404 与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
<p class="tip" style="margin:8px 0 6px">计数方式404
与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 8px">
<span class="tip" style="min-width:148px">主提示(留空=默认)</span>
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string)$rcfg['_tip']['raw_main']) ?>" placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string) $rcfg['_tip']['raw_main']) ?>"
placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案"
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
</div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px">
<span class="tip" style="min-width:148px">次行说明(留空=默认)</span>
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string)$rcfg['_tip']['raw_sub']) ?>" placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string) $rcfg['_tip']['raw_sub']) ?>"
placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案"
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
</div>
<button type="submit" class="btn btn-primary">保存风控规则</button>
</form>
@ -409,27 +375,34 @@ admin_topbar('visits');
<?= csrf_field() ?><input type="hidden" name="act" value="risk_clear">
<button type="submit" class="btn btn-sm">清空全部风险计数</button>
<?php if ($curRisk): ?>
<span class="tip" style="margin-left:8px">当前访问 IP<?= he($curIp) ?>)计数:登录失败 <?= (int)$curRisk['login_fail'] ?> 次 · 404 <?= (int)$curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求 <?= (int)$curRisk['rate_count'] ?> 次<?php endif; ?></span>
<span class="tip" style="margin-left:8px">当前访问 IP<?= he($curIp) ?>)计数:登录失败 <?= (int) $curRisk['login_fail'] ?>
· 404 <?= (int) $curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求
<?= (int) $curRisk['rate_count'] ?> 次<?php endif; ?></span>
<?php endif; ?>
</form>
</div>
<!-- IP 黑名单 -->
<div class="fieldset-card">
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</div>
<div class="fs-title">黑名单管理</div>
<form method="post" class="v-filter">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_add">
<input type="text" name="rule" required placeholder="203.0.113.7 / 203.0.113.0/24 / 203.0.113.*" style="flex:1 1 260px;max-width:420px">
<input type="text" name="rule" required placeholder="203.0.113.7 / 203.0.113.0/24 / 203.0.113.*"
style="flex:1 1 260px;max-width:420px">
<input type="text" name="note" placeholder="备注(可选,如:恶意扫描器)" style="flex:1 1 220px;max-width:340px">
<button type="submit" class="btn btn-primary">加入黑名单</button>
</form>
<p class="tip" style="margin:10px 0 2px">
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> <code>192.168.*.*</code>
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP <strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db ip_blacklist 表中删除记录。
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code>
<code>192.168.*.*</code>
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP <strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在
data/homepage.db ip_blacklist 表中删除记录。
</p>
<div style="margin:6px 0 10px">
<?php if ($curHit): ?>
<span class="vis-warn-tip">当前访问 IP<?= he($curIp) ?> —— 命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string)$curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
<span class="vis-warn-tip">当前访问 IP<?= he($curIp) ?> ——
命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string) $curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
<?php else: ?>
<span class="vis-ok-tip">当前访问 IP<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
<?php endif; ?>
@ -439,7 +412,9 @@ admin_topbar('visits');
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:8px 0 4px">
<form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_batch">
<label class="tip" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input type="checkbox" id="blCheckAll"> 全选</label>
<label class="tip"
style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input
type="checkbox" id="blCheckAll"> 全选</label>
<button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中0</button>
</form>
<?php if ($banAutoCount > 0): ?>
@ -455,17 +430,20 @@ admin_topbar('visits');
</div>
<?php foreach ($banRows as $b): ?>
<div class="bl-row">
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int)$b['id'] ?>"></label>
<code class="bl-rule"><?= he((string)$b['rule']) ?></code>
<?php if (((string)($b['source'] ?? 'manual')) === 'auto'): ?>
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input
type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int) $b['id'] ?>"></label>
<code class="bl-rule"><?= he((string) $b['rule']) ?></code>
<?php if (((string) ($b['source'] ?? 'manual')) === 'auto'): ?>
<span class="v-tag warn" style="min-width:0">风控自动</span>
<?php else: ?>
<span class="v-tag info" style="min-width:0">手动</span>
<?php endif; ?>
<span class="bl-note"><?= $b['note'] !== '' ? he((string)$b['note']) : '<span class="tip">(无备注)</span>' ?></span>
<span class="bl-time"><?= he((string)$b['created_at']) ?></span>
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string)$b['rule']) ?>」?">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
<span
class="bl-note"><?= $b['note'] !== '' ? he((string) $b['note']) : '<span class="tip">(无备注)</span>' ?></span>
<span class="bl-time"><?= he((string) $b['created_at']) ?></span>
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string) $b['rule']) ?>」?">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id"
value="<?= (int) $b['id'] ?>">
<button type="submit" class="btn btn-sm btn-danger">移除</button>
</form>
</div>
@ -480,23 +458,24 @@ admin_topbar('visits');
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(function () {
(function () {
var all = document.getElementById('blCheckAll');
var btn = document.getElementById('blBatchBtn');
var boxes = Array.prototype.slice.call(document.querySelectorAll('.js-bl-check'));
if (!all || !btn || !boxes.length) return;
function refresh() {
var n = boxes.filter(function (c) { return c.checked; }).length;
btn.disabled = n === 0;
btn.textContent = '移除选中(' + n + '';
all.checked = n > 0 && n === boxes.length;
var n = boxes.filter(function (c) { return c.checked; }).length;
btn.disabled = n === 0;
btn.textContent = '移除选中(' + n + '';
all.checked = n > 0 && n === boxes.length;
}
all.addEventListener('change', function () {
boxes.forEach(function (c) { c.checked = all.checked; });
refresh();
boxes.forEach(function (c) { c.checked = all.checked; });
refresh();
});
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
})();
})();
</script>
</body>
</html>

39
admin/visits_data.php Normal file
View File

@ -0,0 +1,39 @@
<?php
/**
* admin/visits_data.php —— 风控管理页局部刷新接口(返回 HTML 片段,供本页 AJAX 替换,不整页跳转)
* 用法:
* visits_data.php?section=access&mode=full|compact&ip=&q=&page=2 访问记录
* visits_data.php?section=loginfail&lf=203.0 登录失败日志
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/auth.php';
require_once dirname(__DIR__) . '/includes/layout.php';
require_once __DIR__ . '/_visits_lib.php';
if (!is_logged_in()) {
http_response_code(401);
header('Content-Type: text/html; charset=utf-8');
echo '<div class="err-msg">登录状态已失效,请刷新页面后重新登录。</div>';
exit;
}
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
$section = (string) ($_GET['section'] ?? 'access');
if ($section === 'loginfail') {
$lf = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64);
echo vis_render_loginfail(vis_query_loginfail($lf, 200));
exit;
}
$mode = (string) ($_GET['mode'] ?? 'full');
if (!in_array($mode, ['full', 'compact'], true)) {
$mode = 'full';
}
$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64);
$q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
$page = max(1, (int) ($_GET['page'] ?? 1));
echo vis_render_access(vis_query_access($mode, $ipF, $q, $page, 30));

View File

@ -184,7 +184,7 @@ select { width: auto; min-width: 150px; }
/* ---------- 首页顶部组件空白精简模式hero_mode=blank ---------- */
.hero-slim { min-height: 0; padding: 16px 0 12px; }
/* ============ 后台访问信息页 ============ */
/* ============ 后台风控管理页 ============ */
.vis-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.vis-stat { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
.vis-stat b { display: block; font-size: 22px; line-height: 1.3; font-variant-numeric: tabular-nums; }
@ -471,7 +471,7 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
/* 跨整行的设置卡片(内容较多的底部大卡) */
.fieldset-card.col-span-2 { grid-column: 1 / -1; }
/* 后台导航内容管理 */
/* 后台导航管理 */
.m-block { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 16px; overflow: hidden; }
.m-block-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-2); flex-wrap: wrap; }
.m-block-head input[type=text] { max-width: 320px; }
@ -825,3 +825,83 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
.hp-toast.hide { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
@keyframes hpToastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
@media (max-width: 600px) { .hp-toasts { right: 12px; bottom: 12px; left: 12px; align-items: stretch; } .hp-toast { min-width: 0; } }
/* ---------- 后台侧边栏(原顶部分区栏改为可收缩侧栏,含“本页功能”锚点) ---------- */
body.hp-admin-side { padding-left: 232px; transition: padding-left .2s ease; }
body.hp-admin-side.hp-side-collapsed { padding-left: 68px; }
.admin-mobilebar { display: none; }
.admin-side {
position: fixed; top: 0; left: 0; bottom: 0; width: 232px; z-index: 920;
display: flex; flex-direction: column; gap: 4px;
background: var(--brand-2); color: #fff;
padding: 12px 10px 10px; overflow-y: auto; box-sizing: border-box;
transition: width .2s ease, transform .2s ease;
}
.admin-side a { color: rgba(255, 255, 255, .92); }
.as-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, .16); margin-bottom: 8px; }
.as-brand { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; }
.as-logo { width: 32px; height: 32px; border-radius: 8px; object-fit: contain; background: #fff; padding: 2px; flex: none; }
.as-brand-tx { display: flex; flex-direction: column; min-width: 0; font-weight: 700; font-size: 14px; line-height: 1.25; }
.as-brand-tx em { font-style: normal; font-weight: 400; font-size: 11px; opacity: .7; }
.as-toggle { flex: none; width: 26px; height: 26px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .08); color: #fff; cursor: pointer; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; }
.as-toggle:hover { background: rgba(255, 255, 255, .2); }
.as-toggle-ic { display: inline-block; transition: transform .2s ease; }
.as-nav { display: flex; flex-direction: column; gap: 2px; }
.as-link { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 10px; font-size: 14px; white-space: nowrap; }
.as-link:hover { background: rgba(255, 255, 255, .12); }
.as-link.active { background: rgba(255, 255, 255, .2); font-weight: 700; }
.as-ic { flex: none; width: 20px; text-align: center; font-size: 15px; }
.as-sec { margin-top: 10px; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; }
.as-sec-t { font-size: 11px; letter-spacing: .06em; opacity: .6; padding: 2px 10px 6px; }
.as-sec-list { display: flex; flex-direction: column; gap: 1px; }
.as-sub { display: block; padding: 6px 10px 6px 12px; border-radius: 8px; font-size: 12.5px; color: rgba(255, 255, 255, .78); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 2px solid transparent; }
.as-sub:hover { background: rgba(255, 255, 255, .12); color: #fff; }
.as-sub.active { background: rgba(255, 255, 255, .18); color: #fff; border-left-color: #fff; font-weight: 600; }
.as-foot { margin-top: auto; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; }
.as-user { font-size: 13px; opacity: .78; padding: 4px 10px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.admin-side-mask { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 919; }
/* 滚动定位到区块时预留间距(避免贴顶) */
.fieldset-card { scroll-margin-top: 18px; }
/* 收缩态:仅图标 */
body.hp-side-collapsed .as-brand-tx,
body.hp-side-collapsed .as-tx { display: none; }
body.hp-side-collapsed .admin-side { width: 68px; }
body.hp-side-collapsed .as-head { flex-direction: column; gap: 6px; }
body.hp-side-collapsed .as-link { justify-content: center; padding: 9px 0; }
body.hp-side-collapsed .as-toggle-ic { transform: rotate(180deg); }
body.hp-side-collapsed .as-sec { display: none; }
/* 小屏:侧栏改为抽屉 */
@media (max-width: 900px) {
body.hp-admin-side { padding-left: 0; }
.admin-mobilebar {
display: flex; align-items: center; gap: 10px; position: sticky; top: 0; z-index: 921;
background: var(--brand-2); color: #fff; padding: 8px 12px;
}
.amb-btn { border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .1); color: #fff; border-radius: 8px; width: 34px; height: 30px; font-size: 15px; cursor: pointer; }
.amb-brand { display: flex; align-items: center; gap: 8px; min-width: 0; color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.amb-logo { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #fff; padding: 1px; flex: none; }
.amb-out { margin-left: auto; color: rgba(255, 255, 255, .9); font-size: 13px; }
.admin-side { width: 240px; transform: translateX(-100%); box-shadow: 2px 0 18px rgba(0, 0, 0, .3); }
body.hp-side-open .admin-side { transform: none; }
body.hp-side-open .admin-side-mask { display: block; }
/* 小屏忽略收缩态(始终保持完整文字) */
body.hp-side-collapsed .admin-side { width: 240px; }
body.hp-side-collapsed .as-tx { display: block; }
body.hp-side-collapsed .as-brand-tx { display: flex; }
body.hp-side-collapsed .as-link { justify-content: flex-start; padding: 9px 10px; }
body.hp-side-collapsed .as-sec { display: block; }
.fieldset-card { scroll-margin-top: 66px; }
}
/* ---------- 风控管理页:筛选 / 翻页局部刷新时的轻量加载态 ---------- */
.vis-loading { opacity: .5; pointer-events: none; transition: opacity .15s ease; }

View File

@ -426,38 +426,242 @@
}
}
/* ---------- 返回顶部(右下角按钮,滚动后出现;兼容内滚容器页面 ---------- */
/* ---------- 返回顶部(右下角按钮;滚动后出现,回到顶部即隐藏 ---------- */
function initToTop() {
var btn = document.querySelector('.js-to-top');
if (!btn) return;
var scrollables = [];
var doc = document.scrollingElement || document.documentElement;
if (doc) scrollables.push(doc);
Array.prototype.forEach.call(document.querySelectorAll('*'), function (el) {
if (el === doc || el === btn) return;
if (el.scrollHeight <= el.clientHeight + 40) return;
var cs;
try { cs = window.getComputedStyle(el); } catch (e) { cs = null; }
if (!cs) return;
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollables.push(el);
});
function measure() {
var v = 0;
for (var i = 0; i < scrollables.length; i++) {
if (scrollables[i].scrollTop > v) v = scrollables[i].scrollTop;
var containers = [];
var ticking = false;
function currentY() {
var y = window.pageYOffset || (doc && doc.scrollTop) || 0;
for (var i = 0; i < containers.length; i++) {
var t = containers[i].scrollTop || 0;
if (t > y) y = t;
}
btn.classList.toggle('show', v > 260);
return y;
}
for (var s = 0; s < scrollables.length; s++) {
scrollables[s].addEventListener('scroll', measure, { passive: true });
function apply() {
ticking = false;
btn.classList.toggle('show', currentY() > 180);
}
window.addEventListener('resize', measure);
function schedule() {
if (ticking) return;
ticking = true;
if (window.requestAnimationFrame) window.requestAnimationFrame(apply);
else setTimeout(apply, 30);
}
// 捕获阶段监听:主文档与任意内滚容器的滚动都能收到,避免漏触发
document.addEventListener('scroll', schedule, true);
window.addEventListener('resize', schedule, { passive: true });
// 采集内滚容器DOM 就绪与资源加载后各测一次(布局完成后才能正确判断是否溢出)
function collect() {
var list = document.querySelectorAll('main, section, article, div, ul, ol');
Array.prototype.forEach.call(list, function (el) {
if (el === btn || containers.indexOf(el) >= 0) return;
if (el.closest('.admin-side, .hp-toasts, .art-topbar')) return; // 侧栏/弹窗/顶栏自身滚动不应触发
if (el.scrollHeight <= el.clientHeight + 40) return;
var cs;
try { cs = window.getComputedStyle(el); } catch (e) { return; }
if (cs.position === 'fixed' || cs.position === 'sticky') return;
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') containers.push(el);
});
apply();
}
collect();
if (document.readyState !== 'complete') window.addEventListener('load', collect);
btn.addEventListener('click', function () {
var root = doc || document.documentElement;
if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: 'smooth' });
else window.scrollTo({ top: 0, behavior: 'smooth' });
btn.classList.remove('show'); // 乐观隐藏,避免到顶后仍残留
for (var i = 0; i < containers.length; i++) {
if (containers[i].scrollTop > 0) containers[i].scrollTop = 0;
}
if (doc && doc.scrollTop > 0) {
try { doc.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { doc.scrollTop = 0; }
} else {
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, 0); }
}
schedule();
});
measure();
schedule();
}
/* ---------- 后台侧边栏:收缩 / 移动端抽屉 / 本页功能锚点 ---------- */
function initAdminSide() {
var side = document.querySelector('.admin-side');
if (!side) return;
var body = document.body;
var mq = window.matchMedia ? window.matchMedia('(max-width: 900px)') : null;
function isMobile() { return mq ? mq.matches : window.innerWidth <= 900; }
// 收缩(桌面)/ 抽屉开关(小屏)
Array.prototype.forEach.call(document.querySelectorAll('.js-admin-side-toggle'), function (btn) {
btn.addEventListener('click', function () {
if (isMobile()) {
body.classList.toggle('hp-side-open');
} else {
var collapsed = body.classList.toggle('hp-side-collapsed');
try { localStorage.setItem('hp-admin-side-collapsed', collapsed ? '1' : '0'); } catch (e) { /* ignore */ }
}
});
});
var mask = document.getElementById('adminSideMask');
if (mask) mask.addEventListener('click', function () { body.classList.remove('hp-side-open'); });
window.addEventListener('resize', function () { if (!isMobile()) body.classList.remove('hp-side-open'); });
Array.prototype.forEach.call(side.querySelectorAll('a'), function (a) {
a.addEventListener('click', function () { if (isMobile()) body.classList.remove('hp-side-open'); });
});
// 采集当前页“本页功能”fieldset-card 的标题
var secWrap = document.getElementById('adminSideSections');
var secList = document.getElementById('adminSideSectionList');
if (!secWrap || !secList) return;
var items = [];
Array.prototype.forEach.call(document.querySelectorAll('.fieldset-card'), function (card) {
var t = card.querySelector('.fs-title');
if (!t) return;
var clone = t.cloneNode(true);
Array.prototype.forEach.call(clone.querySelectorAll('.tip'), function (n) { if (n.parentNode) n.parentNode.removeChild(n); });
var text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
if (text === '') return;
items.push({ el: card, text: text });
});
if (items.length < 1) return;
var idx = 0;
items.forEach(function (it) {
idx++;
if (!it.el.id) it.el.id = 'hp-sec-' + idx;
var a = document.createElement('a');
a.className = 'as-sub';
a.href = '#' + it.el.id;
a.textContent = it.text;
a.title = it.text;
a.addEventListener('click', function (ev) {
ev.preventDefault();
try { it.el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
catch (e) { it.el.scrollIntoView(); }
});
secList.appendChild(a);
});
secWrap.hidden = false;
// 高亮当前可视区块
var subs = secList.querySelectorAll('.as-sub');
function spy() {
var best = -1, bestTop = -Infinity;
for (var i = 0; i < items.length; i++) {
var top = items[i].el.getBoundingClientRect().top - 120;
if (top <= 0 && top > bestTop) { bestTop = top; best = i; }
}
for (var j = 0; j < subs.length; j++) subs[j].classList.toggle('active', j === best);
}
var st = false;
function onScroll() {
if (st) return;
st = true;
if (window.requestAnimationFrame) window.requestAnimationFrame(function () { st = false; spy(); });
else { st = false; spy(); }
}
document.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
spy();
}
/* ---------- 登录页:采集设备信息(系统 / 机型 / 屏幕等,能拿到什么就带什么)随表单一并提交 ---------- */
function initLoginDevice() {
var field = document.getElementById('devField');
if (!field) return;
function baseInfo() {
var info = {};
try {
info.ua = navigator.userAgent || '';
info.platform = navigator.platform || '';
info.lang = navigator.language || '';
info.screen = (window.screen && screen.width && screen.height) ? (screen.width + '×' + screen.height + '@' + (window.devicePixelRatio || 1)) : '';
info.touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
} catch (e) { /* ignore */ }
return info;
}
function fill() {
try { field.value = JSON.stringify(baseInfo()); } catch (e) { /* ignore */ }
}
fill();
// Chromium 系 UA-CH可拿到机型model、系统版本、架构等高熵值
try {
if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion'])
.then(function (h) {
var info = baseInfo();
info.uch = h || {};
field.value = JSON.stringify(info);
})
.catch(function () { /* ignore */ });
}
} catch (e) { /* ignore */ }
}
/* ---------- 风控管理页:筛选 / 翻页局部刷新(不整页跳转、不回到顶部) ---------- */
function initVisitsAjax() {
var boxes = {
access: document.getElementById('visResults'),
loginfail: document.getElementById('lfResults')
};
if (!boxes.access && !boxes.loginfail) return;
function serialize(form) {
var parts = [];
var els = form.elements;
for (var i = 0; i < els.length; i++) {
var el = els[i];
if (!el.name || el.disabled) continue;
var t = (el.type || '').toLowerCase();
if ((t === 'checkbox' || t === 'radio') && !el.checked) continue;
parts.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value));
}
return parts.join('&');
}
function load(section, query, pushUrl) {
var box = boxes[section];
if (!box) return;
box.classList.add('vis-loading');
var xhr = new XMLHttpRequest();
try {
xhr.open('GET', 'visits_data.php?section=' + encodeURIComponent(section) + (query ? ('&' + query) : ''), true);
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
box.classList.remove('vis-loading');
if (xhr.status === 200) {
box.innerHTML = xhr.responseText;
if (pushUrl && history.replaceState) {
history.replaceState(null, '', 'visits.php' + (query ? ('?' + query) : ''));
}
} else {
box.innerHTML = '<div class="err-msg">加载失败(可能登录状态已失效),请刷新页面后重试。</div>';
}
};
xhr.send();
} catch (e) {
box.classList.remove('vis-loading');
}
}
// 筛选表单(事件委托,局部刷新后依然有效)
document.addEventListener('submit', function (ev) {
var form = ev.target;
if (!form || !form.getAttribute || !form.hasAttribute('data-vis-form')) return;
if (!/get/i.test(form.method || '')) return;
ev.preventDefault();
load(form.getAttribute('data-vis-form'), serialize(form), true);
}, true);
// 分页链接(委托到 document避开局部刷新后监听丢失
document.addEventListener('click', function (ev) {
var a = (ev.target && ev.target.closest) ? ev.target.closest('a[data-vis-page]') : null;
if (!a) return;
ev.preventDefault();
load(a.getAttribute('data-vis-section') || 'access', a.getAttribute('data-vis-page') || '', true);
}, true);
}
/* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */
@ -564,6 +768,9 @@
initCopyButtons();
bindConfirmForms();
initVisitorInfo();
initLoginDevice();
initVisitsAjax();
initAdminSide();
initToTop();
initFuncBack();
hpShowPageToast();

347
assets/js/navicat.js Normal file
View File

@ -0,0 +1,347 @@
/* navicat.js —— Navicat 数据库连接密码 本地解密 / 加密纯前端不上传服务器
* 支持两种算法依据 HyperSinehow-does-navicat-encrypt-password实现
* 版本 11 及以前Blowfish/ECB/NoPadding + 自定义链式
* 密钥 = SHA1("3DC5CA39")IV = E(FFFFFFFFFFFFFFFF) = d9c7c3c8870d64bd无填充
* 版本 12 及以后AES-128-CBC/PKCS7
* 密钥 = "libcckeylibcckey"IV = "libcciv libcciv "
* 组件依赖优先本地 assets/js/缺失回退公共 CDN仅加载代码不含你的内容
* crypto-js 4.2.0 版本 12 AES-128-CBC
* egoroof-blowfish 2.2.2 版本 11 Blowfish/ECB 单块
*/
(function (global) {
'use strict';
var AES_KEY = 'libcckeylibcckey';
var AES_IV = 'libcciv libcciv ';
var BLOW_KEY_HEX = '42ceb271a5e458b74aea93947922354391873340'; // SHA1("3DC5CA39") 的 20 字节密钥
var BLOW_IV_HEX = 'd9c7c3c8870d64bd'; // Blowfish E(FFFFFFFFFFFFFFFF)
var LIBS = {
cryptojs: {
key: 'CryptoJS',
urls: [
'../assets/js/crypto-js.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js',
'https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.min.js'
]
},
blowfish: {
key: 'Blowfish',
urls: [
'../assets/js/blowfish.js',
'https://cdn.jsdelivr.net/npm/egoroof-blowfish@2.2.2/dist/blowfish.js',
'https://unpkg.com/egoroof-blowfish@2.2.2/dist/blowfish.js'
]
}
};
var loadCache = {};
/* 懒加载指定组件(首个可用地址即可,返回 Promise<库对象|null> */
function loadLib(name) {
var spec = LIBS[name];
if (global[spec.key]) return Promise.resolve(global[spec.key]);
if (loadCache[name]) return loadCache[name];
loadCache[name] = new Promise(function (resolve) {
var go = function (idx) {
if (global[spec.key]) { resolve(global[spec.key]); return; }
if (idx >= spec.urls.length) { resolve(null); return; }
var s = document.createElement('script');
s.src = spec.urls[idx];
s.async = true;
s.onload = function () { resolve(global[spec.key] || null); };
s.onerror = function () { go(idx + 1); };
document.head.appendChild(s);
};
go(0);
});
return loadCache[name];
}
/* ================= 字节 / 编码工具 ================= */
function cleanHex(s) { return String(s == null ? '' : s).replace(/[^0-9a-fA-F]/g, ''); }
function hexToBytes(hex) {
var h = cleanHex(hex);
if (h.length % 2 !== 0) throw new Error('十六进制长度必须为偶数');
var out = new Uint8Array(h.length / 2);
for (var i = 0; i < out.length; i++) out[i] = parseInt(h.substr(i * 2, 2), 16);
return out;
}
function bytesToHex(bytes) {
var s = '';
for (var i = 0; i < bytes.length; i++) {
var b = bytes[i] & 0xff;
s += (b < 16 ? '0' : '') + b.toString(16);
}
return s;
}
function utf8Bytes(str) {
str = String(str);
if (typeof TextEncoder !== 'undefined') return new TextEncoder().encode(str);
var out = [], i, c;
for (i = 0; i < str.length; i++) {
c = str.charCodeAt(i);
if (c < 0x80) out.push(c);
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f));
else if (c < 0xd800 || c >= 0xe000) out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f));
else {
var ch = (c - 0xd800) * 0x400 + (str.charCodeAt(++i) - 0xdc00) + 0x10000;
out.push(0xf0 | (ch >> 18), 0x80 | ((ch >> 12) & 0x3f), 0x80 | ((ch >> 6) & 0x3f), 0x80 | (ch & 0x3f));
}
}
return new Uint8Array(out);
}
function utf8Str(bytes) {
if (typeof TextDecoder !== 'undefined') return new TextDecoder('utf-8').decode(bytes);
var out = '', i = 0, b1, b2, b3, b4, c;
while (i < bytes.length) {
b1 = bytes[i++];
if (b1 < 0x80) out += String.fromCharCode(b1);
else if (b1 < 0xe0) { b2 = bytes[i++]; out += String.fromCharCode(((b1 & 0x1f) << 6) | (b2 & 0x3f)); }
else if (b1 < 0xf0) {
b2 = bytes[i++]; b3 = bytes[i++];
out += String.fromCharCode(((b1 & 0x0f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f));
} else {
b2 = bytes[i++]; b3 = bytes[i++]; b4 = bytes[i++];
c = (((b1 & 0x07) << 18) | ((b2 & 0x3f) << 12) | ((b3 & 0x3f) << 6) | (b4 & 0x3f)) - 0x10000;
out += String.fromCharCode(0xd800 + (c >> 10), 0xdc00 + (c & 0x3ff));
}
}
return out;
}
function xorBytes(a, b) {
var n = Math.min(a.length, b.length), out = new Uint8Array(n);
for (var i = 0; i < n; i++) out[i] = a[i] ^ b[i];
return out;
}
function concatBytes(list) {
var len = 0, i;
for (i = 0; i < list.length; i++) len += list[i].length;
var out = new Uint8Array(len), off = 0;
for (i = 0; i < list.length; i++) { out.set(list[i], off); off += list[i].length; }
return out;
}
function ab(u8) { return u8.buffer.slice(u8.byteOffset, u8.byteOffset + u8.byteLength); }
function asU8(x) {
if (x instanceof Uint8Array) return x;
if (typeof ArrayBuffer !== 'undefined' && x instanceof ArrayBuffer) return new Uint8Array(x);
if (Array.isArray(x)) return new Uint8Array(x);
if (x && typeof x.length === 'number') return new Uint8Array(x);
throw new Error('无法识别的字节数据');
}
/* ================= 版本 11Blowfish 自定义链式(无填充) ================= */
function newBlowfish(Blowfish) {
return new Blowfish(ab(hexToBytes(BLOW_KEY_HEX)), Blowfish.MODE.ECB, Blowfish.PADDING.NULL);
}
function bfEnc(bf, block) { return asU8(bf.encode(ab(block))).slice(0, 8); }
function bfDec(bf, block, Blowfish) { return asU8(bf.decode(ab(block), Blowfish.TYPE.UINT8_ARRAY)).slice(0, 8); }
function encrypt11(Blowfish, text) {
var bf = newBlowfish(Blowfish);
var v = hexToBytes(BLOW_IV_HEX);
var data = utf8Bytes(text);
var rounds = Math.floor(data.length / 8);
var left = data.length % 8;
var out = '';
for (var i = 0; i < rounds; i++) {
var block = data.slice(i * 8, i * 8 + 8);
var temp = bfEnc(bf, xorBytes(block, v));
v = xorBytes(v, temp);
out += bytesToHex(temp).toUpperCase();
}
if (left > 0) {
v = bfEnc(bf, v);
out += bytesToHex(xorBytes(data.slice(rounds * 8), v)).toUpperCase();
}
return out;
}
function decrypt11(Blowfish, hex) {
var bf = newBlowfish(Blowfish);
var v = hexToBytes(BLOW_IV_HEX);
var data = hexToBytes(hex);
var rounds = Math.floor(data.length / 8);
var left = data.length % 8;
var parts = [];
for (var i = 0; i < rounds; i++) {
var block = data.slice(i * 8, i * 8 + 8);
parts.push(xorBytes(bfDec(bf, block, Blowfish), v));
v = xorBytes(v, block);
}
if (left > 0) {
v = bfEnc(bf, v);
parts.push(xorBytes(data.slice(rounds * 8), v));
}
return utf8Str(concatBytes(parts));
}
/* ================= 版本 12AES-128-CBC / PKCS7 ================= */
function pkcs7Pad(u8, block) {
var pad = block - (u8.length % block);
var out = new Uint8Array(u8.length + pad);
out.set(u8);
for (var i = 0; i < pad; i++) out[u8.length + i] = pad;
return out;
}
function pkcs7Unpad(u8, block) {
if (!u8.length || u8.length % block) return u8;
var pad = u8[u8.length - 1];
if (pad < 1 || pad > block || pad > u8.length) return u8; // 非合法填充:按原文返回
for (var i = u8.length - pad; i < u8.length; i++) {
if (u8[i] !== pad) return u8;
}
return u8.slice(0, u8.length - pad);
}
function u8ToWa(CryptoJS, u8) {
var words = [];
for (var i = 0; i < u8.length; i++) words[i >>> 2] = (words[i >>> 2] || 0) | (u8[i] << (24 - (i % 4) * 8));
return CryptoJS.lib.WordArray.create(words, u8.length);
}
function waToU8(wa) {
var words = wa.words, n = wa.sigBytes, out = new Uint8Array(n);
for (var i = 0; i < n; i++) out[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
return out;
}
function encrypt12(CryptoJS, text) {
var key = CryptoJS.enc.Utf8.parse(AES_KEY);
var iv = CryptoJS.enc.Utf8.parse(AES_IV);
var data = u8ToWa(CryptoJS, pkcs7Pad(utf8Bytes(text), 16));
var enc = CryptoJS.AES.encrypt(data, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding });
return enc.ciphertext.toString(CryptoJS.enc.Hex).toUpperCase();
}
function decrypt12(CryptoJS, hex) {
var key = CryptoJS.enc.Utf8.parse(AES_KEY);
var iv = CryptoJS.enc.Utf8.parse(AES_IV);
var ct = CryptoJS.enc.Hex.parse(cleanHex(hex).toLowerCase());
var dec = CryptoJS.AES.decrypt({ ciphertext: ct }, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding });
return utf8Str(pkcs7Unpad(waToU8(dec), 16));
}
/* ================= .ncx 连接文件解析 ================= */
function decodeEntities(s) {
return String(s).replace(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-f]+);/gi, function (m, g) {
var k = g.toLowerCase();
if (k === 'amp') return '&';
if (k === 'lt') return '<';
if (k === 'gt') return '>';
if (k === 'quot') return '"';
if (k === 'apos') return "'";
if (g.charAt(0) === '#') {
var code = g.charAt(1).toLowerCase() === 'x' ? parseInt(g.substr(2), 16) : parseInt(g.substr(1), 10);
return isNaN(code) ? m : String.fromCharCode(code);
}
return m;
});
}
/* 解析 .ncxNavicat 导出的 XML 连接文件),返回连接数组 */
function parseNcx(xmlText) {
var text = String(xmlText == null ? '' : xmlText);
if (!/\S/.test(text)) throw new Error('文件内容为空');
var connRe = /<Connection\b[^>]*?\/?>/gi;
var attrRe = /([A-Za-z_][\w:.-]*)\s*=\s*"([^"]*)"/g;
var out = [], m;
while ((m = connRe.exec(text))) {
var attrs = {}, a;
attrRe.lastIndex = 0;
while ((a = attrRe.exec(m[0]))) attrs[a[1]] = decodeEntities(a[2]);
out.push({
name: attrs.ConnectionName || '',
type: attrs.ConnType || '',
host: attrs.Host || '',
port: attrs.Port || '',
user: attrs.UserName || '',
password: attrs.Password || attrs.Pwd || '',
attrs: attrs
});
}
if (!out.length) throw new Error('未找到 <Connection> 节点,请确认是 Navicat 导出的 .ncx 文件');
return out;
}
function formatReport(rows, version) {
var lines = ['共解析 ' + rows.length + ' 个连接(密码按 Navicat ' + version + ' 解密)'];
for (var i = 0; i < rows.length; i++) {
var r = rows[i];
lines.push('');
lines.push('【' + (i + 1) + '】' + (r.name || '(未命名)') + (r.type ? '' + r.type + '' : ''));
lines.push(' 主机:' + (r.host || '(空)'));
lines.push(' 端口:' + (r.port || '(空)'));
lines.push(' 用户名:' + (r.user || '(空)'));
lines.push(' 密码:' + (r.plain ? r.plain : '(空)') + (r.plainNote || ''));
if (r.password && !/^null$/i.test(r.password)) lines.push(' 密码密文:' + r.password);
}
return lines.join('\n') + '\n';
}
/* 解析文件并解密每个连接密码返回规范格式文本Promise<string> */
function buildConnectionReport(xmlText, version) {
var ver = normalizeVersion(version);
var rows;
try { rows = parseNcx(xmlText); } catch (e) { return Promise.reject(e); }
return Promise.all(rows.map(function (c) {
var pwd = c.password;
var make = function (plain, note) {
return {
name: c.name, type: c.type, host: c.host, port: c.port, user: c.user,
password: pwd, plain: plain, plainNote: note || ''
};
};
if (!pwd || /^null$/i.test(pwd)) return Promise.resolve(make('', ''));
if (!/^[0-9a-fA-F]+$/.test(pwd) || pwd.length % 2 !== 0) {
return Promise.resolve(make(pwd, '(非十六进制密文,按原文显示)'));
}
return decrypt(pwd, ver).then(function (t) {
return make(t, t.indexOf('\uFFFD') >= 0 ? '(解密结果含乱码,请核对版本)' : '');
}).catch(function (e) {
return make(pwd, '(解密失败:' + (e && e.message ? e.message : e) + '');
});
})).then(function (list) {
return formatReport(list, ver);
});
}
/* ================= 对外接口 ================= */
function normalizeVersion(v) { return String(v) === '11' ? 11 : 12; }
function decrypt(cipher, version) {
var ver = normalizeVersion(version);
if (!cleanHex(cipher).length) return Promise.reject(new Error('请输入十六进制密文'));
return loadLib(ver === 11 ? 'blowfish' : 'cryptojs').then(function (lib) {
if (!lib) throw new Error('加密组件加载失败,请检查网络后重试');
return ver === 11 ? decrypt11(lib, cipher) : decrypt12(lib, cipher);
});
}
function encrypt(text, version) {
var ver = normalizeVersion(version);
if (text === '' || text == null) return Promise.reject(new Error('请输入明文'));
return loadLib(ver === 11 ? 'blowfish' : 'cryptojs').then(function (lib) {
if (!lib) throw new Error('加密组件加载失败,请检查网络后重试');
return ver === 11 ? encrypt11(lib, text) : encrypt12(lib, text);
});
}
global.NavicatCipher = {
decrypt: decrypt,
encrypt: encrypt,
buildConnectionReport: buildConnectionReport,
_core: {
encrypt11: encrypt11, decrypt11: decrypt11, encrypt12: encrypt12, decrypt12: decrypt12,
parseNcx: parseNcx, formatReport: formatReport,
hexToBytes: hexToBytes, bytesToHex: bytesToHex, utf8Bytes: utf8Bytes, utf8Str: utf8Str,
pkcs7Pad: pkcs7Pad, pkcs7Unpad: pkcs7Unpad
}
};
})(typeof window !== 'undefined' ? window : globalThis);

View File

@ -1,6 +1,6 @@
<?php
/**
* func/index.php —— 功能区首页(工具入口,由后台“功能区管理”动态驱动)
* func/index.php —— 功能区首页(工具入口,由后台“内部功能”动态驱动)
*/
$P = '../';
require_once dirname(__DIR__) . '/includes/layout.php';
@ -14,29 +14,30 @@ layout_head('功能区');
<section class="sub-hero">
<a class="back-home" href="../index.php"> 返回首页</a>
<h1>功能区</h1>
<p class="st-tip">工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p>
<p class="st-tip">工具统一收纳于此;新工具的入口可在后台“内部功能”中登记</p>
</section>
<div class="wrap page-body">
<?php if ($tools): ?>
<div class="func-cards">
<?php foreach ($tools as $t):
$rawIcon = trim((string)($t['icon'] ?? ''));
$rawIcon = trim((string) ($t['icon'] ?? ''));
$rawIcon = $rawIcon !== '' ? $rawIcon : '🧩';
$isExt = (int)$t['is_external'] === 1;
$isExt = (int) $t['is_external'] === 1;
?>
<a class="func-card" href="<?= he((string)$t['url']) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
<a class="func-card" href="<?= he((string) $t['url']) ?>" <?php if ($isExt): ?> target="_blank"
rel="noopener noreferrer" <?php endif; ?>>
<span class="fc-icon"><?= site_icon($rawIcon, '') ?></span>
<span>
<span class="fc-name"><?= he((string)$t['name']) ?></span>
<span class="fc-desc"><?= he((string)$t['description']) ?></span>
<span class="fc-name"><?= he((string) $t['name']) ?></span>
<span class="fc-desc"><?= he((string) $t['description']) ?></span>
</span>
</a>
<?php endforeach; ?>
</div>
<?php else: ?>
<div class="panel-card">
<div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“功能区管理”中添加</span></div>
<div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“内部功能”中添加</span></div>
</div>
<?php endif; ?>
</div>
@ -46,4 +47,5 @@ layout_head('功能区');
<?php layout_footer(); ?>
<script src="../assets/js/common.js"></script>
</body>
</html>

194
func/navicat.html Normal file
View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="renderer" content="webkit">
<script>(function(){var t;try{t=localStorage.getItem("hp-theme")}catch(e){}if(!t){t=(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches)?"dark":"light"}document.documentElement.setAttribute("data-theme",t);})();</script>
<title>Navicat 密码解密</title>
<link rel="icon" href="../assets/img/logo.svg">
<link rel="stylesheet" href="../assets/css/common.css">
</head>
<body>
<div class="func-bar"><div class="wrap func-bar-inner">
<a class="btn btn-sm js-fb-back" href="index.php" title="返回功能区首页">← 返回功能区首页</a>
<span class="func-bar-title">Navicat 密码解密</span>
<a class="btn btn-sm" href="../index.php">首页</a>
</div></div>
<main class="page-main">
<div class="wrap page-body">
<div class="panel-card">
<p class="tip" style="margin-top:0">🔒 全部运算在本机浏览器完成,<b>密文 / 明文不会上传服务器</b>,可放心处理。</p>
<p class="tip" style="margin-top:4px">打开Navicat点击左上角<code>文件</code>,点击<code>导出连接</code>选项,必须选择<code>导出密码</code>然后上传ncx或者自行筛选password即可</p>
<div class="field-row" style="align-items:center;margin-top:12px">
<label class="fl" style="margin:0" for="nvVer">Navicat 版本</label>
<select id="nvVer">
<option value="12">12 及以后AES-128-CBC</option>
<option value="11">11 及以前Blowfish</option>
</select>
<div class="seg" id="nvDir">
<button type="button" data-dir="dec" class="active">解密</button>
<button type="button" data-dir="enc">加密</button>
</div>
<button type="button" class="btn btn-primary" id="nvRun">执行</button>
<button type="button" class="btn" id="nvSwap" title="把输出内容放入输入区">输出 → 输入</button>
<button type="button" class="btn" id="nvClear">清空</button>
</div>
<div id="nvMsg"></div>
</div>
<div class="panel-card">
<h2>解析 .ncx 连接文件</h2>
<p class="tip">选择 Navicat 导出的 <code>.ncx</code> 连接文件,本工具在本地解析其中全部连接并按规范格式输出;连接密码会用<b>上方所选 Navicat 版本</b>自动解密。文件不会上传服务器。</p>
<div class="field-row" style="align-items:center;flex-wrap:wrap">
<input type="file" id="nvFile" accept=".ncx,.xml,text/xml,application/xml" style="flex:1 1 280px;min-width:220px">
<button type="button" class="btn btn-primary" id="nvParse">解析文件</button>
<span class="tip" style="align-self:center">选择后自动解析;切换版本后可点“解析文件”重新解密</span>
</div>
<div id="nvFileMsg"></div>
</div>
<div class="io-area">
<div class="io-box">
<div class="io-label">
<span>输入区(密文 / 明文,可编辑)</span>
<button type="button" class="btn btn-sm js-copy" data-target="#nvIn">复制</button>
</div>
<textarea id="nvIn" placeholder="解密:粘贴十六进制密文(如 CE3AAB73CBBE383C加密输入明文密码" spellcheck="false"></textarea>
</div>
<div class="io-box">
<div class="io-label">
<span>输出区(可编辑)</span>
<button type="button" class="btn btn-sm js-copy" data-target="#nvOut">复制</button>
</div>
<textarea id="nvOut" placeholder="结果输出到这里,可手动修改后再次使用" spellcheck="false"></textarea>
</div>
</div>
</div>
</main>
<button type="button" class="theme-fab js-theme-btn" title="切换昼夜模式" aria-label="切换昼夜模式"><span class="js-theme-icon"></span></button>
<button type="button" class="theme-fab to-top js-to-top" title="返回顶部" aria-label="返回顶部"></button>
<script src="../assets/js/common.js"></script>
<script src="../assets/js/navicat.js"></script>
<script>
(function () {
'use strict';
function $(id) { return document.getElementById(id); }
var verSel = $('nvVer');
var dirButtons = Array.prototype.slice.call(document.querySelectorAll('#nvDir button'));
var nvIn = $('nvIn');
var nvOut = $('nvOut');
var nvMsg = $('nvMsg');
function activeDir() {
var b = dirButtons.filter(function (x) { return x.classList.contains('active'); })[0];
return b ? b.getAttribute('data-dir') : 'dec';
}
function setMsg(text, isErr) {
nvMsg.innerHTML = '';
if (!text) return;
var d = document.createElement('div');
d.className = isErr ? 'err-msg' : 'ok-msg';
d.textContent = text;
nvMsg.appendChild(d);
}
function busy(on) {
$('nvRun').disabled = on;
$('nvRun').textContent = on ? '处理中…' : '执行';
}
function label(dir) { return dir === 'dec' ? '解密' : '加密'; }
function run() {
var ver = verSel.value;
var dir = activeDir();
var text = nvIn.value;
if (!text) { setMsg('请先输入内容', true); nvIn.focus(); return; }
var C = window.NavicatCipher;
if (!C) { setMsg('组件未加载,请刷新页面重试', true); return; }
busy(true);
setMsg('正在' + label(dir) + '…', false);
var p = (dir === 'dec') ? C.decrypt(text, ver) : C.encrypt(text, ver);
p.then(function (out) {
nvOut.value = out;
busy(false);
if (dir === 'dec' && out && out.indexOf('\uFFFD') >= 0) {
setMsg('解密完成,但结果含乱码字符:可能是密文与所选版本不匹配,或密文不完整 / 不合法,请核对版本后重试。', true);
} else {
setMsg(label(dir) + '完成Navicat ' + ver + '', false);
}
}).catch(function (e) {
busy(false);
setMsg(label(dir) + '失败:' + (e && e.message ? e.message : e), true);
});
}
dirButtons.forEach(function (b) {
b.addEventListener('click', function () {
dirButtons.forEach(function (x) { x.classList.remove('active'); });
b.classList.add('active');
nvIn.placeholder = (b.getAttribute('data-dir') === 'dec')
? '解密:粘贴十六进制密文(如 CE3AAB73CBBE383C'
: '加密:输入明文密码';
});
});
$('nvRun').addEventListener('click', run);
$('nvSwap').addEventListener('click', function () {
if (nvOut.value) { nvIn.value = nvOut.value; setMsg('已将输出内容回填到输入区', false); }
});
$('nvClear').addEventListener('click', function () {
nvIn.value = ''; nvOut.value = ''; setMsg('', false); nvIn.focus();
});
nvIn.addEventListener('keydown', function (ev) {
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'Enter') { ev.preventDefault(); run(); }
});
/* ---------- .ncx 连接文件解析 ---------- */
var nvFile = $('nvFile');
var nvFileMsg = $('nvFileMsg');
var nvParseBtn = $('nvParse');
var ncxText = '';
function setFileMsg(text, isErr) {
nvFileMsg.innerHTML = '';
if (!text) return;
var d = document.createElement('div');
d.className = isErr ? 'err-msg' : 'ok-msg';
d.textContent = text;
nvFileMsg.appendChild(d);
}
function parseBusy(on) {
nvParseBtn.disabled = on;
nvParseBtn.textContent = on ? '解析中…' : '解析文件';
}
function runNcx() {
var C = window.NavicatCipher;
if (!C) { setFileMsg('组件未加载,请刷新页面重试', true); return; }
if (!ncxText) { setFileMsg('请先选择 .ncx 文件', true); return; }
parseBusy(true);
setFileMsg('正在解析并解密…', false);
C.buildConnectionReport(ncxText, verSel.value).then(function (report) {
nvOut.value = report;
parseBusy(false);
setFileMsg('解析完成,结果已输出到下方输出区', false);
}).catch(function (e) {
parseBusy(false);
setFileMsg('解析失败:' + (e && e.message ? e.message : e), true);
});
}
nvFile.addEventListener('change', function () {
var f = nvFile.files && nvFile.files[0];
if (!f) return;
var reader = new FileReader();
reader.onload = function () { ncxText = String(reader.result || ''); setFileMsg('已读取:' + f.name, false); runNcx(); };
reader.onerror = function () { setFileMsg('文件读取失败', true); };
reader.readAsText(f, 'UTF-8');
});
nvParseBtn.addEventListener('click', runNcx);
})();
</script>
</body>
</html>

View File

@ -42,14 +42,22 @@ function hp_verify_user(string $username, string $plain): bool
return hash_equals($row['password_hash'], hp_password_hash($plain));
}
/** 执行登录(成功返回 true */
function hp_login(string $username, string $plain): bool
/** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息 */
function hp_login(string $username, string $plain, string $deviceClient = ''): bool
{
$uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
if (hp_verify_user($username, $plain)) {
session_regenerate_id(true);
$_SESSION['admin'] = $username;
// 记录本次登录信息(供后台“登录信息”卡片展示)
$_SESSION['login_at'] = date('Y-m-d H:i:s');
$_SESSION['login_ip'] = client_ip();
$_SESSION['login_ua'] = login_ua_brief($uaRaw);
$_SESSION['login_device'] = login_device_brief($deviceClient, $uaRaw);
login_log_write($username, 'ok', $deviceClient);
return true;
}
login_log_write($username, 'fail', $deviceClient);
return false;
}
@ -59,14 +67,14 @@ function hp_change_password(string $oldPlain, string $newPlain): array
if (!is_logged_in()) {
return [false, '未登录'];
}
if (!hp_verify_user((string)$_SESSION['admin'], $oldPlain)) {
if (!hp_verify_user((string) $_SESSION['admin'], $oldPlain)) {
return [false, '原密码不正确'];
}
if (strlen($newPlain) < 6) {
return [false, '新密码至少 6 位'];
}
$st = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
$st->execute([hp_password_hash($newPlain), (string)$_SESSION['admin']]);
$st->execute([hp_password_hash($newPlain), (string) $_SESSION['admin']]);
return [true, '密码修改成功'];
}
@ -87,7 +95,7 @@ function csrf_token(): string
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return (string)$_SESSION['csrf'];
return (string) $_SESSION['csrf'];
}
function csrf_field(): string

View File

@ -35,7 +35,7 @@ function db_ensure_column(PDO $pdo, string $table, string $column, string $defin
{
$cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll();
foreach ($cols as $col) {
if (strtolower((string)$col['name']) === strtolower($column)) {
if (strtolower((string) $col['name']) === strtolower($column)) {
return;
}
}
@ -138,6 +138,21 @@ function db_init(PDO $pdo): void
updated_at TEXT NOT NULL DEFAULT \'\'
)');
// ---------- 登录日志(成功 / 失败均记录:时间 / 用户名 / IP / UA / 设备) ----------
$pdo->exec('CREATE TABLE IF NOT EXISTS login_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT DEFAULT \'\',
ip TEXT DEFAULT \'\',
ua TEXT DEFAULT \'\',
device TEXT DEFAULT \'\',
status TEXT NOT NULL DEFAULT \'ok\',
created_at TEXT NOT NULL DEFAULT \'\'
)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_status ON login_log (status, created_at)');
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)');
// 兼容旧库:登录日志补 device 列
db_ensure_column($pdo, 'login_log', 'device', "TEXT DEFAULT ''");
// 兼容旧库ip_blacklist 补 source 列categories 补充 icon 列
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
@ -151,7 +166,7 @@ function db_init(PDO $pdo): void
db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''");
// ---------- 默认分类种子 ----------
$count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
$count = (int) $pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
if ($count === 0) {
$seedCats = [
['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'],
@ -166,30 +181,30 @@ function db_init(PDO $pdo): void
}
// ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ----------
$hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
$hasArticleCat = (int) $pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
if ($hasArticleCat === 0) {
$stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)');
$stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]);
}
// ---------- 默认设置种子 ----------
$hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
$hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
if ($hasSettings === 0) {
$defaults = [
'site_name' => '知识导航站',
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
'icp_no' => '京ICP备00000000号-1',
'gongan_no' => '京公网安备11000000000000号',
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
'footer_text' => '',
'search_engines'=> '', // 搜索引擎列表 JSON空 = 使用默认 4 个
'last_updated' => '',
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
'hero_mode' => 'weather', // 首页顶部组件模式weather=天气(默认) / info=访客信息 / blank=空白精简
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
'article_note' => '', // 文章区首页顶部注要Markdown 风格,纯文本)
'site_name' => '知识导航站',
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
'icp_no' => '京ICP备00000000号-1',
'gongan_no' => '京公网安备11000000000000号',
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
'footer_text' => '',
'search_engines' => '', // 搜索引擎列表 JSON空 = 使用默认 4 个
'last_updated' => '',
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
'hero_mode' => 'weather', // 首页顶部组件模式weather=天气(默认) / info=访客信息 / blank=空白精简
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
'article_note' => '', // 文章区首页顶部注要Markdown 风格,纯文本)
];
$st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)');
foreach ($defaults as $k => $v) {
@ -198,7 +213,7 @@ function db_init(PDO $pdo): void
}
// ---------- 默认管理员 ----------
$userCount = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($userCount === 0) {
// 密码算法:先 base64 再 md5按原始需求实现
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
@ -206,7 +221,7 @@ function db_init(PDO $pdo): void
}
// ---------- 功能区工具默认种子 ----------
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
$toolCount = (int) $pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
if ($toolCount === 0) {
$seedTools = [
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0],
@ -226,19 +241,20 @@ function db_init(PDO $pdo): void
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密SM1 为不公开硬件算法)', 'gmcodec.html'],
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务systemd 与 init.d', 'persist.html'],
['Navicat 密码解密', '🔓', 'Navicat 数据库连接密码本地解密 / 加密11 及以前 Blowfish / 12 及以后 AES-128-CBC', 'navicat.html'],
];
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
foreach ($extraTools as $t) {
$chkUrl->execute([$t[3]]);
if ((int)$chkUrl->fetchColumn() === 0) {
$mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
if ((int) $chkUrl->fetchColumn() === 0) {
$mx = (int) $pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
$insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]);
}
}
// ---------- 库首页“外部工具区”默认种子cat_keys 为空=展示于所有库首页grp=分区/栏目,留空不分) ----------
$extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
$extCount = (int) $pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
if ($extCount === 0) {
$seedExt = [
// [分区/栏目, 名称, 网址, 简介]
@ -257,7 +273,7 @@ function db_init(PDO $pdo): void
}
// ---------- 文章库示例文章(首次建库时提供一篇使用说明) ----------
$artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
$artCount = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
if ($artCount === 0) {
$sampleMd = <<<MD
## 分区与文章
@ -289,13 +305,13 @@ MD;
// ---------- 功能区工具登记:旧 PHP 外壳 → 静态 HTML.php 已改为 302 重定向,入口自动同步迁移) ----------
$toolHtmlMap = [
'codec.php' => 'codec.html',
'codec.php' => 'codec.html',
'password.php' => 'password.html',
'qrcode.php' => 'qrcode.html',
'av.php' => 'av.html',
'ip.php' => 'ip.html',
'persist.php' => 'persist.html',
'gmcodec.php' => 'gmcodec.html',
'qrcode.php' => 'qrcode.html',
'av.php' => 'av.html',
'ip.php' => 'ip.html',
'persist.php' => 'persist.html',
'gmcodec.php' => 'gmcodec.html',
];
$updUrl = $pdo->prepare('UPDATE func_tools SET url = ? WHERE url = ?');
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
@ -322,7 +338,7 @@ function setting_get(string $key, string $default = ''): string
if ($cache === null) {
$cache = [];
foreach (db()->query('SELECT key, value FROM settings') as $row) {
$cache[$row['key']] = (string)$row['value'];
$cache[$row['key']] = (string) $row['value'];
}
}
return array_key_exists($key, $cache) ? $cache[$key] : $default;
@ -342,7 +358,7 @@ function client_ip(): string
if ($cached !== null) {
return $cached;
}
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
$ip = '';
}
@ -376,7 +392,7 @@ function blacklist_parse_rule(string $raw): array
}
// CIDR 网段
if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) {
$prefix = (int)$m[2];
$prefix = (int) $m[2];
if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。'];
}
@ -400,10 +416,10 @@ function blacklist_parse_rule(string $raw): array
}
continue;
}
if ($seen || $p === '' || !ctype_digit($p) || (int)$p > 255) {
if ($seen || $p === '' || !ctype_digit($p) || (int) $p > 255) {
return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。'];
}
$bytes[$i] = (int)$p;
$bytes[$i] = (int) $p;
}
if (!$seen) {
return [false, 0, 0, '规则格式不正确。'];
@ -424,17 +440,17 @@ function blacklist_rules(bool $refresh = false): array
$cache = [];
try {
foreach (db()->query('SELECT id, rule, note, created_at FROM ip_blacklist ORDER BY id ASC') as $row) {
[$ok, $net, $prefix] = blacklist_parse_rule((string)$row['rule']);
[$ok, $net, $prefix] = blacklist_parse_rule((string) $row['rule']);
if (!$ok) {
continue;
}
$cache[] = [
'id' => (int)$row['id'],
'rule' => (string)$row['rule'],
'note' => (string)$row['note'],
'created_at' => (string)$row['created_at'],
'net' => $net,
'prefix' => $prefix,
'id' => (int) $row['id'],
'rule' => (string) $row['rule'],
'note' => (string) $row['note'],
'created_at' => (string) $row['created_at'],
'net' => $net,
'prefix' => $prefix,
];
}
} catch (Throwable $e) {
@ -473,12 +489,12 @@ function access_log_flush(): void
if ($ip === '') {
return;
}
$method = (string)($_SERVER['REQUEST_METHOD'] ?? '');
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? '');
$method = $method !== '' ? strtoupper($method) : 'GET';
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
// 登录口令、CSRF 等敏感 query 参数脱敏后再写日志
$uri = preg_replace('/([?&](?:password|passwd|old_pwd|new_pwd|new_pwd2|csrf|token|secret)[^=]*=)[^&]*/i', '$1***', $uri);
$uri = substr((string)$uri, 0, 600);
$uri = substr((string) $uri, 0, 600);
$code = http_response_code();
if (!is_int($code) || $code < 100 || $code > 599) {
$code = 200;
@ -487,7 +503,7 @@ function access_log_flush(): void
$line = date('Y-m-d H:i:s') . "\t" . $ip . "\t" . $method . "\t" . $code . "\t" . $uri . "\n";
@file_put_contents(ACCESS_LOG_FILE, $line, FILE_APPEND | LOCK_EX);
// 自动风控:前台 404 响应计入累计(是否达到阈值触发封禁见 risk_count_not_found / access_boot 自检)
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
if (!$isAdmin && $code === 404) {
risk_count_not_found($ip);
}
@ -496,7 +512,7 @@ function access_log_flush(): void
}
}
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */
function visitors_entries(): array
{
$out = [];
@ -517,11 +533,11 @@ function visitors_entries(): array
continue;
}
$out[] = [
'created_at' => (string)$p[0],
'ip' => (string)$p[1],
'method' => (string)$p[2],
'status' => (int)$p[3],
'url' => (string)$p[4],
'created_at' => (string) $p[0],
'ip' => (string) $p[1],
'method' => (string) $p[2],
'status' => (int) $p[3],
'url' => (string) $p[4],
];
}
fclose($fh);
@ -539,7 +555,7 @@ function access_boot(): void
if ($ip === '') {
return;
}
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
// 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页
if (blacklist_ip_hit($ip) !== null) {
risk_block_page();
@ -548,15 +564,15 @@ function access_boot(): void
if (!$isAdmin) {
$cfg = risk_rule_cfg();
// 2a) 请求频率:先累计本请求,超过阈值即封禁
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int)$cfg['rate']['n'])) {
risk_ban($ip, '请求频率超过阈值60 秒内 ' . (int)$cfg['rate']['n'] . ' 次)');
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int) $cfg['rate']['n'])) {
risk_ban($ip, '请求频率超过阈值60 秒内 ' . (int) $cfg['rate']['n'] . ' 次)');
risk_block_page();
}
// 2b) 404 累计已达标(若上一请求刚触发封禁,这里会因命中黑名单直接拦截,此分支为兑底)
if ($cfg['not_found']['on']) {
$row = risk_row($ip);
if ((int)$row['not_found'] >= (int)$cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
if ((int) $row['not_found'] >= (int) $cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
risk_block_page();
}
}
@ -573,7 +589,7 @@ function risk_settings_map(): array
$map = [];
try {
foreach (db()->query('SELECT key, value FROM settings') as $r) {
$map[(string)$r['key']] = (string)$r['value'];
$map[(string) $r['key']] = (string) $r['value'];
}
} catch (Throwable $e) {
$map = [];
@ -588,10 +604,10 @@ function risk_settings_map(): array
function risk_block_tip(): array
{
$map = risk_settings_map();
$rawMain = trim((string)($map['risk_tip_main'] ?? ''));
$rawSub = trim((string)($map['risk_tip_sub'] ?? ''));
$rawMain = trim((string) ($map['risk_tip_main'] ?? ''));
$rawSub = trim((string) ($map['risk_tip_sub'] ?? ''));
$main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问';
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub];
}
@ -602,9 +618,9 @@ function risk_block_tip(): array
function risk_rule_cfg(): array
{
$defs = [
'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'],
'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'],
];
$map = risk_settings_map();
$out = [];
@ -613,18 +629,18 @@ function risk_rule_cfg(): array
foreach ($defs as $k => $d) {
$pre = $alias[$k];
$out[$k] = [
'on' => (($map['risk_' . $pre . '_on'] ?? (string)$d[0]) === '1'),
'n' => max(1, min(1000000, (int)($map['risk_' . $pre . '_n'] ?? (string)$d[1]))),
'on' => (($map['risk_' . $pre . '_on'] ?? (string) $d[0]) === '1'),
'n' => max(1, min(1000000, (int) ($map['risk_' . $pre . '_n'] ?? (string) $d[1]))),
'tip' => $d[2],
];
}
// 拦截提示(含原始保存值,供后台编辑回显与占位提示)
$tip = risk_block_tip();
$out['_tip'] = [
'main' => $tip['main'],
'sub' => $tip['sub'],
'main' => $tip['main'],
'sub' => $tip['sub'],
'raw_main' => $tip['raw_main'],
'raw_sub' => $tip['raw_sub'],
'raw_sub' => $tip['raw_sub'],
];
return $out;
}
@ -690,9 +706,9 @@ function risk_login_fail(string $ip): array
}
$cfg = risk_rule_cfg();
$row = risk_row($ip);
$lf = (int)$row['login_fail'] + 1;
$n = (int)$cfg['login_fail']['n'];
risk_upsert($ip, $lf, (int)$row['not_found'], (int)$row['rate_ts'], (int)$row['rate_count']);
$lf = (int) $row['login_fail'] + 1;
$n = (int) $cfg['login_fail']['n'];
risk_upsert($ip, $lf, (int) $row['not_found'], (int) $row['rate_ts'], (int) $row['rate_count']);
$banned = $cfg['login_fail']['on'] && $lf >= $n;
if ($banned) {
risk_ban($ip, '后台登录失败累计达阈值(' . $n . ' 次)');
@ -706,10 +722,10 @@ function risk_bump_rate(string $ip, int $threshold): bool
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$win = (int)floor(time() / 60);
$row = risk_row($ip);
$cnt = ((int)$row['rate_ts'] === $win) ? ((int)$row['rate_count'] + 1) : 1;
risk_upsert($ip, (int)$row['login_fail'], (int)$row['not_found'], $win, $cnt);
$win = (int) floor(time() / 60);
$row = risk_row($ip);
$cnt = ((int) $row['rate_ts'] === $win) ? ((int) $row['rate_count'] + 1) : 1;
risk_upsert($ip, (int) $row['login_fail'], (int) $row['not_found'], $win, $cnt);
return $cnt >= $threshold;
}
@ -721,10 +737,10 @@ function risk_count_not_found(string $ip): void
}
$cfg = risk_rule_cfg();
$row = risk_row($ip);
$nf = (int)$row['not_found'] + 1;
risk_upsert($ip, (int)$row['login_fail'], $nf, (int)$row['rate_ts'], (int)$row['rate_count']);
if ($cfg['not_found']['on'] && $nf >= (int)$cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
$nf = (int) $row['not_found'] + 1;
risk_upsert($ip, (int) $row['login_fail'], $nf, (int) $row['rate_ts'], (int) $row['rate_count']);
if ($cfg['not_found']['on'] && $nf >= (int) $cfg['not_found']['n']) {
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
}
}
@ -767,8 +783,8 @@ function risk_block_page(): void
// ignore
}
$tip = risk_block_tip();
$s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8');
$m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8');
$s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8');
$m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8');
$sb = htmlspecialchars($tip['sub'], ENT_QUOTES, 'UTF-8');
echo '<!DOCTYPE html>' . "\n"
. '<html lang="zh-CN"><head><meta charset="utf-8">'
@ -791,4 +807,201 @@ function risk_block_page(): void
exit;
}
/** 由 UA 串解析浏览器与操作系统(内部复用):返回 [浏览器, 操作系统] */
function ua_parts(string $ua): array
{
$ua = trim($ua);
if ($ua === '') {
return ['未知浏览器', ''];
}
if (strpos($ua, 'MicroMessenger') !== false) {
$browser = '微信内置浏览器';
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
$browser = 'Edge ' . $m[1];
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
$browser = 'Opera ' . $m[1];
} elseif (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) {
$browser = 'Firefox ' . $m[1];
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
$browser = 'Chrome ' . $m[1];
} elseif (preg_match('/Version\/([\d.]+).*Safari/', $ua, $m)) {
$browser = 'Safari ' . $m[1];
} elseif (stripos($ua, 'curl') !== false) {
$browser = 'curl';
} elseif (preg_match('/python|wget|Go-http|Java|okhttp/i', $ua)) {
$browser = '脚本客户端';
} else {
$browser = '未知浏览器';
}
if (strpos($ua, 'Windows NT 10.0') !== false) {
$os = 'Windows 10/11';
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
$os = 'Windows 8.1';
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
$os = 'Windows 7';
} elseif (stripos($ua, 'Windows') !== false) {
$os = 'Windows';
} elseif (strpos($ua, 'Android') !== false) {
$os = 'Android';
} elseif (preg_match('/iPhone|iPad|iPod/', $ua)) {
$os = 'iOS';
} elseif (strpos($ua, 'Mac OS X') !== false) {
$os = 'macOS';
} elseif (strpos($ua, 'Linux') !== false) {
$os = 'Linux';
} else {
$os = '';
}
return [$browser, $os];
}
/** 由 UA 串解析“浏览器 / 操作系统”(简洁展示;容错返回“未知设备”) */
function login_ua_brief(string $ua): string
{
if (trim($ua) === '') {
return '未知设备';
}
list($browser, $os) = ua_parts($ua);
return $os !== '' ? ($browser . ' / ' . $os) : $browser;
}
/**
* 设备信息描述(手机型号 / 操作系统 / 浏览器 / 设备类型 + 屏幕、语言等):
* 客户端采集 JSON$clientJson common.js collectLoginDevice优先服务端 UA 回退;能获取到什么就写什么。
*/
function login_device_brief(string $clientJson, string $ua): string
{
$ua = trim($ua);
$ci = json_decode($clientJson, true);
if (!is_array($ci)) {
$ci = [];
}
$uch = (isset($ci['uch']) && is_array($ci['uch'])) ? $ci['uch'] : [];
list($browser, $os) = ua_parts($ua);
// 系统:客户端高熵值优先(可拿到 Windows/Android 具体版本)
if (!empty($uch['platform'])) {
$os2 = trim((string) $uch['platform']);
if (!empty($uch['platformVersion'])) {
$os2 .= ' ' . trim((string) $uch['platformVersion']);
}
if ($os2 !== '') {
$os = $os2;
}
}
// 型号:客户端 model 优先,其次从 Android UA 提取iOS 只能得到机型系列
$model = '';
if (!empty($uch['model'])) {
$model = trim((string) $uch['model']);
} elseif (preg_match('/Android[\s\d.]+;\s*([^;)]+?)(?:\s+Build|\))/i', $ua, $m)) {
$model = trim($m[1]);
} elseif (preg_match('/(iPhone|iPad|iPod)/i', $ua, $m)) {
$model = 'Apple ' . $m[1];
}
$mobile = (!empty($ci['touch'])) || (stripos($ua, 'Mobile') !== false) || (isset($uch['mobile']) && $uch['mobile']);
$seg = [];
if ($model !== '') {
$seg[] = $model;
}
if ($os !== '') {
$seg[] = $os;
}
if ($browser !== '') {
$seg[] = $browser;
}
$seg[] = $mobile ? '移动设备' : '桌面设备';
$extra = [];
if (!empty($uch['architecture'])) {
$extra[] = '架构 ' . trim((string) $uch['architecture']);
}
if (!empty($ci['screen'])) {
$extra[] = '屏幕 ' . trim((string) $ci['screen']);
}
if (!empty($ci['lang'])) {
$extra[] = trim((string) $ci['lang']);
}
$text = implode(' · ', $seg);
if ($extra) {
$text .= '' . implode('', $extra) . '';
}
return function_exists('mb_substr') ? mb_substr($text, 0, 300, 'UTF-8') : substr($text, 0, 300);
}
/** 写入一条登录日志statusok=成功 / fail=失败);同时保存客户端设备信息($deviceClient 为前端采集 JSON */
function login_log_write(string $username, string $status, string $deviceClient = ''): void
{
$status = ($status === 'fail') ? 'fail' : 'ok';
$uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$ua = function_exists('mb_substr') ? mb_substr($uaRaw, 0, 300, 'UTF-8') : substr($uaRaw, 0, 300);
$device = login_device_brief(substr($deviceClient, 0, 2000), $uaRaw);
try {
$st = db()->prepare('INSERT INTO login_log (username, ip, ua, device, status, created_at) VALUES (?,?,?,?,?,?)');
$st->execute([$username, client_ip(), $ua, $device, $status, date('Y-m-d H:i:s')]);
} catch (Throwable $e) {
// 登录日志写入失败不影响登录流程
}
}
/** 最近的成功登录记录(新→旧) */
function login_log_recent_ok(int $limit = 15): array
{
$limit = max(1, min(200, $limit));
try {
$st = db()->prepare("SELECT username, ip, ua, device, created_at FROM login_log WHERE status = 'ok' ORDER BY id DESC LIMIT ?");
$st->bindValue(1, $limit, PDO::PARAM_INT);
$st->execute();
return $st->fetchAll();
} catch (Throwable $e) {
return [];
}
}
/** 登录失败日志按「IP × 日期」聚合(次数 / 最近时间 / 最近设备),支持 IP 模糊筛选 */
function login_fail_daily_stats(string $ipFilter = '', int $limit = 200): array
{
$limit = max(1, min(1000, $limit));
try {
$sql = "SELECT ip, substr(created_at,1,10) AS day, COUNT(*) AS cnt, MAX(created_at) AS last_at,
(SELECT ua FROM login_log l2 WHERE l2.ip = l1.ip AND l2.status = 'fail' ORDER BY l2.id DESC LIMIT 1) AS ua,
(SELECT device FROM login_log l3 WHERE l3.ip = l1.ip AND l3.status = 'fail' ORDER BY l3.id DESC LIMIT 1) AS device
FROM login_log l1 WHERE status = 'fail'";
$args = [];
if ($ipFilter !== '') {
$sql .= ' AND ip LIKE ?';
$args[] = '%' . $ipFilter . '%';
}
$sql .= ' GROUP BY ip, day ORDER BY last_at DESC LIMIT ' . $limit;
$st = db()->prepare($sql);
$st->execute($args);
return $st->fetchAll();
} catch (Throwable $e) {
return [];
}
}
/** 登录日志统计:今日成功 / 今日失败 / 失败涉及 IP 数 / 总记录数 */
function login_log_stat(): array
{
$out = ['ok_today' => 0, 'fail_today' => 0, 'fail_ips' => 0, 'total' => 0];
try {
$today = db()->quote(date('Y-m-d'));
$out['ok_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'ok' AND substr(created_at,1,10) = " . $today)->fetchColumn();
$out['fail_today'] = (int) db()->query("SELECT COUNT(*) FROM login_log WHERE status = 'fail' AND substr(created_at,1,10) = " . $today)->fetchColumn();
$out['fail_ips'] = (int) db()->query("SELECT COUNT(DISTINCT ip) FROM login_log WHERE status = 'fail'")->fetchColumn();
$out['total'] = (int) db()->query('SELECT COUNT(*) FROM login_log')->fetchColumn();
} catch (Throwable $e) {
// ignore
}
return $out;
}
/** 清空登录日志(成功 + 失败) */
function login_log_clear(): void
{
try {
db()->exec('DELETE FROM login_log');
} catch (Throwable $e) {
// ignore
}
}
access_boot();

543
nav.php
View File

@ -7,25 +7,26 @@
* 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。
* 数据来源:
* 导航区 = nav_blocks / nav_items按当前库
* 内部功能区 = func_tools后台“功能区管理”中 board_cats 控制按库展示,空=通用);
* 内部功能区 = func_tools后台“内部功能”中 board_cats 控制按库展示,空=通用);
* 文章区 = articles后台“文章管理”中 board_cats 勾选分发到库,未分发不显示);
* 外部工具区 = ext_links后台“外部工具”维护cat_keys 控制范围,空=通用)。
*/
$P = '';
require_once __DIR__ . '/includes/layout.php';
$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
$catKey = isset($_GET['cat']) ? (string) $_GET['cat'] : '';
$allowed = ['popular', 'red', 'blue'];
if (!in_array($catKey, $allowed, true)) {
$catKey = 'popular';
$catKey = 'popular';
}
/** 范围列是否包含当前库:空字符串视为“通用(所有库)” */
function board_cats_has(string $csv, string $key): bool
{
$csv = trim($csv);
if ($csv === '') return true;
return in_array($key, array_map('trim', explode(',', $csv)), true);
$csv = trim($csv);
if ($csv === '')
return true;
return in_array($key, array_map('trim', explode(',', $csv)), true);
}
$pdo = db();
@ -33,89 +34,99 @@ $st = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
$st->execute([$catKey]);
$cat = $st->fetch();
if (!$cat) {
http_response_code(404);
layout_head('导航页不存在');
echo '<main class="page-main"><div class="wrap"><div class="err-msg">导航分类不存在,请返回首页。</div></div></main>';
layout_theme_fab();
layout_footer();
echo '<script src="assets/js/common.js"></script></body></html>';
exit;
http_response_code(404);
layout_head('导航页不存在');
echo '<main class="page-main"><div class="wrap"><div class="err-msg">导航分类不存在,请返回首页。</div></div></main>';
layout_theme_fab();
layout_footer();
echo '<script src="assets/js/common.js"></script></body></html>';
exit;
}
// —— 导航区数据 ——
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
$st->execute([(int)$cat['id']]);
$st->execute([(int) $cat['id']]);
$blocks = $st->fetchAll();
$itemsByBlock = [];
if ($blocks) {
$ids = array_map(function ($b) { return (int)$b['id']; }, $blocks);
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
$st->execute($ids);
foreach ($st->fetchAll() as $item) {
$itemsByBlock[(int)$item['block_id']][] = $item;
}
$ids = array_map(function ($b) {
return (int) $b['id'];
}, $blocks);
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
$st->execute($ids);
foreach ($st->fetchAll() as $item) {
$itemsByBlock[(int) $item['block_id']][] = $item;
}
}
$totalNav = 0;
foreach ($itemsByBlock as $list) { $totalNav += count($list); }
foreach ($itemsByBlock as $list) {
$totalNav += count($list);
}
$navCols = [[], [], []];
$idx = 0;
foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; }
foreach ($blocks as $b) {
$navCols[$idx % 3][] = $b;
$idx++;
}
// —— 内部功能区数据(通用或包含当前库) ——
$libTools = [];
foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $t) {
if (board_cats_has((string)($t['board_cats'] ?? ''), $catKey)) {
$libTools[] = $t;
}
if (board_cats_has((string) ($t['board_cats'] ?? ''), $catKey)) {
$libTools[] = $t;
}
}
// —— 文章区数据(仅分发到当前库;按分类分组,分类内按最后更新时间倒序) ——
$libArts = [];
foreach ($pdo->query('SELECT id, part, title, summary, updated_at, board_cats FROM articles WHERE enabled = 1 ORDER BY updated_at DESC, id DESC')->fetchAll() as $a) {
if (in_array($catKey, array_map('trim', explode(',', trim((string)($a['board_cats'] ?? '')))), true)) {
$libArts[] = $a;
}
if (in_array($catKey, array_map('trim', explode(',', trim((string) ($a['board_cats'] ?? '')))), true)) {
$libArts[] = $a;
}
}
// 按分类part分组分类顺序后台拖出的分区顺序优先否则按各组最新更新时间降序
$artGroups = [];
foreach ($libArts as $a) {
$p = trim((string)$a['part']);
if ($p === '') $p = '未分类';
if (!isset($artGroups[$p])) {
$artGroups[$p] = ['ts' => (string)$a['updated_at'], 'items' => []];
}
$artGroups[$p]['items'][] = $a;
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) {
$artGroups[$p]['ts'] = (string)$a['updated_at'];
}
$p = trim((string) $a['part']);
if ($p === '')
$p = '未分类';
if (!isset($artGroups[$p])) {
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []];
}
$artGroups[$p]['items'][] = $a;
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
$artGroups[$p]['ts'] = (string) $a['updated_at'];
}
}
$poIdx = [];
$poCfg = trim((string)setting_get('article_part_order', ''));
$poCfg = trim((string) setting_get('article_part_order', ''));
if ($poCfg !== '') {
foreach (explode(',', $poCfg) as $po) {
$po = trim($po);
if ($po !== '' && isset($artGroups[$po])) $poIdx[$po] = count($poIdx);
}
foreach (explode(',', $poCfg) as $po) {
$po = trim($po);
if ($po !== '' && isset($artGroups[$po]))
$poIdx[$po] = count($poIdx);
}
}
uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) {
$ia = $poIdx[$a] ?? PHP_INT_MAX;
$ib = $poIdx[$b] ?? PHP_INT_MAX;
if ($ia !== $ib) return $ia <=> $ib;
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
return $d !== 0 ? $d : strcmp($a, $b);
$ia = $poIdx[$a] ?? PHP_INT_MAX;
$ib = $poIdx[$b] ?? PHP_INT_MAX;
if ($ia !== $ib)
return $ia <=> $ib;
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
return $d !== 0 ? $d : strcmp($a, $b);
});
$artGroupOrder = [];
foreach ($artGroups as $p => $g) {
$artGroupOrder[] = ['p' => $p, 'items' => $g['items']];
$artGroupOrder[] = ['p' => $p, 'items' => $g['items']];
}
// —— 外部工具区数据(通用或包含当前库) ——
$extLinks = [];
foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $e) {
if (board_cats_has((string)($e['cat_keys'] ?? ''), $catKey)) {
$extLinks[] = $e;
}
if (board_cats_has((string) ($e['cat_keys'] ?? ''), $catKey)) {
$extLinks[] = $e;
}
}
$siteName = setting_get('site_name', '知识导航站');
@ -123,80 +134,293 @@ $engines = search_engines();
$defaultEngine = $engines[0]['key'] ?? 'bing';
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$domainOf = static function (string $u): string {
$h = (string)parse_url($u, PHP_URL_HOST);
return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u;
$h = (string) parse_url($u, PHP_URL_HOST);
return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u;
};
$catName = (string)$cat['name'];
$catIcon = trim((string)$cat['icon']);
$catName = (string) $cat['name'];
$catIcon = trim((string) $cat['icon']);
layout_head($catName);
?>
<style>
/* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */
.lnx-nav {
position: relative; display: flex; justify-content: center; align-items: center;
gap: 0 38px; margin: 6px 0 16px; border-bottom: 1px solid var(--line); padding: 0 4px;
position: relative;
display: flex;
justify-content: center;
align-items: center;
gap: 0 38px;
margin: 6px 0 16px;
border-bottom: 1px solid var(--line);
padding: 0 4px;
}
.lnx-tab { position: relative; z-index: 1; border: none; background: none; cursor: pointer;
padding: 12px 6px 13px; font-size: 15px; color: var(--text-3);
transition: color .2s ease; white-space: nowrap; }
.lnx-tab:hover { color: var(--text); }
.lnx-tab.active { color: var(--brand); font-weight: 700; }
.lnx-tab {
position: relative;
z-index: 1;
border: none;
background: none;
cursor: pointer;
padding: 12px 6px 13px;
font-size: 15px;
color: var(--text-3);
transition: color .2s ease;
white-space: nowrap;
}
.lnx-tab:hover {
color: var(--text);
}
.lnx-tab.active {
color: var(--brand);
font-weight: 700;
}
.lnx-ink {
position: absolute; left: 0; bottom: -1px; width: 0; height: 3px; border-radius: 3px;
background: var(--brand); box-shadow: 0 0 8px var(--accent-soft);
position: absolute;
left: 0;
bottom: -1px;
width: 0;
height: 3px;
border-radius: 3px;
background: var(--brand);
box-shadow: 0 0 8px var(--accent-soft);
transition: left .28s cubic-bezier(.45, 0, .25, 1), width .28s cubic-bezier(.45, 0, .25, 1);
}
.lnx-panel { display: none; }
.lnx-panel.active { display: block; }
.lnx-phead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
.lnx-phead h3 { margin: 0; font-size: 18px; }
.lnx-phead .lnx-go { font-size: 13px; color: var(--text-3); text-decoration: none; }
.lnx-note { font-size: 12.5px; color: var(--text-3); background: var(--card); border: 1px dashed var(--line-2);
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px; }
.lnx-panel {
display: none;
}
.lnx-panel.active {
display: block;
}
.lnx-phead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
margin: 4px 0 14px;
}
.lnx-phead h3 {
margin: 0;
font-size: 18px;
}
.lnx-phead .lnx-go {
font-size: 13px;
color: var(--text-3);
text-decoration: none;
}
.lnx-note {
font-size: 12.5px;
color: var(--text-3);
background: var(--card);
border: 1px dashed var(--line-2);
border-radius: 10px;
padding: 8px 12px;
margin-bottom: 14px;
}
/* 外部工具区分区标题(按 grp 分组的小节) */
.lnx-eg { display: flex; align-items: center; gap: 10px; margin: 18px 0 8px; font-size: 14.5px; font-weight: 600; color: var(--brand); }
.lnx-eg::after { content: ''; flex: 1; height: 1px; background: var(--line); }
.lnx-exts + .lnx-eg { margin-top: 24px; }
.lnx-empty { color: var(--text-3); font-size: 13.5px; background: var(--card); border: 1px solid var(--line);
border-radius: 12px; padding: 22px 16px; text-align: center; }
.lnx-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.lnx-tool { display: flex; gap: 12px; align-items: flex-start; text-decoration: none; color: var(--text);
background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-tool:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-tool .lnx-ti { font-size: 24px; }
.lnx-tool b { display: block; font-size: 15px; margin-bottom: 4px; }
.lnx-tool span.tip { margin: 0; line-height: 1.5; display: block; }
.lnx-arts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.lnx-art { display: block; text-decoration: none; color: var(--text); background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-art:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-art .lnx-ab { display: inline-block; font-size: 12px; color: var(--brand); background: var(--accent-soft);
border-radius: 6px; padding: 2px 8px; margin-bottom: 8px; }
.lnx-art h4 { margin: 0 0 6px; font-size: 15.5px; }
.lnx-art p { margin: 0 0 8px; font-size: 13px; color: var(--text-3); line-height: 1.6; }
.lnx-art time { font-size: 12px; color: var(--text-3); }
.lnx-exts { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
.lnx-ext { display: block; text-decoration: none; color: var(--text); background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease; }
.lnx-ext:hover { transform: translateY(-2px); border-color: var(--brand); }
.lnx-ext .lnx-eh { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.lnx-ext .lnx-en { font-weight: 600; font-size: 14.5px; }
.lnx-ext .lnx-ed { font-size: 12px; color: var(--text-3); word-break: break-all; }
.lnx-ext p { margin: 6px 0 0; font-size: 12.5px; color: var(--text-3); line-height: 1.5; }
.lnx-eg {
display: flex;
align-items: center;
gap: 10px;
margin: 18px 0 8px;
font-size: 14.5px;
font-weight: 600;
color: var(--brand);
}
.lnx-eg::after {
content: '';
flex: 1;
height: 1px;
background: var(--line);
}
.lnx-exts+.lnx-eg {
margin-top: 24px;
}
.lnx-empty {
color: var(--text-3);
font-size: 13.5px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 12px;
padding: 22px 16px;
text-align: center;
}
.lnx-tools {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 12px;
}
.lnx-tool {
display: flex;
gap: 12px;
align-items: flex-start;
text-decoration: none;
color: var(--text);
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease;
}
.lnx-tool:hover {
transform: translateY(-2px);
border-color: var(--brand);
}
.lnx-tool .lnx-ti {
font-size: 24px;
}
.lnx-tool b {
display: block;
font-size: 15px;
margin-bottom: 4px;
}
.lnx-tool span.tip {
margin: 0;
line-height: 1.5;
display: block;
}
.lnx-arts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.lnx-art {
display: block;
text-decoration: none;
color: var(--text);
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 14px;
box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease;
}
.lnx-art:hover {
transform: translateY(-2px);
border-color: var(--brand);
}
.lnx-art .lnx-ab {
display: inline-block;
font-size: 12px;
color: var(--brand);
background: var(--accent-soft);
border-radius: 6px;
padding: 2px 8px;
margin-bottom: 8px;
}
.lnx-art h4 {
margin: 0 0 6px;
font-size: 15.5px;
}
.lnx-art p {
margin: 0 0 8px;
font-size: 13px;
color: var(--text-3);
line-height: 1.6;
}
.lnx-art time {
font-size: 12px;
color: var(--text-3);
}
.lnx-exts {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
}
.lnx-ext {
display: block;
text-decoration: none;
color: var(--text);
background: var(--card);
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
box-shadow: var(--shadow-sm);
transition: transform .15s ease, border-color .15s ease;
}
.lnx-ext:hover {
transform: translateY(-2px);
border-color: var(--brand);
}
.lnx-ext .lnx-eh {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
.lnx-ext .lnx-en {
font-weight: 600;
font-size: 14.5px;
}
.lnx-ext .lnx-ed {
font-size: 12px;
color: var(--text-3);
word-break: break-all;
}
.lnx-ext p {
margin: 6px 0 0;
font-size: 12.5px;
color: var(--text-3);
line-height: 1.5;
}
@media (max-width: 760px) {
.lnx-nav { gap: 0 10px; }
.lnx-tab { flex: 1 1 0; min-width: 0; font-size: 14px; padding: 10px 2px 12px; text-align: center; }
.nav-cols { grid-template-columns: 1fr; }
.lnx-nav {
gap: 0 10px;
}
.lnx-tab {
flex: 1 1 0;
min-width: 0;
font-size: 14px;
padding: 10px 2px 12px;
text-align: center;
}
.nav-cols {
grid-template-columns: 1fr;
}
}
</style>
<main>
<!-- 顶部留白 + 品牌条(上半区不变) -->
<div class="nav-top">
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
<div class="wrap"
style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
<a class="brand brand-sm" href="index.php" title="返回首页">
<?php layout_logo_img('logo', $siteName); ?>
<span class="site-name"><?= he($siteName) ?></span>
@ -241,26 +465,26 @@ layout_head($catName);
<span class="lnx-go"> <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span>
</div>
<?php if (!$blocks): ?>
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航内容管理」中添加</span></div>
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航管理」中添加</span></div>
<?php else: ?>
<div class="nav-cols" style="margin:0">
<?php foreach ($navCols as $colBlocks): ?>
<div class="nav-col">
<?php foreach ($colBlocks as $b):
$items = $itemsByBlock[(int)$b['id']] ?? [];
$items = $itemsByBlock[(int) $b['id']] ?? [];
$show = array_slice($items, 0, 15);
?>
<div class="nav-block">
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
<div class="nav-block-title"><?= he((string) $b['title']) ?></div>
<div class="nav-block-grid">
<?php for ($i = 0; $i < 15; $i++): ?>
<?php if (isset($show[$i])):
$it = $show[$i];
$label = (string)$it['label'];
$url = trim((string)$it['url']);
$note = trim((string)$it['note']);
$label = (string) $it['label'];
$url = trim((string) $it['url']);
$note = trim((string) $it['note']);
if ($url !== ''): ?>
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer" <?php if ($note !== ''): ?> data-note="<?= he($note) ?>" <?php endif; ?>><?= he($label ?: $url) ?></a>
<?php else: ?>
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
<?php endif; ?>
@ -283,23 +507,24 @@ layout_head($catName);
<h3>内部功能区 · <?= he($catName) ?>常用工具</h3>
<a class="lnx-go" href="func/index.php">前往完整功能区 </a>
</div>
<div class="lnx-note st-tip">由后台「功能区管理」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
<div class="lnx-note st-tip">由后台「内部功能」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
<?php if (!$libTools): ?>
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「功能区管理」开启</span></div>
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「内部功能」开启</span></div>
<?php else: ?>
<div class="lnx-tools">
<?php foreach ($libTools as $tk):
$isExt = (int)$tk['is_external'] === 1;
$url = trim((string)$tk['url']);
$isExt = (int) $tk['is_external'] === 1;
$url = trim((string) $tk['url']);
$href = $isExt ? $url : 'func/' . $url . '?from=nav';
$tIcon = trim((string)($tk['icon'] ?? ''));
$tIcon = trim((string) ($tk['icon'] ?? ''));
$tIcon = $tIcon !== '' ? $tIcon : '🧩';
?>
<a class="lnx-tool" href="<?= he($href) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
<a class="lnx-tool" href="<?= he($href) ?>" <?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"
<?php endif; ?>>
<span class="lnx-ti"><?= site_icon($tIcon, '') ?></span>
<span>
<b><?= he((string)$tk['name']) ?></b>
<span class="tip"><?= he((string)$tk['description']) ?></span>
<b><?= he((string) $tk['name']) ?></b>
<span class="tip"><?= he((string) $tk['description']) ?></span>
</span>
</a>
<?php endforeach; ?>
@ -321,10 +546,10 @@ layout_head($catName);
<h4 class="lnx-eg"><?= he($ag['p']) ?></h4>
<div class="lnx-arts">
<?php foreach ($ag['items'] as $ak): ?>
<a class="lnx-art" href="article/index.php?id=<?= (int)$ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
<h4><?= he((string)$ak['title']) ?></h4>
<p><?= he(trim((string)$ak['summary']) !== '' ? (string)$ak['summary'] : '(无摘要)') ?></p>
<time>更新于 <?= he((string)$ak['updated_at']) ?></time>
<a class="lnx-art" href="article/index.php?id=<?= (int) $ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
<h4><?= he((string) $ak['title']) ?></h4>
<p><?= he(trim((string) $ak['summary']) !== '' ? (string) $ak['summary'] : '(无摘要)') ?></p>
<time>更新于 <?= he((string) $ak['updated_at']) ?></time>
</a>
<?php endforeach; ?>
</div>
@ -344,22 +569,23 @@ layout_head($catName);
<?php else:
$extGroups = [];
foreach ($extLinks as $ek) {
$eg = trim((string)($ek['grp'] ?? ''));
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
$eg = trim((string) ($ek['grp'] ?? ''));
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
}
?>
?>
<?php foreach ($extGroups as $eg => $gItems): ?>
<?php if ($eg !== "\x00"): ?><h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
<?php if ($eg !== "\x00"): ?>
<h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
<div class="lnx-exts">
<?php foreach ($gItems as $ek):
$eUrl = trim((string)$ek['url']);
?>
$eUrl = trim((string) $ek['url']);
?>
<a class="lnx-ext" href="<?= he($eUrl) ?>" target="_blank" rel="noopener noreferrer">
<span class="lnx-eh">
<span class="lnx-en"><?= he((string)$ek['name']) ?></span>
<span class="lnx-en"><?= he((string) $ek['name']) ?></span>
<span class="lnx-ed"><?= he($domainOf($eUrl)) ?></span>
</span>
<p><?= he((string)($ek['description'] ?? '')) ?></p>
<p><?= he((string) ($ek['description'] ?? '')) ?></p>
</a>
<?php endforeach; ?>
</div>
@ -373,7 +599,7 @@ layout_head($catName);
<?php layout_footer(); ?>
<script src="assets/js/common.js"></script>
<script>
(function () {
(function () {
'use strict';
// 下划线滑动指示导航:切换分区时激活项高亮,指示条平滑滑动到该项下方
var nav = document.getElementById('lnxNav');
@ -383,25 +609,25 @@ layout_head($catName);
if (!nav || !ink || !tabs.length) return;
function moveInk() {
var cur = nav.querySelector('.lnx-tab.active');
if (!cur) { ink.style.left = '0px'; ink.style.width = '0px'; return; }
ink.style.left = cur.offsetLeft + 'px';
ink.style.width = cur.offsetWidth + 'px';
var cur = nav.querySelector('.lnx-tab.active');
if (!cur) { ink.style.left = '0px'; ink.style.width = '0px'; return; }
ink.style.left = cur.offsetLeft + 'px';
ink.style.width = cur.offsetWidth + 'px';
}
function show(btn, fromHash) {
var t = btn.getAttribute('data-t');
tabs.forEach(function (x) { x.classList.toggle('active', x === btn); });
panels.forEach(function (p) { p.classList.toggle('active', p.getAttribute('data-panel') === t); });
moveInk();
if (!fromHash) window.scrollTo({ top: 0, behavior: 'smooth' });
// 把当前分区写入 URL #hashreplaceState 不产生额外历史记录),
// 这样从本页跳去文章/工具再“返回上一页”或刷新时,仍能回到原来的分区。
if (location.hash !== '#' + t) {
history.replaceState(null, '', '#' + t);
}
var t = btn.getAttribute('data-t');
tabs.forEach(function (x) { x.classList.toggle('active', x === btn); });
panels.forEach(function (p) { p.classList.toggle('active', p.getAttribute('data-panel') === t); });
moveInk();
if (!fromHash) window.scrollTo({ top: 0, behavior: 'smooth' });
// 把当前分区写入 URL #hashreplaceState 不产生额外历史记录),
// 这样从本页跳去文章/工具再“返回上一页”或刷新时,仍能回到原来的分区。
if (location.hash !== '#' + t) {
history.replaceState(null, '', '#' + t);
}
}
tabs.forEach(function (btn) {
btn.addEventListener('click', function () { show(btn); });
btn.addEventListener('click', function () { show(btn); });
});
window.addEventListener('resize', function () { moveInk(); });
// 初始恢复URL 中带 #分区(返回上一页 / 手动分享)时,激活对应分区
@ -409,15 +635,16 @@ layout_head($catName);
var foundTab = null;
tabs.forEach(function (b) { if (!foundTab && b.getAttribute('data-t') === wantTab) foundTab = b; });
if (foundTab) {
show(foundTab, true);
show(foundTab, true);
} else {
moveInk();
// 无分区 hash 时清掉可能遗留的无效 hash如 #footer保持地址干净
if (location.hash !== '') {
history.replaceState(null, '', location.pathname + location.search);
}
moveInk();
// 无分区 hash 时清掉可能遗留的无效 hash如 #footer保持地址干净
if (location.hash !== '') {
history.replaceState(null, '', location.pathname + location.search);
}
}
})();
})();
</script>
</body>
</html>