新增navcat解密工具、优化后台UI
This commit is contained in:
parent
7b3d45b153
commit
bf772046cc
27
GUIDE.md
27
GUIDE.md
@ -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
|
||||||
|
|||||||
@ -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 反馈
|
||||||
|
|||||||
@ -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
274
admin/_visits_lib.php
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* admin/_visits_lib.php —— 风控管理页数据查询与片段渲染(visits.php / visits_data.php 共用)
|
||||||
|
* 依赖:includes/db.php(数据层)、includes/layout.php(he 转义)
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -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></code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
|
<span class="tip">上传后自动在正文末尾插入 <code></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>
|
||||||
@ -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');
|
|||||||
|
|
||||||
<!-- 站点 logo(favicon + 页面内容 logo) -->
|
<!-- 站点 logo(favicon + 页面内容 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>
|
||||||
@ -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='[ { "section": "合集" }, { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" }, { "name": "工具B", "url": "https://example.com/b" }, { "section": "导航" }, { "name": "站点C", "url": "https://example.com/c" } ]'></textarea>
|
<textarea id="extImportTa" rows="8" spellcheck="false"
|
||||||
|
style="font-family:Consolas,Menlo,monospace;font-size:13px"
|
||||||
|
placeholder='[ { "section": "合集" }, { "name": "工具A", "url": "https://example.com/a", "description": "一句话简介" }, { "name": "工具B", "url": "https://example.com/b" }, { "section": "导航" }, { "name": "站点C", "url": "https://example.com/c" } ]'></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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
359
admin/visits.php
359
admin/visits.php
@ -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
39
admin/visits_data.php
Normal 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));
|
||||||
@ -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; }
|
||||||
|
|||||||
@ -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
347
assets/js/navicat.js
Normal file
@ -0,0 +1,347 @@
|
|||||||
|
/* navicat.js —— Navicat 数据库连接密码 本地解密 / 加密(纯前端,不上传服务器)
|
||||||
|
* 支持两种算法(依据 HyperSine《how-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('无法识别的字节数据');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 版本 11:Blowfish 自定义链式(无填充) ================= */
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================= 版本 12:AES-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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 解析 .ncx(Navicat 导出的 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);
|
||||||
@ -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
194
func/navicat.html
Normal 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>
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
215
includes/db.php
215
includes/db.php
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入一条登录日志(status:ok=成功 / 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
357
nav.php
@ -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>
|
||||||
Loading…
Reference in New Issue
Block a user