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

View File

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

View File

@ -1,37 +1,65 @@
<?php <?php
/** /**
* admin/_guard.php —— 后台鉴权守卫 + 公共栏(所有后台页面在 $P='../' 定义后 require * admin/_guard.php —— 后台鉴权守卫 + 公共侧边栏(所有后台页面在 $P='../' 定义后 require
*/ */
$P = $P ?? '../'; $P = $P ?? '../';
require_once dirname(__DIR__) . '/includes/auth.php'; require_once dirname(__DIR__) . '/includes/auth.php';
require_once dirname(__DIR__) . '/includes/layout.php'; require_once dirname(__DIR__) . '/includes/layout.php';
require_login('login.php'); require_login('login.php');
/** 后台顶栏 */ /** 后台侧边栏(原部分区改为可收缩侧栏;含“本页功能”锚点容器) */
function admin_topbar(string $active = ''): void function admin_topbar(string $active = ''): void
{ {
global $P; global $P;
$site = setting_get('site_name', '知识导航站'); $site = setting_get('site_name', '知识导航站');
$admin = (string) ($_SESSION['admin'] ?? ''); $admin = (string) ($_SESSION['admin'] ?? '');
echo '<div class="admin-nav"><div class="admin-nav-inner">'; // 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
echo '<a class="brand brand-sm" href="../index.php" title="查看前台首页">'; 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";
layout_logo_img('logo', $site);
echo '<span class="site-name">' . he($site) . ' · 后台</span></a>'; // 小屏顶部条(仅移动端显示:菜单按钮 + 站名 + 退出)
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 = [ $links = [
'nav' => ['nav.php', '导航内容管理'], 'nav' => ['nav.php', '导航管理', '🗂'],
'tools' => ['tools.php', '功能区管理'], 'tools' => ['tools.php', '内部功能', '🧰'],
'articles' => ['articles.php', '文章管理'], 'articles' => ['articles.php', '文章管理', '📄'],
'exttools' => ['exttools.php', '外部工具'], 'exttools' => ['exttools.php', '外部工具', '🔗'],
'content' => ['content.php', '内容与顺序'], 'content' => ['content.php', '网站管理', '⚙'],
'visits' => ['visits.php', '访问信息'], 'visits' => ['visits.php', '风控管理', '🛡'],
]; ];
echo '<nav class="as-nav">';
foreach ($links as $k => $lk) { foreach ($links as $k => $lk) {
$cls = ($active === $k) ? 'active' : ''; $cls = 'as-link' . ($active === $k ? ' active' : '');
echo '<a href="' . he($lk[0]) . '"' . ($cls ? ' class="' . $cls . '"' : '') . '>' . he($lk[1]) . '</a>'; 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 '</nav>';
echo '<span class="tip" style="color:rgba(255,255,255,.75)">管理员:' . he($admin) . '</span>'; // 本页功能(由 common.js 依据当前页区块自动生成)
echo '<a href="logout.php">退出登录</a>'; echo '<div class="as-sec" id="adminSideSections" hidden>';
echo '</span>'; echo '<div class="as-sec-t"><span class="as-tx">本页功能</span></div>';
echo '</div></div>' . "\n"; 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

@ -146,7 +146,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$parts = []; $parts = [];
foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) { foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) {
$po = trim((string) $po); $po = trim((string) $po);
if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; } if ($po !== '' && !in_array($po, $parts, true)) {
$parts[] = $po;
}
} }
settings_set('article_part_order', implode(',', $parts)); settings_set('article_part_order', implode(',', $parts));
touch_last_updated(); touch_last_updated();
@ -194,23 +196,32 @@ $list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC'
$artGroups = []; $artGroups = [];
foreach ($list as $a) { foreach ($list as $a) {
$p = trim((string) $a['part']); $p = trim((string) $a['part']);
if ($p === '') { $p = '未分类'; } if ($p === '') {
if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; } $p = '未分类';
}
if (!isset($artGroups[$p])) {
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'rows' => []];
}
$artGroups[$p]['rows'][] = $a; $artGroups[$p]['rows'][] = $a;
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; } if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
$artGroups[$p]['ts'] = (string) $a['updated_at'];
}
} }
$poIdx = []; $poIdx = [];
$poCfg = trim((string) setting_get('article_part_order', '')); $poCfg = trim((string) setting_get('article_part_order', ''));
if ($poCfg !== '') { if ($poCfg !== '') {
foreach (explode(',', $poCfg) as $po) { foreach (explode(',', $poCfg) as $po) {
$po = trim($po); $po = trim($po);
if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); } if ($po !== '' && isset($artGroups[$po])) {
$poIdx[$po] = count($poIdx);
}
} }
} }
uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) { uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) {
$ia = $poIdx[$pa] ?? PHP_INT_MAX; $ia = $poIdx[$pa] ?? PHP_INT_MAX;
$ib = $poIdx[$pb] ?? PHP_INT_MAX; $ib = $poIdx[$pb] ?? PHP_INT_MAX;
if ($ia !== $ib) return $ia <=> $ib; if ($ia !== $ib)
return $ia <=> $ib;
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']); $d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
return $d !== 0 ? $d : strcmp($pa, $pb); return $d !== 0 ? $d : strcmp($pa, $pb);
}); });
@ -239,26 +250,40 @@ admin_topbar('articles');
<?php if ($savedFlag): ?> <?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> <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 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 if ($editing || isset($_GET['new'])): ?>
<?php <?php
$cur = $row ? [ $cur = $row ? [
'id' => (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'], 'id' => (int) $row['id'],
'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'], 'part' => (string) $row['part'],
'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'], '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'] ?? '')), 'board_cats' => trim((string) ($row['board_cats'] ?? '')),
] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => '']; ] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => ''];
// 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中 // 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中
$curPart = trim((string) $cur['part']); $curPart = trim((string) $cur['part']);
if ($curPart === '') { $curPart = '未分类'; } if ($curPart === '') {
$curPart = '未分类';
}
$partOptions = ['未分类']; $partOptions = ['未分类'];
foreach ($list as $a2) { foreach ($list as $a2) {
$p2 = trim((string) $a2['part']); $p2 = trim((string) $a2['part']);
if ($p2 === '' || $p2 === '未分类') continue; if ($p2 === '' || $p2 === '未分类')
if (!in_array($p2, $partOptions, true)) { $partOptions[] = $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="fieldset-card">
<div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div> <div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div>
@ -301,9 +326,11 @@ admin_topbar('articles');
<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="blue" <?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
</div> </div>
<label class="fl">正文Markdown</label> <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"> <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> <button type="button" class="btn btn-sm" id="artImgUpload">上传图片并插入引用</button>
<span class="tip">上传后自动在正文末尾插入 <code>![说明](data/articles/img/文件.png)</code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span> <span class="tip">上传后自动在正文末尾插入 <code>![说明](data/articles/img/文件.png)</code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
</div> </div>
@ -318,7 +345,7 @@ admin_topbar('articles');
<!-- 文章区首页注要 --> <!-- 文章区首页注要 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">文章区首页注要(顶部展示给读者的说明文字)</div> <div class="fs-title">顶部说明管理</div>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="note"> <?= csrf_field() ?><input type="hidden" name="act" value="note">
<textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea> <textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea>
@ -329,7 +356,7 @@ admin_topbar('articles');
<!-- 文章列表:分类即分组(默认收起,组可拖动排序) --> <!-- 文章列表:分类即分组(默认收起,组可拖动排序) -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="field-row" style="align-items:center;justify-content:space-between"> <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"> <span style="display:inline-flex;gap:8px;align-items:center">
<button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button> <button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button>
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a> <a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
@ -354,38 +381,53 @@ admin_topbar('articles');
<div class="xg-b"> <div class="xg-b">
<div style="overflow-x:auto"> <div style="overflow-x:auto">
<table class="tbl"> <table class="tbl">
<thead><tr><th>标题</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead> <thead>
<tr>
<th>标题</th>
<th>更新时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody> <tbody>
<?php foreach ($g['rows'] as $a): ?> <?php foreach ($g['rows'] as $a): ?>
<tr> <tr>
<td> <td>
<a href="articles.php?edit=<?= (int) $a['id'] ?>"><?= he((string) $a['title']) ?></a> <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; ?> <?php if (trim((string) $a['summary']) !== ''): ?>
<div class="tip"><?= he((string) $a['summary']) ?></div><?php endif; ?>
</td> </td>
<td style="white-space:nowrap"><?= he((string) $a['updated_at']) ?></td> <td style="white-space:nowrap"><?= he((string) $a['updated_at']) ?></td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
<?php if ((int) $a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?> <?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; ?> <?php if ((int) $a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span
class="badge badge-no">隐藏</span><?php endif; ?>
</td> </td>
<td style="white-space:nowrap"> <td style="white-space:nowrap">
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。"> <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'] ?>"> <?= 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> <button type="submit" class="btn btn-sm btn-danger" title="删除"></button>
</form> </form>
<form method="post" style="display:inline"> <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'] ?>"> <?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
<button type="submit" class="btn btn-sm" title="<?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button> 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>
<form method="post" style="display:inline"> <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'] ?>"> <?= 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> <button type="submit" class="btn btn-sm"><?= (int) $a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
</form> </form>
<form method="post" style="display:inline"> <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'] ?>"> <?= 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> <button type="submit" class="btn btn-sm" title="本分类内上移"></button>
</form> </form>
<form method="post" style="display:inline"> <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'] ?>"> <?= 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> <button type="submit" class="btn btn-sm" title="本分类内下移"></button>
</form> </form>
</td> </td>
@ -605,4 +647,5 @@ admin_topbar('articles');
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
<?php <?php
/** /**
* admin/content.php —— 内容与顺序 / 站点设置 * admin/content.php —— 网站管理 / 站点设置
* 站点信息、板块名称与说明、logo 上传favicon + 内容 logo 同源)、 * 站点信息、板块名称与说明、logo 上传favicon + 内容 logo 同源)、
* 名言库quota.txt、底部备案信息、分类顺序、修改密码。 * 名言库quota.txt、底部备案信息、分类顺序、修改密码。
*/ */
@ -71,7 +71,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$sort = max(0, (int) ($sorts[$i] ?? 0)); $sort = max(0, (int) ($sorts[$i] ?? 0));
$id = (int) $i; $id = (int) $i;
$key = $pdo->query('SELECT key FROM categories WHERE id = ' . $id)->fetchColumn(); $key = $pdo->query('SELECT key FROM categories WHERE id = ' . $id)->fetchColumn();
if ($name === '' || !$key || !in_array((string)$key, $catKeys, true)) continue; if ($name === '' || !$key || !in_array((string) $key, $catKeys, true))
continue;
$stUpd->execute([$name, $desc, $icon, $sort, $id]); $stUpd->execute([$name, $desc, $icon, $sort, $id]);
} }
touch_last_updated(); touch_last_updated();
@ -91,7 +92,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
case 'quotes': case 'quotes':
$content = (string) ($_POST['quotes'] ?? ''); $content = (string) ($_POST['quotes'] ?? '');
$file = DATA_DIR . '/quota.txt'; $file = DATA_DIR . '/quota.txt';
if (!is_dir(DATA_DIR)) @mkdir(DATA_DIR, 0777, true); if (!is_dir(DATA_DIR))
@mkdir(DATA_DIR, 0777, true);
$savedBytes = @file_put_contents($file, $content); $savedBytes = @file_put_contents($file, $content);
if ($savedBytes === false) { if ($savedBytes === false) {
$msgKind = 'err'; $msgKind = 'err';
@ -115,8 +117,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
break; break;
} }
$extMap = [ $extMap = [
'png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'png' => 'png',
'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg', 'ico' => 'ico', 'jpg' => 'jpg',
'jpeg' => 'jpg',
'gif' => 'gif',
'webp' => 'webp',
'svg' => 'svg',
'ico' => 'ico',
]; ];
$ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION)); $ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION));
if (!isset($extMap[$ext])) { if (!isset($extMap[$ext])) {
@ -180,11 +187,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$list = []; $list = [];
foreach ($rows as $e) { foreach ($rows as $e) {
if (!is_array($e)) continue; if (!is_array($e))
continue;
$name = trim((string) ($e['name'] ?? '')); $name = trim((string) ($e['name'] ?? ''));
$url = trim((string) ($e['url'] ?? '')); $url = trim((string) ($e['url'] ?? ''));
$icon = trim((string) ($e['icon'] ?? '')); $icon = trim((string) ($e['icon'] ?? ''));
if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue; if ($name === '' || $url === '' || strpos($url, '{kw}') === false)
continue;
$list[] = [ $list[] = [
'key' => 'e' . (count($list) + 1), 'key' => 'e' . (count($list) + 1),
'name' => $name, 'name' => $name,
@ -218,12 +227,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
$clean = []; $clean = [];
foreach ($rows as $i => $r) { foreach ($rows as $i => $r) {
if (!is_array($r)) continue; if (!is_array($r))
continue;
$name = trim((string) ($r['name'] ?? '')); $name = trim((string) ($r['name'] ?? ''));
$url = trim((string) ($r['url'] ?? '')); $url = trim((string) ($r['url'] ?? ''));
$icon = trim((string) ($r['icon'] ?? '')); $icon = trim((string) ($r['icon'] ?? ''));
$note = trim((string) ($r['note'] ?? '')); $note = trim((string) ($r['note'] ?? ''));
if ($name === '' && $url === '') continue; if ($name === '' && $url === '')
continue;
if ($name === '' || $url === '') { if ($name === '' || $url === '') {
$msgKind = 'err'; $msgKind = 'err';
$msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。'; $msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。';
@ -253,7 +264,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_links')->fetchAll(), 'id')); $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_links')->fetchAll(), 'id'));
$keep = []; $keep = [];
foreach ($clean as $c) { foreach ($clean as $c) {
if ($c['id'] > 0) $keep[] = $c['id']; if ($c['id'] > 0)
$keep[] = $c['id'];
} }
$del = array_values(array_diff($oldIds, $keep)); $del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) { foreach ($del as $did) {
@ -334,26 +346,31 @@ foreach ($pdo->query('SELECT key, value FROM settings') as $r) {
} }
$site = $all['site_name'] ?? '知识导航站'; $site = $all['site_name'] ?? '知识导航站';
layout_head('内容与顺序'); layout_head('网站管理');
admin_topbar('content'); admin_topbar('content');
?> ?>
<main class="page-main"> <main class="page-main">
<div class="wrap page-body"> <div class="wrap page-body">
<h2 style="margin-bottom:6px">内容与顺序</h2> <h2 style="margin-bottom:6px">网站管理</h2>
<p class="tip">对全站展示内容与 logo 进行维护,保存后自动更新首页"上次更新时间"</p> <p class="tip">对全站展示内容与 logo 进行维护,保存后自动更新首页"上次更新时间"</p>
<?php if ($msgText !== ''): alert_html($msgKind, $msgText); endif; ?> <?php if ($msgText !== ''):
alert_html($msgKind, $msgText); endif; ?>
<div class="form-grid"> <div class="form-grid">
<!-- 站长提示总开关 --> <!-- 站长提示总开关 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">站长提示(全站辅助说明文案总开关</div> <div class="fs-title">提示开关</div>
<?= csrf_field() ?> <?= csrf_field() ?>
<div class="field-row" style="align-items:center;margin:2px 0"> <div class="field-row" style="align-items:center;margin:2px 0">
<label class="sw" title="点击立即启用/关闭站长提示(即时生效,无需保存)"><input type="checkbox" class="sw-in tips-toggle"<?= ((string)($all['show_st_tips'] ?? '1') === '1') ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> <label class="sw" title="点击立即启用/关闭站长提示(即时生效,无需保存)"><input type="checkbox" class="sw-in tips-toggle"
<?= ((string) ($all['show_st_tips'] ?? '1') === '1') ? ' checked' : '' ?>><span class="sw-b"><span
class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
<span class="tip" style="margin:0">开启时,前台显示“可切换搜索引擎进行搜索…”等辅助旁白</span> <span class="tip" style="margin:0">开启时,前台显示“可切换搜索引擎进行搜索…”等辅助旁白</span>
</div> </div>
<p class="tip" style="margin-top:6px">前台类似“可切换搜索引擎进行搜索4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带 <code>st-tip</code> 标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。</p> <p class="tip" style="margin-top:6px">前台类似“可切换搜索引擎进行搜索4 个可用,后台可维护)”、各分区说明条等辅助旁白,以及文案中带后台入口字样的引导句,均带
<code>st-tip</code>
标记;关闭后这些文案在全部前台页面统一隐藏,站点导航、搜索、内容等核心功能不受影响。各分区/页面在没有数据时展示的空态占位框(如“该库还没有导航内容”)不受本开关影响,会始终显示以避免空页突兀。</p>
</div> </div>
<!-- 站点信息 --> <!-- 站点信息 -->
@ -371,7 +388,7 @@ admin_topbar('content');
<!-- 站点 logofavicon + 页面内容 logo --> <!-- 站点 logofavicon + 页面内容 logo -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">站点 LOGO(浏览器标签页图标与页面内容同步生效)</div> <div class="fs-title">站点LOGO</div>
<div class="logo-preview"> <div class="logo-preview">
<img src="<?= he($P . site_logo_rel()) ?>" alt="当前logo"> <img src="<?= he($P . site_logo_rel()) ?>" alt="当前logo">
<span class="tip">当前生效的 logo更换后浏览器标签页图标favicon与页面头部 logo 都会更新。</span> <span class="tip">当前生效的 logo更换后浏览器标签页图标favicon与页面头部 logo 都会更新。</span>
@ -394,28 +411,35 @@ admin_topbar('content');
<!-- 首页顶部标识栏背景 --> <!-- 首页顶部标识栏背景 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">首页顶部标识栏背景Hero 区,支持纯色或 CSS 渐变)</div> <div class="fs-title">首页顶部背景</div>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="hero"> <?= csrf_field() ?><input type="hidden" name="act" value="hero">
<label class="fl">背景样式(纯色值或 CSS 渐变;留空恢复主题默认渐变)</label> <label class="fl">背景样式(纯色值或 CSS 渐变;留空恢复主题默认渐变)</label>
<input type="text" name="hero_bg" id="heroBg" value="<?= he($all['hero_bg'] ?? '') ?>" placeholder="如 #2563eb 或 linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)"> <input type="text" name="hero_bg" id="heroBg" value="<?= he($all['hero_bg'] ?? '') ?>"
placeholder="如 #2563eb 或 linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)">
<div class="field-row" style="margin:10px 0;align-items:center"> <div class="field-row" style="margin:10px 0;align-items:center">
<input type="color" id="heroColor" value="#2563eb" title="拾取纯色并填入" style="width:40px;height:34px;padding:2px"> <input type="color" id="heroColor" value="#2563eb" title="拾取纯色并填入"
style="width:40px;height:34px;padding:2px">
<button type="button" class="btn btn-sm hero-pre" data-v="">默认渐变蓝</button> <button type="button" class="btn btn-sm hero-pre" data-v="">默认渐变蓝</button>
<button type="button" class="btn btn-sm hero-pre" data-v="#2563eb">纯色蓝</button> <button type="button" class="btn btn-sm hero-pre" data-v="#2563eb">纯色蓝</button>
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#1e3a8a 0%,#1d4ed8 55%,#7c3aed 100%)">深蓝紫渐变</button> <button type="button" class="btn btn-sm hero-pre"
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#0f766e 0%,#06b6d4 100%)">青碧渐变</button> data-v="linear-gradient(135deg,#1e3a8a 0%,#1d4ed8 55%,#7c3aed 100%)">深蓝紫渐变</button>
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#7f1d1d 0%,#dc2626 100%)">绯红渐变</button> <button type="button" class="btn btn-sm hero-pre"
<button type="button" class="btn btn-sm hero-pre" data-v="linear-gradient(135deg,#111827 0%,#374151 100%)">石墨渐变</button> data-v="linear-gradient(135deg,#0f766e 0%,#06b6d4 100%)">青碧渐变</button>
<button type="button" class="btn btn-sm hero-pre"
data-v="linear-gradient(135deg,#7f1d1d 0%,#dc2626 100%)">绯红渐变</button>
<button type="button" class="btn btn-sm hero-pre"
data-v="linear-gradient(135deg,#111827 0%,#374151 100%)">石墨渐变</button>
</div> </div>
<p class="tip" style="margin-top:2px">纯色直接填色值(如 #2563eb渐变填完整 CSS如 <code>linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)</code>。夜间模式沿用同一背景。</p> <p class="tip" style="margin-top:2px">纯色直接填色值(如 #2563eb渐变填完整 CSS
<code>linear-gradient(135deg, #1f3a5f 0%, #2563eb 100%)</code>。夜间模式沿用同一背景。</p>
<div style="margin-top:10px"><button type="submit" class="btn btn-primary">保存顶部背景</button></div> <div style="margin-top:10px"><button type="submit" class="btn btn-primary">保存顶部背景</button></div>
</form> </form>
</div> </div>
<!-- 首页顶部组件模式Hero 下方横幅:天气 / 访客信息 / 空白) --> <!-- 首页顶部组件模式Hero 下方横幅:天气 / 访客信息 / 空白) -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">首页顶部组件Hero 下方横幅区)</div> <div class="fs-title">首页顶部组件</div>
<?php $hm = (string) ($all['hero_mode'] ?? 'weather'); ?> <?php $hm = (string) ($all['hero_mode'] ?? 'weather'); ?>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="hero_mode"> <?= csrf_field() ?><input type="hidden" name="act" value="hero_mode">
@ -441,7 +465,8 @@ admin_topbar('content');
<label class="fl">公安备案号</label> <label class="fl">公安备案号</label>
<input type="text" name="gongan_no" value="<?= he($all['gongan_no'] ?? '') ?>"> <input type="text" name="gongan_no" value="<?= he($all['gongan_no'] ?? '') ?>">
<label class="fl">公安备案号链接(可选,填写后备案号可点击跳转;建议使用官方查询页 https://beian.mps.gov.cn/</label> <label class="fl">公安备案号链接(可选,填写后备案号可点击跳转;建议使用官方查询页 https://beian.mps.gov.cn/</label>
<input type="url" name="gongan_link" value="<?= he($all['gongan_link'] ?? '') ?>" placeholder="https://beian.mps.gov.cn/…"> <input type="url" name="gongan_link" value="<?= he($all['gongan_link'] ?? '') ?>"
placeholder="https://beian.mps.gov.cn/…">
<label class="fl">版权声明(可用 {site_name} 占位)</label> <label class="fl">版权声明(可用 {site_name} 占位)</label>
<input type="text" name="copyright" value="<?= he($all['copyright'] ?? '') ?>"> <input type="text" name="copyright" value="<?= he($all['copyright'] ?? '') ?>">
<label class="fl">其他底部文本(可选)</label> <label class="fl">其他底部文本(可选)</label>
@ -452,17 +477,21 @@ admin_topbar('content');
<!-- 搜索引擎(导航区搜索栏) --> <!-- 搜索引擎(导航区搜索栏) -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">搜索引擎(导航区搜索栏可切换的引擎)</div> <div class="fs-title">搜索引擎管理</div>
<form method="post" id="engineForm"> <form method="post" id="engineForm">
<?= csrf_field() ?><input type="hidden" name="act" value="engines"> <?= csrf_field() ?><input type="hidden" name="act" value="engines">
<textarea name="data" id="engData" hidden></textarea> <textarea name="data" id="engData" hidden></textarea>
<p class="tip">图标用于搜索栏切换按钮常驻展示;搜索引擎全称在展开下拉列表时才显示。搜索地址需包含 {kw} 占位符,例如 https://www.bing.com/search?q={kw}。列表按从上到下顺序生效,可先删行再重新添加来调整顺序。</p> <p class="tip">图标用于搜索栏切换按钮常驻展示;搜索引擎全称在展开下拉列表时才显示。搜索地址需包含 {kw} 占位符,例如
https://www.bing.com/search?q={kw}。列表按从上到下顺序生效,可先删行再重新添加来调整顺序。</p>
<div id="engineRows"> <div id="engineRows">
<?php $curEngines = search_engines(); foreach ($curEngines as $e): ?> <?php $curEngines = search_engines();
foreach ($curEngines as $e): ?>
<div class="e-row"> <div class="e-row">
<input type="text" class="e-icon" value="<?= he((string)($e['icon'] ?? 'S')) ?>" maxlength="2" placeholder="图标" title="按钮图标1 个字符或 emoji"> <input type="text" class="e-icon" value="<?= he((string) ($e['icon'] ?? 'S')) ?>" maxlength="2"
placeholder="图标" title="按钮图标1 个字符或 emoji">
<input type="text" class="e-name" value="<?= he((string) ($e['name'] ?? '')) ?>" placeholder="名称(如:百度)"> <input type="text" class="e-name" value="<?= he((string) ($e['name'] ?? '')) ?>" placeholder="名称(如:百度)">
<input type="text" class="e-url" value="<?= he((string)($e['url'] ?? '')) ?>" placeholder="搜索地址模板,必须包含 {kw}"> <input type="text" class="e-url" value="<?= he((string) ($e['url'] ?? '')) ?>"
placeholder="搜索地址模板,必须包含 {kw}">
<button type="button" class="btn btn-sm btn-danger e-del" title="移除该引擎"></button> <button type="button" class="btn btn-sm btn-danger e-del" title="移除该引擎"></button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@ -480,30 +509,33 @@ admin_topbar('content');
<!-- 名言库 --> <!-- 名言库 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">名言库data/quota.txt首页随机展示</div> <div class="fs-title">滚动信息栏</div>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="quotes"> <?= csrf_field() ?><input type="hidden" name="act" value="quotes">
<label class="fl">逐行一条;以 # 开头或空行会被忽略(仅作注释与分隔)</label> <label class="fl">逐行一条;以 # 开头或空行会被忽略(仅作注释与分隔)</label>
<textarea name="quotes" rows="10" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($quotesContent) ?></textarea> <textarea name="quotes" rows="10"
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存名言库</button></div> style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($quotesContent) ?></textarea>
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存信息</button></div>
</form> </form>
</div> </div>
<!-- CDN IP 段库IP 地址处理工具用) --> <!-- CDN IP 段库IP 地址处理工具用) -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">CDN IP 供“IP 地址处理”工具识别 CDN 来源)</div> <div class="fs-title">CDN</div>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="cdn"> <?= csrf_field() ?><input type="hidden" name="act" value="cdn">
<label class="fl">每行一条:<code>CIDR </code> + 空格 + <code>厂商名</code>;以 # 开头的行为注释,不影响识别</label> <label class="fl">每行一条:<code>CIDR </code> + 空格 + <code>厂商名</code>;以 # 开头的行为注释,不影响识别</label>
<textarea name="cdn_ranges" rows="10" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he(cdn_ranges_text()) ?></textarea> <textarea name="cdn_ranges" rows="10"
style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he(cdn_ranges_text()) ?></textarea>
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存 CDN 段库</button></div> <div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存 CDN 段库</button></div>
<p class="tip" style="margin-top:6px">识别时会按“先命中即归为 CDN”处理未命中且非内网的 IP 一律归为公网。默认内置 Cloudflare 官方 IPv4 段,可按需补充阿里云 / 腾讯云等厂商段。</p> <p class="tip" style="margin-top:6px">识别时会按“先命中即归为 CDN”处理未命中且非内网的 IP 一律归为公网。默认内置 Cloudflare 官方 IPv4
段,可按需补充阿里云 / 腾讯云等厂商段。</p>
</form> </form>
</div> </div>
<!-- 修改密码 --> <!-- 修改密码 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">修改登录密码</div> <div class="fs-title">修改密码</div>
<form method="post" id="pwdForm"> <form method="post" id="pwdForm">
<?= csrf_field() ?><input type="hidden" name="act" value="pwd"> <?= csrf_field() ?><input type="hidden" name="act" value="pwd">
<label class="fl">原密码</label> <label class="fl">原密码</label>
@ -516,26 +548,30 @@ admin_topbar('content');
</form> </form>
</div> </div>
<!-- 四大板块:名称 / 说明 / 顺序 -->
<div class="fieldset-card col-span-2"> <div class="fieldset-card col-span-2">
<div class="fs-title">四大板块(名称 / 悬停说明文案 / 顺序)</div> <div class="fs-title">板块设置</div>
<form method="post"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="boards"> <?= csrf_field() ?><input type="hidden" name="act" value="boards">
<?php foreach ($cats as $c): $id = (int)$c['id']; ?> <?php foreach ($cats as $c):
$id = (int) $c['id']; ?>
<div class="m-block" style="margin-bottom:10px"> <div class="m-block" style="margin-bottom:10px">
<div class="m-block-head"> <div class="m-block-head">
<strong><?= he((string) $c['key']) ?></strong> <strong><?= he((string) $c['key']) ?></strong>
<span class="ops"><input type="number" name="b_sort[<?= $id ?>]" value="<?= (int)$c['sort'] ?>" min="1" max="99" style="width:70px" title="顺序数值,越小越靠前"></span> <span class="ops"><input type="number" name="b_sort[<?= $id ?>]" value="<?= (int) $c['sort'] ?>" min="1"
max="99" style="width:70px" title="顺序数值,越小越靠前"></span>
</div> </div>
<div class="m-items"> <div class="m-items">
<div class="icon-ctl" style="margin-top:8px"> <div class="icon-ctl" style="margin-top:8px">
<span class="icon-preview"></span> <span class="icon-preview"></span>
<input type="text" class="icon-input" name="b_icon[<?= $id ?>]" value="<?= he((string)($c['icon'] ?? '')) ?>" placeholder="字符 / emoji或粘贴/上传图片"> <input type="text" class="icon-input" name="b_icon[<?= $id ?>]"
value="<?= he((string) ($c['icon'] ?? '')) ?>" placeholder="字符 / emoji或粘贴/上传图片">
<button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button> <button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>
<input type="file" class="icon-file" accept="image/*" hidden> <input type="file" class="icon-file" accept="image/*" hidden>
</div> </div>
<input type="text" name="b_name[<?= $id ?>]" value="<?= he((string)$c['name']) ?>" placeholder="板块名称" style="margin-top:8px"> <input type="text" name="b_name[<?= $id ?>]" value="<?= he((string) $c['name']) ?>" placeholder="板块名称"
<textarea name="b_desc[<?= $id ?>]" rows="2" placeholder="悬停说明文案"><?= he(trim((string)$c['description']) !== '' ? (string)$c['description'] : ($boardDefaults[(string)$c['key']] ?? '')) ?></textarea> style="margin-top:8px">
<textarea name="b_desc[<?= $id ?>]" rows="2"
placeholder="悬停说明文案"><?= he(trim((string) $c['description']) !== '' ? (string) $c['description'] : ($boardDefaults[(string) $c['key']] ?? '')) ?></textarea>
</div> </div>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@ -545,7 +581,7 @@ admin_topbar('content');
<!-- 首页快捷站点(本域名其它网站) --> <!-- 首页快捷站点(本域名其它网站) -->
<div class="fieldset-card col-span-2"> <div class="fieldset-card col-span-2">
<div class="fs-title">首页快捷站点(四大板块上方的胶囊按钮,新标签页打开)</div> <div class="fs-title">首页快捷站点</div>
<form method="post" id="qlForm"> <form method="post" id="qlForm">
<?= csrf_field() ?><input type="hidden" name="act" value="quicklinks"> <?= csrf_field() ?><input type="hidden" name="act" value="quicklinks">
<textarea name="data" id="qlData" hidden></textarea> <textarea name="data" id="qlData" hidden></textarea>
@ -561,9 +597,13 @@ admin_topbar('content');
<input type="file" class="icon-file" accept="image/*" hidden> <input type="file" class="icon-file" accept="image/*" hidden>
</span> </span>
<input type="text" class="l-name" value="<?= he((string) $ql['name']) ?>" placeholder="站点名称(必填)"> <input type="text" class="l-name" value="<?= he((string) $ql['name']) ?>" placeholder="站点名称(必填)">
<input type="text" class="l-url" value="<?= he((string)$ql['url']) ?>" placeholder="完整网址,如 https://子站.域名.com"> <input type="text" class="l-url" value="<?= he((string) $ql['url']) ?>"
<input type="text" class="l-note" value="<?= he((string)($ql['note'] ?? '')) ?>" placeholder="备注 / 网站介绍(悬停首页按钮显示)"> placeholder="完整网址,如 https://子站.域名.com">
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled"<?= (int)$ql['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label> <input type="text" class="l-note" value="<?= he((string) ($ql['note'] ?? '')) ?>"
placeholder="备注 / 网站介绍(悬停首页按钮显示)">
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in l-enabled"
<?= (int) $ql['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span
class="sw-off">OFF</span></span></label>
<span class="ops"> <span class="ops">
<button type="button" class="btn btn-sm l-up" title="上移"></button> <button type="button" class="btn btn-sm l-up" title="上移"></button>
<button type="button" class="btn btn-sm l-down" title="下移"></button> <button type="button" class="btn btn-sm l-down" title="下移"></button>
@ -777,4 +817,5 @@ admin_topbar('content');
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

@ -25,7 +25,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
} else { } else {
$clean = []; $clean = [];
foreach ($rows as $i => $r) { foreach ($rows as $i => $r) {
if (!is_array($r)) continue; if (!is_array($r))
continue;
$name = trim((string) ($r['name'] ?? '')); $name = trim((string) ($r['name'] ?? ''));
$grp = trim(preg_replace('/\s+/', ' ', (string) ($r['grp'] ?? ''))); $grp = trim(preg_replace('/\s+/', ' ', (string) ($r['grp'] ?? '')));
$desc = trim((string) ($r['description'] ?? '')); $desc = trim((string) ($r['description'] ?? ''));
@ -39,7 +40,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
} }
} }
} }
if ($name === '' && $url === '') continue; // 空行忽略 if ($name === '' && $url === '')
continue; // 空行忽略
if ($name === '' || $url === '') { if ($name === '' || $url === '') {
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与网址均不能为空。'; $errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与网址均不能为空。';
break; break;
@ -63,7 +65,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$pdo->beginTransaction(); $pdo->beginTransaction();
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id')); $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id'));
$keep = []; $keep = [];
foreach ($clean as $c) { if ($c['id'] > 0) $keep[] = $c['id']; } foreach ($clean as $c) {
if ($c['id'] > 0)
$keep[] = $c['id'];
}
$del = array_values(array_diff($oldIds, $keep)); $del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) { foreach ($del as $did) {
$pdo->prepare('DELETE FROM ext_links WHERE id = ?')->execute([$did]); $pdo->prepare('DELETE FROM ext_links WHERE id = ?')->execute([$did]);
@ -84,7 +89,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
header('Location: exttools.php?ok=1'); header('Location: exttools.php?ok=1');
exit; exit;
} catch (Throwable $e) { } catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack(); if ($pdo->inTransaction())
$pdo->rollBack();
$errorMsg = '保存失败:' . $e->getMessage(); $errorMsg = '保存失败:' . $e->getMessage();
} }
} }
@ -172,14 +178,17 @@ admin_topbar('exttools');
<!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) --> <!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">批量导入外部工具(快速导入,自动带分区)</div> <div class="fs-title">批量导入</div>
<textarea id="extImportTa" rows="8" spellcheck="false" style="font-family:Consolas,Menlo,monospace;font-size:13px" placeholder='[&#10; { "section": "合集" },&#10; { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" },&#10; { "name": "工具B", "url": "https://example.com/b" },&#10; { "section": "导航" },&#10; { "name": "站点C", "url": "https://example.com/c" }&#10;]'></textarea> <textarea id="extImportTa" rows="8" spellcheck="false"
style="font-family:Consolas,Menlo,monospace;font-size:13px"
placeholder='[&#10; { "section": "合集" },&#10; { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" },&#10; { "name": "工具B", "url": "https://example.com/b" },&#10; { "section": "导航" },&#10; { "name": "站点C", "url": "https://example.com/c" }&#10;]'></textarea>
<p class="tip" style="margin-top:6px"> <p class="tip" style="margin-top:6px">
数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记); 数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记);
工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。 工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。
点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。 点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。
</p> </p>
<div style="margin-top:8px"><button type="button" class="btn btn-sm btn-primary" id="extImportBtn">解析并追加到列表</button></div> <div style="margin-top:8px"><button type="button" class="btn btn-sm btn-primary"
id="extImportBtn">解析并追加到列表</button></div>
</div> </div>
<!-- 分区折叠编辑列表 --> <!-- 分区折叠编辑列表 -->
@ -204,14 +213,17 @@ admin_topbar('exttools');
$gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0]; $gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0];
foreach ($gItems as $gt) { foreach ($gItems as $gt) {
foreach (array_map('trim', explode(',', trim((string) $gt['cat_keys']))) as $ck) { foreach (array_map('trim', explode(',', trim((string) $gt['cat_keys']))) as $ck) {
if (isset($gCnt[$ck])) { $gCnt[$ck]++; } if (isset($gCnt[$ck])) {
$gCnt[$ck]++;
}
} }
} }
?> ?>
<div class="xg is-fold" data-g="<?= he($gData) ?>"> <div class="xg is-fold" data-g="<?= he($gData) ?>">
<div class="xg-h" title="点击折叠 / 展开"> <div class="xg-h" title="点击折叠 / 展开">
<button type="button" class="xg-arr" aria-hidden="true"></button> <button type="button" class="xg-arr" aria-hidden="true"></button>
<button type="button" class="drag-h" title="按住拖动,调整该分区先后顺序(点保存后生效)" draggable="true"></button> <button type="button" class="drag-h" title="按住拖动,调整该分区先后顺序(点保存后生效)"
draggable="true"></button>
<span class="xg-name"><?= he($gLabel) ?></span> <span class="xg-name"><?= he($gLabel) ?></span>
<span class="xg-cnt"><?= $gN ?> 条</span> <span class="xg-cnt"><?= $gN ?> 条</span>
<?php if ($isNone): ?> <?php if ($isNone): ?>
@ -230,17 +242,24 @@ admin_topbar('exttools');
<?php foreach ($gItems as $t): <?php foreach ($gItems as $t):
$cats = array_map('trim', explode(',', trim((string) $t['cat_keys']))); ?> $cats = array_map('trim', explode(',', trim((string) $t['cat_keys']))); ?>
<div class="t-row" data-id="<?= (int) $t['id'] ?>"> <div class="t-row" data-id="<?= (int) $t['id'] ?>">
<input type="text" class="t-grp" value="<?= he((string)($t['grp'] ?? '')) ?>" placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组平铺"> <input type="text" class="t-grp" value="<?= he((string) ($t['grp'] ?? '')) ?>"
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="工具名称(必填)"> placeholder="分区 / 栏目(如:合集 / 导航)" title="前台按此分区加标题分组展示;留空则不分组平铺">
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话简介"> <input type="text" class="t-name" value="<?= he((string) $t['name']) ?>"
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="完整网址,如 https://example.com"> 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="完整网址,如 https://example.com">
<span class="t-cats" title="展示范围(不勾选=所有库通用)"> <span class="t-cats" title="展示范围(不勾选=所有库通用)">
<span class="t-cats-t">展示于</span> <span class="t-cats-t">展示于</span>
<label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $cats, true) ? ' checked' : '' ?>>科普</label> <label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $cats, true) ? ' checked' : '' ?>>科普</label>
<label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $cats, true) ? ' checked' : '' ?>>红队</label> <label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $cats, true) ? ' checked' : '' ?>>红队</label>
<label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $cats, true) ? ' checked' : '' ?>>蓝队</label> <label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $cats, true) ? ' checked' : '' ?>>蓝队</label>
</span> </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> <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"> <span class="ops">
<button type="button" class="btn btn-sm t-up" title="同分区内上移"></button> <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 t-down" title="同分区内下移"></button>
@ -658,4 +677,5 @@ admin_topbar('exttools');
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

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

View File

@ -1,6 +1,6 @@
<?php <?php
/** /**
* admin/nav.php —— 导航内容管理(块/链接增删改查 + 顺序) * admin/nav.php —— 导航管理(块/链接增删改查 + 顺序)
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。 * 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
*/ */
$P = '../'; $P = '../';
@ -52,7 +52,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
foreach ($data as $b) { foreach ($data as $b) {
if (is_array($b) && isset($b['title'])) { if (is_array($b) && isset($b['title'])) {
$id = (int) ($b['id'] ?? 0); $id = (int) ($b['id'] ?? 0);
if ($id > 0) $keepBlocks[] = $id; if ($id > 0)
$keepBlocks[] = $id;
foreach (($b['items'] ?? []) as $it) { foreach (($b['items'] ?? []) as $it) {
if (is_array($it) && (int) ($it['id'] ?? 0) > 0) { if (is_array($it) && (int) ($it['id'] ?? 0) > 0) {
$keepItems[] = (int) $it['id']; $keepItems[] = (int) $it['id'];
@ -81,9 +82,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?'); $stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
$blockSort = 0; $blockSort = 0;
foreach ($data as $b) { foreach ($data as $b) {
if (!is_array($b) || !isset($b['title'])) continue; if (!is_array($b) || !isset($b['title']))
continue;
$title = trim((string) $b['title']); $title = trim((string) $b['title']);
if ($title === '') continue; if ($title === '')
continue;
$bid = (int) ($b['id'] ?? 0); $bid = (int) ($b['id'] ?? 0);
$blockBelongs = in_array($bid, $oldBlocks, true); $blockBelongs = in_array($bid, $oldBlocks, true);
if ($bid > 0 && $blockBelongs) { if ($bid > 0 && $blockBelongs) {
@ -94,11 +97,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
} }
$itemSort = 0; $itemSort = 0;
foreach (($b['items'] ?? []) as $it) { foreach (($b['items'] ?? []) as $it) {
if (!is_array($it)) continue; if (!is_array($it))
continue;
$label = trim((string) ($it['label'] ?? '')); $label = trim((string) ($it['label'] ?? ''));
$url = trim((string) ($it['url'] ?? '')); $url = trim((string) ($it['url'] ?? ''));
$note = trim((string) ($it['note'] ?? '')); $note = trim((string) ($it['note'] ?? ''));
if ($label === '' && $url === '') continue; if ($label === '' && $url === '')
continue;
$iid = (int) ($it['id'] ?? 0); $iid = (int) ($it['id'] ?? 0);
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) { if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]); $stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
@ -146,18 +151,19 @@ foreach ($catKeys as $ck) {
$csrfVal = csrf_token(); $csrfVal = csrf_token();
layout_head('导航内容管理'); layout_head('导航管理');
admin_topbar('nav'); admin_topbar('nav');
?> ?>
<main class="page-main"> <main class="page-main">
<div class="wrap page-body"> <div class="wrap page-body">
<h2 style="margin-bottom:6px">导航内容管理</h2> <h2 style="margin-bottom:6px">导航管理</h2>
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接3 小列 × 5 行),超出部分请拆分到新块。</p> <p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接3 小列 × 5 行),超出部分请拆分到新块。</p>
<!-- 分类切换 --> <!-- 分类切换 -->
<div class="field-row" style="margin:12px 0"> <div class="field-row" style="margin:12px 0">
<?php foreach ($catKeys as $ck): ?> <?php foreach ($catKeys as $ck): ?>
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>" href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a> <a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>"
href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
@ -169,11 +175,13 @@ admin_topbar('nav');
<?php endif; ?> <?php endif; ?>
<div class="fieldset-card" style="margin-bottom:16px"> <div class="fieldset-card" style="margin-bottom:16px">
<div class="fs-title">批量导入导航链接(<?= he($catNames[$cat]) ?></div> <div class="fs-title">批量导入</div>
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br> <p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
每行一条,支持几种写法: 每行一条,支持几种写法:
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> <code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。 <code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code>
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code><code>块名 3</code> </p> <code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code><code>块名 3</code>
</p>
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如: <textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
## 安全研究 ## 安全研究
看雪 | https://www.kanxue.com | 逆向/安全社区 看雪 | https://www.kanxue.com | 逆向/安全社区
@ -202,7 +210,8 @@ https://www.ithome.com | IT之家"></textarea>
<div class="m-block-head"> <div class="m-block-head">
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true"></button> <button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true"></button>
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true"></button> <button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true"></button>
<input type="text" class="b-title" value="<?= he((string)$b['title']) ?>" placeholder="导航块标题(必填)"> <input type="text" class="b-title" value="<?= he((string) $b['title']) ?>"
placeholder="导航块标题(必填)">
<span class="ops"> <span class="ops">
<button type="button" class="btn btn-sm b-up" title="上移"></button> <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 b-down" title="下移"></button>
@ -212,9 +221,12 @@ https://www.ithome.com | IT之家"></textarea>
<div class="m-items"> <div class="m-items">
<?php foreach (array_slice($items, 0, 15) as $it): ?> <?php foreach (array_slice($items, 0, 15) as $it): ?>
<div class="m-item"> <div class="m-item">
<input type="text" class="i-label" value="<?= he((string)$it['label']) ?>" placeholder="文字(必填)"> <input type="text" class="i-label" value="<?= he((string) $it['label']) ?>"
<input type="text" class="i-url" value="<?= he((string)$it['url']) ?>" placeholder="链接,如 https://…(必填)"> placeholder="文字(必填)">
<input type="text" class="i-note" value="<?= he((string)$it['note']) ?>" 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> <button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接"></button>
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
@ -545,4 +557,5 @@ https://www.ithome.com | IT之家"></textarea>
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,6 +1,6 @@
<?php <?php
/** /**
* admin/tools.php —— 功能区管理 * admin/tools.php —— 内部功能
* 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。 * 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。
* 站内工具页需先在 func/ 下开发对应 .php再到此处登记入口。 * 站内工具页需先在 func/ 下开发对应 .php再到此处登记入口。
*/ */
@ -24,13 +24,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
// 先整体校验 // 先整体校验
$clean = []; $clean = [];
foreach ($rows as $i => $r) { foreach ($rows as $i => $r) {
if (!is_array($r)) continue; if (!is_array($r))
continue;
$name = trim((string) ($r['name'] ?? '')); $name = trim((string) ($r['name'] ?? ''));
$url = trim((string) ($r['url'] ?? '')); $url = trim((string) ($r['url'] ?? ''));
$icon = trim((string) ($r['icon'] ?? '')); $icon = trim((string) ($r['icon'] ?? ''));
$desc = trim((string) ($r['description'] ?? '')); $desc = trim((string) ($r['description'] ?? ''));
$ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0; $ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0;
if ($name === '' && $url === '') continue; // 空行忽略 if ($name === '' && $url === '')
continue; // 空行忽略
// 库首页“内部功能区”展示范围(空=所有库通用) // 库首页“内部功能区”展示范围(空=所有库通用)
$cats = []; $cats = [];
if (isset($r['cats']) && is_array($r['cats'])) { if (isset($r['cats']) && is_array($r['cats'])) {
@ -81,7 +83,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id')); $oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id'));
$keep = []; $keep = [];
foreach ($clean as $c) { foreach ($clean as $c) {
if ($c['id'] > 0) $keep[] = $c['id']; if ($c['id'] > 0)
$keep[] = $c['id'];
} }
$del = array_values(array_diff($oldIds, $keep)); $del = array_values(array_diff($oldIds, $keep));
foreach ($del as $did) { foreach ($del as $did) {
@ -136,12 +139,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
// ---------- 展示现有工具 ---------- // ---------- 展示现有工具 ----------
$tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll(); $tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll();
layout_head('功能区管理'); layout_head('内部功能');
admin_topbar('tools'); admin_topbar('tools');
?> ?>
<main class="page-main"> <main class="page-main">
<div class="wrap page-body"> <div class="wrap page-body">
<h2 style="margin-bottom:6px">功能区管理</h2> <h2 style="margin-bottom:6px">内部功能</h2>
<p class="tip"> <p class="tip">
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口; 管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
也可登记外部网址作为外链卡片(新窗口打开)。 也可登记外部网址作为外链卡片(新窗口打开)。
@ -155,6 +158,7 @@ admin_topbar('tools');
<?php endif; ?> <?php endif; ?>
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">工具卡片管理</div>
<form method="post" id="toolsForm" autocomplete="off"> <form method="post" id="toolsForm" autocomplete="off">
<?= csrf_field() ?> <?= csrf_field() ?>
<input type="hidden" name="action" value="save"> <input type="hidden" name="action" value="save">
@ -166,13 +170,16 @@ admin_topbar('tools');
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序"></button> <button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序"></button>
<span class="icon-ctl"> <span class="icon-ctl">
<span class="icon-preview"></span> <span class="icon-preview"></span>
<input type="text" class="icon-input t-icon" value="<?= he((string)$t['icon']) ?>" placeholder="图标"> <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> <button type="button" class="btn btn-sm icon-upload" title="上传图片≤200KB">上传</button>
<input type="file" class="icon-file" accept="image/*" hidden> <input type="file" class="icon-file" accept="image/*" hidden>
</span> </span>
<input type="text" class="t-name" value="<?= he((string) $t['name']) ?>" placeholder="名称(必填)"> <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-desc" value="<?= he((string) $t['description']) ?>"
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="站内如 codec.php / 外链 https://…"> placeholder="一句话说明">
<input type="text" class="t-url" value="<?= he((string) $t['url']) ?>"
placeholder="站内如 codec.php / 外链 https://…">
<select class="t-ext"> <select class="t-ext">
<option value="0" <?= (int) $t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option> <option value="0" <?= (int) $t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
<option value="1" <?= (int) $t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option> <option value="1" <?= (int) $t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
@ -184,7 +191,9 @@ admin_topbar('tools');
<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="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> <label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
</span> </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> <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"> <span class="ops">
<button type="button" class="btn btn-sm t-up" title="上移"></button> <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 t-down" title="下移"></button>
@ -386,4 +395,5 @@ admin_topbar('tools');
})(); })();
</script> </script>
</body> </body>
</html> </html>

View File

@ -1,11 +1,12 @@
<?php <?php
/** /**
* admin/visits.php —— 访问信息 * admin/visits.php —— 风控管理
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。 * 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。 * 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
*/ */
$P = '../'; $P = '../';
require_once __DIR__ . '/_guard.php'; require_once __DIR__ . '/_guard.php';
require_once __DIR__ . '/_visits_lib.php';
$pdo = db(); $pdo = db();
@ -19,8 +20,11 @@ function settings_set(string $key, string $value): void
/* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */ /* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */
function bl_delete_ids(PDO $pdo, array $ids): int 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; }))); $ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) {
if (!$ids) return 0; return $v > 0;
})));
if (!$ids)
return 0;
$ph = implode(',', array_fill(0, count($ids), '?')); $ph = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')'); $st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
$st->execute($ids); $st->execute($ids);
@ -90,7 +94,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$msgText = '访问日志已清空data/vistors.log。'; $msgText = '访问日志已清空data/vistors.log。';
break; break;
case 'risk_save': case 'risk_save':
$clip = function ($v, $d) { $raw = trim((string)($v ?? '')); $x = ($raw === '') ? (int)$d : (int)$raw; return max(1, min(1000000, $x)); }; $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_on', isset($_POST['r_login_on']) ? '1' : '0');
settings_set('risk_login_n', (string) $clip($_POST['r_login_n'] ?? null, 10)); 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_on', isset($_POST['r_404_on']) ? '1' : '0');
@ -107,6 +115,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$msgKind = 'ok'; $msgKind = 'ok';
$msgText = '全部风险计数已清空(黑名单本身不受影响)。'; $msgText = '全部风险计数已清空(黑名单本身不受影响)。';
break; break;
case 'loginlog_clear':
login_log_clear();
$msgKind = 'ok';
$msgText = '登录日志已清空(含成功与失败记录)。';
break;
case 'ban_batch': case 'ban_batch':
$n = bl_delete_ids($pdo, (array) ($_POST['bid'] ?? [])); $n = bl_delete_ids($pdo, (array) ($_POST['bid'] ?? []));
$banChanged = true; $banChanged = true;
@ -143,100 +156,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
/* ---------- 概要统计数据来源data/vistors.log ---------- */ /* ---------- 概要统计数据来源data/vistors.log ---------- */
$entries = visitors_entries(); $visStat = vis_stat_all();
$statTotal = count($entries); $statTotal = $visStat['total'];
$ipSet = []; $statIps = $visStat['ips'];
foreach ($entries as $e) { $statToday = $visStat['today'];
$ipSet[$e['ip']] = 1; $statBan = $visStat['ban'];
}
$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');
/* ---------- 筛选与分页 ---------- */ /* ---------- 筛选参数(供表单回显)与访问记录查询 ---------- */
$mode = (string) ($_GET['mode'] ?? 'full'); $mode = (string) ($_GET['mode'] ?? 'full');
if (!in_array($mode, ['full', 'compact'], true)) { if (!in_array($mode, ['full', 'compact'], true)) {
$mode = 'full'; $mode = 'full';
} }
$ipF = trim((string)($_GET['ip'] ?? '')); $ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64);
$q = trim((string)($_GET['q'] ?? '')); $q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
$ipF = substr($ipF, 0, 64);
$q = substr($q, 0, 100);
$page = max(1, (int) ($_GET['page'] ?? 1)); $page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 30; $perPage = 30;
$visAccess = vis_query_access($mode, $ipF, $q, $page, $perPage);
// 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);
}
/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */ /* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
$curIp = client_ip(); $curIp = client_ip();
@ -255,19 +190,34 @@ if ($curIp !== '未知') {
$rcfg = risk_rule_cfg(); $rcfg = risk_rule_cfg();
$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null; $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(); $banRows = $pdo->query('SELECT id, rule, note, created_at, source FROM ip_blacklist ORDER BY id DESC')->fetchAll();
$banAutoCount = 0; $banAutoCount = 0;
foreach ($banRows as $b) { 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'); admin_topbar('visits');
?> ?>
<main class="page-main"> <main class="page-main">
<div class="wrap page-body"> <div class="wrap page-body">
<h2 style="margin-bottom:6px">访问信息</h2> <h2 style="margin-bottom:6px">风控管理</h2>
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p> <p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
<?php if ($msgText !== ''): ?> <?php if ($msgText !== ''): ?>
@ -286,122 +236,138 @@ admin_topbar('visits');
<form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px"> <form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px">
<?= csrf_field() ?><input type="hidden" name="act" value="log_clear"> <?= csrf_field() ?><input type="hidden" name="act" value="log_clear">
<button type="submit" class="btn btn-sm btn-danger">清空访问日志</button> <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> </form>
</div> </div>
<!-- 筛选 + 日志列表 --> <!-- 筛选 + 日志列表 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">访问记录<?= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?></div> <div class="fs-title">访问记录</div>
<form method="get" class="v-filter"> <form method="get" class="v-filter" data-vis-form="access">
<select name="mode" title="展示模式" onchange="this.form.submit()"> <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="full" <?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
<option value="compact" <?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option> <option value="compact" <?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
</select> </select>
<input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0"> <input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0">
<input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式"> <input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式">
<button type="submit" class="btn btn-primary">筛选</button> <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> </form>
<?php if ($logs): ?> <div id="visResults"><?= vis_render_access($visAccess) ?></div>
<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> </div>
<!-- 自动风控规则 --> <!-- 自动风控规则 -->
<div class="fieldset-card"> <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"> <form method="post">
<?= csrf_field() ?><input type="hidden" name="act" value="risk_save"> <?= 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"> <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> <label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"
<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> <?= $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>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0"> <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> <label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"
<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> <?= $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>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0"> <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> <label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"
<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> <?= $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> </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"> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 8px">
<span class="tip" style="min-width:148px">主提示(留空=默认)</span> <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>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px"> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px">
<span class="tip" style="min-width:148px">次行说明(留空=默认)</span> <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> </div>
<button type="submit" class="btn btn-primary">保存风控规则</button> <button type="submit" class="btn btn-primary">保存风控规则</button>
</form> </form>
@ -409,27 +375,34 @@ admin_topbar('visits');
<?= csrf_field() ?><input type="hidden" name="act" value="risk_clear"> <?= csrf_field() ?><input type="hidden" name="act" value="risk_clear">
<button type="submit" class="btn btn-sm">清空全部风险计数</button> <button type="submit" class="btn btn-sm">清空全部风险计数</button>
<?php if ($curRisk): ?> <?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; ?> <?php endif; ?>
</form> </form>
</div> </div>
<!-- IP 黑名单 --> <!-- IP 黑名单 -->
<div class="fieldset-card"> <div class="fieldset-card">
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</div> <div class="fs-title">黑名单管理</div>
<form method="post" class="v-filter"> <form method="post" class="v-filter">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_add"> <?= 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"> <input type="text" name="note" placeholder="备注(可选,如:恶意扫描器)" style="flex:1 1 220px;max-width:340px">
<button type="submit" class="btn btn-primary">加入黑名单</button> <button type="submit" class="btn btn-primary">加入黑名单</button>
</form> </form>
<p class="tip" style="margin:10px 0 2px"> <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> 支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code>
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP <strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db ip_blacklist 表中删除记录。 <code>192.168.*.*</code>
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP <strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在
data/homepage.db ip_blacklist 表中删除记录。
</p> </p>
<div style="margin:6px 0 10px"> <div style="margin:6px 0 10px">
<?php if ($curHit): ?> <?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: ?> <?php else: ?>
<span class="vis-ok-tip">当前访问 IP<?= he($curIp) ?> —— 未命中任何黑名单规则</span> <span class="vis-ok-tip">当前访问 IP<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
<?php endif; ?> <?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"> <div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:8px 0 4px">
<form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。"> <form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_batch"> <?= 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> <button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中0</button>
</form> </form>
<?php if ($banAutoCount > 0): ?> <?php if ($banAutoCount > 0): ?>
@ -455,17 +430,20 @@ admin_topbar('visits');
</div> </div>
<?php foreach ($banRows as $b): ?> <?php foreach ($banRows as $b): ?>
<div class="bl-row"> <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> <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> <code class="bl-rule"><?= he((string) $b['rule']) ?></code>
<?php if (((string) ($b['source'] ?? 'manual')) === 'auto'): ?> <?php if (((string) ($b['source'] ?? 'manual')) === 'auto'): ?>
<span class="v-tag warn" style="min-width:0">风控自动</span> <span class="v-tag warn" style="min-width:0">风控自动</span>
<?php else: ?> <?php else: ?>
<span class="v-tag info" style="min-width:0">手动</span> <span class="v-tag info" style="min-width:0">手动</span>
<?php endif; ?> <?php endif; ?>
<span class="bl-note"><?= $b['note'] !== '' ? he((string)$b['note']) : '<span class="tip">(无备注)</span>' ?></span> <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> <span class="bl-time"><?= he((string) $b['created_at']) ?></span>
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string) $b['rule']) ?>」?"> <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'] ?>"> <?= 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> <button type="submit" class="btn btn-sm btn-danger">移除</button>
</form> </form>
</div> </div>
@ -499,4 +477,5 @@ admin_topbar('visits');
})(); })();
</script> </script>
</body> </body>
</html> </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_mode=blank ---------- */
.hero-slim { min-height: 0; padding: 16px 0 12px; } .hero-slim { min-height: 0; padding: 16px 0 12px; }
/* ============ 后台访问信息页 ============ */ /* ============ 后台风控管理页 ============ */
.vis-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 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 { 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; } .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; } .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 { 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 { 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; } .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; } .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; } } @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; } } @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() { function initToTop() {
var btn = document.querySelector('.js-to-top'); var btn = document.querySelector('.js-to-top');
if (!btn) return; if (!btn) return;
var scrollables = [];
var doc = document.scrollingElement || document.documentElement; var doc = document.scrollingElement || document.documentElement;
if (doc) scrollables.push(doc); var containers = [];
Array.prototype.forEach.call(document.querySelectorAll('*'), function (el) { var ticking = false;
if (el === doc || el === btn) return;
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;
}
return y;
}
function apply() {
ticking = false;
btn.classList.toggle('show', currentY() > 180);
}
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; if (el.scrollHeight <= el.clientHeight + 40) return;
var cs; var cs;
try { cs = window.getComputedStyle(el); } catch (e) { cs = null; } try { cs = window.getComputedStyle(el); } catch (e) { return; }
if (!cs) return; if (cs.position === 'fixed' || cs.position === 'sticky') return;
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollables.push(el); if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') containers.push(el);
}); });
function measure() { apply();
var v = 0;
for (var i = 0; i < scrollables.length; i++) {
if (scrollables[i].scrollTop > v) v = scrollables[i].scrollTop;
} }
btn.classList.toggle('show', v > 260); collect();
} if (document.readyState !== 'complete') window.addEventListener('load', collect);
for (var s = 0; s < scrollables.length; s++) {
scrollables[s].addEventListener('scroll', measure, { passive: true });
}
window.addEventListener('resize', measure);
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
var root = doc || document.documentElement; btn.classList.remove('show'); // 乐观隐藏,避免到顶后仍残留
if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: 'smooth' }); for (var i = 0; i < containers.length; i++) {
else window.scrollTo({ top: 0, behavior: 'smooth' }); 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 进入时,返回按钮改为“返回上一页” ---------- */ /* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */
@ -564,6 +768,9 @@
initCopyButtons(); initCopyButtons();
bindConfirmForms(); bindConfirmForms();
initVisitorInfo(); initVisitorInfo();
initLoginDevice();
initVisitsAjax();
initAdminSide();
initToTop(); initToTop();
initFuncBack(); initFuncBack();
hpShowPageToast(); 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 <?php
/** /**
* func/index.php —— 功能区首页(工具入口,由后台“功能区管理”动态驱动) * func/index.php —— 功能区首页(工具入口,由后台“内部功能”动态驱动)
*/ */
$P = '../'; $P = '../';
require_once dirname(__DIR__) . '/includes/layout.php'; require_once dirname(__DIR__) . '/includes/layout.php';
@ -14,7 +14,7 @@ layout_head('功能区');
<section class="sub-hero"> <section class="sub-hero">
<a class="back-home" href="../index.php"> 返回首页</a> <a class="back-home" href="../index.php"> 返回首页</a>
<h1>功能区</h1> <h1>功能区</h1>
<p class="st-tip">工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p> <p class="st-tip">工具统一收纳于此;新工具的入口可在后台“内部功能”中登记</p>
</section> </section>
<div class="wrap page-body"> <div class="wrap page-body">
@ -25,7 +25,8 @@ layout_head('功能区');
$rawIcon = $rawIcon !== '' ? $rawIcon : '🧩'; $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 class="fc-icon"><?= site_icon($rawIcon, '') ?></span>
<span> <span>
<span class="fc-name"><?= he((string) $t['name']) ?></span> <span class="fc-name"><?= he((string) $t['name']) ?></span>
@ -36,7 +37,7 @@ layout_head('功能区');
</div> </div>
<?php else: ?> <?php else: ?>
<div class="panel-card"> <div class="panel-card">
<div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“功能区管理”中添加</span></div> <div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“内部功能”中添加</span></div>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
@ -46,4 +47,5 @@ layout_head('功能区');
<?php layout_footer(); ?> <?php layout_footer(); ?>
<script src="../assets/js/common.js"></script> <script src="../assets/js/common.js"></script>
</body> </body>
</html> </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)); return hash_equals($row['password_hash'], hp_password_hash($plain));
} }
/** 执行登录(成功返回 true */ /** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息 */
function hp_login(string $username, string $plain): bool function hp_login(string $username, string $plain, string $deviceClient = ''): bool
{ {
$uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
if (hp_verify_user($username, $plain)) { if (hp_verify_user($username, $plain)) {
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['admin'] = $username; $_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; return true;
} }
login_log_write($username, 'fail', $deviceClient);
return false; return false;
} }

View File

@ -138,6 +138,21 @@ function db_init(PDO $pdo): void
updated_at TEXT NOT NULL DEFAULT \'\' 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 列 // 兼容旧库ip_blacklist 补 source 列categories 补充 icon 列
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'"); db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''"); db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
@ -226,6 +241,7 @@ function db_init(PDO $pdo): void
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密SM1 为不公开硬件算法)', 'gmcodec.html'], ['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密SM1 为不公开硬件算法)', 'gmcodec.html'],
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'], ['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务systemd 与 init.d', 'persist.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 = ?'); $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,?)'); $insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
@ -496,7 +512,7 @@ function access_log_flush(): void
} }
} }
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */ /** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */
function visitors_entries(): array function visitors_entries(): array
{ {
$out = []; $out = [];
@ -791,4 +807,201 @@ function risk_block_page(): void
exit; 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(); access_boot();

357
nav.php
View File

@ -7,7 +7,7 @@
* 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。 * 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。
* 数据来源: * 数据来源:
* 导航区 = nav_blocks / nav_items按当前库 * 导航区 = nav_blocks / nav_items按当前库
* 内部功能区 = func_tools后台“功能区管理”中 board_cats 控制按库展示,空=通用); * 内部功能区 = func_tools后台“内部功能”中 board_cats 控制按库展示,空=通用);
* 文章区 = articles后台“文章管理”中 board_cats 勾选分发到库,未分发不显示); * 文章区 = articles后台“文章管理”中 board_cats 勾选分发到库,未分发不显示);
* 外部工具区 = ext_links后台“外部工具”维护cat_keys 控制范围,空=通用)。 * 外部工具区 = ext_links后台“外部工具”维护cat_keys 控制范围,空=通用)。
*/ */
@ -24,7 +24,8 @@ if (!in_array($catKey, $allowed, true)) {
function board_cats_has(string $csv, string $key): bool function board_cats_has(string $csv, string $key): bool
{ {
$csv = trim($csv); $csv = trim($csv);
if ($csv === '') return true; if ($csv === '')
return true;
return in_array($key, array_map('trim', explode(',', $csv)), true); return in_array($key, array_map('trim', explode(',', $csv)), true);
} }
@ -48,7 +49,9 @@ $st->execute([(int)$cat['id']]);
$blocks = $st->fetchAll(); $blocks = $st->fetchAll();
$itemsByBlock = []; $itemsByBlock = [];
if ($blocks) { if ($blocks) {
$ids = array_map(function ($b) { return (int)$b['id']; }, $blocks); $ids = array_map(function ($b) {
return (int) $b['id'];
}, $blocks);
$in = implode(',', array_fill(0, count($ids), '?')); $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 = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
$st->execute($ids); $st->execute($ids);
@ -57,10 +60,15 @@ if ($blocks) {
} }
} }
$totalNav = 0; $totalNav = 0;
foreach ($itemsByBlock as $list) { $totalNav += count($list); } foreach ($itemsByBlock as $list) {
$totalNav += count($list);
}
$navCols = [[], [], []]; $navCols = [[], [], []];
$idx = 0; $idx = 0;
foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; } foreach ($blocks as $b) {
$navCols[$idx % 3][] = $b;
$idx++;
}
// —— 内部功能区数据(通用或包含当前库) —— // —— 内部功能区数据(通用或包含当前库) ——
$libTools = []; $libTools = [];
@ -81,7 +89,8 @@ foreach ($pdo->query('SELECT id, part, title, summary, updated_at, board_cats FR
$artGroups = []; $artGroups = [];
foreach ($libArts as $a) { foreach ($libArts as $a) {
$p = trim((string) $a['part']); $p = trim((string) $a['part']);
if ($p === '') $p = '未分类'; if ($p === '')
$p = '未分类';
if (!isset($artGroups[$p])) { if (!isset($artGroups[$p])) {
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []]; $artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []];
} }
@ -95,13 +104,15 @@ $poCfg = trim((string)setting_get('article_part_order', ''));
if ($poCfg !== '') { if ($poCfg !== '') {
foreach (explode(',', $poCfg) as $po) { foreach (explode(',', $poCfg) as $po) {
$po = trim($po); $po = trim($po);
if ($po !== '' && isset($artGroups[$po])) $poIdx[$po] = count($poIdx); if ($po !== '' && isset($artGroups[$po]))
$poIdx[$po] = count($poIdx);
} }
} }
uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) { uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) {
$ia = $poIdx[$a] ?? PHP_INT_MAX; $ia = $poIdx[$a] ?? PHP_INT_MAX;
$ib = $poIdx[$b] ?? PHP_INT_MAX; $ib = $poIdx[$b] ?? PHP_INT_MAX;
if ($ia !== $ib) return $ia <=> $ib; if ($ia !== $ib)
return $ia <=> $ib;
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']); $d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
return $d !== 0 ? $d : strcmp($a, $b); return $d !== 0 ? $d : strcmp($a, $b);
}); });
@ -134,69 +145,282 @@ layout_head($catName);
<style> <style>
/* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */ /* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */
.lnx-nav { .lnx-nav {
position: relative; display: flex; justify-content: center; align-items: center; position: relative;
gap: 0 38px; margin: 6px 0 16px; border-bottom: 1px solid var(--line); padding: 0 4px; 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); .lnx-tab {
transition: color .2s ease; white-space: nowrap; } position: relative;
.lnx-tab:hover { color: var(--text); } z-index: 1;
.lnx-tab.active { color: var(--brand); font-weight: 700; } 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 { .lnx-ink {
position: absolute; left: 0; bottom: -1px; width: 0; height: 3px; border-radius: 3px; position: absolute;
background: var(--brand); box-shadow: 0 0 8px var(--accent-soft); 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); 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-panel {
.lnx-phead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; } display: none;
.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); .lnx-panel.active {
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px; } 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 分组的小节) */ /* 外部工具区分区标题(按 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 {
.lnx-eg::after { content: ''; flex: 1; height: 1px; background: var(--line); } display: flex;
.lnx-exts + .lnx-eg { margin-top: 24px; } align-items: center;
.lnx-empty { color: var(--text-3); font-size: 13.5px; background: var(--card); border: 1px solid var(--line); gap: 10px;
border-radius: 12px; padding: 22px 16px; text-align: center; } margin: 18px 0 8px;
.lnx-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } font-size: 14.5px;
.lnx-tool { display: flex; gap: 12px; align-items: flex-start; text-decoration: none; color: var(--text); font-weight: 600;
background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm); color: var(--brand);
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-eg::after {
.lnx-tool b { display: block; font-size: 15px; margin-bottom: 4px; } content: '';
.lnx-tool span.tip { margin: 0; line-height: 1.5; display: block; } flex: 1;
.lnx-arts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } height: 1px;
.lnx-art { display: block; text-decoration: none; color: var(--text); background: var(--card); background: var(--line);
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-exts+.lnx-eg {
.lnx-art .lnx-ab { display: inline-block; font-size: 12px; color: var(--brand); background: var(--accent-soft); margin-top: 24px;
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-empty {
.lnx-art time { font-size: 12px; color: var(--text-3); } color: var(--text-3);
.lnx-exts { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; } font-size: 13.5px;
.lnx-ext { display: block; text-decoration: none; color: var(--text); background: var(--card); background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow-sm); border: 1px solid var(--line);
transition: transform .15s ease, border-color .15s ease; } border-radius: 12px;
.lnx-ext:hover { transform: translateY(-2px); border-color: var(--brand); } padding: 22px 16px;
.lnx-ext .lnx-eh { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } text-align: center;
.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-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) { @media (max-width: 760px) {
.lnx-nav { gap: 0 10px; } .lnx-nav {
.lnx-tab { flex: 1 1 0; min-width: 0; font-size: 14px; padding: 10px 2px 12px; text-align: center; } gap: 0 10px;
.nav-cols { grid-template-columns: 1fr; } }
.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> </style>
<main> <main>
<!-- 顶部留白 + 品牌条(上半区不变) --> <!-- 顶部留白 + 品牌条(上半区不变) -->
<div class="nav-top"> <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="返回首页"> <a class="brand brand-sm" href="index.php" title="返回首页">
<?php layout_logo_img('logo', $siteName); ?> <?php layout_logo_img('logo', $siteName); ?>
<span class="site-name"><?= he($siteName) ?></span> <span class="site-name"><?= he($siteName) ?></span>
@ -241,7 +465,7 @@ layout_head($catName);
<span class="lnx-go"> <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span> <span class="lnx-go"> <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span>
</div> </div>
<?php if (!$blocks): ?> <?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: ?> <?php else: ?>
<div class="nav-cols" style="margin:0"> <div class="nav-cols" style="margin:0">
<?php foreach ($navCols as $colBlocks): ?> <?php foreach ($navCols as $colBlocks): ?>
@ -283,9 +507,9 @@ layout_head($catName);
<h3>内部功能区 · <?= he($catName) ?>常用工具</h3> <h3>内部功能区 · <?= he($catName) ?>常用工具</h3>
<a class="lnx-go" href="func/index.php">前往完整功能区 </a> <a class="lnx-go" href="func/index.php">前往完整功能区 </a>
</div> </div>
<div class="lnx-note st-tip">由后台「功能区管理」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div> <div class="lnx-note st-tip">由后台「内部功能」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
<?php if (!$libTools): ?> <?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: ?> <?php else: ?>
<div class="lnx-tools"> <div class="lnx-tools">
<?php foreach ($libTools as $tk): <?php foreach ($libTools as $tk):
@ -295,7 +519,8 @@ layout_head($catName);
$tIcon = trim((string) ($tk['icon'] ?? '')); $tIcon = trim((string) ($tk['icon'] ?? ''));
$tIcon = $tIcon !== '' ? $tIcon : '🧩'; $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 class="lnx-ti"><?= site_icon($tIcon, '') ?></span>
<span> <span>
<b><?= he((string) $tk['name']) ?></b> <b><?= he((string) $tk['name']) ?></b>
@ -349,7 +574,8 @@ layout_head($catName);
} }
?> ?>
<?php foreach ($extGroups as $eg => $gItems): ?> <?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"> <div class="lnx-exts">
<?php foreach ($gItems as $ek): <?php foreach ($gItems as $ek):
$eUrl = trim((string) $ek['url']); $eUrl = trim((string) $ek['url']);
@ -420,4 +646,5 @@ layout_head($catName);
})(); })();
</script> </script>
</body> </body>
</html> </html>