新增navcat解密工具、优化后台UI
This commit is contained in:
parent
7b3d45b153
commit
bf772046cc
27
GUIDE.md
27
GUIDE.md
@ -48,14 +48,14 @@
|
||||
|
||||
入口:站点域名 `/admin`,登录后进入;顶栏依次为:
|
||||
|
||||
### 1. 导航内容管理(nav.php)
|
||||
### 1. 导航管理(nav.php)
|
||||
|
||||
- 顶部切换当前要编辑的库(科普 / 红队 / 蓝队)
|
||||
- 每个块可改标题、增删链接、拖拽排序;删除后有确认弹窗
|
||||
- **批量导入**:在导入面板粘贴文本,格式支持 `名称 网址` 或 `名称 | 网址`,自动拆分成导航块;解析后可核对再「保存全部修改」
|
||||
- 操作结果以右下角 Toast 提示,且保持原滚动位置
|
||||
|
||||
### 2. 功能区管理(tools.php)
|
||||
### 2. 内部功能(tools.php)
|
||||
|
||||
- 添加工具:填写名称、图标(emoji)、简介、**入口地址**与**展示库范围**(未勾选=所有库通用)
|
||||
- 站内页面:填 `codec.html` 等相对地址(工具本体放在 `func/`)
|
||||
@ -73,7 +73,7 @@
|
||||
- 维护各库推荐的外部站点:名称、网址、简介、分组(栏目)、展示范围(cat_keys)
|
||||
- 保存后库首页「外部工具区」即时更新;同样支持批量导入
|
||||
|
||||
### 5. 内容与顺序(content.php)
|
||||
### 5. 网站管理(content.php)
|
||||
|
||||
集中站点配置:
|
||||
|
||||
@ -85,15 +85,26 @@
|
||||
- **首页顶部组件**:天气(默认)/ 访客信息 / 空白精简
|
||||
- **修改密码**:建议首次登录即修改默认 `admin/admin123`
|
||||
|
||||
### 6. 访问信息(visits.php)—— 日志与安全
|
||||
### 6. 风控管理(visits.php)—— 日志与安全
|
||||
|
||||
**访问概况**:累计访问 / 独立 IP / 今日访问 / 黑名单规则数。
|
||||
**访问记录**:
|
||||
|
||||
- 完整模式逐条明细;精简模式按 IP 聚合(最近访问 / 次数)
|
||||
- 支持 IP 模糊筛选、URL/方法关键词搜索、分页
|
||||
- 筛选与翻页为**页面内局部刷新**(由 `visits_data.php` 查询),不会整页跳转、也不会跳回页面顶部
|
||||
- 日志按行追加写于 `data/vistors.log`,无上限;可一键清空
|
||||
|
||||
**登录信息**:
|
||||
|
||||
- 顶部展示当前用户、本次登录时间 / 登录 IP / 登录设备(操作系统 / 手机型号 / 浏览器 / 屏幕等,能获取到什么就记录什么,由浏览器采集 + 服务端 UA 解析)
|
||||
- 「历史登录记录」列出最近成功登录(时间 / IP / 设备 / 用户名)
|
||||
|
||||
**登录失败日志**:
|
||||
|
||||
- 按「IP × 日期」聚合:同一 IP 当天的失败次数、最近失败时间与设备信息
|
||||
- 支持按 IP 模糊筛选(同样为页面内局部刷新);可一键清空全部登录日志(含成功与失败,存于数据库 `login_log` 表)
|
||||
|
||||
**自动风控规则**(阈值到达即永久封禁):
|
||||
| 规则 | 默认 | 建议 |
|
||||
|---|---|---|
|
||||
@ -114,16 +125,16 @@
|
||||
|
||||
## 三、安全运维
|
||||
|
||||
- **改口令**:登录后台 → 内容与顺序 → 修改密码(至少 6 位)
|
||||
- **改口令**:登录后台 → 网站管理 → 修改密码(至少 6 位)
|
||||
- **被自动风控误封**:从其它未被封的网络登录后台移除对应规则;或直接编辑数据库 `data/homepage.db` 中 `ip_blacklist` 表删除该行(同表可删除“风控自动”记录)
|
||||
- **备份与升级**:站点数据集中在 `data/`(数据库、访问日志、上传、文章图片)。升级前备份整个 `data/` 目录即可无损迁移
|
||||
- **恢复出厂**:删除 `data/homepage.db`,刷新后自动重建默认内容
|
||||
|
||||
## 四、常见问题(FAQ)
|
||||
|
||||
1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「访问信息 → IP 黑名单」移除即可。
|
||||
2. **想让某个 IP 永久无法访问**:后台「访问信息」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。
|
||||
3. **工具页图标不是自己的 logo**:后台「内容与顺序」上传过 logo 后,强刷(Ctrl+F5)工具页即可同步;浏览器对旧图标有缓存,可多刷新一次。
|
||||
1. **登录提示“已被风控自动封禁,全站访问被拒绝”**:说明该 IP 登录失败达阈值,已被自动封禁。请从未被封的网络登录后台,在「风控管理 → IP 黑名单」移除即可。
|
||||
2. **想让某个 IP 永久无法访问**:后台「风控管理」加入黑名单(单 IP / 网段 / 通配段均可),即刻全站生效。
|
||||
3. **工具页图标不是自己的 logo**:后台「网站管理」上传过 logo 后,强刷(Ctrl+F5)工具页即可同步;浏览器对旧图标有缓存,可多刷新一次。
|
||||
4. **工具页想完全离线使用**:把第三方库放入对应位置即可(在线未放时自动回退 CDN):
|
||||
```
|
||||
assets/js/qrcode.min.js qrcodejs 1.0.0
|
||||
|
||||
@ -26,7 +26,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区
|
||||
### 访问审计与风控
|
||||
|
||||
- 每次访问自动追加写入 `data/vistors.log`(完整保留、可归档)
|
||||
- **访问信息页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页
|
||||
- **风控管理页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页(页面内局部刷新,不跳回顶部)
|
||||
- **登录日志**:成功登录记录(时间 / IP / 设备)与失败日志(按 IP × 日期聚合次数与设备);设备信息含系统 / 手机型号 / 屏幕等,由浏览器采集
|
||||
- **IP 黑名单**:支持单 IP、CIDR 网段、星号通配;命中后**全站统一拦截**(含后台与登录页)
|
||||
- **自动风控**:登录失败、404 次数、请求频率三条规则,阈值可配、即时启停;自动封禁支持一键 / 批量移除
|
||||
- 拦截提示文案可自定义;后台操作结果以右下角 Toast 反馈
|
||||
|
||||
@ -1,37 +1,65 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/_guard.php —— 后台鉴权守卫 + 公共顶栏(所有后台页面在 $P='../' 定义后 require)
|
||||
* admin/_guard.php —— 后台鉴权守卫 + 公共侧边栏(所有后台页面在 $P='../' 定义后 require)
|
||||
*/
|
||||
$P = $P ?? '../';
|
||||
require_once dirname(__DIR__) . '/includes/auth.php';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
require_login('login.php');
|
||||
|
||||
/** 后台顶栏 */
|
||||
/** 后台侧边栏(原顶部分区栏改为可收缩侧栏;含“本页功能”锚点容器) */
|
||||
function admin_topbar(string $active = ''): void
|
||||
{
|
||||
global $P;
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
$admin = (string)($_SESSION['admin'] ?? '');
|
||||
echo '<div class="admin-nav"><div class="admin-nav-inner">';
|
||||
echo '<a class="brand brand-sm" href="../index.php" title="查看前台首页">';
|
||||
layout_logo_img('logo', $site);
|
||||
echo '<span class="site-name">' . he($site) . ' · 后台</span></a>';
|
||||
$admin = (string) ($_SESSION['admin'] ?? '');
|
||||
// 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
|
||||
echo '<script>(function(){var b=document.body;if(!b)return;b.classList.add("hp-admin-side");try{if(localStorage.getItem("hp-admin-side-collapsed")==="1")b.classList.add("hp-side-collapsed");}catch(e){}})();</script>' . "\n";
|
||||
|
||||
// 小屏顶部条(仅移动端显示:菜单按钮 + 站名 + 退出)
|
||||
echo '<div class="admin-mobilebar">';
|
||||
echo '<button type="button" class="amb-btn js-admin-side-toggle" title="展开 / 收起菜单" aria-label="展开或收起菜单">☰</button>';
|
||||
echo '<a class="amb-brand" href="../index.php" title="查看前台首页">';
|
||||
layout_logo_img('amb-logo', $site);
|
||||
echo '<span>' . he($site) . ' · 后台</span></a>';
|
||||
echo '<a class="amb-out" href="logout.php">退出</a>';
|
||||
echo '</div>' . "\n";
|
||||
|
||||
echo '<aside class="admin-side" id="adminSide">';
|
||||
// 品牌区 + 折叠按钮
|
||||
echo '<div class="as-head">';
|
||||
echo '<a class="as-brand" href="../index.php" title="查看前台首页">';
|
||||
layout_logo_img('as-logo', $site);
|
||||
echo '<span class="as-brand-tx">' . he($site) . '<em>后台管理</em></span></a>';
|
||||
echo '<button type="button" class="as-toggle js-admin-side-toggle" title="收起 / 展开侧栏" aria-label="收起或展开侧栏"><span class="as-toggle-ic">«</span></button>';
|
||||
echo '</div>';
|
||||
// 主导航
|
||||
$links = [
|
||||
'nav' => ['nav.php', '导航内容管理'],
|
||||
'tools' => ['tools.php', '功能区管理'],
|
||||
'articles' => ['articles.php', '文章管理'],
|
||||
'exttools' => ['exttools.php', '外部工具'],
|
||||
'content' => ['content.php', '内容与顺序'],
|
||||
'visits' => ['visits.php', '访问信息'],
|
||||
'nav' => ['nav.php', '导航管理', '🗂'],
|
||||
'tools' => ['tools.php', '内部功能', '🧰'],
|
||||
'articles' => ['articles.php', '文章管理', '📄'],
|
||||
'exttools' => ['exttools.php', '外部工具', '🔗'],
|
||||
'content' => ['content.php', '网站管理', '⚙'],
|
||||
'visits' => ['visits.php', '风控管理', '🛡'],
|
||||
];
|
||||
echo '<nav class="as-nav">';
|
||||
foreach ($links as $k => $lk) {
|
||||
$cls = ($active === $k) ? 'active' : '';
|
||||
echo '<a href="' . he($lk[0]) . '"' . ($cls ? ' class="' . $cls . '"' : '') . '>' . he($lk[1]) . '</a>';
|
||||
$cls = 'as-link' . ($active === $k ? ' active' : '');
|
||||
echo '<a class="' . $cls . '" href="' . he($lk[0]) . '" title="' . he($lk[1]) . '">';
|
||||
echo '<span class="as-ic">' . he($lk[2]) . '</span><span class="as-tx">' . he($lk[1]) . '</span></a>';
|
||||
}
|
||||
echo '<span class="sp">';
|
||||
echo '<span class="tip" style="color:rgba(255,255,255,.75)">管理员:' . he($admin) . '</span>';
|
||||
echo '<a href="logout.php">退出登录</a>';
|
||||
echo '</span>';
|
||||
echo '</div></div>' . "\n";
|
||||
echo '</nav>';
|
||||
// 本页功能(由 common.js 依据当前页区块自动生成)
|
||||
echo '<div class="as-sec" id="adminSideSections" hidden>';
|
||||
echo '<div class="as-sec-t"><span class="as-tx">本页功能</span></div>';
|
||||
echo '<div class="as-sec-list" id="adminSideSectionList"></div>';
|
||||
echo '</div>';
|
||||
// 底部:前台入口 / 退出 / 当前管理员
|
||||
echo '<div class="as-foot">';
|
||||
echo '<a class="as-link" href="../index.php" title="查看前台首页"><span class="as-ic">⌂</span><span class="as-tx">查看前台</span></a>';
|
||||
echo '<a class="as-link" href="logout.php" title="退出登录"><span class="as-ic">⎋</span><span class="as-tx">退出登录</span></a>';
|
||||
echo '<div class="as-user as-tx" title="当前登录用户">当前用户(' . he($admin) . ')</div>';
|
||||
echo '</div>';
|
||||
echo '</aside>' . "\n";
|
||||
echo '<div class="admin-side-mask" id="adminSideMask"></div>' . "\n";
|
||||
}
|
||||
|
||||
274
admin/_visits_lib.php
Normal file
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();
|
||||
}
|
||||
@ -22,11 +22,11 @@ function renumber_sorts(PDO $pdo): void
|
||||
$idxByPart = [];
|
||||
$st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
$key = (string)$r['part'];
|
||||
$key = (string) $r['part'];
|
||||
if (!isset($idxByPart[$key])) {
|
||||
$idxByPart[$key] = 1;
|
||||
}
|
||||
$st->execute([$idxByPart[$key], (int)$r['id']]);
|
||||
$st->execute([$idxByPart[$key], (int) $r['id']]);
|
||||
$idxByPart[$key]++;
|
||||
}
|
||||
}
|
||||
@ -45,27 +45,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
$act = (string) ($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'note':
|
||||
settings_set('article_note', trim((string)($_POST['note'] ?? '')));
|
||||
settings_set('article_note', trim((string) ($_POST['note'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章区首页注要已保存。';
|
||||
break;
|
||||
case 'save':
|
||||
$id = max(0, (int)($_POST['id'] ?? 0));
|
||||
$part = trim((string)($_POST['part'] ?? ''));
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$summary = trim((string)($_POST['summary'] ?? ''));
|
||||
$md = (string)($_POST['markdown'] ?? '');
|
||||
$sort = max(0, (int)($_POST['sort'] ?? 0));
|
||||
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||||
$part = trim((string) ($_POST['part'] ?? ''));
|
||||
$title = trim((string) ($_POST['title'] ?? ''));
|
||||
$summary = trim((string) ($_POST['summary'] ?? ''));
|
||||
$md = (string) ($_POST['markdown'] ?? '');
|
||||
$sort = max(0, (int) ($_POST['sort'] ?? 0));
|
||||
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
|
||||
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
|
||||
// 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页
|
||||
$bcKept = [];
|
||||
foreach ((array)($_POST['board_cats'] ?? []) as $bk) {
|
||||
$bk = trim((string)$bk);
|
||||
foreach ((array) ($_POST['board_cats'] ?? []) as $bk) {
|
||||
$bk = trim((string) $bk);
|
||||
if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) {
|
||||
$bcKept[] = $bk;
|
||||
}
|
||||
@ -86,7 +86,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已发布(可继续编辑)。';
|
||||
}
|
||||
@ -98,7 +98,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
break;
|
||||
case 'del':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]);
|
||||
renumber_sorts($pdo);
|
||||
@ -108,22 +108,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
break;
|
||||
case 'toggle':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$field = (string)($_POST['field'] ?? '');
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$field = (string) ($_POST['field'] ?? '');
|
||||
if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) {
|
||||
$cur = (int)$pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
|
||||
$cur = (int) $pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
|
||||
$pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
|
||||
$msgKind = 'ok';
|
||||
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。';
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$dir = ((string)($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$dir = ((string) ($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
|
||||
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
|
||||
$idx = -1;
|
||||
foreach ($rows as $i => $r) {
|
||||
if ((int)$r['id'] === $id) {
|
||||
if ((int) $r['id'] === $id) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
@ -144,9 +144,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
// 分区(分类)拖动排序后持久化到设置:CSV 顺序
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$parts = [];
|
||||
foreach (explode(',', (string)($_POST['order'] ?? '')) as $po) {
|
||||
$po = trim((string)$po);
|
||||
if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; }
|
||||
foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) {
|
||||
$po = trim((string) $po);
|
||||
if ($po !== '' && !in_array($po, $parts, true)) {
|
||||
$parts[] = $po;
|
||||
}
|
||||
}
|
||||
settings_set('article_part_order', implode(',', $parts));
|
||||
touch_last_updated();
|
||||
@ -165,7 +167,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
exit;
|
||||
}
|
||||
$extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg'];
|
||||
$ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION));
|
||||
$ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION));
|
||||
if (!isset($extMap[$ext])) {
|
||||
echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
@ -193,24 +195,33 @@ $list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC'
|
||||
// —— 文章按分区(分类)分组;分区顺序:后台拖动保存的 article_part_order 优先,其余按各组最新更新时间降序 ——
|
||||
$artGroups = [];
|
||||
foreach ($list as $a) {
|
||||
$p = trim((string)$a['part']);
|
||||
if ($p === '') { $p = '未分类'; }
|
||||
if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; }
|
||||
$p = trim((string) $a['part']);
|
||||
if ($p === '') {
|
||||
$p = '未分类';
|
||||
}
|
||||
if (!isset($artGroups[$p])) {
|
||||
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'rows' => []];
|
||||
}
|
||||
$artGroups[$p]['rows'][] = $a;
|
||||
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; }
|
||||
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string) $a['updated_at'];
|
||||
}
|
||||
}
|
||||
$poIdx = [];
|
||||
$poCfg = trim((string)setting_get('article_part_order', ''));
|
||||
$poCfg = trim((string) setting_get('article_part_order', ''));
|
||||
if ($poCfg !== '') {
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); }
|
||||
if ($po !== '' && isset($artGroups[$po])) {
|
||||
$poIdx[$po] = count($poIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) {
|
||||
$ia = $poIdx[$pa] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$pb] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib) return $ia <=> $ib;
|
||||
if ($ia !== $ib)
|
||||
return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($pa, $pb);
|
||||
});
|
||||
@ -218,7 +229,7 @@ $noteText = setting_get('article_note', '');
|
||||
|
||||
$editing = false;
|
||||
$row = null;
|
||||
$editId = (int)($_GET['edit'] ?? 0);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
if ($editId > 0) {
|
||||
$st = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
|
||||
$st->execute([$editId]);
|
||||
@ -239,39 +250,53 @@ admin_topbar('articles');
|
||||
<?php if ($savedFlag): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,文章库前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($msgText !== ''): echo msg_block($msgKind, $msgText); endif; ?>
|
||||
<?php if ($msgText !== ''):
|
||||
echo msg_block($msgKind, $msgText);
|
||||
endif; ?>
|
||||
|
||||
<?php if ($editing || isset($_GET['new'])): ?>
|
||||
<?php
|
||||
$cur = $row ? [
|
||||
'id' => (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'],
|
||||
'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'],
|
||||
'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'],
|
||||
'board_cats' => trim((string)($row['board_cats'] ?? '')),
|
||||
'id' => (int) $row['id'],
|
||||
'part' => (string) $row['part'],
|
||||
'title' => (string) $row['title'],
|
||||
'summary' => (string) $row['summary'],
|
||||
'md' => (string) $row['markdown'],
|
||||
'sort' => (int) $row['sort'],
|
||||
'pinned' => (int) $row['pinned'],
|
||||
'enabled' => (int) $row['enabled'],
|
||||
'board_cats' => trim((string) ($row['board_cats'] ?? '')),
|
||||
] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => ''];
|
||||
// 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中
|
||||
$curPart = trim((string)$cur['part']);
|
||||
if ($curPart === '') { $curPart = '未分类'; }
|
||||
$curPart = trim((string) $cur['part']);
|
||||
if ($curPart === '') {
|
||||
$curPart = '未分类';
|
||||
}
|
||||
$partOptions = ['未分类'];
|
||||
foreach ($list as $a2) {
|
||||
$p2 = trim((string)$a2['part']);
|
||||
if ($p2 === '' || $p2 === '未分类') continue;
|
||||
if (!in_array($p2, $partOptions, true)) { $partOptions[] = $p2; }
|
||||
$p2 = trim((string) $a2['part']);
|
||||
if ($p2 === '' || $p2 === '未分类')
|
||||
continue;
|
||||
if (!in_array($p2, $partOptions, true)) {
|
||||
$partOptions[] = $p2;
|
||||
}
|
||||
}
|
||||
if (!in_array($curPart, $partOptions, true)) {
|
||||
array_unshift($partOptions, $curPart);
|
||||
}
|
||||
if (!in_array($curPart, $partOptions, true)) { array_unshift($partOptions, $curPart); }
|
||||
?>
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="act" value="save">
|
||||
<input type="hidden" name="id" value="<?= (int)$cur['id'] ?>">
|
||||
<input type="hidden" name="id" value="<?= (int) $cur['id'] ?>">
|
||||
<div class="field-row" style="flex-wrap:wrap">
|
||||
<div style="flex:1 1 200px;min-width:160px">
|
||||
<label class="fl">分区 / 栏目</label>
|
||||
<select name="part" id="artPartSel">
|
||||
<?php foreach ($partOptions as $po): ?>
|
||||
<option value="<?= he($po) ?>"<?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
|
||||
<option value="<?= he($po) ?>" <?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<option value="__new__">+ 新建分区…</option>
|
||||
</select>
|
||||
@ -284,26 +309,28 @@ admin_topbar('articles');
|
||||
</div>
|
||||
<div style="flex:0 1 120px;min-width:90px">
|
||||
<label class="fl">排序(组内)</label>
|
||||
<input type="number" name="sort" value="<?= (int)$cur['sort'] ?>" min="0" max="999">
|
||||
<input type="number" name="sort" value="<?= (int) $cur['sort'] ?>" min="0" max="999">
|
||||
</div>
|
||||
</div>
|
||||
<label class="fl">摘要 / 一句话简介(文章区列表展示)</label>
|
||||
<input type="text" name="summary" value="<?= he($cur['summary']) ?>" placeholder="可选">
|
||||
<div class="field-row" style="gap:18px;margin:6px 0">
|
||||
<label class="seg-check"><input type="checkbox" name="pinned" value="1"<?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
|
||||
<label class="seg-check"><input type="checkbox" name="enabled" value="1"<?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
|
||||
<label class="seg-check"><input type="checkbox" name="pinned" value="1" <?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
|
||||
<label class="seg-check"><input type="checkbox" name="enabled" value="1" <?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
|
||||
</div>
|
||||
<?php $artBc = array_map('trim', explode(',', (string)$cur['board_cats'])); ?>
|
||||
<?php $artBc = array_map('trim', explode(',', (string) $cur['board_cats'])); ?>
|
||||
<label class="fl">分发到库首页「文章区」(不勾选=不在科普 / 红队 / 蓝队库首页展示)</label>
|
||||
<div class="field-row" style="gap:18px;margin:6px 0">
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular"<?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red"<?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue"<?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular" <?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red" <?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue" <?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
|
||||
</div>
|
||||
<label class="fl">正文(Markdown)</label>
|
||||
<textarea name="markdown" rows="16" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
|
||||
<textarea name="markdown" rows="16"
|
||||
style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
|
||||
<div class="field-row" style="margin-top:8px;align-items:center">
|
||||
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="flex:0 1 260px">
|
||||
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
style="flex:0 1 260px">
|
||||
<button type="button" class="btn btn-sm" id="artImgUpload">上传图片并插入引用</button>
|
||||
<span class="tip">上传后自动在正文末尾插入 <code></code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
|
||||
</div>
|
||||
@ -318,7 +345,7 @@ admin_topbar('articles');
|
||||
|
||||
<!-- 文章区首页注要 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">文章区首页注要(顶部展示给读者的说明文字)</div>
|
||||
<div class="fs-title">顶部说明管理</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="note">
|
||||
<textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea>
|
||||
@ -329,7 +356,7 @@ admin_topbar('articles');
|
||||
<!-- 文章列表:分类即分组(默认收起,组可拖动排序) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="field-row" style="align-items:center;justify-content:space-between">
|
||||
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表(共 <?= count($list) ?> 篇,按分类分组)</span>
|
||||
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表</span>
|
||||
<span style="display:inline-flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button>
|
||||
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
|
||||
@ -354,38 +381,53 @@ admin_topbar('articles');
|
||||
<div class="xg-b">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>标题</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题</th>
|
||||
<th>更新时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($g['rows'] as $a): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="articles.php?edit=<?= (int)$a['id'] ?>"><?= he((string)$a['title']) ?></a>
|
||||
<?php if (trim((string)$a['summary']) !== ''): ?><div class="tip"><?= he((string)$a['summary']) ?></div><?php endif; ?>
|
||||
<a href="articles.php?edit=<?= (int) $a['id'] ?>"><?= he((string) $a['title']) ?></a>
|
||||
<?php if (trim((string) $a['summary']) !== ''): ?>
|
||||
<div class="tip"><?= he((string) $a['summary']) ?></div><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap"><?= he((string)$a['updated_at']) ?></td>
|
||||
<td style="white-space:nowrap"><?= he((string) $a['updated_at']) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php if ((int)$a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
|
||||
<?php if ((int)$a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span class="badge badge-no">隐藏</span><?php endif; ?>
|
||||
<?php if ((int) $a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
|
||||
<?php if ((int) $a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span
|
||||
class="badge badge-no">隐藏</span><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id"
|
||||
value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="删除">✕</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="pinned"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="<?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
|
||||
name="field" value="pinned"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"
|
||||
title="<?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="enabled"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"><?= (int)$a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
|
||||
name="field" value="enabled"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"><?= (int) $a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="up"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
|
||||
value="up"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内上移">↑</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="down"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
|
||||
value="down"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内下移">↓</button>
|
||||
</form>
|
||||
</td>
|
||||
@ -407,7 +449,7 @@ admin_topbar('articles');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('artImgUpload');
|
||||
var file = document.getElementById('artImg');
|
||||
@ -442,8 +484,8 @@ admin_topbar('articles');
|
||||
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
|
||||
xhr.send(fd);
|
||||
});
|
||||
})();
|
||||
(function () {
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
// 分区下拉:选择「+ 新建分区…」时切换为新分区名输入框,回车/失焦确认后回落
|
||||
var sel = document.getElementById('artPartSel');
|
||||
@ -495,8 +537,8 @@ admin_topbar('articles');
|
||||
sel.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
(function () {
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
// 文章列表:分类即分组 —— 组头点击折叠/展开、整组拖动排序并即时持久化 part_order
|
||||
var gWrap = document.getElementById('artGroups');
|
||||
@ -554,7 +596,7 @@ admin_topbar('articles');
|
||||
if (!xg) return;
|
||||
dragXg = xg;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) {}
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) { }
|
||||
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
gWrap.addEventListener('dragover', function (ev) {
|
||||
@ -602,7 +644,8 @@ admin_topbar('articles');
|
||||
})
|
||||
.catch(function () { alert('网络错误,分类顺序未保存。'); });
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/content.php —— 内容与顺序 / 站点设置
|
||||
* admin/content.php —— 网站管理 / 站点设置
|
||||
* 含:站点信息、板块名称与说明、logo 上传(favicon + 内容 logo 同源)、
|
||||
* 名言库(quota.txt)、底部备案信息、分类顺序、修改密码。
|
||||
*/
|
||||
@ -43,35 +43,36 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
$act = (string) ($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'site':
|
||||
settings_set('site_name', trim((string)($_POST['site_name'] ?? '知识导航站')));
|
||||
settings_set('site_slogan', trim((string)($_POST['site_slogan'] ?? '')));
|
||||
settings_set('site_name', trim((string) ($_POST['site_name'] ?? '知识导航站')));
|
||||
settings_set('site_slogan', trim((string) ($_POST['site_slogan'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '站点信息已保存。';
|
||||
break;
|
||||
case 'tips':
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
settings_set('show_st_tips', (isset($_POST['show_tips']) && (string)$_POST['show_tips'] === '1') ? '1' : '0');
|
||||
settings_set('show_st_tips', (isset($_POST['show_tips']) && (string) $_POST['show_tips'] === '1') ? '1' : '0');
|
||||
touch_last_updated();
|
||||
echo json_encode(['ok' => true, 'msg' => '站长提示开关已保存。'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
case 'boards':
|
||||
$names = (array)($_POST['b_name'] ?? []);
|
||||
$descs = (array)($_POST['b_desc'] ?? []);
|
||||
$icons = (array)($_POST['b_icon'] ?? []);
|
||||
$sorts = (array)($_POST['b_sort'] ?? []);
|
||||
$names = (array) ($_POST['b_name'] ?? []);
|
||||
$descs = (array) ($_POST['b_desc'] ?? []);
|
||||
$icons = (array) ($_POST['b_icon'] ?? []);
|
||||
$sorts = (array) ($_POST['b_sort'] ?? []);
|
||||
$stUpd = $pdo->prepare('UPDATE categories SET name = ?, description = ?, icon = ?, sort = ? WHERE id = ?');
|
||||
foreach (array_keys($names) as $i) {
|
||||
$name = trim((string)($names[$i] ?? ''));
|
||||
$desc = trim((string)($descs[$i] ?? ''));
|
||||
$icon = trim((string)($icons[$i] ?? ''));
|
||||
$sort = max(0, (int)($sorts[$i] ?? 0));
|
||||
$id = (int)$i;
|
||||
$name = trim((string) ($names[$i] ?? ''));
|
||||
$desc = trim((string) ($descs[$i] ?? ''));
|
||||
$icon = trim((string) ($icons[$i] ?? ''));
|
||||
$sort = max(0, (int) ($sorts[$i] ?? 0));
|
||||
$id = (int) $i;
|
||||
$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]);
|
||||
}
|
||||
touch_last_updated();
|
||||
@ -79,19 +80,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '板块名称 / 说明 / 图标 / 顺序已保存。';
|
||||
break;
|
||||
case 'footer':
|
||||
settings_set('icp_no', trim((string)($_POST['icp_no'] ?? '')));
|
||||
settings_set('gongan_no', trim((string)($_POST['gongan_no'] ?? '')));
|
||||
settings_set('gongan_link', trim((string)($_POST['gongan_link'] ?? '')));
|
||||
settings_set('copyright', trim((string)($_POST['copyright'] ?? '')));
|
||||
settings_set('footer_text', trim((string)($_POST['footer_text'] ?? '')));
|
||||
settings_set('icp_no', trim((string) ($_POST['icp_no'] ?? '')));
|
||||
settings_set('gongan_no', trim((string) ($_POST['gongan_no'] ?? '')));
|
||||
settings_set('gongan_link', trim((string) ($_POST['gongan_link'] ?? '')));
|
||||
settings_set('copyright', trim((string) ($_POST['copyright'] ?? '')));
|
||||
settings_set('footer_text', trim((string) ($_POST['footer_text'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '底部信息已保存。';
|
||||
break;
|
||||
case 'quotes':
|
||||
$content = (string)($_POST['quotes'] ?? '');
|
||||
$content = (string) ($_POST['quotes'] ?? '');
|
||||
$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);
|
||||
if ($savedBytes === false) {
|
||||
$msgKind = 'err';
|
||||
@ -115,10 +117,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
break;
|
||||
}
|
||||
$extMap = [
|
||||
'png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg',
|
||||
'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg', 'ico' => 'ico',
|
||||
'png' => 'png',
|
||||
'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])) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '仅支持 png / jpg / gif / webp / svg / ico 图片。';
|
||||
@ -148,13 +155,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '已恢复默认 LOGO。';
|
||||
break;
|
||||
case 'hero':
|
||||
settings_set('hero_bg', trim((string)($_POST['hero_bg'] ?? '')));
|
||||
settings_set('hero_bg', trim((string) ($_POST['hero_bg'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '首页顶部标识栏背景已保存。';
|
||||
break;
|
||||
case 'hero_mode':
|
||||
$mode = (string)($_POST['hero_mode'] ?? 'weather');
|
||||
$mode = (string) ($_POST['hero_mode'] ?? 'weather');
|
||||
if (!in_array($mode, ['weather', 'info', 'blank'], true)) {
|
||||
$mode = 'weather';
|
||||
}
|
||||
@ -165,13 +172,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '首页顶部组件模式已保存:' . $modeText . '。';
|
||||
break;
|
||||
case 'cdn':
|
||||
settings_set('cdn_ranges', trim((string)($_POST['cdn_ranges'] ?? '')));
|
||||
settings_set('cdn_ranges', trim((string) ($_POST['cdn_ranges'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = 'CDN IP 段库已保存。';
|
||||
break;
|
||||
case 'engines':
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$msgKind = 'err';
|
||||
@ -180,11 +187,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
$list = [];
|
||||
foreach ($rows as $e) {
|
||||
if (!is_array($e)) continue;
|
||||
$name = trim((string)($e['name'] ?? ''));
|
||||
$url = trim((string)($e['url'] ?? ''));
|
||||
$icon = trim((string)($e['icon'] ?? ''));
|
||||
if ($name === '' || $url === '' || strpos($url, '{kw}') === false) continue;
|
||||
if (!is_array($e))
|
||||
continue;
|
||||
$name = trim((string) ($e['name'] ?? ''));
|
||||
$url = trim((string) ($e['url'] ?? ''));
|
||||
$icon = trim((string) ($e['icon'] ?? ''));
|
||||
if ($name === '' || $url === '' || strpos($url, '{kw}') === false)
|
||||
continue;
|
||||
$list[] = [
|
||||
'key' => 'e' . (count($list) + 1),
|
||||
'name' => $name,
|
||||
@ -209,7 +218,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '已恢复默认搜索引擎(Bing / 百度 / GitHub / Google)。';
|
||||
break;
|
||||
case 'quicklinks':
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$msgKind = 'err';
|
||||
@ -218,29 +227,31 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
$clean = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
if (!is_array($r)) continue;
|
||||
$name = trim((string)($r['name'] ?? ''));
|
||||
$url = trim((string)($r['url'] ?? ''));
|
||||
$icon = trim((string)($r['icon'] ?? ''));
|
||||
$note = trim((string)($r['note'] ?? ''));
|
||||
if ($name === '' && $url === '') continue;
|
||||
if (!is_array($r))
|
||||
continue;
|
||||
$name = trim((string) ($r['name'] ?? ''));
|
||||
$url = trim((string) ($r['url'] ?? ''));
|
||||
$icon = trim((string) ($r['icon'] ?? ''));
|
||||
$note = trim((string) ($r['note'] ?? ''));
|
||||
if ($name === '' && $url === '')
|
||||
continue;
|
||||
if ($name === '' || $url === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
|
||||
$msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。';
|
||||
break;
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '快捷站点第 ' . ((int)$i + 1) . ' 行:地址需为完整 http(s) 链接。';
|
||||
$msgText = '快捷站点第 ' . ((int) $i + 1) . ' 行:地址需为完整 http(s) 链接。';
|
||||
break;
|
||||
}
|
||||
$clean[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'icon' => ($icon !== '' ? $icon : '🔗'),
|
||||
'name' => $name,
|
||||
'url' => $url,
|
||||
'note' => $note,
|
||||
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if ($msgKind === '' && !$clean) {
|
||||
@ -253,7 +264,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM quick_links')->fetchAll(), 'id'));
|
||||
$keep = [];
|
||||
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));
|
||||
foreach ($del as $did) {
|
||||
@ -285,8 +297,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
break;
|
||||
case 'qtoggle':
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$qlId = (int)($_POST['id'] ?? 0);
|
||||
$qlEn = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
$qlId = (int) ($_POST['id'] ?? 0);
|
||||
$qlEn = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
if ($qlId > 0) {
|
||||
$pdo->prepare('UPDATE quick_links SET enabled = ? WHERE id = ?')->execute([$qlEn, $qlId]);
|
||||
touch_last_updated();
|
||||
@ -296,13 +308,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
exit;
|
||||
case 'pwd':
|
||||
$np = (string)($_POST['new_pwd'] ?? '');
|
||||
$np2 = (string)($_POST['new_pwd2'] ?? '');
|
||||
$np = (string) ($_POST['new_pwd'] ?? '');
|
||||
$np2 = (string) ($_POST['new_pwd2'] ?? '');
|
||||
if ($np !== $np2) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '两次输入的新密码不一致。';
|
||||
} else {
|
||||
[$ok, $text] = hp_change_password((string)($_POST['old_pwd'] ?? ''), $np);
|
||||
[$ok, $text] = hp_change_password((string) ($_POST['old_pwd'] ?? ''), $np);
|
||||
$msgKind = $ok ? 'ok' : 'err';
|
||||
$msgText = $text;
|
||||
}
|
||||
@ -327,33 +339,38 @@ $boardDefaults = [
|
||||
|
||||
$logo = setting_get('site_logo', '');
|
||||
$quotesFile = DATA_DIR . '/quota.txt';
|
||||
$quotesContent = is_file($quotesFile) ? (string)@file_get_contents($quotesFile) : '';
|
||||
$quotesContent = is_file($quotesFile) ? (string) @file_get_contents($quotesFile) : '';
|
||||
$all = [];
|
||||
foreach ($pdo->query('SELECT key, value FROM settings') as $r) {
|
||||
$all[$r['key']] = (string)$r['value'];
|
||||
$all[$r['key']] = (string) $r['value'];
|
||||
}
|
||||
$site = $all['site_name'] ?? '知识导航站';
|
||||
|
||||
layout_head('内容与顺序');
|
||||
layout_head('网站管理');
|
||||
admin_topbar('content');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">内容与顺序</h2>
|
||||
<h2 style="margin-bottom:6px">网站管理</h2>
|
||||
<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="fieldset-card">
|
||||
<div class="fs-title">站长提示(全站辅助说明文案总开关)</div>
|
||||
<div class="fs-title">提示开关</div>
|
||||
<?= csrf_field() ?>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 站点信息 -->
|
||||
@ -371,7 +388,7 @@ admin_topbar('content');
|
||||
|
||||
<!-- 站点 logo(favicon + 页面内容 logo) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">站点 LOGO(浏览器标签页图标与页面内容同步生效)</div>
|
||||
<div class="fs-title">站点LOGO</div>
|
||||
<div class="logo-preview">
|
||||
<img src="<?= he($P . site_logo_rel()) ?>" alt="当前logo">
|
||||
<span class="tip">当前生效的 logo;更换后浏览器标签页图标(favicon)与页面头部 logo 都会更新。</span>
|
||||
@ -394,35 +411,42 @@ admin_topbar('content');
|
||||
|
||||
<!-- 首页顶部标识栏背景 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">首页顶部标识栏背景(Hero 区,支持纯色或 CSS 渐变)</div>
|
||||
<div class="fs-title">首页顶部背景</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="hero">
|
||||
<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">
|
||||
<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="#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" 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>
|
||||
<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"
|
||||
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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 首页顶部组件模式(Hero 下方横幅:天气 / 访客信息 / 空白) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">首页顶部组件(Hero 下方横幅区)</div>
|
||||
<?php $hm = (string)($all['hero_mode'] ?? 'weather'); ?>
|
||||
<div class="fs-title">首页顶部组件</div>
|
||||
<?php $hm = (string) ($all['hero_mode'] ?? 'weather'); ?>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="hero_mode">
|
||||
<div class="field-row" style="align-items:center">
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="weather"<?= $hm === 'weather' ? ' checked' : '' ?>> 🌤️ 天气模式(默认)</label>
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="info"<?= $hm === 'info' ? ' checked' : '' ?>> 🖥️ 访客信息模式</label>
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="blank"<?= $hm === 'blank' ? ' checked' : '' ?>> ⬜ 空白模式</label>
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="weather" <?= $hm === 'weather' ? ' checked' : '' ?>> 🌤️ 天气模式(默认)</label>
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="info" <?= $hm === 'info' ? ' checked' : '' ?>> 🖥️ 访客信息模式</label>
|
||||
<label class="seg-check"><input type="radio" name="hero_mode" value="blank" <?= $hm === 'blank' ? ' checked' : '' ?>> ⬜ 空白模式</label>
|
||||
</div>
|
||||
<div style="margin-top:12px"><button type="submit" class="btn btn-primary">保存顶部组件模式</button></div>
|
||||
</form>
|
||||
@ -441,7 +465,8 @@ admin_topbar('content');
|
||||
<label class="fl">公安备案号</label>
|
||||
<input type="text" name="gongan_no" value="<?= he($all['gongan_no'] ?? '') ?>">
|
||||
<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>
|
||||
<input type="text" name="copyright" value="<?= he($all['copyright'] ?? '') ?>">
|
||||
<label class="fl">其他底部文本(可选)</label>
|
||||
@ -452,17 +477,21 @@ admin_topbar('content');
|
||||
|
||||
<!-- 搜索引擎(导航区搜索栏) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">搜索引擎(导航区搜索栏可切换的引擎)</div>
|
||||
<div class="fs-title">搜索引擎管理</div>
|
||||
<form method="post" id="engineForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="engines">
|
||||
<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">
|
||||
<?php $curEngines = search_engines(); foreach ($curEngines as $e): ?>
|
||||
<?php $curEngines = search_engines();
|
||||
foreach ($curEngines as $e): ?>
|
||||
<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-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-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-url" value="<?= he((string) ($e['url'] ?? '')) ?>"
|
||||
placeholder="搜索地址模板,必须包含 {kw}">
|
||||
<button type="button" class="btn btn-sm btn-danger e-del" title="移除该引擎">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
@ -480,30 +509,33 @@ admin_topbar('content');
|
||||
|
||||
<!-- 名言库 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">名言库(data/quota.txt,首页随机展示)</div>
|
||||
<div class="fs-title">滚动信息栏</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="quotes">
|
||||
<label class="fl">逐行一条;以 # 开头或空行会被忽略(仅作注释与分隔)</label>
|
||||
<textarea name="quotes" rows="10" 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>
|
||||
<textarea name="quotes" rows="10"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- CDN IP 段库(IP 地址处理工具用) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">CDN IP 段库(供“IP 地址处理”工具识别 CDN 来源)</div>
|
||||
<div class="fs-title">CDN库</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="cdn">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">修改登录密码</div>
|
||||
<div class="fs-title">修改密码</div>
|
||||
<form method="post" id="pwdForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="pwd">
|
||||
<label class="fl">原密码</label>
|
||||
@ -516,26 +548,30 @@ admin_topbar('content');
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 四大板块:名称 / 说明 / 顺序 -->
|
||||
<div class="fieldset-card col-span-2">
|
||||
<div class="fs-title">四大板块(名称 / 悬停说明文案 / 顺序)</div>
|
||||
<div class="fs-title">板块设置</div>
|
||||
<form method="post">
|
||||
<?= 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-head">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="m-items">
|
||||
<div class="icon-ctl" style="margin-top:8px">
|
||||
<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>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</div>
|
||||
<input type="text" name="b_name[<?= $id ?>]" value="<?= he((string)$c['name']) ?>" placeholder="板块名称" 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>
|
||||
<input type="text" name="b_name[<?= $id ?>]" value="<?= he((string) $c['name']) ?>" placeholder="板块名称"
|
||||
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>
|
||||
<?php endforeach; ?>
|
||||
@ -545,7 +581,7 @@ admin_topbar('content');
|
||||
|
||||
<!-- 首页快捷站点(本域名其它网站) -->
|
||||
<div class="fieldset-card col-span-2">
|
||||
<div class="fs-title">首页快捷站点(四大板块上方的胶囊按钮,新标签页打开)</div>
|
||||
<div class="fs-title">首页快捷站点</div>
|
||||
<form method="post" id="qlForm">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="quicklinks">
|
||||
<textarea name="data" id="qlData" hidden></textarea>
|
||||
@ -553,17 +589,21 @@ admin_topbar('content');
|
||||
<div id="qlRows">
|
||||
<?php $quickLinks = $pdo->query('SELECT * FROM quick_links ORDER BY sort ASC, id ASC')->fetchAll(); ?>
|
||||
<?php foreach ($quickLinks as $ql): ?>
|
||||
<div class="l-row" data-id="<?= (int)$ql['id'] ?>">
|
||||
<div class="l-row" data-id="<?= (int) $ql['id'] ?>">
|
||||
<span class="icon-ctl">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input l-icon" value="<?= he((string)$ql['icon']) ?>" placeholder="图标">
|
||||
<input type="text" class="icon-input l-icon" value="<?= he((string) $ql['icon']) ?>" placeholder="图标">
|
||||
<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</span>
|
||||
<input type="text" class="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-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>
|
||||
<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-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">
|
||||
<button type="button" class="btn btn-sm l-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm l-down" title="下移">↓</button>
|
||||
@ -585,7 +625,7 @@ admin_topbar('content');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('engineRows');
|
||||
function esc(s) { return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
|
||||
@ -642,10 +682,10 @@ admin_topbar('content');
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var qlRows = document.getElementById('qlRows');
|
||||
if (!qlRows) return;
|
||||
@ -733,10 +773,10 @@ admin_topbar('content');
|
||||
if (!list.length) { ev.preventDefault(); alert('至少需要保留一个快捷站点。'); return; }
|
||||
document.getElementById('qlData').value = JSON.stringify(list);
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var heroBg = document.getElementById('heroBg');
|
||||
if (!heroBg) return;
|
||||
@ -747,10 +787,10 @@ admin_topbar('content');
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.hero-pre'), function (b) {
|
||||
b.addEventListener('click', function () { heroBg.value = b.getAttribute('data-v') || ''; });
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var tips = document.querySelector('.tips-toggle');
|
||||
if (!tips) return;
|
||||
@ -774,7 +814,8 @@ admin_topbar('content');
|
||||
alert('网络错误,开关更新未保存。');
|
||||
});
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -18,44 +18,46 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
} else {
|
||||
$clean = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
if (!is_array($r)) continue;
|
||||
$name = trim((string)($r['name'] ?? ''));
|
||||
$grp = trim(preg_replace('/\s+/', ' ', (string)($r['grp'] ?? '')));
|
||||
$desc = trim((string)($r['description'] ?? ''));
|
||||
$url = trim((string)($r['url'] ?? ''));
|
||||
if (!is_array($r))
|
||||
continue;
|
||||
$name = trim((string) ($r['name'] ?? ''));
|
||||
$grp = trim(preg_replace('/\s+/', ' ', (string) ($r['grp'] ?? '')));
|
||||
$desc = trim((string) ($r['description'] ?? ''));
|
||||
$url = trim((string) ($r['url'] ?? ''));
|
||||
$cats = [];
|
||||
if (isset($r['cats']) && is_array($r['cats'])) {
|
||||
foreach ($r['cats'] as $ck) {
|
||||
$s = trim((string)$ck);
|
||||
$s = trim((string) $ck);
|
||||
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
|
||||
$cats[] = $s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($name === '' && $url === '') continue; // 空行忽略
|
||||
if ($name === '' && $url === '')
|
||||
continue; // 空行忽略
|
||||
if ($name === '' || $url === '') {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与网址均不能为空。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与网址均不能为空。';
|
||||
break;
|
||||
}
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部工具需以 http(s):// 开头的完整网址。';
|
||||
break;
|
||||
}
|
||||
$clean[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'name' => $name,
|
||||
'grp' => $grp,
|
||||
'description' => $desc,
|
||||
'url' => $url,
|
||||
'cats' => implode(',', $cats),
|
||||
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
if ($errorMsg === '') {
|
||||
@ -63,7 +65,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$pdo->beginTransaction();
|
||||
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM ext_links')->fetchAll(), 'id'));
|
||||
$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));
|
||||
foreach ($del as $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');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
if ($pdo->inTransaction())
|
||||
$pdo->rollBack();
|
||||
$errorMsg = '保存失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
@ -98,8 +104,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
|
||||
if (!csrf_verify()) {
|
||||
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$tid = (int)($_POST['id'] ?? 0);
|
||||
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
$tid = (int) ($_POST['id'] ?? 0);
|
||||
$ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
if ($tid > 0) {
|
||||
$pdo->prepare('UPDATE ext_links SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
|
||||
touch_last_updated();
|
||||
@ -116,7 +122,7 @@ $tools = $pdo->query('SELECT * FROM ext_links ORDER BY sort ASC, id ASC')->fetch
|
||||
// 供“分区”筛选下拉使用(按出现顺序去重)
|
||||
$grpNames = [];
|
||||
foreach ($tools as $t) {
|
||||
$g = trim((string)($t['grp'] ?? ''));
|
||||
$g = trim((string) ($t['grp'] ?? ''));
|
||||
if ($g !== '' && !in_array($g, $grpNames, true)) {
|
||||
$grpNames[] = $g;
|
||||
}
|
||||
@ -172,14 +178,17 @@ admin_topbar('exttools');
|
||||
|
||||
<!-- 批量导入(模板格式与 SecHub/assets/template.json 一致) -->
|
||||
<div class="fieldset-card">
|
||||
<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>
|
||||
<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>
|
||||
<p class="tip" style="margin-top:6px">
|
||||
数组内可穿插分区标记 <code>{"section":"栏目名"}</code>(其后的工具归入该分区,直至下一分区标记);
|
||||
工具行为 <code>{"name","url","description"}</code>,名称与网址缺失的空行会自动忽略。
|
||||
点击「解析并追加」后将<strong>追加到对应分区下方</strong>,核对后统一点「保存外部工具」生效。
|
||||
</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>
|
||||
|
||||
<!-- 分区折叠编辑列表 -->
|
||||
@ -193,7 +202,7 @@ admin_topbar('exttools');
|
||||
<?php
|
||||
$extGroups = [];
|
||||
foreach ($tools as $t) {
|
||||
$g = trim((string)($t['grp'] ?? ''));
|
||||
$g = trim((string) ($t['grp'] ?? ''));
|
||||
$extGroups[$g === '' ? "\x00" : $g][] = $t;
|
||||
}
|
||||
foreach ($extGroups as $gk => $gItems):
|
||||
@ -203,15 +212,18 @@ admin_topbar('exttools');
|
||||
$gN = count($gItems);
|
||||
$gCnt = ['popular' => 0, 'red' => 0, 'blue' => 0];
|
||||
foreach ($gItems as $gt) {
|
||||
foreach (array_map('trim', explode(',', trim((string)$gt['cat_keys']))) as $ck) {
|
||||
if (isset($gCnt[$ck])) { $gCnt[$ck]++; }
|
||||
foreach (array_map('trim', explode(',', trim((string) $gt['cat_keys']))) as $ck) {
|
||||
if (isset($gCnt[$ck])) {
|
||||
$gCnt[$ck]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
?>
|
||||
<div class="xg is-fold" data-g="<?= he($gData) ?>">
|
||||
<div class="xg-h" title="点击折叠 / 展开">
|
||||
<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-cnt"><?= $gN ?> 条</span>
|
||||
<?php if ($isNone): ?>
|
||||
@ -220,27 +232,34 @@ admin_topbar('exttools');
|
||||
<span class="xg-cat" title="整组库选择:勾选 / 取消会应用到本分区全部行(部分勾选时显示半选)">
|
||||
<span class="t-cats">
|
||||
<span class="t-cats-t">整组库</span>
|
||||
<label><input type="checkbox" class="g-catc" data-k="popular"<?= ($gN > 0 && $gCnt['popular'] === $gN) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="g-catc" data-k="red"<?= ($gN > 0 && $gCnt['red'] === $gN) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="g-catc" data-k="blue"<?= ($gN > 0 && $gCnt['blue'] === $gN) ? ' checked' : '' ?>>蓝队</label>
|
||||
<label><input type="checkbox" class="g-catc" data-k="popular" <?= ($gN > 0 && $gCnt['popular'] === $gN) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="g-catc" data-k="red" <?= ($gN > 0 && $gCnt['red'] === $gN) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="g-catc" data-k="blue" <?= ($gN > 0 && $gCnt['blue'] === $gN) ? ' checked' : '' ?>>蓝队</label>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="xg-b">
|
||||
<?php foreach ($gItems as $t):
|
||||
$cats = array_map('trim', explode(',', trim((string)$t['cat_keys']))); ?>
|
||||
<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-name" value="<?= he((string)$t['name']) ?>" placeholder="工具名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话简介">
|
||||
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="完整网址,如 https://example.com">
|
||||
$cats = array_map('trim', explode(',', trim((string) $t['cat_keys']))); ?>
|
||||
<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-name" value="<?= he((string) $t['name']) ?>"
|
||||
placeholder="工具名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string) $t['description']) ?>"
|
||||
placeholder="一句话简介">
|
||||
<input type="text" class="t-url" value="<?= he((string) $t['url']) ?>"
|
||||
placeholder="完整网址,如 https://example.com">
|
||||
<span class="t-cats" title="展示范围(不勾选=所有库通用)">
|
||||
<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="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="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="blue" <?= in_array('blue', $cats, true) ? ' checked' : '' ?>>蓝队</label>
|
||||
</span>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox"
|
||||
class="sw-in t-enabled" <?= (int) $t['enabled'] === 1 ? ' checked' : '' ?>><span
|
||||
class="sw-b"><span class="sw-on">ON</span><span
|
||||
class="sw-off">OFF</span></span></label>
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm t-up" title="同分区内上移">↑</button>
|
||||
<button type="button" class="btn btn-sm t-down" title="同分区内下移">↓</button>
|
||||
@ -266,7 +285,7 @@ admin_topbar('exttools');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('extRows');
|
||||
|
||||
@ -567,7 +586,7 @@ admin_topbar('exttools');
|
||||
if (!xg) return;
|
||||
dragXg = xg;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-g') || ''); } catch (e) {}
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-g') || ''); } catch (e) { }
|
||||
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
rows.addEventListener('dragover', function (ev) {
|
||||
@ -655,7 +674,8 @@ admin_topbar('exttools');
|
||||
|
||||
// 初始状态
|
||||
refreshStats();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -11,11 +11,11 @@ $loggedOut = isset($_GET['out']);
|
||||
$curIp = client_ip();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
$username = trim((string) ($_POST['username'] ?? ''));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
if ($username === '' || $password === '') {
|
||||
$err = '请输入用户名和密码。';
|
||||
} elseif (hp_login($username, $password)) {
|
||||
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
|
||||
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
|
||||
risk_clear_for_ip($curIp);
|
||||
header('Location: nav.php');
|
||||
@ -26,7 +26,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$risk = risk_login_fail($curIp);
|
||||
if ($risk[1]) {
|
||||
$err = '用户名或密码错误。';
|
||||
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「访问信息 → IP 黑名单」移除该规则解除,或联系站长处理。';
|
||||
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「风控管理 → IP 黑名单」移除该规则解除,或联系站长处理。';
|
||||
} else {
|
||||
$err = '用户名或密码错误。';
|
||||
}
|
||||
@ -52,6 +52,7 @@ layout_head('后台登录');
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<label class="fl" for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<input type="hidden" name="dev" id="devField" value="">
|
||||
<div style="margin-top:16px">
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">登录</button>
|
||||
</div>
|
||||
@ -61,4 +62,5 @@ layout_head('后台登录');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/nav.php —— 导航内容管理(块/链接增删改查 + 顺序)
|
||||
* admin/nav.php —— 导航管理(块/链接增删改查 + 顺序)
|
||||
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
|
||||
*/
|
||||
$P = '../';
|
||||
@ -8,7 +8,7 @@ require_once __DIR__ . '/_guard.php';
|
||||
|
||||
$pdo = db();
|
||||
$catKeys = ['popular', 'red', 'blue'];
|
||||
$cat = isset($_GET['c']) ? (string)$_GET['c'] : 'popular';
|
||||
$cat = isset($_GET['c']) ? (string) $_GET['c'] : 'popular';
|
||||
if (!in_array($cat, $catKeys, true)) {
|
||||
$cat = 'popular';
|
||||
}
|
||||
@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$pdo->beginTransaction();
|
||||
// 现有块
|
||||
$st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$st->execute([(int) $catRow['id']]);
|
||||
$oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
// 现有链接(按块分组)
|
||||
$oldItemsByBlock = [];
|
||||
@ -51,11 +51,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$keepItems = [];
|
||||
foreach ($data as $b) {
|
||||
if (is_array($b) && isset($b['title'])) {
|
||||
$id = (int)($b['id'] ?? 0);
|
||||
if ($id > 0) $keepBlocks[] = $id;
|
||||
$id = (int) ($b['id'] ?? 0);
|
||||
if ($id > 0)
|
||||
$keepBlocks[] = $id;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (is_array($it) && (int)($it['id'] ?? 0) > 0) {
|
||||
$keepItems[] = (int)$it['id'];
|
||||
if (is_array($it) && (int) ($it['id'] ?? 0) > 0) {
|
||||
$keepItems[] = (int) $it['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -81,25 +82,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
|
||||
$blockSort = 0;
|
||||
foreach ($data as $b) {
|
||||
if (!is_array($b) || !isset($b['title'])) continue;
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') continue;
|
||||
$bid = (int)($b['id'] ?? 0);
|
||||
if (!is_array($b) || !isset($b['title']))
|
||||
continue;
|
||||
$title = trim((string) $b['title']);
|
||||
if ($title === '')
|
||||
continue;
|
||||
$bid = (int) ($b['id'] ?? 0);
|
||||
$blockBelongs = in_array($bid, $oldBlocks, true);
|
||||
if ($bid > 0 && $blockBelongs) {
|
||||
$stUpdBlock->execute([$title, $blockSort, $bid]);
|
||||
} else {
|
||||
$stInsBlock->execute([(int)$catRow['id'], $title, $blockSort]);
|
||||
$bid = (int)$pdo->lastInsertId();
|
||||
$stInsBlock->execute([(int) $catRow['id'], $title, $blockSort]);
|
||||
$bid = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$itemSort = 0;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (!is_array($it)) continue;
|
||||
$label = trim((string)($it['label'] ?? ''));
|
||||
$url = trim((string)($it['url'] ?? ''));
|
||||
$note = trim((string)($it['note'] ?? ''));
|
||||
if ($label === '' && $url === '') continue;
|
||||
$iid = (int)($it['id'] ?? 0);
|
||||
if (!is_array($it))
|
||||
continue;
|
||||
$label = trim((string) ($it['label'] ?? ''));
|
||||
$url = trim((string) ($it['url'] ?? ''));
|
||||
$note = trim((string) ($it['note'] ?? ''));
|
||||
if ($label === '' && $url === '')
|
||||
continue;
|
||||
$iid = (int) ($it['id'] ?? 0);
|
||||
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
|
||||
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
|
||||
} else {
|
||||
@ -125,7 +130,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
|
||||
// ---------- 读取现有数据用于展示 ----------
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$st->execute([(int) $catRow['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemsMap = [];
|
||||
if ($blocks) {
|
||||
@ -134,7 +139,7 @@ if ($blocks) {
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $it) {
|
||||
$itemsMap[(int)$it['block_id']][] = $it;
|
||||
$itemsMap[(int) $it['block_id']][] = $it;
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,18 +151,19 @@ foreach ($catKeys as $ck) {
|
||||
|
||||
$csrfVal = csrf_token();
|
||||
|
||||
layout_head('导航内容管理');
|
||||
layout_head('导航管理');
|
||||
admin_topbar('nav');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">导航内容管理</h2>
|
||||
<h2 style="margin-bottom:6px">导航管理</h2>
|
||||
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。</p>
|
||||
|
||||
<!-- 分类切换 -->
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<?php foreach ($catKeys as $ck): ?>
|
||||
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>" href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
|
||||
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>"
|
||||
href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
@ -169,11 +175,13 @@ admin_topbar('nav');
|
||||
<?php endif; ?>
|
||||
|
||||
<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>
|
||||
每行一条,支持几种写法:
|
||||
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> 或 <code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
|
||||
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code>、<code>块名 3</code> …</p>
|
||||
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> 或
|
||||
<code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
|
||||
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code>、<code>块名 3</code> …
|
||||
</p>
|
||||
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
|
||||
## 安全研究
|
||||
看雪 | https://www.kanxue.com | 逆向/安全社区
|
||||
@ -197,12 +205,13 @@ https://www.ithome.com | IT之家"></textarea>
|
||||
|
||||
<div id="blocksWrap">
|
||||
<?php foreach ($blocks as $b):
|
||||
$items = $itemsMap[(int)$b['id']] ?? []; ?>
|
||||
<div class="m-block is-fold" data-id="<?= (int)$b['id'] ?>">
|
||||
$items = $itemsMap[(int) $b['id']] ?? []; ?>
|
||||
<div class="m-block is-fold" data-id="<?= (int) $b['id'] ?>">
|
||||
<div class="m-block-head">
|
||||
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>
|
||||
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>
|
||||
<input type="text" class="b-title" value="<?= he((string)$b['title']) ?>" placeholder="导航块标题(必填)">
|
||||
<input type="text" class="b-title" value="<?= he((string) $b['title']) ?>"
|
||||
placeholder="导航块标题(必填)">
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm b-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm b-down" title="下移">↓</button>
|
||||
@ -212,9 +221,12 @@ https://www.ithome.com | IT之家"></textarea>
|
||||
<div class="m-items">
|
||||
<?php foreach (array_slice($items, 0, 15) as $it): ?>
|
||||
<div class="m-item">
|
||||
<input type="text" class="i-label" value="<?= he((string)$it['label']) ?>" placeholder="文字(必填)">
|
||||
<input type="text" class="i-url" value="<?= he((string)$it['url']) ?>" placeholder="链接,如 https://…(必填)">
|
||||
<input type="text" class="i-note" value="<?= he((string)$it['note']) ?>" placeholder="备注(可选,前台悬浮提示)">
|
||||
<input type="text" class="i-label" value="<?= he((string) $it['label']) ?>"
|
||||
placeholder="文字(必填)">
|
||||
<input type="text" class="i-url" value="<?= he((string) $it['url']) ?>"
|
||||
placeholder="链接,如 https://…(必填)">
|
||||
<input type="text" class="i-note" value="<?= he((string) $it['note']) ?>"
|
||||
placeholder="备注(可选,前台悬浮提示)">
|
||||
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
@ -239,7 +251,7 @@ https://www.ithome.com | IT之家"></textarea>
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var wrap = document.getElementById('blocksWrap');
|
||||
var jsonData = document.getElementById('jsonData');
|
||||
@ -346,7 +358,7 @@ https://www.ithome.com | IT之家"></textarea>
|
||||
if (!bl) return;
|
||||
dragBlock = bl;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) {}
|
||||
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) { }
|
||||
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
wrap.addEventListener('dragover', function (ev) {
|
||||
@ -542,7 +554,8 @@ https://www.ithome.com | IT之家"></textarea>
|
||||
jsonData.value = JSON.stringify(data);
|
||||
document.getElementById('navForm').submit();
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/tools.php —— 功能区管理
|
||||
* admin/tools.php —— 内部功能
|
||||
* 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。
|
||||
* 站内工具页需先在 func/ 下开发对应 .php,再到此处登记入口。
|
||||
*/
|
||||
@ -16,7 +16,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
@ -24,18 +24,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
// 先整体校验
|
||||
$clean = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
if (!is_array($r)) continue;
|
||||
$name = trim((string)($r['name'] ?? ''));
|
||||
$url = trim((string)($r['url'] ?? ''));
|
||||
$icon = trim((string)($r['icon'] ?? ''));
|
||||
$desc = trim((string)($r['description'] ?? ''));
|
||||
$ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0;
|
||||
if ($name === '' && $url === '') continue; // 空行忽略
|
||||
if (!is_array($r))
|
||||
continue;
|
||||
$name = trim((string) ($r['name'] ?? ''));
|
||||
$url = trim((string) ($r['url'] ?? ''));
|
||||
$icon = trim((string) ($r['icon'] ?? ''));
|
||||
$desc = trim((string) ($r['description'] ?? ''));
|
||||
$ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0;
|
||||
if ($name === '' && $url === '')
|
||||
continue; // 空行忽略
|
||||
// 库首页“内部功能区”展示范围(空=所有库通用)
|
||||
$cats = [];
|
||||
if (isset($r['cats']) && is_array($r['cats'])) {
|
||||
foreach ($r['cats'] as $ck) {
|
||||
$s = trim((string)$ck);
|
||||
$s = trim((string) $ck);
|
||||
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
|
||||
$cats[] = $s;
|
||||
}
|
||||
@ -43,32 +45,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
}
|
||||
$boardCats = implode(',', $cats);
|
||||
if ($name === '' || $url === '') {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。';
|
||||
break;
|
||||
}
|
||||
if ($ext === 1) {
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部链接需以 http(s):// 开头。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部链接需以 http(s):// 开头。';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。';
|
||||
break;
|
||||
}
|
||||
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || strpos($url, '//') === 0) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。';
|
||||
break;
|
||||
}
|
||||
}
|
||||
$clean[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'icon' => ($icon !== '' ? $icon : '🧩'),
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
'url' => $url,
|
||||
'ext' => $ext,
|
||||
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'board_cats' => $boardCats,
|
||||
];
|
||||
}
|
||||
@ -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'));
|
||||
$keep = [];
|
||||
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));
|
||||
foreach ($del as $did) {
|
||||
@ -120,8 +123,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
|
||||
if (!csrf_verify()) {
|
||||
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$tid = (int)($_POST['id'] ?? 0);
|
||||
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
$tid = (int) ($_POST['id'] ?? 0);
|
||||
$ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
if ($tid > 0) {
|
||||
$pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
|
||||
touch_last_updated();
|
||||
@ -136,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();
|
||||
|
||||
layout_head('功能区管理');
|
||||
layout_head('内部功能');
|
||||
admin_topbar('tools');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">功能区管理</h2>
|
||||
<h2 style="margin-bottom:6px">内部功能</h2>
|
||||
<p class="tip">
|
||||
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
|
||||
也可登记外部网址作为外链卡片(新窗口打开)。
|
||||
@ -155,6 +158,7 @@ admin_topbar('tools');
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">工具卡片管理</div>
|
||||
<form method="post" id="toolsForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
@ -162,29 +166,34 @@ admin_topbar('tools');
|
||||
|
||||
<div id="toolsRows">
|
||||
<?php foreach ($tools as $t): ?>
|
||||
<div class="t-row" data-id="<?= (int)$t['id'] ?>">
|
||||
<div class="t-row" data-id="<?= (int) $t['id'] ?>">
|
||||
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>
|
||||
<span class="icon-ctl">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input t-icon" value="<?= he((string)$t['icon']) ?>" placeholder="图标">
|
||||
<input type="text" class="icon-input t-icon" value="<?= he((string) $t['icon']) ?>"
|
||||
placeholder="图标">
|
||||
<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</span>
|
||||
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话说明">
|
||||
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="站内如 codec.php / 外链 https://…">
|
||||
<input type="text" class="t-name" value="<?= he((string) $t['name']) ?>" placeholder="名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string) $t['description']) ?>"
|
||||
placeholder="一句话说明">
|
||||
<input type="text" class="t-url" value="<?= he((string) $t['url']) ?>"
|
||||
placeholder="站内如 codec.php / 外链 https://…">
|
||||
<select class="t-ext">
|
||||
<option value="0"<?= (int)$t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
|
||||
<option value="1"<?= (int)$t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
|
||||
<option value="0" <?= (int) $t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
|
||||
<option value="1" <?= (int) $t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
|
||||
</select>
|
||||
<?php $bcT = array_map('trim', explode(',', trim((string)($t['board_cats'] ?? '')))); ?>
|
||||
<?php $bcT = array_map('trim', explode(',', trim((string) ($t['board_cats'] ?? '')))); ?>
|
||||
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
|
||||
<span class="t-cats-t">库首页</span>
|
||||
<label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
|
||||
</span>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"
|
||||
<?= (int) $t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span
|
||||
class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm t-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm t-down" title="下移">↓</button>
|
||||
@ -207,7 +216,7 @@ admin_topbar('tools');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('toolsRows');
|
||||
|
||||
@ -313,7 +322,7 @@ admin_topbar('tools');
|
||||
if (!row) return;
|
||||
dragRow = row;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) {}
|
||||
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) { }
|
||||
setTimeout(function () { row.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
rows.addEventListener('dragover', function (ev) {
|
||||
@ -383,7 +392,8 @@ admin_topbar('tools');
|
||||
}
|
||||
document.getElementById('toolsData').value = JSON.stringify(list);
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
411
admin/visits.php
411
admin/visits.php
@ -1,11 +1,12 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/visits.php —— 访问信息
|
||||
* admin/visits.php —— 风控管理
|
||||
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
|
||||
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once __DIR__ . '/_guard.php';
|
||||
require_once __DIR__ . '/_visits_lib.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
@ -19,16 +20,19 @@ function settings_set(string $key, string $value): void
|
||||
/* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */
|
||||
function bl_delete_ids(PDO $pdo, array $ids): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) { return $v > 0; })));
|
||||
if (!$ids) return 0;
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) {
|
||||
return $v > 0;
|
||||
})));
|
||||
if (!$ids)
|
||||
return 0;
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
|
||||
$st->execute($ids);
|
||||
$rules = $st->fetchAll();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
|
||||
foreach ($rules as $r) {
|
||||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$r['rule']);
|
||||
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $r['rule']);
|
||||
}
|
||||
}
|
||||
return count($rules);
|
||||
@ -43,11 +47,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
$act = (string) ($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'ban_add':
|
||||
$rule = trim((string)($_POST['rule'] ?? ''));
|
||||
$note = trim((string)($_POST['note'] ?? ''));
|
||||
$rule = trim((string) ($_POST['rule'] ?? ''));
|
||||
$note = trim((string) ($_POST['note'] ?? ''));
|
||||
if ($rule === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '请填写要拦截的 IP / 网段。';
|
||||
@ -66,15 +70,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
|
||||
break;
|
||||
case 'ban_del':
|
||||
$id = max(0, (int)($_POST['id'] ?? 0));
|
||||
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||||
if ($id > 0) {
|
||||
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
|
||||
$bs->execute([$id]);
|
||||
$brow = $bs->fetch();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
|
||||
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
|
||||
if ($brow && filter_var((string)$brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$brow['rule']);
|
||||
if ($brow && filter_var((string) $brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $brow['rule']);
|
||||
}
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
@ -90,15 +94,19 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgText = '访问日志已清空(data/vistors.log)。';
|
||||
break;
|
||||
case 'risk_save':
|
||||
$clip = function ($v, $d) { $raw = trim((string)($v ?? '')); $x = ($raw === '') ? (int)$d : (int)$raw; return max(1, min(1000000, $x)); };
|
||||
$clip = function ($v, $d) {
|
||||
$raw = trim((string) ($v ?? ''));
|
||||
$x = ($raw === '') ? (int) $d : (int) $raw;
|
||||
return max(1, min(1000000, $x));
|
||||
};
|
||||
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
|
||||
settings_set('risk_login_n', (string)$clip($_POST['r_login_n'] ?? null, 10));
|
||||
settings_set('risk_login_n', (string) $clip($_POST['r_login_n'] ?? null, 10));
|
||||
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
|
||||
settings_set('risk_404_n', (string)$clip($_POST['r_404_n'] ?? null, 100));
|
||||
settings_set('risk_404_n', (string) $clip($_POST['r_404_n'] ?? null, 100));
|
||||
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
|
||||
settings_set('risk_rate_n', (string)$clip($_POST['r_rate_n'] ?? null, 120));
|
||||
settings_set('risk_tip_main', trim((string)($_POST['r_tip_main'] ?? '')));
|
||||
settings_set('risk_tip_sub', trim((string)($_POST['r_tip_sub'] ?? '')));
|
||||
settings_set('risk_rate_n', (string) $clip($_POST['r_rate_n'] ?? null, 120));
|
||||
settings_set('risk_tip_main', trim((string) ($_POST['r_tip_main'] ?? '')));
|
||||
settings_set('risk_tip_sub', trim((string) ($_POST['r_tip_sub'] ?? '')));
|
||||
$msgKind = 'ok';
|
||||
$msgText = '风控规则已保存并即时生效。';
|
||||
break;
|
||||
@ -107,8 +115,13 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$msgKind = 'ok';
|
||||
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
|
||||
break;
|
||||
case 'loginlog_clear':
|
||||
login_log_clear();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '登录日志已清空(含成功与失败记录)。';
|
||||
break;
|
||||
case 'ban_batch':
|
||||
$n = bl_delete_ids($pdo, (array)($_POST['bid'] ?? []));
|
||||
$n = bl_delete_ids($pdo, (array) ($_POST['bid'] ?? []));
|
||||
$banChanged = true;
|
||||
$msgKind = $n > 0 ? 'ok' : 'err';
|
||||
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
|
||||
@ -118,8 +131,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$cnt = count($rows);
|
||||
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
|
||||
foreach ($rows as $r) {
|
||||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$r['rule']);
|
||||
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $r['rule']);
|
||||
}
|
||||
}
|
||||
blacklist_rules(true);
|
||||
@ -143,100 +156,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
}
|
||||
|
||||
/* ---------- 概要统计(数据来源:data/vistors.log) ---------- */
|
||||
$entries = visitors_entries();
|
||||
$statTotal = count($entries);
|
||||
$ipSet = [];
|
||||
foreach ($entries as $e) {
|
||||
$ipSet[$e['ip']] = 1;
|
||||
}
|
||||
$statIps = count($ipSet);
|
||||
$statToday = 0;
|
||||
$todayStart = date('Y-m-d 00:00:00');
|
||||
foreach ($entries as $e) {
|
||||
if ($e['created_at'] >= $todayStart) {
|
||||
$statToday++;
|
||||
}
|
||||
}
|
||||
function vis_scalar(PDO $pdo, string $sql): int
|
||||
{
|
||||
$v = $pdo->query($sql)->fetchColumn();
|
||||
return (int)$v;
|
||||
}
|
||||
$statBan = vis_scalar($pdo, 'SELECT COUNT(*) FROM ip_blacklist');
|
||||
$visStat = vis_stat_all();
|
||||
$statTotal = $visStat['total'];
|
||||
$statIps = $visStat['ips'];
|
||||
$statToday = $visStat['today'];
|
||||
$statBan = $visStat['ban'];
|
||||
|
||||
/* ---------- 筛选与分页 ---------- */
|
||||
$mode = (string)($_GET['mode'] ?? 'full');
|
||||
/* ---------- 筛选参数(供表单回显)与访问记录查询 ---------- */
|
||||
$mode = (string) ($_GET['mode'] ?? 'full');
|
||||
if (!in_array($mode, ['full', 'compact'], true)) {
|
||||
$mode = 'full';
|
||||
}
|
||||
$ipF = trim((string)($_GET['ip'] ?? ''));
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$ipF = substr($ipF, 0, 64);
|
||||
$q = substr($q, 0, 100);
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64);
|
||||
$q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = 30;
|
||||
|
||||
// entries 已按新→旧排列,内存中过滤 / 聚合后分页
|
||||
$logs = [];
|
||||
$total = 0;
|
||||
$totalPages = 1;
|
||||
if ($mode === 'compact') {
|
||||
// 精简模式:按 IP 聚合(最近访问时间 / IP / 访问次数),首次出现即该 IP 最新一条
|
||||
$grp = [];
|
||||
$order = [];
|
||||
foreach ($entries as $e) {
|
||||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||||
if (!isset($grp[$e['ip']])) {
|
||||
$grp[$e['ip']] = ['ip' => $e['ip'], 'last_at' => $e['created_at'], 'cnt' => 0];
|
||||
$order[] = $e['ip'];
|
||||
}
|
||||
$grp[$e['ip']]['cnt']++;
|
||||
}
|
||||
$all = [];
|
||||
foreach ($order as $ip) {
|
||||
$all[] = $grp[$ip];
|
||||
}
|
||||
$total = count($all);
|
||||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||||
if ($page > $totalPages) {
|
||||
$page = $totalPages;
|
||||
}
|
||||
$off = ($page - 1) * $perPage;
|
||||
$logs = array_slice($all, $off, $perPage);
|
||||
} else {
|
||||
// 完整模式:逐条明细
|
||||
$all = [];
|
||||
foreach ($entries as $e) {
|
||||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||||
$all[] = $e;
|
||||
}
|
||||
$total = count($all);
|
||||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||||
if ($page > $totalPages) {
|
||||
$page = $totalPages;
|
||||
}
|
||||
$off = ($page - 1) * $perPage;
|
||||
$logs = array_slice($all, $off, $perPage);
|
||||
}
|
||||
|
||||
/* ---------- 链接工具(保留当前筛选参数) ---------- */
|
||||
function vis_url(array $ov): string
|
||||
{
|
||||
$p = $_GET;
|
||||
foreach ($ov as $k => $v) {
|
||||
if ($v === null || $v === '') {
|
||||
unset($p[$k]);
|
||||
} else {
|
||||
$p[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (!$p) {
|
||||
return 'visits.php';
|
||||
}
|
||||
return 'visits.php?' . http_build_query($p);
|
||||
}
|
||||
$visAccess = vis_query_access($mode, $ipF, $q, $page, $perPage);
|
||||
|
||||
/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
|
||||
$curIp = client_ip();
|
||||
@ -255,19 +190,34 @@ if ($curIp !== '未知') {
|
||||
$rcfg = risk_rule_cfg();
|
||||
$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null;
|
||||
|
||||
/* ---------- 登录信息:当前会话 / 历史成功登录 / 登录失败按「IP × 日期」聚合 ---------- */
|
||||
$lgStat = login_log_stat();
|
||||
$lgRecent = login_log_recent_ok(15);
|
||||
$lgFailF = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64);
|
||||
$lgFailData = vis_query_loginfail($lgFailF, 200);
|
||||
$curAdmin = (string) ($_SESSION['admin'] ?? '');
|
||||
$curLoginAt = (string) ($_SESSION['login_at'] ?? '');
|
||||
$curLoginIp = (string) ($_SESSION['login_ip'] ?? ($curIp !== '未知' ? $curIp : ''));
|
||||
$curLoginUa = (string) ($_SESSION['login_ua'] ?? '');
|
||||
$curLoginDevice = (string) ($_SESSION['login_device'] ?? '');
|
||||
if ($curLoginDevice === '') {
|
||||
$curLoginDevice = login_ua_brief((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
}
|
||||
|
||||
/* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */
|
||||
$banRows = $pdo->query('SELECT id, rule, note, created_at, source FROM ip_blacklist ORDER BY id DESC')->fetchAll();
|
||||
$banAutoCount = 0;
|
||||
foreach ($banRows as $b) {
|
||||
if (((string)($b['source'] ?? 'manual')) === 'auto') $banAutoCount++;
|
||||
if (((string) ($b['source'] ?? 'manual')) === 'auto')
|
||||
$banAutoCount++;
|
||||
}
|
||||
|
||||
layout_head('访问信息');
|
||||
layout_head('风控管理');
|
||||
admin_topbar('visits');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">访问信息</h2>
|
||||
<h2 style="margin-bottom:6px">风控管理</h2>
|
||||
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
|
||||
|
||||
<?php if ($msgText !== ''): ?>
|
||||
@ -286,122 +236,138 @@ admin_topbar('visits');
|
||||
<form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="log_clear">
|
||||
<button type="submit" class="btn btn-sm btn-danger">清空访问日志</button>
|
||||
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL),完整保留不设上限;可自行归档或删除该文件。</span>
|
||||
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 /
|
||||
URL),完整保留不设上限;可自行归档或删除该文件。</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 登录信息(当前会话 + 历史成功登录) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">登录信息</div>
|
||||
<div class="vis-stats">
|
||||
<div class="vis-stat"><b><?= he($curAdmin) ?></b><span>当前用户</span></div>
|
||||
<div class="vis-stat"><b
|
||||
style="font-size:15px"><?= he($curLoginAt !== '' ? $curLoginAt : '本次会话未记录') ?></b><span>本次登录时间</span></div>
|
||||
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginIp !== '' ? $curLoginIp : '未知') ?></b><span>本次登录
|
||||
IP</span></div>
|
||||
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginDevice) ?></b><span>本次登录设备</span></div>
|
||||
</div>
|
||||
<p class="tip" style="margin:12px 0 4px">历史登录记录(最近 <?= count($lgRecent) ?> 条成功登录;今日成功
|
||||
<?= (int) $lgStat['ok_today'] ?> 次)
|
||||
</p>
|
||||
<div class="v-scroll">
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>登录时间</th>
|
||||
<th>登录 IP</th>
|
||||
<th>登录设备(浏览器)</th>
|
||||
<th>用户名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($lgRecent): ?>
|
||||
<?php foreach ($lgRecent as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string) $r['created_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
|
||||
<td><?= he(vis_device_text($r)) ?></td>
|
||||
<td class="v-nowrap"><?= he((string) $r['username']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="4"><span class="tip">暂无成功登录记录(本次登录为首次记录)。</span></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录失败日志(按 IP × 日期聚合) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">登录失败日志</div>
|
||||
<div class="vis-stats">
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['fail_today'] ?></b><span>今日失败次数</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['fail_ips'] ?></b><span>失败涉及 IP</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['ok_today'] ?></b><span>今日成功登录</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['total'] ?></b><span>登录日志总数</span></div>
|
||||
</div>
|
||||
<form method="get" class="v-filter" style="margin-top:12px" data-vis-form="loginfail">
|
||||
<input type="text" name="lf" value="<?= he($lgFailF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0)">
|
||||
<button type="submit" class="btn btn-primary">筛选</button>
|
||||
<a class="btn" href="visits.php" data-vis-section="loginfail" data-vis-page="">重置</a>
|
||||
<span class="tip" style="align-self:center">按「IP × 日期」聚合:同一 IP 当天的失败次数与最近一次设备信息</span>
|
||||
</form>
|
||||
<div id="lfResults"><?= vis_render_loginfail($lgFailData) ?></div>
|
||||
<form method="post" data-confirm="确认清空全部登录日志(成功与失败记录)?此操作不可恢复。" style="margin-top:12px">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="loginlog_clear">
|
||||
<button type="submit" class="btn btn-sm btn-danger">清空登录日志</button>
|
||||
<span class="tip" style="margin-left:8px">记录存于数据库 <code>login_log</code> 表(含成功与失败、时间 / 用户名 / IP / 设备)。</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 + 日志列表 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">访问记录<?= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?></div>
|
||||
<form method="get" class="v-filter">
|
||||
<select name="mode" title="展示模式" onchange="this.form.submit()">
|
||||
<option value="full"<?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
|
||||
<option value="compact"<?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
|
||||
<div class="fs-title">访问记录</div>
|
||||
<form method="get" class="v-filter" data-vis-form="access">
|
||||
<select name="mode" title="展示模式"
|
||||
onchange="if(this.form.requestSubmit){this.form.requestSubmit();}else{this.form.submit();}">
|
||||
<option value="full" <?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
|
||||
<option value="compact" <?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
|
||||
</select>
|
||||
<input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0)">
|
||||
<input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式">
|
||||
<button type="submit" class="btn btn-primary">筛选</button>
|
||||
<a class="btn" href="visits.php">重置</a>
|
||||
<a class="btn" href="visits.php" data-vis-section="access" data-vis-page="">重置</a>
|
||||
</form>
|
||||
|
||||
<?php if ($logs): ?>
|
||||
<div class="tip" style="margin:10px 0 6px">
|
||||
共命中 <?= $total ?> <?= $mode === 'compact' ? '个 IP' : '条记录' ?>;
|
||||
当前第 <?= $page ?> / <?= $totalPages ?> 页,每页 <?= $perPage ?> 条。
|
||||
</div>
|
||||
<div class="v-scroll">
|
||||
<?php if ($mode === 'compact'): ?>
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr><th>最近访问时间</th><th>IP 地址</th><th>访问次数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logs as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string)$r['last_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||||
<td><b><?= (int)$r['cnt'] ?></b> 次</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>访问时间</th><th>IP 地址</th><th>请求方式</th><th>访问 URL</th><th>响应码</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logs as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string)$r['created_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||||
<td><span class="v-tag"><?= he((string)$r['method']) ?></span></td>
|
||||
<td><?= he((string)$r['url']) ?></td>
|
||||
<td class="v-nowrap"><?php
|
||||
$code = (int)$r['status'];
|
||||
$cls = ($code >= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad'));
|
||||
?><span class="v-tag <?= $cls ?>"><?= $code ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pager">
|
||||
<span class="pg-info">共 <?= $total ?> 条</span>
|
||||
<?php if ($page > 1): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page - 1])) ?>">← 上一页</a><?php endif; ?>
|
||||
<?php
|
||||
$win = 2;
|
||||
$start = max(1, $page - $win);
|
||||
$end = min($totalPages, $page + $win);
|
||||
if ($start > 1) echo '<span class="tip">…</span>';
|
||||
for ($p = $start; $p <= $end; $p++) {
|
||||
if ($p === $page) {
|
||||
echo '<span class="btn btn-sm btn-primary">' . $p . '</span>';
|
||||
} else {
|
||||
echo '<a class="btn btn-sm" href="' . he(vis_url(['page' => $p])) . '">' . $p . '</a>';
|
||||
}
|
||||
}
|
||||
if ($end < $totalPages) echo '<span class="tip">…</span>';
|
||||
?>
|
||||
<?php if ($page < $totalPages): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page + 1])) ?>">下一页 →</a><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="err-msg" style="margin-top:12px"><?= ($ipF !== '' || $q !== '') ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?></div>
|
||||
<?php endif; ?>
|
||||
<div id="visResults"><?= vis_render_access($visAccess) ?></div>
|
||||
</div>
|
||||
|
||||
<!-- 自动风控规则 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">自动风控规则<span class="tip" style="font-weight:400">(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)</span></div>
|
||||
<div class="fs-title">风控规则</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_save">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
|
||||
<span class="tip">失败次数 ≥ <input type="number" name="r_login_n" min="1" max="1000000" value="<?= (int)$rcfg['login_fail']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 5-20)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"
|
||||
<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
|
||||
<span class="tip">失败次数 ≥ <input type="number" name="r_login_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['login_fail']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(建议 5-20)</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
|
||||
<span class="tip">累计次数 ≥ <input type="number" name="r_404_n" min="1" max="1000000" value="<?= (int)$rcfg['not_found']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 50-300)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"
|
||||
<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
|
||||
<span class="tip">累计次数 ≥ <input type="number" name="r_404_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['not_found']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(建议 50-300)</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
|
||||
<span class="tip">60 秒内 ≥ <input type="number" name="r_rate_n" min="1" max="1000000" value="<?= (int)$rcfg['rate']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(多人共用出口 IP 时建议关闭)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"
|
||||
<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
|
||||
<span class="tip">60 秒内 ≥ <input type="number" name="r_rate_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['rate']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(多人共用出口 IP 时建议关闭)</span>
|
||||
</div>
|
||||
<p class="tip" style="margin:8px 0 6px">计数方式:404 与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
|
||||
<p class="tip" style="margin:8px 0 6px">计数方式:404
|
||||
与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 8px">
|
||||
<span class="tip" style="min-width:148px">主提示(留空=默认)</span>
|
||||
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string)$rcfg['_tip']['raw_main']) ?>" placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string) $rcfg['_tip']['raw_main']) ?>"
|
||||
placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案"
|
||||
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px">
|
||||
<span class="tip" style="min-width:148px">次行说明(留空=默认)</span>
|
||||
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string)$rcfg['_tip']['raw_sub']) ?>" placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string) $rcfg['_tip']['raw_sub']) ?>"
|
||||
placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案"
|
||||
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">保存风控规则</button>
|
||||
</form>
|
||||
@ -409,27 +375,34 @@ admin_topbar('visits');
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_clear">
|
||||
<button type="submit" class="btn btn-sm">清空全部风险计数</button>
|
||||
<?php if ($curRisk): ?>
|
||||
<span class="tip" style="margin-left:8px">当前访问 IP(<?= he($curIp) ?>)计数:登录失败 <?= (int)$curRisk['login_fail'] ?> 次 · 404 <?= (int)$curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求 <?= (int)$curRisk['rate_count'] ?> 次<?php endif; ?></span>
|
||||
<span class="tip" style="margin-left:8px">当前访问 IP(<?= he($curIp) ?>)计数:登录失败 <?= (int) $curRisk['login_fail'] ?>
|
||||
次
|
||||
· 404 <?= (int) $curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求
|
||||
<?= (int) $curRisk['rate_count'] ?> 次<?php endif; ?></span>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- IP 黑名单 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</div>
|
||||
<div class="fs-title">黑名单管理</div>
|
||||
<form method="post" class="v-filter">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_add">
|
||||
<input type="text" name="rule" required placeholder="如:203.0.113.7 / 203.0.113.0/24 / 203.0.113.*" style="flex:1 1 260px;max-width:420px">
|
||||
<input type="text" name="rule" required placeholder="如:203.0.113.7 / 203.0.113.0/24 / 203.0.113.*"
|
||||
style="flex:1 1 260px;max-width:420px">
|
||||
<input type="text" name="note" placeholder="备注(可选,如:恶意扫描器)" style="flex:1 1 220px;max-width:340px">
|
||||
<button type="submit" class="btn btn-primary">加入黑名单</button>
|
||||
</form>
|
||||
<p class="tip" style="margin:10px 0 2px">
|
||||
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> 或 <code>192.168.*.*</code>);
|
||||
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的<strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db 的 ip_blacklist 表中删除记录。
|
||||
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> 或
|
||||
<code>192.168.*.*</code>);
|
||||
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的<strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在
|
||||
data/homepage.db 的 ip_blacklist 表中删除记录。
|
||||
</p>
|
||||
<div style="margin:6px 0 10px">
|
||||
<?php if ($curHit): ?>
|
||||
<span class="vis-warn-tip">当前访问 IP:<?= he($curIp) ?> —— 命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string)$curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
|
||||
<span class="vis-warn-tip">当前访问 IP:<?= he($curIp) ?> ——
|
||||
命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string) $curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
|
||||
<?php else: ?>
|
||||
<span class="vis-ok-tip">当前访问 IP:<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
|
||||
<?php endif; ?>
|
||||
@ -439,7 +412,9 @@ admin_topbar('visits');
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:8px 0 4px">
|
||||
<form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_batch">
|
||||
<label class="tip" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input type="checkbox" id="blCheckAll"> 全选</label>
|
||||
<label class="tip"
|
||||
style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input
|
||||
type="checkbox" id="blCheckAll"> 全选</label>
|
||||
<button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中(0)</button>
|
||||
</form>
|
||||
<?php if ($banAutoCount > 0): ?>
|
||||
@ -455,17 +430,20 @@ admin_topbar('visits');
|
||||
</div>
|
||||
<?php foreach ($banRows as $b): ?>
|
||||
<div class="bl-row">
|
||||
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int)$b['id'] ?>"></label>
|
||||
<code class="bl-rule"><?= he((string)$b['rule']) ?></code>
|
||||
<?php if (((string)($b['source'] ?? 'manual')) === 'auto'): ?>
|
||||
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input
|
||||
type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int) $b['id'] ?>"></label>
|
||||
<code class="bl-rule"><?= he((string) $b['rule']) ?></code>
|
||||
<?php if (((string) ($b['source'] ?? 'manual')) === 'auto'): ?>
|
||||
<span class="v-tag warn" style="min-width:0">风控自动</span>
|
||||
<?php else: ?>
|
||||
<span class="v-tag info" style="min-width:0">手动</span>
|
||||
<?php endif; ?>
|
||||
<span class="bl-note"><?= $b['note'] !== '' ? he((string)$b['note']) : '<span class="tip">(无备注)</span>' ?></span>
|
||||
<span class="bl-time"><?= he((string)$b['created_at']) ?></span>
|
||||
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string)$b['rule']) ?>」?">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<span
|
||||
class="bl-note"><?= $b['note'] !== '' ? he((string) $b['note']) : '<span class="tip">(无备注)</span>' ?></span>
|
||||
<span class="bl-time"><?= he((string) $b['created_at']) ?></span>
|
||||
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string) $b['rule']) ?>」?">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id"
|
||||
value="<?= (int) $b['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">移除</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -480,7 +458,7 @@ admin_topbar('visits');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
var all = document.getElementById('blCheckAll');
|
||||
var btn = document.getElementById('blBatchBtn');
|
||||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.js-bl-check'));
|
||||
@ -496,7 +474,8 @@ admin_topbar('visits');
|
||||
refresh();
|
||||
});
|
||||
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</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-slim { min-height: 0; padding: 16px 0 12px; }
|
||||
|
||||
/* ============ 后台访问信息页 ============ */
|
||||
/* ============ 后台风控管理页 ============ */
|
||||
.vis-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.vis-stat { background: var(--bg-2); border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||||
.vis-stat b { display: block; font-size: 22px; line-height: 1.3; font-variant-numeric: tabular-nums; }
|
||||
@ -471,7 +471,7 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
|
||||
/* 跨整行的设置卡片(内容较多的底部大卡) */
|
||||
.fieldset-card.col-span-2 { grid-column: 1 / -1; }
|
||||
|
||||
/* 后台导航内容管理 */
|
||||
/* 后台导航管理 */
|
||||
.m-block { border: 1px solid var(--line); border-radius: 12px; margin-bottom: 16px; overflow: hidden; }
|
||||
.m-block-head { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--bg-2); flex-wrap: wrap; }
|
||||
.m-block-head input[type=text] { max-width: 320px; }
|
||||
@ -825,3 +825,83 @@ a.nav-btn[data-note]:hover::after { opacity: 1; transform: translateX(-50%) tran
|
||||
.hp-toast.hide { opacity: 0; transform: translateY(8px); transition: opacity .22s ease, transform .22s ease; }
|
||||
@keyframes hpToastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
@media (max-width: 600px) { .hp-toasts { right: 12px; bottom: 12px; left: 12px; align-items: stretch; } .hp-toast { min-width: 0; } }
|
||||
|
||||
/* ---------- 后台侧边栏(原顶部分区栏改为可收缩侧栏,含“本页功能”锚点) ---------- */
|
||||
body.hp-admin-side { padding-left: 232px; transition: padding-left .2s ease; }
|
||||
body.hp-admin-side.hp-side-collapsed { padding-left: 68px; }
|
||||
|
||||
.admin-mobilebar { display: none; }
|
||||
|
||||
.admin-side {
|
||||
position: fixed; top: 0; left: 0; bottom: 0; width: 232px; z-index: 920;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
background: var(--brand-2); color: #fff;
|
||||
padding: 12px 10px 10px; overflow-y: auto; box-sizing: border-box;
|
||||
transition: width .2s ease, transform .2s ease;
|
||||
}
|
||||
.admin-side a { color: rgba(255, 255, 255, .92); }
|
||||
|
||||
.as-head { display: flex; align-items: center; gap: 8px; padding: 4px 6px 10px; border-bottom: 1px solid rgba(255, 255, 255, .16); margin-bottom: 8px; }
|
||||
.as-brand { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; }
|
||||
.as-logo { width: 32px; height: 32px; border-radius: 8px; object-fit: contain; background: #fff; padding: 2px; flex: none; }
|
||||
.as-brand-tx { display: flex; flex-direction: column; min-width: 0; font-weight: 700; font-size: 14px; line-height: 1.25; }
|
||||
.as-brand-tx em { font-style: normal; font-weight: 400; font-size: 11px; opacity: .7; }
|
||||
.as-toggle { flex: none; width: 26px; height: 26px; border-radius: 8px; border: 1px solid rgba(255, 255, 255, .28); background: rgba(255, 255, 255, .08); color: #fff; cursor: pointer; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.as-toggle:hover { background: rgba(255, 255, 255, .2); }
|
||||
.as-toggle-ic { display: inline-block; transition: transform .2s ease; }
|
||||
|
||||
.as-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.as-link { display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 10px; font-size: 14px; white-space: nowrap; }
|
||||
.as-link:hover { background: rgba(255, 255, 255, .12); }
|
||||
.as-link.active { background: rgba(255, 255, 255, .2); font-weight: 700; }
|
||||
.as-ic { flex: none; width: 20px; text-align: center; font-size: 15px; }
|
||||
|
||||
.as-sec { margin-top: 10px; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; }
|
||||
.as-sec-t { font-size: 11px; letter-spacing: .06em; opacity: .6; padding: 2px 10px 6px; }
|
||||
.as-sec-list { display: flex; flex-direction: column; gap: 1px; }
|
||||
.as-sub { display: block; padding: 6px 10px 6px 12px; border-radius: 8px; font-size: 12.5px; color: rgba(255, 255, 255, .78); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; border-left: 2px solid transparent; }
|
||||
.as-sub:hover { background: rgba(255, 255, 255, .12); color: #fff; }
|
||||
.as-sub.active { background: rgba(255, 255, 255, .18); color: #fff; border-left-color: #fff; font-weight: 600; }
|
||||
|
||||
.as-foot { margin-top: auto; border-top: 1px solid rgba(255, 255, 255, .16); padding-top: 8px; }
|
||||
.as-user { font-size: 13px; opacity: .78; padding: 4px 10px 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.admin-side-mask { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 919; }
|
||||
|
||||
/* 滚动定位到区块时预留间距(避免贴顶) */
|
||||
.fieldset-card { scroll-margin-top: 18px; }
|
||||
|
||||
/* 收缩态:仅图标 */
|
||||
body.hp-side-collapsed .as-brand-tx,
|
||||
body.hp-side-collapsed .as-tx { display: none; }
|
||||
body.hp-side-collapsed .admin-side { width: 68px; }
|
||||
body.hp-side-collapsed .as-head { flex-direction: column; gap: 6px; }
|
||||
body.hp-side-collapsed .as-link { justify-content: center; padding: 9px 0; }
|
||||
body.hp-side-collapsed .as-toggle-ic { transform: rotate(180deg); }
|
||||
body.hp-side-collapsed .as-sec { display: none; }
|
||||
|
||||
/* 小屏:侧栏改为抽屉 */
|
||||
@media (max-width: 900px) {
|
||||
body.hp-admin-side { padding-left: 0; }
|
||||
.admin-mobilebar {
|
||||
display: flex; align-items: center; gap: 10px; position: sticky; top: 0; z-index: 921;
|
||||
background: var(--brand-2); color: #fff; padding: 8px 12px;
|
||||
}
|
||||
.amb-btn { border: 1px solid rgba(255, 255, 255, .3); background: rgba(255, 255, 255, .1); color: #fff; border-radius: 8px; width: 34px; height: 30px; font-size: 15px; cursor: pointer; }
|
||||
.amb-brand { display: flex; align-items: center; gap: 8px; min-width: 0; color: #fff; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.amb-logo { width: 26px; height: 26px; border-radius: 6px; object-fit: contain; background: #fff; padding: 1px; flex: none; }
|
||||
.amb-out { margin-left: auto; color: rgba(255, 255, 255, .9); font-size: 13px; }
|
||||
.admin-side { width: 240px; transform: translateX(-100%); box-shadow: 2px 0 18px rgba(0, 0, 0, .3); }
|
||||
body.hp-side-open .admin-side { transform: none; }
|
||||
body.hp-side-open .admin-side-mask { display: block; }
|
||||
/* 小屏忽略收缩态(始终保持完整文字) */
|
||||
body.hp-side-collapsed .admin-side { width: 240px; }
|
||||
body.hp-side-collapsed .as-tx { display: block; }
|
||||
body.hp-side-collapsed .as-brand-tx { display: flex; }
|
||||
body.hp-side-collapsed .as-link { justify-content: flex-start; padding: 9px 10px; }
|
||||
body.hp-side-collapsed .as-sec { display: block; }
|
||||
.fieldset-card { scroll-margin-top: 66px; }
|
||||
}
|
||||
|
||||
/* ---------- 风控管理页:筛选 / 翻页局部刷新时的轻量加载态 ---------- */
|
||||
.vis-loading { opacity: .5; pointer-events: none; transition: opacity .15s ease; }
|
||||
|
||||
@ -426,38 +426,242 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 返回顶部(右下角按钮,滚动后出现;兼容内滚容器页面) ---------- */
|
||||
/* ---------- 返回顶部(右下角按钮;滚动后出现,回到顶部即隐藏) ---------- */
|
||||
function initToTop() {
|
||||
var btn = document.querySelector('.js-to-top');
|
||||
if (!btn) return;
|
||||
var scrollables = [];
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
if (doc) scrollables.push(doc);
|
||||
Array.prototype.forEach.call(document.querySelectorAll('*'), function (el) {
|
||||
if (el === doc || el === btn) return;
|
||||
var containers = [];
|
||||
var ticking = false;
|
||||
|
||||
function currentY() {
|
||||
var y = window.pageYOffset || (doc && doc.scrollTop) || 0;
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
var t = containers[i].scrollTop || 0;
|
||||
if (t > y) y = t;
|
||||
}
|
||||
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;
|
||||
var cs;
|
||||
try { cs = window.getComputedStyle(el); } catch (e) { cs = null; }
|
||||
if (!cs) return;
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollables.push(el);
|
||||
try { cs = window.getComputedStyle(el); } catch (e) { return; }
|
||||
if (cs.position === 'fixed' || cs.position === 'sticky') return;
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') containers.push(el);
|
||||
});
|
||||
function measure() {
|
||||
var v = 0;
|
||||
for (var i = 0; i < scrollables.length; i++) {
|
||||
if (scrollables[i].scrollTop > v) v = scrollables[i].scrollTop;
|
||||
apply();
|
||||
}
|
||||
btn.classList.toggle('show', v > 260);
|
||||
}
|
||||
for (var s = 0; s < scrollables.length; s++) {
|
||||
scrollables[s].addEventListener('scroll', measure, { passive: true });
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
collect();
|
||||
if (document.readyState !== 'complete') window.addEventListener('load', collect);
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var root = doc || document.documentElement;
|
||||
if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
btn.classList.remove('show'); // 乐观隐藏,避免到顶后仍残留
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
if (containers[i].scrollTop > 0) containers[i].scrollTop = 0;
|
||||
}
|
||||
if (doc && doc.scrollTop > 0) {
|
||||
try { doc.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { doc.scrollTop = 0; }
|
||||
} else {
|
||||
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, 0); }
|
||||
}
|
||||
schedule();
|
||||
});
|
||||
measure();
|
||||
schedule();
|
||||
}
|
||||
|
||||
/* ---------- 后台侧边栏:收缩 / 移动端抽屉 / 本页功能锚点 ---------- */
|
||||
function initAdminSide() {
|
||||
var side = document.querySelector('.admin-side');
|
||||
if (!side) return;
|
||||
var body = document.body;
|
||||
var mq = window.matchMedia ? window.matchMedia('(max-width: 900px)') : null;
|
||||
function isMobile() { return mq ? mq.matches : window.innerWidth <= 900; }
|
||||
|
||||
// 收缩(桌面)/ 抽屉开关(小屏)
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.js-admin-side-toggle'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (isMobile()) {
|
||||
body.classList.toggle('hp-side-open');
|
||||
} else {
|
||||
var collapsed = body.classList.toggle('hp-side-collapsed');
|
||||
try { localStorage.setItem('hp-admin-side-collapsed', collapsed ? '1' : '0'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
var mask = document.getElementById('adminSideMask');
|
||||
if (mask) mask.addEventListener('click', function () { body.classList.remove('hp-side-open'); });
|
||||
window.addEventListener('resize', function () { if (!isMobile()) body.classList.remove('hp-side-open'); });
|
||||
Array.prototype.forEach.call(side.querySelectorAll('a'), function (a) {
|
||||
a.addEventListener('click', function () { if (isMobile()) body.classList.remove('hp-side-open'); });
|
||||
});
|
||||
|
||||
// 采集当前页“本页功能”:fieldset-card 的标题
|
||||
var secWrap = document.getElementById('adminSideSections');
|
||||
var secList = document.getElementById('adminSideSectionList');
|
||||
if (!secWrap || !secList) return;
|
||||
var items = [];
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.fieldset-card'), function (card) {
|
||||
var t = card.querySelector('.fs-title');
|
||||
if (!t) return;
|
||||
var clone = t.cloneNode(true);
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('.tip'), function (n) { if (n.parentNode) n.parentNode.removeChild(n); });
|
||||
var text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text === '') return;
|
||||
items.push({ el: card, text: text });
|
||||
});
|
||||
if (items.length < 1) return;
|
||||
var idx = 0;
|
||||
items.forEach(function (it) {
|
||||
idx++;
|
||||
if (!it.el.id) it.el.id = 'hp-sec-' + idx;
|
||||
var a = document.createElement('a');
|
||||
a.className = 'as-sub';
|
||||
a.href = '#' + it.el.id;
|
||||
a.textContent = it.text;
|
||||
a.title = it.text;
|
||||
a.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
try { it.el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
catch (e) { it.el.scrollIntoView(); }
|
||||
});
|
||||
secList.appendChild(a);
|
||||
});
|
||||
secWrap.hidden = false;
|
||||
|
||||
// 高亮当前可视区块
|
||||
var subs = secList.querySelectorAll('.as-sub');
|
||||
function spy() {
|
||||
var best = -1, bestTop = -Infinity;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var top = items[i].el.getBoundingClientRect().top - 120;
|
||||
if (top <= 0 && top > bestTop) { bestTop = top; best = i; }
|
||||
}
|
||||
for (var j = 0; j < subs.length; j++) subs[j].classList.toggle('active', j === best);
|
||||
}
|
||||
var st = false;
|
||||
function onScroll() {
|
||||
if (st) return;
|
||||
st = true;
|
||||
if (window.requestAnimationFrame) window.requestAnimationFrame(function () { st = false; spy(); });
|
||||
else { st = false; spy(); }
|
||||
}
|
||||
document.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onScroll);
|
||||
spy();
|
||||
}
|
||||
|
||||
/* ---------- 登录页:采集设备信息(系统 / 机型 / 屏幕等,能拿到什么就带什么)随表单一并提交 ---------- */
|
||||
function initLoginDevice() {
|
||||
var field = document.getElementById('devField');
|
||||
if (!field) return;
|
||||
function baseInfo() {
|
||||
var info = {};
|
||||
try {
|
||||
info.ua = navigator.userAgent || '';
|
||||
info.platform = navigator.platform || '';
|
||||
info.lang = navigator.language || '';
|
||||
info.screen = (window.screen && screen.width && screen.height) ? (screen.width + '×' + screen.height + '@' + (window.devicePixelRatio || 1)) : '';
|
||||
info.touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
} catch (e) { /* ignore */ }
|
||||
return info;
|
||||
}
|
||||
function fill() {
|
||||
try { field.value = JSON.stringify(baseInfo()); } catch (e) { /* ignore */ }
|
||||
}
|
||||
fill();
|
||||
// Chromium 系 UA-CH:可拿到机型(model)、系统版本、架构等高熵值
|
||||
try {
|
||||
if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
|
||||
navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion'])
|
||||
.then(function (h) {
|
||||
var info = baseInfo();
|
||||
info.uch = h || {};
|
||||
field.value = JSON.stringify(info);
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ---------- 风控管理页:筛选 / 翻页局部刷新(不整页跳转、不回到顶部) ---------- */
|
||||
function initVisitsAjax() {
|
||||
var boxes = {
|
||||
access: document.getElementById('visResults'),
|
||||
loginfail: document.getElementById('lfResults')
|
||||
};
|
||||
if (!boxes.access && !boxes.loginfail) return;
|
||||
|
||||
function serialize(form) {
|
||||
var parts = [];
|
||||
var els = form.elements;
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (!el.name || el.disabled) continue;
|
||||
var t = (el.type || '').toLowerCase();
|
||||
if ((t === 'checkbox' || t === 'radio') && !el.checked) continue;
|
||||
parts.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value));
|
||||
}
|
||||
return parts.join('&');
|
||||
}
|
||||
function load(section, query, pushUrl) {
|
||||
var box = boxes[section];
|
||||
if (!box) return;
|
||||
box.classList.add('vis-loading');
|
||||
var xhr = new XMLHttpRequest();
|
||||
try {
|
||||
xhr.open('GET', 'visits_data.php?section=' + encodeURIComponent(section) + (query ? ('&' + query) : ''), true);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
box.classList.remove('vis-loading');
|
||||
if (xhr.status === 200) {
|
||||
box.innerHTML = xhr.responseText;
|
||||
if (pushUrl && history.replaceState) {
|
||||
history.replaceState(null, '', 'visits.php' + (query ? ('?' + query) : ''));
|
||||
}
|
||||
} else {
|
||||
box.innerHTML = '<div class="err-msg">加载失败(可能登录状态已失效),请刷新页面后重试。</div>';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
} catch (e) {
|
||||
box.classList.remove('vis-loading');
|
||||
}
|
||||
}
|
||||
// 筛选表单(事件委托,局部刷新后依然有效)
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form || !form.getAttribute || !form.hasAttribute('data-vis-form')) return;
|
||||
if (!/get/i.test(form.method || '')) return;
|
||||
ev.preventDefault();
|
||||
load(form.getAttribute('data-vis-form'), serialize(form), true);
|
||||
}, true);
|
||||
// 分页链接(委托到 document,避开局部刷新后监听丢失)
|
||||
document.addEventListener('click', function (ev) {
|
||||
var a = (ev.target && ev.target.closest) ? ev.target.closest('a[data-vis-page]') : null;
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
load(a.getAttribute('data-vis-section') || 'access', a.getAttribute('data-vis-page') || '', true);
|
||||
}, true);
|
||||
}
|
||||
|
||||
/* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */
|
||||
@ -564,6 +768,9 @@
|
||||
initCopyButtons();
|
||||
bindConfirmForms();
|
||||
initVisitorInfo();
|
||||
initLoginDevice();
|
||||
initVisitsAjax();
|
||||
initAdminSide();
|
||||
initToTop();
|
||||
initFuncBack();
|
||||
hpShowPageToast();
|
||||
|
||||
347
assets/js/navicat.js
Normal file
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
|
||||
/**
|
||||
* func/index.php —— 功能区首页(工具入口,由后台“功能区管理”动态驱动)
|
||||
* func/index.php —— 功能区首页(工具入口,由后台“内部功能”动态驱动)
|
||||
*/
|
||||
$P = '../';
|
||||
require_once dirname(__DIR__) . '/includes/layout.php';
|
||||
@ -14,29 +14,30 @@ layout_head('功能区');
|
||||
<section class="sub-hero">
|
||||
<a class="back-home" href="../index.php">← 返回首页</a>
|
||||
<h1>功能区</h1>
|
||||
<p class="st-tip">工具统一收纳于此;新工具的入口可在后台“功能区管理”中登记</p>
|
||||
<p class="st-tip">工具统一收纳于此;新工具的入口可在后台“内部功能”中登记</p>
|
||||
</section>
|
||||
|
||||
<div class="wrap page-body">
|
||||
<?php if ($tools): ?>
|
||||
<div class="func-cards">
|
||||
<?php foreach ($tools as $t):
|
||||
$rawIcon = trim((string)($t['icon'] ?? ''));
|
||||
$rawIcon = trim((string) ($t['icon'] ?? ''));
|
||||
$rawIcon = $rawIcon !== '' ? $rawIcon : '🧩';
|
||||
$isExt = (int)$t['is_external'] === 1;
|
||||
$isExt = (int) $t['is_external'] === 1;
|
||||
?>
|
||||
<a class="func-card" href="<?= he((string)$t['url']) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
|
||||
<a class="func-card" href="<?= he((string) $t['url']) ?>" <?php if ($isExt): ?> target="_blank"
|
||||
rel="noopener noreferrer" <?php endif; ?>>
|
||||
<span class="fc-icon"><?= site_icon($rawIcon, '') ?></span>
|
||||
<span>
|
||||
<span class="fc-name"><?= he((string)$t['name']) ?></span>
|
||||
<span class="fc-desc"><?= he((string)$t['description']) ?></span>
|
||||
<span class="fc-name"><?= he((string) $t['name']) ?></span>
|
||||
<span class="fc-desc"><?= he((string) $t['description']) ?></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="panel-card">
|
||||
<div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“功能区管理”中添加</span>。</div>
|
||||
<div class="tip">功能区还没有可用的工具<span class="st-tip">,请到后台“内部功能”中添加</span>。</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@ -46,4 +47,5 @@ layout_head('功能区');
|
||||
<?php layout_footer(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
194
func/navicat.html
Normal file
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));
|
||||
}
|
||||
|
||||
/** 执行登录(成功返回 true) */
|
||||
function hp_login(string $username, string $plain): bool
|
||||
/** 执行登录(成功返回 true;成功/失败均写入登录日志与设备信息) */
|
||||
function hp_login(string $username, string $plain, string $deviceClient = ''): bool
|
||||
{
|
||||
$uaRaw = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
if (hp_verify_user($username, $plain)) {
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['admin'] = $username;
|
||||
// 记录本次登录信息(供后台“登录信息”卡片展示)
|
||||
$_SESSION['login_at'] = date('Y-m-d H:i:s');
|
||||
$_SESSION['login_ip'] = client_ip();
|
||||
$_SESSION['login_ua'] = login_ua_brief($uaRaw);
|
||||
$_SESSION['login_device'] = login_device_brief($deviceClient, $uaRaw);
|
||||
login_log_write($username, 'ok', $deviceClient);
|
||||
return true;
|
||||
}
|
||||
login_log_write($username, 'fail', $deviceClient);
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -59,14 +67,14 @@ function hp_change_password(string $oldPlain, string $newPlain): array
|
||||
if (!is_logged_in()) {
|
||||
return [false, '未登录'];
|
||||
}
|
||||
if (!hp_verify_user((string)$_SESSION['admin'], $oldPlain)) {
|
||||
if (!hp_verify_user((string) $_SESSION['admin'], $oldPlain)) {
|
||||
return [false, '原密码不正确'];
|
||||
}
|
||||
if (strlen($newPlain) < 6) {
|
||||
return [false, '新密码至少 6 位'];
|
||||
}
|
||||
$st = db()->prepare('UPDATE users SET password_hash = ? WHERE username = ?');
|
||||
$st->execute([hp_password_hash($newPlain), (string)$_SESSION['admin']]);
|
||||
$st->execute([hp_password_hash($newPlain), (string) $_SESSION['admin']]);
|
||||
return [true, '密码修改成功'];
|
||||
}
|
||||
|
||||
@ -87,7 +95,7 @@ function csrf_token(): string
|
||||
if (empty($_SESSION['csrf'])) {
|
||||
$_SESSION['csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
return (string)$_SESSION['csrf'];
|
||||
return (string) $_SESSION['csrf'];
|
||||
}
|
||||
|
||||
function csrf_field(): string
|
||||
|
||||
315
includes/db.php
315
includes/db.php
@ -35,7 +35,7 @@ function db_ensure_column(PDO $pdo, string $table, string $column, string $defin
|
||||
{
|
||||
$cols = $pdo->query('PRAGMA table_info(' . $table . ')')->fetchAll();
|
||||
foreach ($cols as $col) {
|
||||
if (strtolower((string)$col['name']) === strtolower($column)) {
|
||||
if (strtolower((string) $col['name']) === strtolower($column)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -138,6 +138,21 @@ function db_init(PDO $pdo): void
|
||||
updated_at TEXT NOT NULL DEFAULT \'\'
|
||||
)');
|
||||
|
||||
// ---------- 登录日志(成功 / 失败均记录:时间 / 用户名 / IP / UA / 设备) ----------
|
||||
$pdo->exec('CREATE TABLE IF NOT EXISTS login_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT DEFAULT \'\',
|
||||
ip TEXT DEFAULT \'\',
|
||||
ua TEXT DEFAULT \'\',
|
||||
device TEXT DEFAULT \'\',
|
||||
status TEXT NOT NULL DEFAULT \'ok\',
|
||||
created_at TEXT NOT NULL DEFAULT \'\'
|
||||
)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_status ON login_log (status, created_at)');
|
||||
$pdo->exec('CREATE INDEX IF NOT EXISTS idx_login_log_ip ON login_log (ip)');
|
||||
// 兼容旧库:登录日志补 device 列
|
||||
db_ensure_column($pdo, 'login_log', 'device', "TEXT DEFAULT ''");
|
||||
|
||||
// 兼容旧库:ip_blacklist 补 source 列;categories 补充 icon 列
|
||||
db_ensure_column($pdo, 'ip_blacklist', 'source', "TEXT NOT NULL DEFAULT 'manual'");
|
||||
db_ensure_column($pdo, 'categories', 'icon', "TEXT DEFAULT ''");
|
||||
@ -151,7 +166,7 @@ function db_init(PDO $pdo): void
|
||||
db_ensure_column($pdo, 'ext_links', 'grp', "TEXT DEFAULT ''");
|
||||
|
||||
// ---------- 默认分类种子 ----------
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
|
||||
$count = (int) $pdo->query('SELECT COUNT(*) FROM categories')->fetchColumn();
|
||||
if ($count === 0) {
|
||||
$seedCats = [
|
||||
['popular', '科普库', '📖', 1, '面向大众与初学者的计算机科学、网络安全与前沿技术科普,帮你建立体系化的安全认知。'],
|
||||
@ -166,14 +181,14 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ----------
|
||||
$hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
|
||||
$hasArticleCat = (int) $pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
|
||||
if ($hasArticleCat === 0) {
|
||||
$stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)');
|
||||
$stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]);
|
||||
}
|
||||
|
||||
// ---------- 默认设置种子 ----------
|
||||
$hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||
$hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||
if ($hasSettings === 0) {
|
||||
$defaults = [
|
||||
'site_name' => '知识导航站',
|
||||
@ -184,7 +199,7 @@ function db_init(PDO $pdo): void
|
||||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
||||
'footer_text' => '',
|
||||
'search_engines'=> '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||
'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||
'last_updated' => '',
|
||||
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
|
||||
'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
||||
@ -198,7 +213,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 默认管理员 ----------
|
||||
$userCount = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
if ($userCount === 0) {
|
||||
// 密码算法:先 base64 再 md5(按原始需求实现)
|
||||
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
|
||||
@ -206,7 +221,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 功能区工具默认种子 ----------
|
||||
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||||
$toolCount = (int) $pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||||
if ($toolCount === 0) {
|
||||
$seedTools = [
|
||||
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0],
|
||||
@ -226,19 +241,20 @@ function db_init(PDO $pdo): void
|
||||
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.html'],
|
||||
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
|
||||
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务(systemd 与 init.d)', 'persist.html'],
|
||||
['Navicat 密码解密', '🔓', 'Navicat 数据库连接密码本地解密 / 加密(11 及以前 Blowfish / 12 及以后 AES-128-CBC)', 'navicat.html'],
|
||||
];
|
||||
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
|
||||
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
|
||||
foreach ($extraTools as $t) {
|
||||
$chkUrl->execute([$t[3]]);
|
||||
if ((int)$chkUrl->fetchColumn() === 0) {
|
||||
$mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||||
if ((int) $chkUrl->fetchColumn() === 0) {
|
||||
$mx = (int) $pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||||
$insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ----------
|
||||
$extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
|
||||
$extCount = (int) $pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
|
||||
if ($extCount === 0) {
|
||||
$seedExt = [
|
||||
// [分区/栏目, 名称, 网址, 简介]
|
||||
@ -257,7 +273,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 文章库示例文章(首次建库时提供一篇使用说明) ----------
|
||||
$artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||||
$artCount = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||||
if ($artCount === 0) {
|
||||
$sampleMd = <<<MD
|
||||
## 分区与文章
|
||||
@ -322,7 +338,7 @@ function setting_get(string $key, string $default = ''): string
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
foreach (db()->query('SELECT key, value FROM settings') as $row) {
|
||||
$cache[$row['key']] = (string)$row['value'];
|
||||
$cache[$row['key']] = (string) $row['value'];
|
||||
}
|
||||
}
|
||||
return array_key_exists($key, $cache) ? $cache[$key] : $default;
|
||||
@ -342,7 +358,7 @@ function client_ip(): string
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ip = '';
|
||||
}
|
||||
@ -376,7 +392,7 @@ function blacklist_parse_rule(string $raw): array
|
||||
}
|
||||
// CIDR 网段
|
||||
if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) {
|
||||
$prefix = (int)$m[2];
|
||||
$prefix = (int) $m[2];
|
||||
if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。'];
|
||||
}
|
||||
@ -400,10 +416,10 @@ function blacklist_parse_rule(string $raw): array
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($seen || $p === '' || !ctype_digit($p) || (int)$p > 255) {
|
||||
if ($seen || $p === '' || !ctype_digit($p) || (int) $p > 255) {
|
||||
return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。'];
|
||||
}
|
||||
$bytes[$i] = (int)$p;
|
||||
$bytes[$i] = (int) $p;
|
||||
}
|
||||
if (!$seen) {
|
||||
return [false, 0, 0, '规则格式不正确。'];
|
||||
@ -424,15 +440,15 @@ function blacklist_rules(bool $refresh = false): array
|
||||
$cache = [];
|
||||
try {
|
||||
foreach (db()->query('SELECT id, rule, note, created_at FROM ip_blacklist ORDER BY id ASC') as $row) {
|
||||
[$ok, $net, $prefix] = blacklist_parse_rule((string)$row['rule']);
|
||||
[$ok, $net, $prefix] = blacklist_parse_rule((string) $row['rule']);
|
||||
if (!$ok) {
|
||||
continue;
|
||||
}
|
||||
$cache[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'rule' => (string)$row['rule'],
|
||||
'note' => (string)$row['note'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'id' => (int) $row['id'],
|
||||
'rule' => (string) $row['rule'],
|
||||
'note' => (string) $row['note'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'net' => $net,
|
||||
'prefix' => $prefix,
|
||||
];
|
||||
@ -473,12 +489,12 @@ function access_log_flush(): void
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
$method = (string)($_SERVER['REQUEST_METHOD'] ?? '');
|
||||
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? '');
|
||||
$method = $method !== '' ? strtoupper($method) : 'GET';
|
||||
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
// 登录口令、CSRF 等敏感 query 参数脱敏后再写日志
|
||||
$uri = preg_replace('/([?&](?:password|passwd|old_pwd|new_pwd|new_pwd2|csrf|token|secret)[^=]*=)[^&]*/i', '$1***', $uri);
|
||||
$uri = substr((string)$uri, 0, 600);
|
||||
$uri = substr((string) $uri, 0, 600);
|
||||
$code = http_response_code();
|
||||
if (!is_int($code) || $code < 100 || $code > 599) {
|
||||
$code = 200;
|
||||
@ -487,7 +503,7 @@ function access_log_flush(): void
|
||||
$line = date('Y-m-d H:i:s') . "\t" . $ip . "\t" . $method . "\t" . $code . "\t" . $uri . "\n";
|
||||
@file_put_contents(ACCESS_LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
// 自动风控:前台 404 响应计入累计(是否达到阈值触发封禁见 risk_count_not_found / access_boot 自检)
|
||||
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
if (!$isAdmin && $code === 404) {
|
||||
risk_count_not_found($ip);
|
||||
}
|
||||
@ -496,7 +512,7 @@ function access_log_flush(): void
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */
|
||||
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */
|
||||
function visitors_entries(): array
|
||||
{
|
||||
$out = [];
|
||||
@ -517,11 +533,11 @@ function visitors_entries(): array
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'created_at' => (string)$p[0],
|
||||
'ip' => (string)$p[1],
|
||||
'method' => (string)$p[2],
|
||||
'status' => (int)$p[3],
|
||||
'url' => (string)$p[4],
|
||||
'created_at' => (string) $p[0],
|
||||
'ip' => (string) $p[1],
|
||||
'method' => (string) $p[2],
|
||||
'status' => (int) $p[3],
|
||||
'url' => (string) $p[4],
|
||||
];
|
||||
}
|
||||
fclose($fh);
|
||||
@ -539,7 +555,7 @@ function access_boot(): void
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
// 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页
|
||||
if (blacklist_ip_hit($ip) !== null) {
|
||||
risk_block_page();
|
||||
@ -548,15 +564,15 @@ function access_boot(): void
|
||||
if (!$isAdmin) {
|
||||
$cfg = risk_rule_cfg();
|
||||
// 2a) 请求频率:先累计本请求,超过阈值即封禁
|
||||
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int)$cfg['rate']['n'])) {
|
||||
risk_ban($ip, '请求频率超过阈值(60 秒内 ' . (int)$cfg['rate']['n'] . ' 次)');
|
||||
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int) $cfg['rate']['n'])) {
|
||||
risk_ban($ip, '请求频率超过阈值(60 秒内 ' . (int) $cfg['rate']['n'] . ' 次)');
|
||||
risk_block_page();
|
||||
}
|
||||
// 2b) 404 累计已达标(若上一请求刚触发封禁,这里会因命中黑名单直接拦截,此分支为兑底)
|
||||
if ($cfg['not_found']['on']) {
|
||||
$row = risk_row($ip);
|
||||
if ((int)$row['not_found'] >= (int)$cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
|
||||
if ((int) $row['not_found'] >= (int) $cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
|
||||
risk_block_page();
|
||||
}
|
||||
}
|
||||
@ -573,7 +589,7 @@ function risk_settings_map(): array
|
||||
$map = [];
|
||||
try {
|
||||
foreach (db()->query('SELECT key, value FROM settings') as $r) {
|
||||
$map[(string)$r['key']] = (string)$r['value'];
|
||||
$map[(string) $r['key']] = (string) $r['value'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$map = [];
|
||||
@ -588,8 +604,8 @@ function risk_settings_map(): array
|
||||
function risk_block_tip(): array
|
||||
{
|
||||
$map = risk_settings_map();
|
||||
$rawMain = trim((string)($map['risk_tip_main'] ?? ''));
|
||||
$rawSub = trim((string)($map['risk_tip_sub'] ?? ''));
|
||||
$rawMain = trim((string) ($map['risk_tip_main'] ?? ''));
|
||||
$rawSub = trim((string) ($map['risk_tip_sub'] ?? ''));
|
||||
$main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问';
|
||||
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
|
||||
return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub];
|
||||
@ -613,8 +629,8 @@ function risk_rule_cfg(): array
|
||||
foreach ($defs as $k => $d) {
|
||||
$pre = $alias[$k];
|
||||
$out[$k] = [
|
||||
'on' => (($map['risk_' . $pre . '_on'] ?? (string)$d[0]) === '1'),
|
||||
'n' => max(1, min(1000000, (int)($map['risk_' . $pre . '_n'] ?? (string)$d[1]))),
|
||||
'on' => (($map['risk_' . $pre . '_on'] ?? (string) $d[0]) === '1'),
|
||||
'n' => max(1, min(1000000, (int) ($map['risk_' . $pre . '_n'] ?? (string) $d[1]))),
|
||||
'tip' => $d[2],
|
||||
];
|
||||
}
|
||||
@ -690,9 +706,9 @@ function risk_login_fail(string $ip): array
|
||||
}
|
||||
$cfg = risk_rule_cfg();
|
||||
$row = risk_row($ip);
|
||||
$lf = (int)$row['login_fail'] + 1;
|
||||
$n = (int)$cfg['login_fail']['n'];
|
||||
risk_upsert($ip, $lf, (int)$row['not_found'], (int)$row['rate_ts'], (int)$row['rate_count']);
|
||||
$lf = (int) $row['login_fail'] + 1;
|
||||
$n = (int) $cfg['login_fail']['n'];
|
||||
risk_upsert($ip, $lf, (int) $row['not_found'], (int) $row['rate_ts'], (int) $row['rate_count']);
|
||||
$banned = $cfg['login_fail']['on'] && $lf >= $n;
|
||||
if ($banned) {
|
||||
risk_ban($ip, '后台登录失败累计达阈值(' . $n . ' 次)');
|
||||
@ -706,10 +722,10 @@ function risk_bump_rate(string $ip, int $threshold): bool
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$win = (int)floor(time() / 60);
|
||||
$win = (int) floor(time() / 60);
|
||||
$row = risk_row($ip);
|
||||
$cnt = ((int)$row['rate_ts'] === $win) ? ((int)$row['rate_count'] + 1) : 1;
|
||||
risk_upsert($ip, (int)$row['login_fail'], (int)$row['not_found'], $win, $cnt);
|
||||
$cnt = ((int) $row['rate_ts'] === $win) ? ((int) $row['rate_count'] + 1) : 1;
|
||||
risk_upsert($ip, (int) $row['login_fail'], (int) $row['not_found'], $win, $cnt);
|
||||
return $cnt >= $threshold;
|
||||
}
|
||||
|
||||
@ -721,10 +737,10 @@ function risk_count_not_found(string $ip): void
|
||||
}
|
||||
$cfg = risk_rule_cfg();
|
||||
$row = risk_row($ip);
|
||||
$nf = (int)$row['not_found'] + 1;
|
||||
risk_upsert($ip, (int)$row['login_fail'], $nf, (int)$row['rate_ts'], (int)$row['rate_count']);
|
||||
if ($cfg['not_found']['on'] && $nf >= (int)$cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
|
||||
$nf = (int) $row['not_found'] + 1;
|
||||
risk_upsert($ip, (int) $row['login_fail'], $nf, (int) $row['rate_ts'], (int) $row['rate_count']);
|
||||
if ($cfg['not_found']['on'] && $nf >= (int) $cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
|
||||
}
|
||||
}
|
||||
|
||||
@ -791,4 +807,201 @@ function risk_block_page(): void
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 由 UA 串解析浏览器与操作系统(内部复用):返回 [浏览器, 操作系统] */
|
||||
function ua_parts(string $ua): array
|
||||
{
|
||||
$ua = trim($ua);
|
||||
if ($ua === '') {
|
||||
return ['未知浏览器', ''];
|
||||
}
|
||||
if (strpos($ua, 'MicroMessenger') !== false) {
|
||||
$browser = '微信内置浏览器';
|
||||
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Edge ' . $m[1];
|
||||
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Opera ' . $m[1];
|
||||
} elseif (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Firefox ' . $m[1];
|
||||
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Chrome ' . $m[1];
|
||||
} elseif (preg_match('/Version\/([\d.]+).*Safari/', $ua, $m)) {
|
||||
$browser = 'Safari ' . $m[1];
|
||||
} elseif (stripos($ua, 'curl') !== false) {
|
||||
$browser = 'curl';
|
||||
} elseif (preg_match('/python|wget|Go-http|Java|okhttp/i', $ua)) {
|
||||
$browser = '脚本客户端';
|
||||
} else {
|
||||
$browser = '未知浏览器';
|
||||
}
|
||||
if (strpos($ua, 'Windows NT 10.0') !== false) {
|
||||
$os = 'Windows 10/11';
|
||||
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
|
||||
$os = 'Windows 8.1';
|
||||
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
|
||||
$os = 'Windows 7';
|
||||
} elseif (stripos($ua, 'Windows') !== false) {
|
||||
$os = 'Windows';
|
||||
} elseif (strpos($ua, 'Android') !== false) {
|
||||
$os = 'Android';
|
||||
} elseif (preg_match('/iPhone|iPad|iPod/', $ua)) {
|
||||
$os = 'iOS';
|
||||
} elseif (strpos($ua, 'Mac OS X') !== false) {
|
||||
$os = 'macOS';
|
||||
} elseif (strpos($ua, 'Linux') !== false) {
|
||||
$os = 'Linux';
|
||||
} else {
|
||||
$os = '';
|
||||
}
|
||||
return [$browser, $os];
|
||||
}
|
||||
|
||||
/** 由 UA 串解析“浏览器 / 操作系统”(简洁展示;容错返回“未知设备”) */
|
||||
function login_ua_brief(string $ua): string
|
||||
{
|
||||
if (trim($ua) === '') {
|
||||
return '未知设备';
|
||||
}
|
||||
list($browser, $os) = ua_parts($ua);
|
||||
return $os !== '' ? ($browser . ' / ' . $os) : $browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备信息描述(手机型号 / 操作系统 / 浏览器 / 设备类型 + 屏幕、语言等):
|
||||
* 客户端采集 JSON($clientJson,见 common.js collectLoginDevice)优先,服务端 UA 回退;能获取到什么就写什么。
|
||||
*/
|
||||
function login_device_brief(string $clientJson, string $ua): string
|
||||
{
|
||||
$ua = trim($ua);
|
||||
$ci = json_decode($clientJson, true);
|
||||
if (!is_array($ci)) {
|
||||
$ci = [];
|
||||
}
|
||||
$uch = (isset($ci['uch']) && is_array($ci['uch'])) ? $ci['uch'] : [];
|
||||
list($browser, $os) = ua_parts($ua);
|
||||
// 系统:客户端高熵值优先(可拿到 Windows/Android 具体版本)
|
||||
if (!empty($uch['platform'])) {
|
||||
$os2 = trim((string) $uch['platform']);
|
||||
if (!empty($uch['platformVersion'])) {
|
||||
$os2 .= ' ' . trim((string) $uch['platformVersion']);
|
||||
}
|
||||
if ($os2 !== '') {
|
||||
$os = $os2;
|
||||
}
|
||||
}
|
||||
// 型号:客户端 model 优先,其次从 Android UA 提取,iOS 只能得到机型系列
|
||||
$model = '';
|
||||
if (!empty($uch['model'])) {
|
||||
$model = trim((string) $uch['model']);
|
||||
} elseif (preg_match('/Android[\s\d.]+;\s*([^;)]+?)(?:\s+Build|\))/i', $ua, $m)) {
|
||||
$model = trim($m[1]);
|
||||
} elseif (preg_match('/(iPhone|iPad|iPod)/i', $ua, $m)) {
|
||||
$model = 'Apple ' . $m[1];
|
||||
}
|
||||
$mobile = (!empty($ci['touch'])) || (stripos($ua, 'Mobile') !== false) || (isset($uch['mobile']) && $uch['mobile']);
|
||||
$seg = [];
|
||||
if ($model !== '') {
|
||||
$seg[] = $model;
|
||||
}
|
||||
if ($os !== '') {
|
||||
$seg[] = $os;
|
||||
}
|
||||
if ($browser !== '') {
|
||||
$seg[] = $browser;
|
||||
}
|
||||
$seg[] = $mobile ? '移动设备' : '桌面设备';
|
||||
$extra = [];
|
||||
if (!empty($uch['architecture'])) {
|
||||
$extra[] = '架构 ' . trim((string) $uch['architecture']);
|
||||
}
|
||||
if (!empty($ci['screen'])) {
|
||||
$extra[] = '屏幕 ' . trim((string) $ci['screen']);
|
||||
}
|
||||
if (!empty($ci['lang'])) {
|
||||
$extra[] = trim((string) $ci['lang']);
|
||||
}
|
||||
$text = implode(' · ', $seg);
|
||||
if ($extra) {
|
||||
$text .= '(' . implode(',', $extra) . ')';
|
||||
}
|
||||
return function_exists('mb_substr') ? mb_substr($text, 0, 300, 'UTF-8') : substr($text, 0, 300);
|
||||
}
|
||||
|
||||
/** 写入一条登录日志(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();
|
||||
|
||||
427
nav.php
427
nav.php
@ -7,14 +7,14 @@
|
||||
* 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。
|
||||
* 数据来源:
|
||||
* 导航区 = nav_blocks / nav_items(按当前库);
|
||||
* 内部功能区 = func_tools(后台“功能区管理”中 board_cats 控制按库展示,空=通用);
|
||||
* 内部功能区 = func_tools(后台“内部功能”中 board_cats 控制按库展示,空=通用);
|
||||
* 文章区 = articles(后台“文章管理”中 board_cats 勾选分发到库,未分发不显示);
|
||||
* 外部工具区 = ext_links(后台“外部工具”维护,cat_keys 控制范围,空=通用)。
|
||||
*/
|
||||
$P = '';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
|
||||
$catKey = isset($_GET['cat']) ? (string) $_GET['cat'] : '';
|
||||
$allowed = ['popular', 'red', 'blue'];
|
||||
if (!in_array($catKey, $allowed, true)) {
|
||||
$catKey = 'popular';
|
||||
@ -24,7 +24,8 @@ if (!in_array($catKey, $allowed, true)) {
|
||||
function board_cats_has(string $csv, string $key): bool
|
||||
{
|
||||
$csv = trim($csv);
|
||||
if ($csv === '') return true;
|
||||
if ($csv === '')
|
||||
return true;
|
||||
return in_array($key, array_map('trim', explode(',', $csv)), true);
|
||||
}
|
||||
|
||||
@ -44,28 +45,35 @@ if (!$cat) {
|
||||
|
||||
// —— 导航区数据 ——
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$cat['id']]);
|
||||
$st->execute([(int) $cat['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemsByBlock = [];
|
||||
if ($blocks) {
|
||||
$ids = array_map(function ($b) { return (int)$b['id']; }, $blocks);
|
||||
$ids = array_map(function ($b) {
|
||||
return (int) $b['id'];
|
||||
}, $blocks);
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $item) {
|
||||
$itemsByBlock[(int)$item['block_id']][] = $item;
|
||||
$itemsByBlock[(int) $item['block_id']][] = $item;
|
||||
}
|
||||
}
|
||||
$totalNav = 0;
|
||||
foreach ($itemsByBlock as $list) { $totalNav += count($list); }
|
||||
foreach ($itemsByBlock as $list) {
|
||||
$totalNav += count($list);
|
||||
}
|
||||
$navCols = [[], [], []];
|
||||
$idx = 0;
|
||||
foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; }
|
||||
foreach ($blocks as $b) {
|
||||
$navCols[$idx % 3][] = $b;
|
||||
$idx++;
|
||||
}
|
||||
|
||||
// —— 内部功能区数据(通用或包含当前库) ——
|
||||
$libTools = [];
|
||||
foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $t) {
|
||||
if (board_cats_has((string)($t['board_cats'] ?? ''), $catKey)) {
|
||||
if (board_cats_has((string) ($t['board_cats'] ?? ''), $catKey)) {
|
||||
$libTools[] = $t;
|
||||
}
|
||||
}
|
||||
@ -73,35 +81,38 @@ foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort A
|
||||
// —— 文章区数据(仅分发到当前库;按分类分组,分类内按最后更新时间倒序) ——
|
||||
$libArts = [];
|
||||
foreach ($pdo->query('SELECT id, part, title, summary, updated_at, board_cats FROM articles WHERE enabled = 1 ORDER BY updated_at DESC, id DESC')->fetchAll() as $a) {
|
||||
if (in_array($catKey, array_map('trim', explode(',', trim((string)($a['board_cats'] ?? '')))), true)) {
|
||||
if (in_array($catKey, array_map('trim', explode(',', trim((string) ($a['board_cats'] ?? '')))), true)) {
|
||||
$libArts[] = $a;
|
||||
}
|
||||
}
|
||||
// 按分类(part)分组;分类顺序:后台拖出的分区顺序优先,否则按各组最新更新时间降序
|
||||
$artGroups = [];
|
||||
foreach ($libArts as $a) {
|
||||
$p = trim((string)$a['part']);
|
||||
if ($p === '') $p = '未分类';
|
||||
$p = trim((string) $a['part']);
|
||||
if ($p === '')
|
||||
$p = '未分类';
|
||||
if (!isset($artGroups[$p])) {
|
||||
$artGroups[$p] = ['ts' => (string)$a['updated_at'], 'items' => []];
|
||||
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []];
|
||||
}
|
||||
$artGroups[$p]['items'][] = $a;
|
||||
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string)$a['updated_at'];
|
||||
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string) $a['updated_at'];
|
||||
}
|
||||
}
|
||||
$poIdx = [];
|
||||
$poCfg = trim((string)setting_get('article_part_order', ''));
|
||||
$poCfg = trim((string) setting_get('article_part_order', ''));
|
||||
if ($poCfg !== '') {
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po])) $poIdx[$po] = count($poIdx);
|
||||
if ($po !== '' && isset($artGroups[$po]))
|
||||
$poIdx[$po] = count($poIdx);
|
||||
}
|
||||
}
|
||||
uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) {
|
||||
$ia = $poIdx[$a] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$b] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib) return $ia <=> $ib;
|
||||
if ($ia !== $ib)
|
||||
return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($a, $b);
|
||||
});
|
||||
@ -113,7 +124,7 @@ foreach ($artGroups as $p => $g) {
|
||||
// —— 外部工具区数据(通用或包含当前库) ——
|
||||
$extLinks = [];
|
||||
foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $e) {
|
||||
if (board_cats_has((string)($e['cat_keys'] ?? ''), $catKey)) {
|
||||
if (board_cats_has((string) ($e['cat_keys'] ?? ''), $catKey)) {
|
||||
$extLinks[] = $e;
|
||||
}
|
||||
}
|
||||
@ -123,80 +134,293 @@ $engines = search_engines();
|
||||
$defaultEngine = $engines[0]['key'] ?? 'bing';
|
||||
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$domainOf = static function (string $u): string {
|
||||
$h = (string)parse_url($u, PHP_URL_HOST);
|
||||
$h = (string) parse_url($u, PHP_URL_HOST);
|
||||
return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u;
|
||||
};
|
||||
$catName = (string)$cat['name'];
|
||||
$catIcon = trim((string)$cat['icon']);
|
||||
$catName = (string) $cat['name'];
|
||||
$catIcon = trim((string) $cat['icon']);
|
||||
|
||||
layout_head($catName);
|
||||
?>
|
||||
<style>
|
||||
/* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */
|
||||
.lnx-nav {
|
||||
position: relative; display: flex; justify-content: center; align-items: center;
|
||||
gap: 0 38px; margin: 6px 0 16px; border-bottom: 1px solid var(--line); padding: 0 4px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0 38px;
|
||||
margin: 6px 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.lnx-tab { position: relative; z-index: 1; border: none; background: none; cursor: pointer;
|
||||
padding: 12px 6px 13px; font-size: 15px; color: var(--text-3);
|
||||
transition: color .2s ease; white-space: nowrap; }
|
||||
.lnx-tab:hover { color: var(--text); }
|
||||
.lnx-tab.active { color: var(--brand); font-weight: 700; }
|
||||
|
||||
.lnx-tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 12px 6px 13px;
|
||||
font-size: 15px;
|
||||
color: var(--text-3);
|
||||
transition: color .2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lnx-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lnx-tab.active {
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lnx-ink {
|
||||
position: absolute; left: 0; bottom: -1px; width: 0; height: 3px; border-radius: 3px;
|
||||
background: var(--brand); box-shadow: 0 0 8px var(--accent-soft);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 8px var(--accent-soft);
|
||||
transition: left .28s cubic-bezier(.45, 0, .25, 1), width .28s cubic-bezier(.45, 0, .25, 1);
|
||||
}
|
||||
.lnx-panel { display: none; }
|
||||
.lnx-panel.active { display: block; }
|
||||
.lnx-phead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
|
||||
.lnx-phead h3 { margin: 0; font-size: 18px; }
|
||||
.lnx-phead .lnx-go { font-size: 13px; color: var(--text-3); text-decoration: none; }
|
||||
.lnx-note { font-size: 12.5px; color: var(--text-3); background: var(--card); border: 1px dashed var(--line-2);
|
||||
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px; }
|
||||
|
||||
.lnx-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lnx-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lnx-phead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.lnx-phead h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lnx-phead .lnx-go {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lnx-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
background: var(--card);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 外部工具区分区标题(按 grp 分组的小节) */
|
||||
.lnx-eg { display: flex; align-items: center; gap: 10px; margin: 18px 0 8px; font-size: 14.5px; font-weight: 600; color: var(--brand); }
|
||||
.lnx-eg::after { content: ''; flex: 1; height: 1px; background: var(--line); }
|
||||
.lnx-exts + .lnx-eg { margin-top: 24px; }
|
||||
.lnx-empty { color: var(--text-3); font-size: 13.5px; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 22px 16px; text-align: center; }
|
||||
.lnx-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.lnx-tool { display: flex; gap: 12px; align-items: flex-start; text-decoration: none; color: var(--text);
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-tool:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-tool .lnx-ti { font-size: 24px; }
|
||||
.lnx-tool b { display: block; font-size: 15px; margin-bottom: 4px; }
|
||||
.lnx-tool span.tip { margin: 0; line-height: 1.5; display: block; }
|
||||
.lnx-arts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.lnx-art { display: block; text-decoration: none; color: var(--text); background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-art:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-art .lnx-ab { display: inline-block; font-size: 12px; color: var(--brand); background: var(--accent-soft);
|
||||
border-radius: 6px; padding: 2px 8px; margin-bottom: 8px; }
|
||||
.lnx-art h4 { margin: 0 0 6px; font-size: 15.5px; }
|
||||
.lnx-art p { margin: 0 0 8px; font-size: 13px; color: var(--text-3); line-height: 1.6; }
|
||||
.lnx-art time { font-size: 12px; color: var(--text-3); }
|
||||
.lnx-exts { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
|
||||
.lnx-ext { display: block; text-decoration: none; color: var(--text); background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-ext:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-ext .lnx-eh { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.lnx-ext .lnx-en { font-weight: 600; font-size: 14.5px; }
|
||||
.lnx-ext .lnx-ed { font-size: 12px; color: var(--text-3); word-break: break-all; }
|
||||
.lnx-ext p { margin: 6px 0 0; font-size: 12.5px; color: var(--text-3); line-height: 1.5; }
|
||||
.lnx-eg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-eg::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.lnx-exts+.lnx-eg {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.lnx-empty {
|
||||
color: var(--text-3);
|
||||
font-size: 13.5px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 22px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lnx-tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-tool {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-tool:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-tool .lnx-ti {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.lnx-tool b {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lnx-tool span.tip {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lnx-arts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-art {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-art:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-art .lnx-ab {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--brand);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lnx-art h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
|
||||
.lnx-art p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lnx-art time {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.lnx-exts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-ext {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-ext:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-eh {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-en {
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-ed {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lnx-ext p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lnx-nav { gap: 0 10px; }
|
||||
.lnx-tab { flex: 1 1 0; min-width: 0; font-size: 14px; padding: 10px 2px 12px; text-align: center; }
|
||||
.nav-cols { grid-template-columns: 1fr; }
|
||||
.lnx-nav {
|
||||
gap: 0 10px;
|
||||
}
|
||||
|
||||
.lnx-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
padding: 10px 2px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<main>
|
||||
<!-- 顶部留白 + 品牌条(上半区不变) -->
|
||||
<div class="nav-top">
|
||||
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<div class="wrap"
|
||||
style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<a class="brand brand-sm" href="index.php" title="返回首页">
|
||||
<?php layout_logo_img('logo', $siteName); ?>
|
||||
<span class="site-name"><?= he($siteName) ?></span>
|
||||
@ -241,26 +465,26 @@ layout_head($catName);
|
||||
<span class="lnx-go">共 <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span>
|
||||
</div>
|
||||
<?php if (!$blocks): ?>
|
||||
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航内容管理」中添加</span>。</div>
|
||||
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航管理」中添加</span>。</div>
|
||||
<?php else: ?>
|
||||
<div class="nav-cols" style="margin:0">
|
||||
<?php foreach ($navCols as $colBlocks): ?>
|
||||
<div class="nav-col">
|
||||
<?php foreach ($colBlocks as $b):
|
||||
$items = $itemsByBlock[(int)$b['id']] ?? [];
|
||||
$items = $itemsByBlock[(int) $b['id']] ?? [];
|
||||
$show = array_slice($items, 0, 15);
|
||||
?>
|
||||
<div class="nav-block">
|
||||
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
|
||||
<div class="nav-block-title"><?= he((string) $b['title']) ?></div>
|
||||
<div class="nav-block-grid">
|
||||
<?php for ($i = 0; $i < 15; $i++): ?>
|
||||
<?php if (isset($show[$i])):
|
||||
$it = $show[$i];
|
||||
$label = (string)$it['label'];
|
||||
$url = trim((string)$it['url']);
|
||||
$note = trim((string)$it['note']);
|
||||
$label = (string) $it['label'];
|
||||
$url = trim((string) $it['url']);
|
||||
$note = trim((string) $it['note']);
|
||||
if ($url !== ''): ?>
|
||||
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
|
||||
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer" <?php if ($note !== ''): ?> data-note="<?= he($note) ?>" <?php endif; ?>><?= he($label ?: $url) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
|
||||
<?php endif; ?>
|
||||
@ -283,23 +507,24 @@ layout_head($catName);
|
||||
<h3>内部功能区 · <?= he($catName) ?>常用工具</h3>
|
||||
<a class="lnx-go" href="func/index.php">前往完整功能区 ›</a>
|
||||
</div>
|
||||
<div class="lnx-note st-tip">由后台「功能区管理」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
|
||||
<div class="lnx-note st-tip">由后台「内部功能」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
|
||||
<?php if (!$libTools): ?>
|
||||
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「功能区管理」开启</span>。</div>
|
||||
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「内部功能」开启</span>。</div>
|
||||
<?php else: ?>
|
||||
<div class="lnx-tools">
|
||||
<?php foreach ($libTools as $tk):
|
||||
$isExt = (int)$tk['is_external'] === 1;
|
||||
$url = trim((string)$tk['url']);
|
||||
$isExt = (int) $tk['is_external'] === 1;
|
||||
$url = trim((string) $tk['url']);
|
||||
$href = $isExt ? $url : 'func/' . $url . '?from=nav';
|
||||
$tIcon = trim((string)($tk['icon'] ?? ''));
|
||||
$tIcon = trim((string) ($tk['icon'] ?? ''));
|
||||
$tIcon = $tIcon !== '' ? $tIcon : '🧩';
|
||||
?>
|
||||
<a class="lnx-tool" href="<?= he($href) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
|
||||
<a class="lnx-tool" href="<?= he($href) ?>" <?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"
|
||||
<?php endif; ?>>
|
||||
<span class="lnx-ti"><?= site_icon($tIcon, '') ?></span>
|
||||
<span>
|
||||
<b><?= he((string)$tk['name']) ?></b>
|
||||
<span class="tip"><?= he((string)$tk['description']) ?></span>
|
||||
<b><?= he((string) $tk['name']) ?></b>
|
||||
<span class="tip"><?= he((string) $tk['description']) ?></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
@ -321,10 +546,10 @@ layout_head($catName);
|
||||
<h4 class="lnx-eg"><?= he($ag['p']) ?></h4>
|
||||
<div class="lnx-arts">
|
||||
<?php foreach ($ag['items'] as $ak): ?>
|
||||
<a class="lnx-art" href="article/index.php?id=<?= (int)$ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
|
||||
<h4><?= he((string)$ak['title']) ?></h4>
|
||||
<p><?= he(trim((string)$ak['summary']) !== '' ? (string)$ak['summary'] : '(无摘要)') ?></p>
|
||||
<time>更新于 <?= he((string)$ak['updated_at']) ?></time>
|
||||
<a class="lnx-art" href="article/index.php?id=<?= (int) $ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
|
||||
<h4><?= he((string) $ak['title']) ?></h4>
|
||||
<p><?= he(trim((string) $ak['summary']) !== '' ? (string) $ak['summary'] : '(无摘要)') ?></p>
|
||||
<time>更新于 <?= he((string) $ak['updated_at']) ?></time>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -344,22 +569,23 @@ layout_head($catName);
|
||||
<?php else:
|
||||
$extGroups = [];
|
||||
foreach ($extLinks as $ek) {
|
||||
$eg = trim((string)($ek['grp'] ?? ''));
|
||||
$eg = trim((string) ($ek['grp'] ?? ''));
|
||||
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
|
||||
}
|
||||
?>
|
||||
<?php foreach ($extGroups as $eg => $gItems): ?>
|
||||
<?php if ($eg !== "\x00"): ?><h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
|
||||
<?php if ($eg !== "\x00"): ?>
|
||||
<h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
|
||||
<div class="lnx-exts">
|
||||
<?php foreach ($gItems as $ek):
|
||||
$eUrl = trim((string)$ek['url']);
|
||||
$eUrl = trim((string) $ek['url']);
|
||||
?>
|
||||
<a class="lnx-ext" href="<?= he($eUrl) ?>" target="_blank" rel="noopener noreferrer">
|
||||
<span class="lnx-eh">
|
||||
<span class="lnx-en"><?= he((string)$ek['name']) ?></span>
|
||||
<span class="lnx-en"><?= he((string) $ek['name']) ?></span>
|
||||
<span class="lnx-ed"><?= he($domainOf($eUrl)) ?></span>
|
||||
</span>
|
||||
<p><?= he((string)($ek['description'] ?? '')) ?></p>
|
||||
<p><?= he((string) ($ek['description'] ?? '')) ?></p>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -373,7 +599,7 @@ layout_head($catName);
|
||||
<?php layout_footer(); ?>
|
||||
<script src="assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
// 下划线滑动指示导航:切换分区时激活项高亮,指示条平滑滑动到该项下方
|
||||
var nav = document.getElementById('lnxNav');
|
||||
@ -417,7 +643,8 @@ layout_head($catName);
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user