新增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();
|
||||
}
|
||||
@ -11,181 +11,183 @@ $pdo = db();
|
||||
|
||||
function settings_set(string $key, string $value): void
|
||||
{
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
$st->execute([$key, $value]);
|
||||
$st->execute([$key, $value]);
|
||||
}
|
||||
|
||||
function renumber_sorts(PDO $pdo): void
|
||||
{
|
||||
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
|
||||
$idxByPart = [];
|
||||
$st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
$key = (string)$r['part'];
|
||||
if (!isset($idxByPart[$key])) {
|
||||
$idxByPart[$key] = 1;
|
||||
}
|
||||
$st->execute([$idxByPart[$key], (int)$r['id']]);
|
||||
$idxByPart[$key]++;
|
||||
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
|
||||
$idxByPart = [];
|
||||
$st = $pdo->prepare('UPDATE articles SET sort = ? WHERE id = ?');
|
||||
foreach ($rows as $r) {
|
||||
$key = (string) $r['part'];
|
||||
if (!isset($idxByPart[$key])) {
|
||||
$idxByPart[$key] = 1;
|
||||
}
|
||||
$st->execute([$idxByPart[$key], (int) $r['id']]);
|
||||
$idxByPart[$key]++;
|
||||
}
|
||||
}
|
||||
|
||||
function msg_block(string $kind, string $text): string
|
||||
{
|
||||
// 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出
|
||||
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
|
||||
// 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出
|
||||
return '<script>window.hpToastMsg = ' . json_encode(['kind' => $kind === 'ok' ? 'ok' : 'err', 'text' => $text], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) . ';</script>';
|
||||
}
|
||||
|
||||
$msgKind = '';
|
||||
$msgText = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_verify()) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'note':
|
||||
settings_set('article_note', trim((string)($_POST['note'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章区首页注要已保存。';
|
||||
break;
|
||||
case 'save':
|
||||
$id = max(0, (int)($_POST['id'] ?? 0));
|
||||
$part = trim((string)($_POST['part'] ?? ''));
|
||||
$title = trim((string)($_POST['title'] ?? ''));
|
||||
$summary = trim((string)($_POST['summary'] ?? ''));
|
||||
$md = (string)($_POST['markdown'] ?? '');
|
||||
$sort = max(0, (int)($_POST['sort'] ?? 0));
|
||||
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
|
||||
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
|
||||
// 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页
|
||||
$bcKept = [];
|
||||
foreach ((array)($_POST['board_cats'] ?? []) as $bk) {
|
||||
$bk = trim((string)$bk);
|
||||
if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) {
|
||||
$bcKept[] = $bk;
|
||||
}
|
||||
}
|
||||
$boardCats = implode(',', $bcKept);
|
||||
if ($part === '' || $part === '__new__') {
|
||||
$part = '未分类';
|
||||
}
|
||||
if ($title === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '文章标题不能为空。';
|
||||
} elseif ($id > 0) {
|
||||
$st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?');
|
||||
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已保存。';
|
||||
} else {
|
||||
$st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已发布(可继续编辑)。';
|
||||
}
|
||||
if ($msgKind === 'ok') {
|
||||
renumber_sorts($pdo);
|
||||
touch_last_updated();
|
||||
header('Location: articles.php?edit=' . $id . '&ok=1');
|
||||
exit;
|
||||
}
|
||||
break;
|
||||
case 'del':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]);
|
||||
renumber_sorts($pdo);
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已删除。';
|
||||
}
|
||||
break;
|
||||
case 'toggle':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$field = (string)($_POST['field'] ?? '');
|
||||
if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) {
|
||||
$cur = (int)$pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
|
||||
$pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
|
||||
$msgKind = 'ok';
|
||||
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。';
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
$id = (int)($_POST['id'] ?? 0);
|
||||
$dir = ((string)($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
|
||||
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
|
||||
$idx = -1;
|
||||
foreach ($rows as $i => $r) {
|
||||
if ((int)$r['id'] === $id) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($idx >= 0) {
|
||||
$target = ($dir === 'down') ? $idx + 1 : $idx - 1;
|
||||
if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) {
|
||||
$tmp = $rows[$idx];
|
||||
$rows[$idx] = $rows[$target];
|
||||
$rows[$target] = $tmp;
|
||||
renumber_sorts($pdo);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '顺序已调整。';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'part_order':
|
||||
// 分区(分类)拖动排序后持久化到设置:CSV 顺序
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$parts = [];
|
||||
foreach (explode(',', (string)($_POST['order'] ?? '')) as $po) {
|
||||
$po = trim((string)$po);
|
||||
if ($po !== '' && !in_array($po, $parts, true)) { $parts[] = $po; }
|
||||
}
|
||||
settings_set('article_part_order', implode(',', $parts));
|
||||
touch_last_updated();
|
||||
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
case 'upload':
|
||||
// 图片上传(供编辑器 XHR 使用,返回 JSON)
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$up = $_FILES['file'] ?? null;
|
||||
if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if (($up['size'] ?? 0) > 2 * 1024 * 1024) {
|
||||
echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg'];
|
||||
$ext = strtolower(pathinfo((string)$up['name'], PATHINFO_EXTENSION));
|
||||
if (!isset($extMap[$ext])) {
|
||||
echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$dir = DATA_DIR . '/articles/img';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
$filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext];
|
||||
if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) {
|
||||
echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
default:
|
||||
$msgKind = 'err';
|
||||
$msgText = '未知操作。';
|
||||
if (!csrf_verify()) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string) ($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'note':
|
||||
settings_set('article_note', trim((string) ($_POST['note'] ?? '')));
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章区首页注要已保存。';
|
||||
break;
|
||||
case 'save':
|
||||
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||||
$part = trim((string) ($_POST['part'] ?? ''));
|
||||
$title = trim((string) ($_POST['title'] ?? ''));
|
||||
$summary = trim((string) ($_POST['summary'] ?? ''));
|
||||
$md = (string) ($_POST['markdown'] ?? '');
|
||||
$sort = max(0, (int) ($_POST['sort'] ?? 0));
|
||||
$pinned = (isset($_POST['pinned']) && $_POST['pinned'] === '1') ? 1 : 0;
|
||||
$enabled = (isset($_POST['enabled']) && $_POST['enabled'] === '1') ? 1 : 0;
|
||||
// 分发到库首页(文章区):board_cats 逗号分隔;未勾选=不展示到库页
|
||||
$bcKept = [];
|
||||
foreach ((array) ($_POST['board_cats'] ?? []) as $bk) {
|
||||
$bk = trim((string) $bk);
|
||||
if (in_array($bk, ['popular', 'red', 'blue'], true) && !in_array($bk, $bcKept, true)) {
|
||||
$bcKept[] = $bk;
|
||||
}
|
||||
}
|
||||
$boardCats = implode(',', $bcKept);
|
||||
if ($part === '' || $part === '__new__') {
|
||||
$part = '未分类';
|
||||
}
|
||||
if ($title === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '文章标题不能为空。';
|
||||
} elseif ($id > 0) {
|
||||
$st = $pdo->prepare('UPDATE articles SET part=?, title=?, summary=?, markdown=?, pinned=?, enabled=?, sort=?, board_cats=?, updated_at=? WHERE id=?');
|
||||
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, date('Y-m-d H:i:s'), $id]);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已保存。';
|
||||
} else {
|
||||
$st = $pdo->prepare('INSERT INTO articles (part, title, summary, markdown, pinned, enabled, sort, board_cats, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$st->execute([$part, $title, $summary, $md, $pinned, $enabled, $sort, $boardCats, $now, $now]);
|
||||
$id = (int) $pdo->lastInsertId();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已发布(可继续编辑)。';
|
||||
}
|
||||
if ($msgKind === 'ok') {
|
||||
renumber_sorts($pdo);
|
||||
touch_last_updated();
|
||||
header('Location: articles.php?edit=' . $id . '&ok=1');
|
||||
exit;
|
||||
}
|
||||
break;
|
||||
case 'del':
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
if ($id > 0) {
|
||||
$pdo->prepare('DELETE FROM articles WHERE id = ?')->execute([$id]);
|
||||
renumber_sorts($pdo);
|
||||
touch_last_updated();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '文章已删除。';
|
||||
}
|
||||
break;
|
||||
case 'toggle':
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$field = (string) ($_POST['field'] ?? '');
|
||||
if ($id > 0 && ($field === 'pinned' || $field === 'enabled')) {
|
||||
$cur = (int) $pdo->query('SELECT ' . $field . ' FROM articles WHERE id = ' . $id)->fetchColumn();
|
||||
$pdo->prepare('UPDATE articles SET ' . $field . ' = ? WHERE id = ?')->execute([$cur ? 0 : 1, $id]);
|
||||
$msgKind = 'ok';
|
||||
$msgText = $field === 'pinned' ? '置顶状态已更新。' : '启停状态已更新。';
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$dir = ((string) ($_POST['dir'] ?? '') === 'down') ? 'down' : 'up';
|
||||
$rows = $pdo->query('SELECT id, part, sort FROM articles ORDER BY part ASC, sort ASC, id ASC')->fetchAll();
|
||||
$idx = -1;
|
||||
foreach ($rows as $i => $r) {
|
||||
if ((int) $r['id'] === $id) {
|
||||
$idx = $i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($idx >= 0) {
|
||||
$target = ($dir === 'down') ? $idx + 1 : $idx - 1;
|
||||
if ($target >= 0 && $target < count($rows) && $rows[$target]['part'] === $rows[$idx]['part']) {
|
||||
$tmp = $rows[$idx];
|
||||
$rows[$idx] = $rows[$target];
|
||||
$rows[$target] = $tmp;
|
||||
renumber_sorts($pdo);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '顺序已调整。';
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'part_order':
|
||||
// 分区(分类)拖动排序后持久化到设置:CSV 顺序
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$parts = [];
|
||||
foreach (explode(',', (string) ($_POST['order'] ?? '')) as $po) {
|
||||
$po = trim((string) $po);
|
||||
if ($po !== '' && !in_array($po, $parts, true)) {
|
||||
$parts[] = $po;
|
||||
}
|
||||
}
|
||||
settings_set('article_part_order', implode(',', $parts));
|
||||
touch_last_updated();
|
||||
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
case 'upload':
|
||||
// 图片上传(供编辑器 XHR 使用,返回 JSON)
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
$up = $_FILES['file'] ?? null;
|
||||
if (!$up || ($up['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
echo json_encode(['ok' => false, 'msg' => '未收到文件'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
if (($up['size'] ?? 0) > 2 * 1024 * 1024) {
|
||||
echo json_encode(['ok' => false, 'msg' => '图片不能超过 2MB'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$extMap = ['png' => 'png', 'jpg' => 'jpg', 'jpeg' => 'jpg', 'gif' => 'gif', 'webp' => 'webp', 'svg' => 'svg'];
|
||||
$ext = strtolower(pathinfo((string) $up['name'], PATHINFO_EXTENSION));
|
||||
if (!isset($extMap[$ext])) {
|
||||
echo json_encode(['ok' => false, 'msg' => '仅支持 png / jpg / gif / webp / svg'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
$dir = DATA_DIR . '/articles/img';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0777, true);
|
||||
}
|
||||
$filename = 'art_' . date('YmdHis') . '_' . bin2hex(random_bytes(4)) . '.' . $extMap[$ext];
|
||||
if (!@move_uploaded_file($up['tmp_name'], $dir . '/' . $filename)) {
|
||||
echo json_encode(['ok' => false, 'msg' => '保存失败,请检查 data/articles/img 目录权限'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
echo json_encode(['ok' => true, 'file' => 'data/articles/img/' . $filename], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
default:
|
||||
$msgKind = 'err';
|
||||
$msgText = '未知操作。';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$savedFlag = isset($_GET['ok']);
|
||||
@ -193,39 +195,48 @@ $list = $pdo->query('SELECT * FROM articles ORDER BY part ASC, sort ASC, id ASC'
|
||||
// —— 文章按分区(分类)分组;分区顺序:后台拖动保存的 article_part_order 优先,其余按各组最新更新时间降序 ——
|
||||
$artGroups = [];
|
||||
foreach ($list as $a) {
|
||||
$p = trim((string)$a['part']);
|
||||
if ($p === '') { $p = '未分类'; }
|
||||
if (!isset($artGroups[$p])) { $artGroups[$p] = ['ts' => (string)$a['updated_at'], 'rows' => []]; }
|
||||
$artGroups[$p]['rows'][] = $a;
|
||||
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) { $artGroups[$p]['ts'] = (string)$a['updated_at']; }
|
||||
$p = trim((string) $a['part']);
|
||||
if ($p === '') {
|
||||
$p = '未分类';
|
||||
}
|
||||
if (!isset($artGroups[$p])) {
|
||||
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'rows' => []];
|
||||
}
|
||||
$artGroups[$p]['rows'][] = $a;
|
||||
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string) $a['updated_at'];
|
||||
}
|
||||
}
|
||||
$poIdx = [];
|
||||
$poCfg = trim((string)setting_get('article_part_order', ''));
|
||||
$poCfg = trim((string) setting_get('article_part_order', ''));
|
||||
if ($poCfg !== '') {
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po])) { $poIdx[$po] = count($poIdx); }
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po])) {
|
||||
$poIdx[$po] = count($poIdx);
|
||||
}
|
||||
}
|
||||
}
|
||||
uksort($artGroups, static function ($pa, $pb) use ($poIdx, $artGroups) {
|
||||
$ia = $poIdx[$pa] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$pb] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib) return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($pa, $pb);
|
||||
$ia = $poIdx[$pa] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$pb] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib)
|
||||
return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$pb]['ts'], $artGroups[$pa]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($pa, $pb);
|
||||
});
|
||||
$noteText = setting_get('article_note', '');
|
||||
|
||||
$editing = false;
|
||||
$row = null;
|
||||
$editId = (int)($_GET['edit'] ?? 0);
|
||||
$editId = (int) ($_GET['edit'] ?? 0);
|
||||
if ($editId > 0) {
|
||||
$st = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
|
||||
$st->execute([$editId]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
$editing = true;
|
||||
}
|
||||
$st = $pdo->prepare('SELECT * FROM articles WHERE id = ?');
|
||||
$st->execute([$editId]);
|
||||
$row = $st->fetch();
|
||||
if ($row) {
|
||||
$editing = true;
|
||||
}
|
||||
}
|
||||
|
||||
layout_head('文章管理');
|
||||
@ -239,39 +250,53 @@ admin_topbar('articles');
|
||||
<?php if ($savedFlag): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,文章库前台已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($msgText !== ''): echo msg_block($msgKind, $msgText); endif; ?>
|
||||
<?php if ($msgText !== ''):
|
||||
echo msg_block($msgKind, $msgText);
|
||||
endif; ?>
|
||||
|
||||
<?php if ($editing || isset($_GET['new'])): ?>
|
||||
<?php
|
||||
$cur = $row ? [
|
||||
'id' => (int)$row['id'], 'part' => (string)$row['part'], 'title' => (string)$row['title'],
|
||||
'summary' => (string)$row['summary'], 'md' => (string)$row['markdown'],
|
||||
'sort' => (int)$row['sort'], 'pinned' => (int)$row['pinned'], 'enabled' => (int)$row['enabled'],
|
||||
'board_cats' => trim((string)($row['board_cats'] ?? '')),
|
||||
'id' => (int) $row['id'],
|
||||
'part' => (string) $row['part'],
|
||||
'title' => (string) $row['title'],
|
||||
'summary' => (string) $row['summary'],
|
||||
'md' => (string) $row['markdown'],
|
||||
'sort' => (int) $row['sort'],
|
||||
'pinned' => (int) $row['pinned'],
|
||||
'enabled' => (int) $row['enabled'],
|
||||
'board_cats' => trim((string) ($row['board_cats'] ?? '')),
|
||||
] : ['id' => 0, 'part' => '未分类', 'title' => '', 'summary' => '', 'md' => '', 'sort' => 0, 'pinned' => 0, 'enabled' => 1, 'board_cats' => ''];
|
||||
// 分区下拉选项:未分类 + 全部已有分区;编辑中的自定义分区自动补充,保证能回显选中
|
||||
$curPart = trim((string)$cur['part']);
|
||||
if ($curPart === '') { $curPart = '未分类'; }
|
||||
$curPart = trim((string) $cur['part']);
|
||||
if ($curPart === '') {
|
||||
$curPart = '未分类';
|
||||
}
|
||||
$partOptions = ['未分类'];
|
||||
foreach ($list as $a2) {
|
||||
$p2 = trim((string)$a2['part']);
|
||||
if ($p2 === '' || $p2 === '未分类') continue;
|
||||
if (!in_array($p2, $partOptions, true)) { $partOptions[] = $p2; }
|
||||
$p2 = trim((string) $a2['part']);
|
||||
if ($p2 === '' || $p2 === '未分类')
|
||||
continue;
|
||||
if (!in_array($p2, $partOptions, true)) {
|
||||
$partOptions[] = $p2;
|
||||
}
|
||||
}
|
||||
if (!in_array($curPart, $partOptions, true)) {
|
||||
array_unshift($partOptions, $curPart);
|
||||
}
|
||||
if (!in_array($curPart, $partOptions, true)) { array_unshift($partOptions, $curPart); }
|
||||
?>
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title"><?= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?></div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="act" value="save">
|
||||
<input type="hidden" name="id" value="<?= (int)$cur['id'] ?>">
|
||||
<input type="hidden" name="id" value="<?= (int) $cur['id'] ?>">
|
||||
<div class="field-row" style="flex-wrap:wrap">
|
||||
<div style="flex:1 1 200px;min-width:160px">
|
||||
<label class="fl">分区 / 栏目</label>
|
||||
<select name="part" id="artPartSel">
|
||||
<?php foreach ($partOptions as $po): ?>
|
||||
<option value="<?= he($po) ?>"<?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
|
||||
<option value="<?= he($po) ?>" <?= $curPart === $po ? ' selected' : '' ?>><?= he($po) ?></option>
|
||||
<?php endforeach; ?>
|
||||
<option value="__new__">+ 新建分区…</option>
|
||||
</select>
|
||||
@ -284,26 +309,28 @@ admin_topbar('articles');
|
||||
</div>
|
||||
<div style="flex:0 1 120px;min-width:90px">
|
||||
<label class="fl">排序(组内)</label>
|
||||
<input type="number" name="sort" value="<?= (int)$cur['sort'] ?>" min="0" max="999">
|
||||
<input type="number" name="sort" value="<?= (int) $cur['sort'] ?>" min="0" max="999">
|
||||
</div>
|
||||
</div>
|
||||
<label class="fl">摘要 / 一句话简介(文章区列表展示)</label>
|
||||
<input type="text" name="summary" value="<?= he($cur['summary']) ?>" placeholder="可选">
|
||||
<div class="field-row" style="gap:18px;margin:6px 0">
|
||||
<label class="seg-check"><input type="checkbox" name="pinned" value="1"<?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
|
||||
<label class="seg-check"><input type="checkbox" name="enabled" value="1"<?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
|
||||
<label class="seg-check"><input type="checkbox" name="pinned" value="1" <?= $cur['pinned'] ? ' checked' : '' ?>> 置顶(首页文章区默认最前)</label>
|
||||
<label class="seg-check"><input type="checkbox" name="enabled" value="1" <?= $cur['enabled'] ? ' checked' : '' ?>> 前台可见</label>
|
||||
</div>
|
||||
<?php $artBc = array_map('trim', explode(',', (string)$cur['board_cats'])); ?>
|
||||
<?php $artBc = array_map('trim', explode(',', (string) $cur['board_cats'])); ?>
|
||||
<label class="fl">分发到库首页「文章区」(不勾选=不在科普 / 红队 / 蓝队库首页展示)</label>
|
||||
<div class="field-row" style="gap:18px;margin:6px 0">
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular"<?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red"<?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue"<?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="popular" <?= in_array('popular', $artBc, true) ? ' checked' : '' ?>>科普库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="red" <?= in_array('red', $artBc, true) ? ' checked' : '' ?>>红队库首页</label>
|
||||
<label class="seg-check"><input type="checkbox" name="board_cats[]" value="blue" <?= in_array('blue', $artBc, true) ? ' checked' : '' ?>>蓝队库首页</label>
|
||||
</div>
|
||||
<label class="fl">正文(Markdown)</label>
|
||||
<textarea name="markdown" rows="16" style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
|
||||
<textarea name="markdown" rows="16"
|
||||
style="font-family:Consolas,Menlo,monospace;font-size:13px"><?= he($cur['md']) ?></textarea>
|
||||
<div class="field-row" style="margin-top:8px;align-items:center">
|
||||
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" style="flex:0 1 260px">
|
||||
<input type="file" id="artImg" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
|
||||
style="flex:0 1 260px">
|
||||
<button type="button" class="btn btn-sm" id="artImgUpload">上传图片并插入引用</button>
|
||||
<span class="tip">上传后自动在正文末尾插入 <code></code> 形式引用;引用路径相对站点根目录,渲染时自动补全前缀。</span>
|
||||
</div>
|
||||
@ -318,7 +345,7 @@ admin_topbar('articles');
|
||||
|
||||
<!-- 文章区首页注要 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">文章区首页注要(顶部展示给读者的说明文字)</div>
|
||||
<div class="fs-title">顶部说明管理</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="note">
|
||||
<textarea name="note" rows="3" placeholder="例如:这里收录本人在安全与开发方向的原创文章,欢迎阅读。"><?= he($noteText) ?></textarea>
|
||||
@ -329,7 +356,7 @@ admin_topbar('articles');
|
||||
<!-- 文章列表:分类即分组(默认收起,组可拖动排序) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="field-row" style="align-items:center;justify-content:space-between">
|
||||
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表(共 <?= count($list) ?> 篇,按分类分组)</span>
|
||||
<span class="fs-title" style="margin:0;border:none;padding:0">文章列表</span>
|
||||
<span style="display:inline-flex;gap:8px;align-items:center">
|
||||
<button type="button" class="btn btn-sm" id="artFoldAll" title="折叠 / 展开所有分类">展开全部</button>
|
||||
<a class="btn btn-primary btn-sm" href="articles.php?new=1">+ 新增文章</a>
|
||||
@ -354,43 +381,58 @@ admin_topbar('articles');
|
||||
<div class="xg-b">
|
||||
<div style="overflow-x:auto">
|
||||
<table class="tbl">
|
||||
<thead><tr><th>标题</th><th>更新时间</th><th>状态</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($g['rows'] as $a): ?>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="articles.php?edit=<?= (int)$a['id'] ?>"><?= he((string)$a['title']) ?></a>
|
||||
<?php if (trim((string)$a['summary']) !== ''): ?><div class="tip"><?= he((string)$a['summary']) ?></div><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap"><?= he((string)$a['updated_at']) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php if ((int)$a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
|
||||
<?php if ((int)$a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span class="badge badge-no">隐藏</span><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="删除">✕</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="pinned"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="<?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int)$a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden" name="field" value="enabled"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"><?= (int)$a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="up"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内上移">↑</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir" value="down"><input type="hidden" name="id" value="<?= (int)$a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内下移">↓</button>
|
||||
</form>
|
||||
</td>
|
||||
<th>标题</th>
|
||||
<th>更新时间</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($g['rows'] as $a): ?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="articles.php?edit=<?= (int) $a['id'] ?>"><?= he((string) $a['title']) ?></a>
|
||||
<?php if (trim((string) $a['summary']) !== ''): ?>
|
||||
<div class="tip"><?= he((string) $a['summary']) ?></div><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap"><?= he((string) $a['updated_at']) ?></td>
|
||||
<td style="white-space:nowrap">
|
||||
<?php if ((int) $a['pinned'] === 1): ?><span class="badge badge-ok">置顶</span><?php endif; ?>
|
||||
<?php if ((int) $a['enabled'] === 1): ?><span class="badge badge-ok">可见</span><?php else: ?><span
|
||||
class="badge badge-no">隐藏</span><?php endif; ?>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" style="display:inline" data-confirm="确认删除该文章?删除后不可恢复。">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="del"><input type="hidden" name="id"
|
||||
value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="删除">✕</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
|
||||
name="field" value="pinned"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"
|
||||
title="<?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?>"><?= (int) $a['pinned'] === 1 ? '取消置顶' : '置顶' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="toggle"><input type="hidden"
|
||||
name="field" value="enabled"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm"><?= (int) $a['enabled'] === 1 ? '隐藏' : '显示' ?></button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
|
||||
value="up"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内上移">↑</button>
|
||||
</form>
|
||||
<form method="post" style="display:inline">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="move"><input type="hidden" name="dir"
|
||||
value="down"><input type="hidden" name="id" value="<?= (int) $a['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm" title="本分类内下移">↓</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -407,43 +449,43 @@ admin_topbar('articles');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
var btn = document.getElementById('artImgUpload');
|
||||
var file = document.getElementById('artImg');
|
||||
var msg = document.getElementById('artUpMsg');
|
||||
if (!btn || !file) return;
|
||||
btn.addEventListener('click', function () {
|
||||
if (!file.files || !file.files[0]) { alert('请先选择一张图片。'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('act', 'upload');
|
||||
var csrf = document.querySelector('input[name="csrf"]');
|
||||
if (csrf) fd.append(csrf.name, csrf.value);
|
||||
fd.append('file', file.files[0]);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', window.location.href);
|
||||
xhr.onload = function () {
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText || '{}');
|
||||
if (!r.ok) { msg.innerHTML = '<div class="err-msg">上传失败:' + (r.msg || '未知错误') + '</div>'; return; }
|
||||
var ta = document.querySelector('textarea[name="markdown"]');
|
||||
var name = r.file.replace('data/articles/img/', '');
|
||||
var ins = '';
|
||||
if (ta) {
|
||||
if (ta.value && ta.value.charAt(ta.value.length - 1) !== '\n') ta.value += '\n';
|
||||
ta.value += '\n' + ins;
|
||||
}
|
||||
msg.innerHTML = '<div class="ok-msg">图片已上传:data/articles/img/' + name + ',并已插入引用。</div>';
|
||||
file.value = '';
|
||||
} catch (e) {
|
||||
msg.innerHTML = '<div class="err-msg">上传返回异常:' + e + '</div>';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
|
||||
xhr.send(fd);
|
||||
if (!file.files || !file.files[0]) { alert('请先选择一张图片。'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('act', 'upload');
|
||||
var csrf = document.querySelector('input[name="csrf"]');
|
||||
if (csrf) fd.append(csrf.name, csrf.value);
|
||||
fd.append('file', file.files[0]);
|
||||
var xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', window.location.href);
|
||||
xhr.onload = function () {
|
||||
try {
|
||||
var r = JSON.parse(xhr.responseText || '{}');
|
||||
if (!r.ok) { msg.innerHTML = '<div class="err-msg">上传失败:' + (r.msg || '未知错误') + '</div>'; return; }
|
||||
var ta = document.querySelector('textarea[name="markdown"]');
|
||||
var name = r.file.replace('data/articles/img/', '');
|
||||
var ins = '';
|
||||
if (ta) {
|
||||
if (ta.value && ta.value.charAt(ta.value.length - 1) !== '\n') ta.value += '\n';
|
||||
ta.value += '\n' + ins;
|
||||
}
|
||||
msg.innerHTML = '<div class="ok-msg">图片已上传:data/articles/img/' + name + ',并已插入引用。</div>';
|
||||
file.value = '';
|
||||
} catch (e) {
|
||||
msg.innerHTML = '<div class="err-msg">上传返回异常:' + e + '</div>';
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () { msg.innerHTML = '<div class="err-msg">网络错误,上传失败。</div>'; };
|
||||
xhr.send(fd);
|
||||
});
|
||||
})();
|
||||
(function () {
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
// 分区下拉:选择「+ 新建分区…」时切换为新分区名输入框,回车/失焦确认后回落
|
||||
var sel = document.getElementById('artPartSel');
|
||||
@ -451,158 +493,159 @@ admin_topbar('articles');
|
||||
var form = (sel && inp) ? sel.closest('form') : null;
|
||||
if (!sel || !inp || !form) return;
|
||||
function showSel() {
|
||||
sel.style.display = '';
|
||||
inp.style.display = 'none';
|
||||
sel.style.display = '';
|
||||
inp.style.display = 'none';
|
||||
}
|
||||
function acceptNew() {
|
||||
var v = inp.value.replace(/^\s+|\s+$/g, '');
|
||||
if (v === '') return false;
|
||||
var i, found = false;
|
||||
for (i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === v) { sel.selectedIndex = i; found = true; break; }
|
||||
}
|
||||
if (!found) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.text = v;
|
||||
sel.appendChild(opt);
|
||||
sel.value = v;
|
||||
}
|
||||
inp.value = '';
|
||||
showSel();
|
||||
return true;
|
||||
var v = inp.value.replace(/^\s+|\s+$/g, '');
|
||||
if (v === '') return false;
|
||||
var i, found = false;
|
||||
for (i = 0; i < sel.options.length; i++) {
|
||||
if (sel.options[i].value === v) { sel.selectedIndex = i; found = true; break; }
|
||||
}
|
||||
if (!found) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.text = v;
|
||||
sel.appendChild(opt);
|
||||
sel.value = v;
|
||||
}
|
||||
inp.value = '';
|
||||
showSel();
|
||||
return true;
|
||||
}
|
||||
sel.addEventListener('change', function () {
|
||||
if (sel.value === '__new__') {
|
||||
sel.style.display = 'none';
|
||||
inp.style.display = '';
|
||||
inp.focus();
|
||||
}
|
||||
if (sel.value === '__new__') {
|
||||
sel.style.display = 'none';
|
||||
inp.style.display = '';
|
||||
inp.focus();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('keydown', function (ev) {
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (!acceptNew()) showSel();
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (!acceptNew()) showSel();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('blur', function () {
|
||||
if (!acceptNew()) showSel();
|
||||
if (!acceptNew()) showSel();
|
||||
});
|
||||
form.addEventListener('submit', function (ev) {
|
||||
if (sel.value === '__new__' && !acceptNew()) {
|
||||
ev.preventDefault();
|
||||
showSel();
|
||||
sel.focus();
|
||||
}
|
||||
if (sel.value === '__new__' && !acceptNew()) {
|
||||
ev.preventDefault();
|
||||
showSel();
|
||||
sel.focus();
|
||||
}
|
||||
});
|
||||
})();
|
||||
(function () {
|
||||
})();
|
||||
(function () {
|
||||
'use strict';
|
||||
// 文章列表:分类即分组 —— 组头点击折叠/展开、整组拖动排序并即时持久化 part_order
|
||||
var gWrap = document.getElementById('artGroups');
|
||||
if (!gWrap) return;
|
||||
|
||||
function groups() {
|
||||
return Array.prototype.filter.call(gWrap.children, function (c) { return c.classList.contains('xg'); });
|
||||
return Array.prototype.filter.call(gWrap.children, function (c) { return c.classList.contains('xg'); });
|
||||
}
|
||||
function tip(text, isErr) {
|
||||
var old = document.getElementById('artOrderTip');
|
||||
if (old) old.remove();
|
||||
var d = document.createElement('div');
|
||||
d.id = 'artOrderTip';
|
||||
d.className = isErr ? 'err-msg' : 'ok-msg';
|
||||
d.style.margin = '8px 0';
|
||||
d.textContent = text;
|
||||
gWrap.parentNode.insertBefore(d, gWrap);
|
||||
setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 3200);
|
||||
var old = document.getElementById('artOrderTip');
|
||||
if (old) old.remove();
|
||||
var d = document.createElement('div');
|
||||
d.id = 'artOrderTip';
|
||||
d.className = isErr ? 'err-msg' : 'ok-msg';
|
||||
d.style.margin = '8px 0';
|
||||
d.textContent = text;
|
||||
gWrap.parentNode.insertBefore(d, gWrap);
|
||||
setTimeout(function () { if (d.parentNode) d.parentNode.removeChild(d); }, 3200);
|
||||
}
|
||||
function syncFoldBtn() {
|
||||
var b = document.getElementById('artFoldAll');
|
||||
if (!b) return;
|
||||
var gs = groups();
|
||||
var allFolded = gs.length > 0 && gs.every(function (g) { return g.classList.contains('is-fold'); });
|
||||
var allOpen = gs.length > 0 && gs.every(function (g) { return !g.classList.contains('is-fold'); });
|
||||
b.textContent = allOpen ? '收起全部' : '展开全部';
|
||||
b.title = allFolded ? '展开全部分类' : (allOpen ? '收起全部分类' : '折叠 / 展开所有分类');
|
||||
var b = document.getElementById('artFoldAll');
|
||||
if (!b) return;
|
||||
var gs = groups();
|
||||
var allFolded = gs.length > 0 && gs.every(function (g) { return g.classList.contains('is-fold'); });
|
||||
var allOpen = gs.length > 0 && gs.every(function (g) { return !g.classList.contains('is-fold'); });
|
||||
b.textContent = allOpen ? '收起全部' : '展开全部';
|
||||
b.title = allFolded ? '展开全部分类' : (allOpen ? '收起全部分类' : '折叠 / 展开所有分类');
|
||||
}
|
||||
gWrap.addEventListener('click', function (ev) {
|
||||
if (ev.target.classList.contains('drag-h')) return;
|
||||
var xgh = ev.target.closest ? ev.target.closest('.xg-h') : null;
|
||||
if (!xgh || !gWrap.contains(xgh)) return;
|
||||
xgh.parentNode.classList.toggle('is-fold');
|
||||
syncFoldBtn();
|
||||
if (ev.target.classList.contains('drag-h')) return;
|
||||
var xgh = ev.target.closest ? ev.target.closest('.xg-h') : null;
|
||||
if (!xgh || !gWrap.contains(xgh)) return;
|
||||
xgh.parentNode.classList.toggle('is-fold');
|
||||
syncFoldBtn();
|
||||
});
|
||||
var foldAllBtn = document.getElementById('artFoldAll');
|
||||
if (foldAllBtn) {
|
||||
foldAllBtn.addEventListener('click', function () {
|
||||
var gs = groups();
|
||||
var anyOpen = gs.some(function (g) { return !g.classList.contains('is-fold'); });
|
||||
for (var i = 0; i < gs.length; i++) { gs[i].classList.toggle('is-fold', anyOpen); }
|
||||
syncFoldBtn();
|
||||
});
|
||||
foldAllBtn.addEventListener('click', function () {
|
||||
var gs = groups();
|
||||
var anyOpen = gs.some(function (g) { return !g.classList.contains('is-fold'); });
|
||||
for (var i = 0; i < gs.length; i++) { gs[i].classList.toggle('is-fold', anyOpen); }
|
||||
syncFoldBtn();
|
||||
});
|
||||
}
|
||||
|
||||
// 拖动整个分区组排序(鼠标按住手柄拖动,其他组自适应)
|
||||
var dragXg = null;
|
||||
function clearDrop() {
|
||||
groups().forEach(function (g) { g.classList.remove('drop-target', 'drag-src'); });
|
||||
groups().forEach(function (g) { g.classList.remove('drop-target', 'drag-src'); });
|
||||
}
|
||||
gWrap.addEventListener('dragstart', function (ev) {
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !gWrap.contains(h)) return;
|
||||
var xg = h.closest('.xg');
|
||||
if (!xg) return;
|
||||
dragXg = xg;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) {}
|
||||
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !gWrap.contains(h)) return;
|
||||
var xg = h.closest('.xg');
|
||||
if (!xg) return;
|
||||
dragXg = xg;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', xg.getAttribute('data-part') || ''); } catch (e) { }
|
||||
setTimeout(function () { xg.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
gWrap.addEventListener('dragover', function (ev) {
|
||||
if (!dragXg) return;
|
||||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||||
if (!xg || xg === dragXg) {
|
||||
clearDrop();
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
if (!dragXg) return;
|
||||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||||
if (!xg || xg === dragXg) {
|
||||
clearDrop();
|
||||
xg.classList.add('drop-target');
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
clearDrop();
|
||||
xg.classList.add('drop-target');
|
||||
});
|
||||
gWrap.addEventListener('drop', function (ev) {
|
||||
if (!dragXg) return;
|
||||
ev.preventDefault();
|
||||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||||
if (xg && xg !== dragXg) {
|
||||
var rect = xg.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
gWrap.insertBefore(dragXg, after ? xg.nextSibling : xg);
|
||||
savePartOrder();
|
||||
}
|
||||
if (!dragXg) return;
|
||||
ev.preventDefault();
|
||||
var xg = ev.target.closest ? ev.target.closest('.xg') : null;
|
||||
if (xg && xg !== dragXg) {
|
||||
var rect = xg.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
gWrap.insertBefore(dragXg, after ? xg.nextSibling : xg);
|
||||
savePartOrder();
|
||||
}
|
||||
});
|
||||
gWrap.addEventListener('dragend', function () {
|
||||
clearDrop();
|
||||
dragXg = null;
|
||||
clearDrop();
|
||||
dragXg = null;
|
||||
});
|
||||
|
||||
function savePartOrder() {
|
||||
var order = groups().map(function (g) { return g.getAttribute('data-part') || ''; })
|
||||
.filter(function (v) { return v !== ''; }).join(',');
|
||||
var csrfEl = document.querySelector('input[name="csrf"]');
|
||||
if (!csrfEl) { alert('找不到安全令牌,请刷新页面重试。'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('act', 'part_order');
|
||||
fd.append('csrf', csrfEl.value);
|
||||
fd.append('order', order);
|
||||
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
|
||||
.then(function (j) {
|
||||
if (j && j.ok === true) tip('分类顺序已保存,前台文章区已同步更新。');
|
||||
else alert('分类顺序保存失败:' + ((j && j.msg) || '未知错误'));
|
||||
})
|
||||
.catch(function () { alert('网络错误,分类顺序未保存。'); });
|
||||
var order = groups().map(function (g) { return g.getAttribute('data-part') || ''; })
|
||||
.filter(function (v) { return v !== ''; }).join(',');
|
||||
var csrfEl = document.querySelector('input[name="csrf"]');
|
||||
if (!csrfEl) { alert('找不到安全令牌,请刷新页面重试。'); return; }
|
||||
var fd = new FormData();
|
||||
fd.append('act', 'part_order');
|
||||
fd.append('csrf', csrfEl.value);
|
||||
fd.append('order', order);
|
||||
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
|
||||
.then(function (j) {
|
||||
if (j && j.ok === true) tip('分类顺序已保存,前台文章区已同步更新。');
|
||||
else alert('分类顺序保存失败:' + ((j && j.msg) || '未知错误'));
|
||||
})
|
||||
.catch(function () { alert('网络错误,分类顺序未保存。'); });
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
1058
admin/exttools.php
1058
admin/exttools.php
File diff suppressed because it is too large
Load Diff
@ -11,26 +11,26 @@ $loggedOut = isset($_GET['out']);
|
||||
$curIp = client_ip();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$username = trim((string)($_POST['username'] ?? ''));
|
||||
$password = (string)($_POST['password'] ?? '');
|
||||
if ($username === '' || $password === '') {
|
||||
$err = '请输入用户名和密码。';
|
||||
} elseif (hp_login($username, $password)) {
|
||||
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
|
||||
risk_clear_for_ip($curIp);
|
||||
header('Location: nav.php');
|
||||
exit;
|
||||
$username = trim((string) ($_POST['username'] ?? ''));
|
||||
$password = (string) ($_POST['password'] ?? '');
|
||||
if ($username === '' || $password === '') {
|
||||
$err = '请输入用户名和密码。';
|
||||
} elseif (hp_login($username, $password, (string) ($_POST['dev'] ?? ''))) {
|
||||
// 正常登录成功:清零本 IP 风控计数(避免携带历史失败次数,防止管理员自误封)
|
||||
risk_clear_for_ip($curIp);
|
||||
header('Location: nav.php');
|
||||
exit;
|
||||
} else {
|
||||
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
|
||||
usleep(500000);
|
||||
$risk = risk_login_fail($curIp);
|
||||
if ($risk[1]) {
|
||||
$err = '用户名或密码错误。';
|
||||
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「风控管理 → IP 黑名单」移除该规则解除,或联系站长处理。';
|
||||
} else {
|
||||
// 轻量防爆破延时(命中黑名单的 IP 在访问本页前即被 access_boot 拦截)
|
||||
usleep(500000);
|
||||
$risk = risk_login_fail($curIp);
|
||||
if ($risk[1]) {
|
||||
$err = '用户名或密码错误。';
|
||||
// $err = '登录失败次数过多(本次累计 ' . $risk[0] . ' 次,达到阈值 ' . $risk[2] . ' 次),当前 IP 已被风控自动封禁,全站(含后台登录)访问均被拒绝。请稍后从未被封禁的网络进入后台「访问信息 → IP 黑名单」移除该规则解除,或联系站长处理。';
|
||||
} else {
|
||||
$err = '用户名或密码错误。';
|
||||
}
|
||||
$err = '用户名或密码错误。';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$site = setting_get('site_name', '知识导航站');
|
||||
@ -52,6 +52,7 @@ layout_head('后台登录');
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
<label class="fl" for="password">密码</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
<input type="hidden" name="dev" id="devField" value="">
|
||||
<div style="margin-top:16px">
|
||||
<button type="submit" class="btn btn-primary" style="width:100%">登录</button>
|
||||
</div>
|
||||
@ -61,4 +62,5 @@ layout_head('后台登录');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
771
admin/nav.php
771
admin/nav.php
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/nav.php —— 导航内容管理(块/链接增删改查 + 顺序)
|
||||
* admin/nav.php —— 导航管理(块/链接增删改查 + 顺序)
|
||||
* 完整重同步保存:提交的 JSON 中不存在的老块/老链接将被删除。
|
||||
*/
|
||||
$P = '../';
|
||||
@ -8,7 +8,7 @@ require_once __DIR__ . '/_guard.php';
|
||||
|
||||
$pdo = db();
|
||||
$catKeys = ['popular', 'red', 'blue'];
|
||||
$cat = isset($_GET['c']) ? (string)$_GET['c'] : 'popular';
|
||||
$cat = isset($_GET['c']) ? (string) $_GET['c'] : 'popular';
|
||||
if (!in_array($cat, $catKeys, true)) {
|
||||
$cat = 'popular';
|
||||
}
|
||||
@ -28,7 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
@ -37,7 +37,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$pdo->beginTransaction();
|
||||
// 现有块
|
||||
$st = $pdo->prepare('SELECT id FROM nav_blocks WHERE cat_id = ?');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$st->execute([(int) $catRow['id']]);
|
||||
$oldBlocks = array_map('intval', array_column($st->fetchAll(), 'id'));
|
||||
// 现有链接(按块分组)
|
||||
$oldItemsByBlock = [];
|
||||
@ -51,11 +51,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$keepItems = [];
|
||||
foreach ($data as $b) {
|
||||
if (is_array($b) && isset($b['title'])) {
|
||||
$id = (int)($b['id'] ?? 0);
|
||||
if ($id > 0) $keepBlocks[] = $id;
|
||||
$id = (int) ($b['id'] ?? 0);
|
||||
if ($id > 0)
|
||||
$keepBlocks[] = $id;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (is_array($it) && (int)($it['id'] ?? 0) > 0) {
|
||||
$keepItems[] = (int)$it['id'];
|
||||
if (is_array($it) && (int) ($it['id'] ?? 0) > 0) {
|
||||
$keepItems[] = (int) $it['id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -81,25 +82,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
$stUpdItem = $pdo->prepare('UPDATE nav_items SET label = ?, url = ?, note = ?, sort = ? WHERE id = ? AND block_id = ?');
|
||||
$blockSort = 0;
|
||||
foreach ($data as $b) {
|
||||
if (!is_array($b) || !isset($b['title'])) continue;
|
||||
$title = trim((string)$b['title']);
|
||||
if ($title === '') continue;
|
||||
$bid = (int)($b['id'] ?? 0);
|
||||
if (!is_array($b) || !isset($b['title']))
|
||||
continue;
|
||||
$title = trim((string) $b['title']);
|
||||
if ($title === '')
|
||||
continue;
|
||||
$bid = (int) ($b['id'] ?? 0);
|
||||
$blockBelongs = in_array($bid, $oldBlocks, true);
|
||||
if ($bid > 0 && $blockBelongs) {
|
||||
$stUpdBlock->execute([$title, $blockSort, $bid]);
|
||||
} else {
|
||||
$stInsBlock->execute([(int)$catRow['id'], $title, $blockSort]);
|
||||
$bid = (int)$pdo->lastInsertId();
|
||||
$stInsBlock->execute([(int) $catRow['id'], $title, $blockSort]);
|
||||
$bid = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$itemSort = 0;
|
||||
foreach (($b['items'] ?? []) as $it) {
|
||||
if (!is_array($it)) continue;
|
||||
$label = trim((string)($it['label'] ?? ''));
|
||||
$url = trim((string)($it['url'] ?? ''));
|
||||
$note = trim((string)($it['note'] ?? ''));
|
||||
if ($label === '' && $url === '') continue;
|
||||
$iid = (int)($it['id'] ?? 0);
|
||||
if (!is_array($it))
|
||||
continue;
|
||||
$label = trim((string) ($it['label'] ?? ''));
|
||||
$url = trim((string) ($it['url'] ?? ''));
|
||||
$note = trim((string) ($it['note'] ?? ''));
|
||||
if ($label === '' && $url === '')
|
||||
continue;
|
||||
$iid = (int) ($it['id'] ?? 0);
|
||||
if ($iid > 0 && in_array($iid, $oldItemsByBlock[$bid] ?? [], true)) {
|
||||
$stUpdItem->execute([$label, $url, $note, $itemSort, $iid, $bid]);
|
||||
} else {
|
||||
@ -125,7 +130,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
|
||||
// ---------- 读取现有数据用于展示 ----------
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$catRow['id']]);
|
||||
$st->execute([(int) $catRow['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemsMap = [];
|
||||
if ($blocks) {
|
||||
@ -134,7 +139,7 @@ if ($blocks) {
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $it) {
|
||||
$itemsMap[(int)$it['block_id']][] = $it;
|
||||
$itemsMap[(int) $it['block_id']][] = $it;
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,403 +151,411 @@ foreach ($catKeys as $ck) {
|
||||
|
||||
$csrfVal = csrf_token();
|
||||
|
||||
layout_head('导航内容管理');
|
||||
layout_head('导航管理');
|
||||
admin_topbar('nav');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">导航内容管理</h2>
|
||||
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。</p>
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">导航管理</h2>
|
||||
<p class="tip">管理 <?= he($catNames[$cat]) ?> 的导航块与链接;块内最多 15 个链接(3 小列 × 5 行),超出部分请拆分到新块。</p>
|
||||
|
||||
<!-- 分类切换 -->
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<?php foreach ($catKeys as $ck): ?>
|
||||
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>" href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<!-- 分类切换 -->
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<?php foreach ($catKeys as $ck): ?>
|
||||
<a class="btn <?= $cat === $ck ? 'btn-primary' : '' ?>"
|
||||
href="nav.php?c=<?= he($ck) ?>"><?= he($catNames[$ck]) ?></a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($saved): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMsg !== ''): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($saved): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,前台页面已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMsg !== ''): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="fieldset-card" style="margin-bottom:16px">
|
||||
<div class="fs-title">⚡ 批量导入导航链接(<?= he($catNames[$cat]) ?>)</div>
|
||||
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
|
||||
每行一条,支持几种写法:
|
||||
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> 或 <code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
|
||||
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code>、<code>块名 3</code> …</p>
|
||||
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
|
||||
<div class="fieldset-card" style="margin-bottom:16px">
|
||||
<div class="fs-title">批量导入</div>
|
||||
<p class="tip">在下面一次粘贴多行内容,点击「解析导入」后会按格式自动生成导航块,并<b>追加到下方编辑器末尾</b>,可再编辑确认后统一点「保存全部修改」。<br>
|
||||
每行一条,支持几种写法:
|
||||
<code>名称 | 网址 [ | 备注]</code>(也兼容 <code>网址 | 名称</code>)或 <code>[名称](网址)</code> 或
|
||||
<code>名称 https://网址</code>;以 <code>## 块名</code> 开头的行开启一个新导航块;未写块名则归入“批量导入”。
|
||||
单个块最多 15 个链接,超出会自动拆分为 <code>块名 2</code>、<code>块名 3</code> …
|
||||
</p>
|
||||
<textarea id="importText" rows="7" spellcheck="false" placeholder="例如:
|
||||
## 安全研究
|
||||
看雪 | https://www.kanxue.com | 逆向/安全社区
|
||||
先知社区 https://xz.aliyun.com
|
||||
[FreeBuf](https://www.freebuf.com)
|
||||
## 资讯
|
||||
https://www.ithome.com | IT之家"></textarea>
|
||||
<div class="field-row" style="margin-top:8px">
|
||||
<button type="button" class="btn btn-primary" id="btnParseImport">解析并导入</button>
|
||||
<button type="button" class="btn" id="btnClearImport">清空输入框</button>
|
||||
<span class="tip" style="align-self:center">导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。</span>
|
||||
</div>
|
||||
<div id="importMsg"></div>
|
||||
<div class="field-row" style="margin-top:8px">
|
||||
<button type="button" class="btn btn-primary" id="btnParseImport">解析并导入</button>
|
||||
<button type="button" class="btn" id="btnClearImport">清空输入框</button>
|
||||
<span class="tip" style="align-self:center">导入仅是“填入编辑区”,不会立即生效,请务必点击下方的「保存全部修改」。</span>
|
||||
</div>
|
||||
<div id="importMsg"></div>
|
||||
</div>
|
||||
|
||||
<form method="post" id="navForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="cat" value="<?= he($cat) ?>">
|
||||
<textarea name="data" id="jsonData" hidden></textarea>
|
||||
|
||||
<div id="blocksWrap">
|
||||
<?php foreach ($blocks as $b):
|
||||
$items = $itemsMap[(int) $b['id']] ?? []; ?>
|
||||
<div class="m-block is-fold" data-id="<?= (int) $b['id'] ?>">
|
||||
<div class="m-block-head">
|
||||
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>
|
||||
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>
|
||||
<input type="text" class="b-title" value="<?= he((string) $b['title']) ?>"
|
||||
placeholder="导航块标题(必填)">
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm b-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm b-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-items">
|
||||
<?php foreach (array_slice($items, 0, 15) as $it): ?>
|
||||
<div class="m-item">
|
||||
<input type="text" class="i-label" value="<?= he((string) $it['label']) ?>"
|
||||
placeholder="文字(必填)">
|
||||
<input type="text" class="i-url" value="<?= he((string) $it['url']) ?>"
|
||||
placeholder="链接,如 https://…(必填)">
|
||||
<input type="text" class="i-note" value="<?= he((string) $it['note']) ?>"
|
||||
placeholder="备注(可选,前台悬浮提示)">
|
||||
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="field-row" style="margin:8px 0 16px">
|
||||
<button type="button" class="btn btn-sm" id="btnAddBlock">+ 新建导航块</button>
|
||||
<button type="button" class="btn btn-sm" id="btnNavFoldAll" title="折叠 / 展开所有块">展开全部</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
|
||||
<span class="tip" id="saveTip"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form method="post" id="navForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<input type="hidden" name="cat" value="<?= he($cat) ?>">
|
||||
<textarea name="data" id="jsonData" hidden></textarea>
|
||||
|
||||
<div id="blocksWrap">
|
||||
<?php foreach ($blocks as $b):
|
||||
$items = $itemsMap[(int)$b['id']] ?? []; ?>
|
||||
<div class="m-block is-fold" data-id="<?= (int)$b['id'] ?>">
|
||||
<div class="m-block-head">
|
||||
<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>
|
||||
<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>
|
||||
<input type="text" class="b-title" value="<?= he((string)$b['title']) ?>" placeholder="导航块标题(必填)">
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm b-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm b-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="m-items">
|
||||
<?php foreach (array_slice($items, 0, 15) as $it): ?>
|
||||
<div class="m-item">
|
||||
<input type="text" class="i-label" value="<?= he((string)$it['label']) ?>" placeholder="文字(必填)">
|
||||
<input type="text" class="i-url" value="<?= he((string)$it['url']) ?>" placeholder="链接,如 https://…(必填)">
|
||||
<input type="text" class="i-note" value="<?= he((string)$it['note']) ?>" placeholder="备注(可选,前台悬浮提示)">
|
||||
<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="field-row" style="margin:8px 0 16px">
|
||||
<button type="button" class="btn btn-sm" id="btnAddBlock">+ 新建导航块</button>
|
||||
<button type="button" class="btn btn-sm" id="btnNavFoldAll" title="折叠 / 展开所有块">展开全部</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn btn-primary" id="btnSave">保存全部修改</button>
|
||||
<span class="tip" id="saveTip"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var wrap = document.getElementById('blocksWrap');
|
||||
var jsonData = document.getElementById('jsonData');
|
||||
(function () {
|
||||
'use strict';
|
||||
var wrap = document.getElementById('blocksWrap');
|
||||
var jsonData = document.getElementById('jsonData');
|
||||
|
||||
/* ---------- 模板 ---------- */
|
||||
function itemHTML(label, url, note) {
|
||||
return '<div class="m-item">' +
|
||||
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
|
||||
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
|
||||
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
|
||||
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function blockHTML(id, title, items) {
|
||||
var inner = items.map(function (it) {
|
||||
return itemHTML(it.label || '', it.url || '', it.note || '');
|
||||
}).join('');
|
||||
return '<div class="m-block is-fold" data-id="' + (id || 0) + '">' +
|
||||
'<div class="m-block-head">' +
|
||||
'<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>' +
|
||||
'<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>' +
|
||||
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
|
||||
'</span></div>' +
|
||||
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/* ---------- 行内事件代理 ---------- */
|
||||
wrap.addEventListener('click', function (ev) {
|
||||
var target = ev.target;
|
||||
var block = target.closest('.m-block');
|
||||
if (!block) return;
|
||||
if (target.classList.contains('c-arr')) {
|
||||
block.classList.toggle('is-fold');
|
||||
updateNavFold();
|
||||
return;
|
||||
/* ---------- 模板 ---------- */
|
||||
function itemHTML(label, url, note) {
|
||||
return '<div class="m-item">' +
|
||||
'<input type="text" class="i-label" value="' + escAttr(label) + '" placeholder="文字(必填)">' +
|
||||
'<input type="text" class="i-url" value="' + escAttr(url) + '" placeholder="链接,如 https://…(必填)">' +
|
||||
'<input type="text" class="i-note" value="' + escAttr(note) + '" placeholder="备注(可选,前台悬浮提示)">' +
|
||||
'<button type="button" class="btn btn-sm btn-danger i-del" title="删除该链接">✕</button>' +
|
||||
'</div>';
|
||||
}
|
||||
function blockHTML(id, title, items) {
|
||||
var inner = items.map(function (it) {
|
||||
return itemHTML(it.label || '', it.url || '', it.note || '');
|
||||
}).join('');
|
||||
return '<div class="m-block is-fold" data-id="' + (id || 0) + '">' +
|
||||
'<div class="m-block-head">' +
|
||||
'<button type="button" class="c-arr" title="展开 / 收起该块链接" aria-hidden="true">▾</button>' +
|
||||
'<button type="button" class="drag-h" title="按住拖动,调整该块先后顺序" draggable="true">⠿</button>' +
|
||||
'<input type="text" class="b-title" value="' + escAttr(title) + '" placeholder="导航块标题(必填)">' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm b-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm b-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger b-del" title="删除该块及其链接">删除块</button>' +
|
||||
'</span></div>' +
|
||||
'<div class="m-items">' + inner + '<button type="button" class="btn btn-sm b-add-item" style="margin-top:8px">+ 添加链接</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
function escAttr(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
if (target.classList.contains('b-del')) {
|
||||
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该导航块及其全部链接?', { okText: '删除块' }) : Promise.resolve(window.confirm('确认删除该导航块及其全部链接?')))
|
||||
.then(function (ok) { if (ok) block.remove(); });
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
|
||||
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
|
||||
if (sib && sib.classList.contains('m-block')) {
|
||||
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
|
||||
else wrap.insertBefore(sib, block);
|
||||
/* ---------- 行内事件代理 ---------- */
|
||||
wrap.addEventListener('click', function (ev) {
|
||||
var target = ev.target;
|
||||
var block = target.closest('.m-block');
|
||||
if (!block) return;
|
||||
if (target.classList.contains('c-arr')) {
|
||||
block.classList.toggle('is-fold');
|
||||
updateNavFold();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-add-item')) {
|
||||
var list = block.querySelector('.m-items');
|
||||
var count = list.querySelectorAll('.m-item').length;
|
||||
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
|
||||
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('i-del')) {
|
||||
target.closest('.m-item').remove();
|
||||
}
|
||||
});
|
||||
|
||||
function updateNavFold() {
|
||||
var btn = document.getElementById('btnNavFoldAll');
|
||||
if (!btn) return;
|
||||
var bls = wrap.querySelectorAll('.m-block');
|
||||
var anyOpen = false;
|
||||
for (var i = 0; i < bls.length; i++) {
|
||||
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
|
||||
}
|
||||
btn.textContent = anyOpen ? '收起全部' : '展开全部';
|
||||
}
|
||||
var foldAllBtn = document.getElementById('btnNavFoldAll');
|
||||
if (foldAllBtn) {
|
||||
foldAllBtn.addEventListener('click', function () {
|
||||
if (target.classList.contains('b-del')) {
|
||||
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该导航块及其全部链接?', { okText: '删除块' }) : Promise.resolve(window.confirm('确认删除该导航块及其全部链接?')))
|
||||
.then(function (ok) { if (ok) block.remove(); });
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-up') || target.classList.contains('b-down')) {
|
||||
var sib = target.classList.contains('b-up') ? block.previousElementSibling : block.nextElementSibling;
|
||||
if (sib && sib.classList.contains('m-block')) {
|
||||
if (target.classList.contains('b-up')) wrap.insertBefore(block, sib);
|
||||
else wrap.insertBefore(sib, block);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('b-add-item')) {
|
||||
var list = block.querySelector('.m-items');
|
||||
var count = list.querySelectorAll('.m-item').length;
|
||||
if (count >= 15) { alert('单个导航块最多 15 个链接,请新建导航块。'); return; }
|
||||
list.insertBefore(createElFromHTML(itemHTML('', '', '')), target);
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains('i-del')) {
|
||||
target.closest('.m-item').remove();
|
||||
}
|
||||
});
|
||||
|
||||
function updateNavFold() {
|
||||
var btn = document.getElementById('btnNavFoldAll');
|
||||
if (!btn) return;
|
||||
var bls = wrap.querySelectorAll('.m-block');
|
||||
var anyOpen = false;
|
||||
for (var i = 0; i < bls.length; i++) {
|
||||
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
|
||||
}
|
||||
for (var j = 0; j < bls.length; j++) { bls[j].classList.toggle('is-fold', anyOpen); }
|
||||
updateNavFold();
|
||||
});
|
||||
}
|
||||
|
||||
// 整块拖动排序(拖动结果随「保存全部修改」一并持久化)
|
||||
var dragBlock = null;
|
||||
function clearBlockDrop() {
|
||||
var bls = wrap.querySelectorAll('.m-block');
|
||||
for (var i = 0; i < bls.length; i++) { bls[i].classList.remove('drag-src', 'drop-target'); }
|
||||
}
|
||||
wrap.addEventListener('dragstart', function (ev) {
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !wrap.contains(h)) return;
|
||||
var bl = h.closest('.m-block');
|
||||
if (!bl) return;
|
||||
dragBlock = bl;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) {}
|
||||
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
wrap.addEventListener('dragover', function (ev) {
|
||||
if (!dragBlock) return;
|
||||
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
|
||||
if (!bl || bl === dragBlock) { clearBlockDrop(); return; }
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
clearBlockDrop();
|
||||
bl.classList.add('drop-target');
|
||||
});
|
||||
wrap.addEventListener('drop', function (ev) {
|
||||
if (!dragBlock) return;
|
||||
ev.preventDefault();
|
||||
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
|
||||
if (bl && bl !== dragBlock) {
|
||||
var rect = bl.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
wrap.insertBefore(dragBlock, after ? bl.nextSibling : bl);
|
||||
btn.textContent = anyOpen ? '收起全部' : '展开全部';
|
||||
}
|
||||
});
|
||||
wrap.addEventListener('dragend', function () {
|
||||
clearBlockDrop();
|
||||
dragBlock = null;
|
||||
});
|
||||
|
||||
document.getElementById('btnAddBlock').addEventListener('click', function () {
|
||||
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
|
||||
var last = wrap.lastElementChild;
|
||||
if (last && last.classList.contains('m-block')) { last.classList.remove('is-fold'); }
|
||||
updateNavFold();
|
||||
});
|
||||
|
||||
/* ---------- 批量导入解析 ---------- */
|
||||
var MAX_ITEMS = 15;
|
||||
var importTextEl = document.getElementById('importText');
|
||||
var importMsg = document.getElementById('importMsg');
|
||||
|
||||
// 单行 -> item;无法识别返回 null
|
||||
function parseImportLine(raw) {
|
||||
var s = String(raw).trim();
|
||||
if (s === '') return null;
|
||||
// 1) | 分隔:名称 | 网址 [ | 备注](也兼容 网址 | 名称)
|
||||
if (s.indexOf('|') !== -1) {
|
||||
var p = s.split('|').map(function (x) { return x.trim(); });
|
||||
var isUrl = function (x) { return /^https?:\/\//i.test(x); };
|
||||
var label1 = p[0], url1 = p[1], note1 = p[2] || '';
|
||||
if (isUrl(p[0]) && p[1] !== '' && !isUrl(p[1])) {
|
||||
// 网址在前、名称在后
|
||||
label1 = p[1]; url1 = p[0]; note1 = p[2] || '';
|
||||
}
|
||||
if (url1 !== '') return { label: label1, url: url1, note: note1 };
|
||||
}
|
||||
// 2) Markdown:[名称](网址 ["备注"])
|
||||
var m = s.match(/^\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+["“]([^"”]*)["”])?\s*\)$/);
|
||||
if (m) return { label: m[1].trim(), url: m[2].trim(), note: (m[3] || '').trim() };
|
||||
// 3) 名称 https://… 或 名称 https://… 备注
|
||||
var m2 = s.match(/^(.*?)(https?:\/\/\S+)(?:\s+(.*))?$/i);
|
||||
if (m2 && m2[2]) {
|
||||
return { label: (m2[1] || '').trim(), url: m2[2].replace(/[),,。]+$/, ''), note: (m2[3] || '').trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// host 作为缺省名称
|
||||
function hostOf(url) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
return u.hostname.replace(/^www\./, '');
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// 全文 -> [{title, items}]
|
||||
function importTextToBlocks(text) {
|
||||
var lines = String(text).split(/\r?\n/);
|
||||
var blocks = [];
|
||||
var cur = null; // 当前块
|
||||
var used = {}; // 已使用的标题(同名自动加序号)
|
||||
var parsed = 0, skipped = 0;
|
||||
|
||||
function makeTitle(base) {
|
||||
var t = base;
|
||||
var n = 1;
|
||||
while (used[t]) {
|
||||
n++;
|
||||
t = base + ' ' + n;
|
||||
}
|
||||
used[t] = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
function ensureBlock() {
|
||||
if (cur && cur.items.length < MAX_ITEMS) return;
|
||||
var title = makeTitle(cur ? cur.title : '批量导入');
|
||||
cur = { title: title, items: [] };
|
||||
blocks.push(cur);
|
||||
}
|
||||
|
||||
lines.forEach(function (ln) {
|
||||
var s = String(ln).trim();
|
||||
if (s === '') return;
|
||||
// 块标题行
|
||||
var hm = s.match(/^#{1,6}\s+(.+)$/);
|
||||
if (hm) {
|
||||
var t = hm[1].trim();
|
||||
if (t !== '') {
|
||||
var t2 = makeTitle(t);
|
||||
cur = { title: t2, items: [] };
|
||||
blocks.push(cur);
|
||||
var foldAllBtn = document.getElementById('btnNavFoldAll');
|
||||
if (foldAllBtn) {
|
||||
foldAllBtn.addEventListener('click', function () {
|
||||
var bls = wrap.querySelectorAll('.m-block');
|
||||
var anyOpen = false;
|
||||
for (var i = 0; i < bls.length; i++) {
|
||||
if (!bls[i].classList.contains('is-fold')) { anyOpen = true; break; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
var it = parseImportLine(s);
|
||||
if (!it) { skipped++; return; }
|
||||
if (it.url === '') { skipped++; return; }
|
||||
if (it.label === '') it.label = hostOf(it.url);
|
||||
ensureBlock();
|
||||
cur.items.push(it);
|
||||
parsed++;
|
||||
});
|
||||
return { blocks: blocks, parsed: parsed, skipped: skipped };
|
||||
}
|
||||
|
||||
function bindImport() {
|
||||
var btn = document.getElementById('btnParseImport');
|
||||
var clearBtn = document.getElementById('btnClearImport');
|
||||
if (!btn || !importTextEl || !importMsg) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var r = importTextToBlocks(importTextEl.value);
|
||||
if (!r.parsed) {
|
||||
importMsg.innerHTML = '<div class="err-msg">未解析到任何链接。请检查格式,每行至少包含“名称 网址”或“名称 | 网址”。</div>';
|
||||
return;
|
||||
}
|
||||
var frag = document.createDocumentFragment();
|
||||
r.blocks.forEach(function (b) {
|
||||
var div = createElFromHTML(blockHTML(0, b.title, b.items));
|
||||
if (div.classList) { div.classList.remove('is-fold'); }
|
||||
frag.appendChild(div);
|
||||
for (var j = 0; j < bls.length; j++) { bls[j].classList.toggle('is-fold', anyOpen); }
|
||||
updateNavFold();
|
||||
});
|
||||
wrap.appendChild(frag);
|
||||
}
|
||||
|
||||
// 整块拖动排序(拖动结果随「保存全部修改」一并持久化)
|
||||
var dragBlock = null;
|
||||
function clearBlockDrop() {
|
||||
var bls = wrap.querySelectorAll('.m-block');
|
||||
for (var i = 0; i < bls.length; i++) { bls[i].classList.remove('drag-src', 'drop-target'); }
|
||||
}
|
||||
wrap.addEventListener('dragstart', function (ev) {
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !wrap.contains(h)) return;
|
||||
var bl = h.closest('.m-block');
|
||||
if (!bl) return;
|
||||
dragBlock = bl;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', bl.getAttribute('data-id') || ''); } catch (e) { }
|
||||
setTimeout(function () { bl.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
wrap.addEventListener('dragover', function (ev) {
|
||||
if (!dragBlock) return;
|
||||
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
|
||||
if (!bl || bl === dragBlock) { clearBlockDrop(); return; }
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
clearBlockDrop();
|
||||
bl.classList.add('drop-target');
|
||||
});
|
||||
wrap.addEventListener('drop', function (ev) {
|
||||
if (!dragBlock) return;
|
||||
ev.preventDefault();
|
||||
var bl = ev.target.closest ? ev.target.closest('.m-block') : null;
|
||||
if (bl && bl !== dragBlock) {
|
||||
var rect = bl.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
wrap.insertBefore(dragBlock, after ? bl.nextSibling : bl);
|
||||
}
|
||||
});
|
||||
wrap.addEventListener('dragend', function () {
|
||||
clearBlockDrop();
|
||||
dragBlock = null;
|
||||
});
|
||||
|
||||
document.getElementById('btnAddBlock').addEventListener('click', function () {
|
||||
wrap.insertAdjacentHTML('beforeend', blockHTML(0, '', []));
|
||||
var last = wrap.lastElementChild;
|
||||
if (last && last.classList.contains('m-block')) { last.classList.remove('is-fold'); }
|
||||
updateNavFold();
|
||||
var parts = [];
|
||||
parts.push('<div class="ok-msg">已解析 ' + r.parsed + ' 条链接,生成 ' + r.blocks.length + ' 个导航块并追加到下方。</div>');
|
||||
if (r.skipped) parts.push('<p class="tip" style="margin-top:6px">另有 ' + r.skipped + ' 行未能识别,已跳过。</p>');
|
||||
parts.push('<p class="tip">请在下方向下核对;如需调整可修改标题/链接后,统一点击「保存全部修改」生效。</p>');
|
||||
importMsg.innerHTML = parts.join('');
|
||||
importTextEl.value = '';
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
clearBtn.addEventListener('click', function () {
|
||||
importTextEl.value = '';
|
||||
importMsg.innerHTML = '';
|
||||
importTextEl.focus();
|
||||
});
|
||||
}
|
||||
bindImport();
|
||||
|
||||
function createElFromHTML(html) {
|
||||
var t = document.createElement('template');
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstChild;
|
||||
}
|
||||
/* ---------- 批量导入解析 ---------- */
|
||||
var MAX_ITEMS = 15;
|
||||
var importTextEl = document.getElementById('importText');
|
||||
var importMsg = document.getElementById('importMsg');
|
||||
|
||||
/* ---------- 收集并保存 ---------- */
|
||||
document.getElementById('btnSave').addEventListener('click', function () {
|
||||
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
|
||||
var data = [];
|
||||
var hasInvalid = false;
|
||||
// 单行 -> item;无法识别返回 null
|
||||
function parseImportLine(raw) {
|
||||
var s = String(raw).trim();
|
||||
if (s === '') return null;
|
||||
// 1) | 分隔:名称 | 网址 [ | 备注](也兼容 网址 | 名称)
|
||||
if (s.indexOf('|') !== -1) {
|
||||
var p = s.split('|').map(function (x) { return x.trim(); });
|
||||
var isUrl = function (x) { return /^https?:\/\//i.test(x); };
|
||||
var label1 = p[0], url1 = p[1], note1 = p[2] || '';
|
||||
if (isUrl(p[0]) && p[1] !== '' && !isUrl(p[1])) {
|
||||
// 网址在前、名称在后
|
||||
label1 = p[1]; url1 = p[0]; note1 = p[2] || '';
|
||||
}
|
||||
if (url1 !== '') return { label: label1, url: url1, note: note1 };
|
||||
}
|
||||
// 2) Markdown:[名称](网址 ["备注"])
|
||||
var m = s.match(/^\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+["“]([^"”]*)["”])?\s*\)$/);
|
||||
if (m) return { label: m[1].trim(), url: m[2].trim(), note: (m[3] || '').trim() };
|
||||
// 3) 名称 https://… 或 名称 https://… 备注
|
||||
var m2 = s.match(/^(.*?)(https?:\/\/\S+)(?:\s+(.*))?$/i);
|
||||
if (m2 && m2[2]) {
|
||||
return { label: (m2[1] || '').trim(), url: m2[2].replace(/[),,。]+$/, ''), note: (m2[3] || '').trim() };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
blocks.forEach(function (block) {
|
||||
if (hasInvalid) return;
|
||||
var title = block.querySelector('.b-title').value.trim();
|
||||
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
|
||||
var items = [];
|
||||
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
|
||||
var label = row.querySelector('.i-label').value.trim();
|
||||
var url = row.querySelector('.i-url').value.trim();
|
||||
var note = row.querySelector('.i-note').value.trim();
|
||||
if (label === '' && url === '') return; // 空行忽略
|
||||
if (label === '' || url === '') {
|
||||
alert('链接的“文字”与“链接”不能为空。');
|
||||
hasInvalid = true;
|
||||
// host 作为缺省名称
|
||||
function hostOf(url) {
|
||||
try {
|
||||
var u = new URL(url);
|
||||
return u.hostname.replace(/^www\./, '');
|
||||
} catch (e) {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// 全文 -> [{title, items}]
|
||||
function importTextToBlocks(text) {
|
||||
var lines = String(text).split(/\r?\n/);
|
||||
var blocks = [];
|
||||
var cur = null; // 当前块
|
||||
var used = {}; // 已使用的标题(同名自动加序号)
|
||||
var parsed = 0, skipped = 0;
|
||||
|
||||
function makeTitle(base) {
|
||||
var t = base;
|
||||
var n = 1;
|
||||
while (used[t]) {
|
||||
n++;
|
||||
t = base + ' ' + n;
|
||||
}
|
||||
used[t] = true;
|
||||
return t;
|
||||
}
|
||||
|
||||
function ensureBlock() {
|
||||
if (cur && cur.items.length < MAX_ITEMS) return;
|
||||
var title = makeTitle(cur ? cur.title : '批量导入');
|
||||
cur = { title: title, items: [] };
|
||||
blocks.push(cur);
|
||||
}
|
||||
|
||||
lines.forEach(function (ln) {
|
||||
var s = String(ln).trim();
|
||||
if (s === '') return;
|
||||
// 块标题行
|
||||
var hm = s.match(/^#{1,6}\s+(.+)$/);
|
||||
if (hm) {
|
||||
var t = hm[1].trim();
|
||||
if (t !== '') {
|
||||
var t2 = makeTitle(t);
|
||||
cur = { title: t2, items: [] };
|
||||
blocks.push(cur);
|
||||
}
|
||||
return;
|
||||
}
|
||||
items.push({ id: 0, label: label, url: url, note: note });
|
||||
var it = parseImportLine(s);
|
||||
if (!it) { skipped++; return; }
|
||||
if (it.url === '') { skipped++; return; }
|
||||
if (it.label === '') it.label = hostOf(it.url);
|
||||
ensureBlock();
|
||||
cur.items.push(it);
|
||||
parsed++;
|
||||
});
|
||||
return { blocks: blocks, parsed: parsed, skipped: skipped };
|
||||
}
|
||||
|
||||
function bindImport() {
|
||||
var btn = document.getElementById('btnParseImport');
|
||||
var clearBtn = document.getElementById('btnClearImport');
|
||||
if (!btn || !importTextEl || !importMsg) return;
|
||||
btn.addEventListener('click', function () {
|
||||
var r = importTextToBlocks(importTextEl.value);
|
||||
if (!r.parsed) {
|
||||
importMsg.innerHTML = '<div class="err-msg">未解析到任何链接。请检查格式,每行至少包含“名称 网址”或“名称 | 网址”。</div>';
|
||||
return;
|
||||
}
|
||||
var frag = document.createDocumentFragment();
|
||||
r.blocks.forEach(function (b) {
|
||||
var div = createElFromHTML(blockHTML(0, b.title, b.items));
|
||||
if (div.classList) { div.classList.remove('is-fold'); }
|
||||
frag.appendChild(div);
|
||||
});
|
||||
wrap.appendChild(frag);
|
||||
updateNavFold();
|
||||
var parts = [];
|
||||
parts.push('<div class="ok-msg">已解析 ' + r.parsed + ' 条链接,生成 ' + r.blocks.length + ' 个导航块并追加到下方。</div>');
|
||||
if (r.skipped) parts.push('<p class="tip" style="margin-top:6px">另有 ' + r.skipped + ' 行未能识别,已跳过。</p>');
|
||||
parts.push('<p class="tip">请在下方向下核对;如需调整可修改标题/链接后,统一点击「保存全部修改」生效。</p>');
|
||||
importMsg.innerHTML = parts.join('');
|
||||
importTextEl.value = '';
|
||||
wrap.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
clearBtn.addEventListener('click', function () {
|
||||
importTextEl.value = '';
|
||||
importMsg.innerHTML = '';
|
||||
importTextEl.focus();
|
||||
});
|
||||
}
|
||||
bindImport();
|
||||
|
||||
function createElFromHTML(html) {
|
||||
var t = document.createElement('template');
|
||||
t.innerHTML = html.trim();
|
||||
return t.content.firstChild;
|
||||
}
|
||||
|
||||
/* ---------- 收集并保存 ---------- */
|
||||
document.getElementById('btnSave').addEventListener('click', function () {
|
||||
var blocks = Array.prototype.slice.call(wrap.querySelectorAll('.m-block'));
|
||||
var data = [];
|
||||
var hasInvalid = false;
|
||||
|
||||
blocks.forEach(function (block) {
|
||||
if (hasInvalid) return;
|
||||
var title = block.querySelector('.b-title').value.trim();
|
||||
if (!title) { alert('存在未填写标题的导航块,请填写或删除。'); hasInvalid = true; return; }
|
||||
var items = [];
|
||||
Array.prototype.slice.call(block.querySelectorAll('.m-item')).forEach(function (row) {
|
||||
var label = row.querySelector('.i-label').value.trim();
|
||||
var url = row.querySelector('.i-url').value.trim();
|
||||
var note = row.querySelector('.i-note').value.trim();
|
||||
if (label === '' && url === '') return; // 空行忽略
|
||||
if (label === '' || url === '') {
|
||||
alert('链接的“文字”与“链接”不能为空。');
|
||||
hasInvalid = true;
|
||||
return;
|
||||
}
|
||||
items.push({ id: 0, label: label, url: url, note: note });
|
||||
});
|
||||
if (hasInvalid) return;
|
||||
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
|
||||
});
|
||||
if (hasInvalid) return;
|
||||
data.push({ id: parseInt(block.getAttribute('data-id'), 10) || 0, title: title, items: items });
|
||||
jsonData.value = JSON.stringify(data);
|
||||
document.getElementById('navForm').submit();
|
||||
});
|
||||
if (hasInvalid) return;
|
||||
jsonData.value = JSON.stringify(data);
|
||||
document.getElementById('navForm').submit();
|
||||
});
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
550
admin/tools.php
550
admin/tools.php
@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/tools.php —— 功能区管理
|
||||
* admin/tools.php —— 内部功能
|
||||
* 功能:工具入口的增删改查 / 排序 / 启停 / 站内或外链类型。
|
||||
* 站内工具页需先在 func/ 下开发对应 .php,再到此处登记入口。
|
||||
*/
|
||||
@ -16,7 +16,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
if (!csrf_verify()) {
|
||||
$errorMsg = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$raw = (string)($_POST['data'] ?? '');
|
||||
$raw = (string) ($_POST['data'] ?? '');
|
||||
$rows = json_decode($raw, true);
|
||||
if (!is_array($rows)) {
|
||||
$errorMsg = '提交数据格式错误。';
|
||||
@ -24,18 +24,20 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
// 先整体校验
|
||||
$clean = [];
|
||||
foreach ($rows as $i => $r) {
|
||||
if (!is_array($r)) continue;
|
||||
$name = trim((string)($r['name'] ?? ''));
|
||||
$url = trim((string)($r['url'] ?? ''));
|
||||
$icon = trim((string)($r['icon'] ?? ''));
|
||||
$desc = trim((string)($r['description'] ?? ''));
|
||||
$ext = ((string)($r['ext'] ?? '0') === '1') ? 1 : 0;
|
||||
if ($name === '' && $url === '') continue; // 空行忽略
|
||||
if (!is_array($r))
|
||||
continue;
|
||||
$name = trim((string) ($r['name'] ?? ''));
|
||||
$url = trim((string) ($r['url'] ?? ''));
|
||||
$icon = trim((string) ($r['icon'] ?? ''));
|
||||
$desc = trim((string) ($r['description'] ?? ''));
|
||||
$ext = ((string) ($r['ext'] ?? '0') === '1') ? 1 : 0;
|
||||
if ($name === '' && $url === '')
|
||||
continue; // 空行忽略
|
||||
// 库首页“内部功能区”展示范围(空=所有库通用)
|
||||
$cats = [];
|
||||
if (isset($r['cats']) && is_array($r['cats'])) {
|
||||
foreach ($r['cats'] as $ck) {
|
||||
$s = trim((string)$ck);
|
||||
$s = trim((string) $ck);
|
||||
if (in_array($s, ['popular', 'red', 'blue'], true) && !in_array($s, $cats, true)) {
|
||||
$cats[] = $s;
|
||||
}
|
||||
@ -43,32 +45,32 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
}
|
||||
$boardCats = implode(',', $cats);
|
||||
if ($name === '' || $url === '') {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:名称与地址均不能为空。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:名称与地址均不能为空。';
|
||||
break;
|
||||
}
|
||||
if ($ext === 1) {
|
||||
if (!preg_match('#^https?://#i', $url)) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:外部链接需以 http(s):// 开头。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:外部链接需以 http(s):// 开头。';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (preg_match('#^https?://#i', $url)) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面地址请勿填写完整网址(填如 codec.php),如需外链请选择“外部链接”。';
|
||||
break;
|
||||
}
|
||||
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url) || strpos($url, '//') === 0) {
|
||||
$errorMsg = '第 ' . ((int)$i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。';
|
||||
$errorMsg = '第 ' . ((int) $i + 1) . ' 行:站内页面仅支持相对地址(如 codec.php),不允许填写协议或跨协议地址。';
|
||||
break;
|
||||
}
|
||||
}
|
||||
$clean[] = [
|
||||
'id' => (int)($r['id'] ?? 0),
|
||||
'id' => (int) ($r['id'] ?? 0),
|
||||
'icon' => ($icon !== '' ? $icon : '🧩'),
|
||||
'name' => $name,
|
||||
'description' => $desc,
|
||||
'url' => $url,
|
||||
'ext' => $ext,
|
||||
'enabled' => (((string)($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'enabled' => (((string) ($r['enabled'] ?? '1') === '1')) ? 1 : 0,
|
||||
'board_cats' => $boardCats,
|
||||
];
|
||||
}
|
||||
@ -79,34 +81,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save'
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
$oldIds = array_map('intval', array_column($pdo->query('SELECT id FROM func_tools')->fetchAll(), 'id'));
|
||||
$keep = [];
|
||||
foreach ($clean as $c) {
|
||||
if ($c['id'] > 0) $keep[] = $c['id'];
|
||||
}
|
||||
$del = array_values(array_diff($oldIds, $keep));
|
||||
foreach ($del as $did) {
|
||||
$pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]);
|
||||
}
|
||||
$stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?');
|
||||
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)');
|
||||
$sort = 0;
|
||||
foreach ($clean as $c) {
|
||||
$sort++;
|
||||
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
|
||||
$stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]);
|
||||
} else {
|
||||
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]);
|
||||
$keep = [];
|
||||
foreach ($clean as $c) {
|
||||
if ($c['id'] > 0)
|
||||
$keep[] = $c['id'];
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
touch_last_updated();
|
||||
header('Location: tools.php?ok=1');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errorMsg = '保存失败:' . $e->getMessage();
|
||||
$del = array_values(array_diff($oldIds, $keep));
|
||||
foreach ($del as $did) {
|
||||
$pdo->prepare('DELETE FROM func_tools WHERE id = ?')->execute([$did]);
|
||||
}
|
||||
$stUpd = $pdo->prepare('UPDATE func_tools SET name=?, icon=?, description=?, url=?, is_external=?, enabled=?, board_cats=?, sort=? WHERE id=?');
|
||||
$stIns = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, board_cats, sort) VALUES (?,?,?,?,?,?,?,?)');
|
||||
$sort = 0;
|
||||
foreach ($clean as $c) {
|
||||
$sort++;
|
||||
if ($c['id'] > 0 && in_array($c['id'], $oldIds, true)) {
|
||||
$stUpd->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort, $c['id']]);
|
||||
} else {
|
||||
$stIns->execute([$c['name'], $c['icon'], $c['description'], $c['url'], $c['ext'], $c['enabled'], $c['board_cats'], $sort]);
|
||||
}
|
||||
}
|
||||
$pdo->commit();
|
||||
touch_last_updated();
|
||||
header('Location: tools.php?ok=1');
|
||||
exit;
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errorMsg = '保存失败:' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -120,8 +123,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
|
||||
if (!csrf_verify()) {
|
||||
echo json_encode(['ok' => false, 'msg' => '安全校验失败,请刷新页面后重试。'], JSON_UNESCAPED_UNICODE);
|
||||
} else {
|
||||
$tid = (int)($_POST['id'] ?? 0);
|
||||
$ten = ((string)($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
$tid = (int) ($_POST['id'] ?? 0);
|
||||
$ten = ((string) ($_POST['enabled'] ?? '') === '1') ? 1 : 0;
|
||||
if ($tid > 0) {
|
||||
$pdo->prepare('UPDATE func_tools SET enabled = ? WHERE id = ?')->execute([$ten, $tid]);
|
||||
touch_last_updated();
|
||||
@ -136,254 +139,261 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'toggl
|
||||
// ---------- 展示现有工具 ----------
|
||||
$tools = $pdo->query('SELECT * FROM func_tools ORDER BY sort ASC, id ASC')->fetchAll();
|
||||
|
||||
layout_head('功能区管理');
|
||||
layout_head('内部功能');
|
||||
admin_topbar('tools');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">功能区管理</h2>
|
||||
<p class="tip">
|
||||
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
|
||||
也可登记外部网址作为外链卡片(新窗口打开)。
|
||||
</p>
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">内部功能</h2>
|
||||
<p class="tip">
|
||||
管理功能区首页展示的工具卡片。站内工具页需先在 <code>func/</code> 下开发对应页面,再到此处登记入口;
|
||||
也可登记外部网址作为外链卡片(新窗口打开)。
|
||||
</p>
|
||||
|
||||
<?php if ($saved): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,功能区首页已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMsg !== ''): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($saved): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'ok', 'text' => '保存成功,功能区首页已同步更新。'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
<?php if ($errorMsg !== ''): ?>
|
||||
<script>window.hpToastMsg = <?= json_encode(['kind' => 'err', 'text' => $errorMsg], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="fieldset-card">
|
||||
<form method="post" id="toolsForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<textarea name="data" id="toolsData" hidden></textarea>
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">工具卡片管理</div>
|
||||
<form method="post" id="toolsForm" autocomplete="off">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save">
|
||||
<textarea name="data" id="toolsData" hidden></textarea>
|
||||
|
||||
<div id="toolsRows">
|
||||
<?php foreach ($tools as $t): ?>
|
||||
<div class="t-row" data-id="<?= (int)$t['id'] ?>">
|
||||
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>
|
||||
<span class="icon-ctl">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input t-icon" value="<?= he((string)$t['icon']) ?>" placeholder="图标">
|
||||
<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</span>
|
||||
<input type="text" class="t-name" value="<?= he((string)$t['name']) ?>" placeholder="名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string)$t['description']) ?>" placeholder="一句话说明">
|
||||
<input type="text" class="t-url" value="<?= he((string)$t['url']) ?>" placeholder="站内如 codec.php / 外链 https://…">
|
||||
<select class="t-ext">
|
||||
<option value="0"<?= (int)$t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
|
||||
<option value="1"<?= (int)$t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
|
||||
</select>
|
||||
<?php $bcT = array_map('trim', explode(',', trim((string)($t['board_cats'] ?? '')))); ?>
|
||||
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
|
||||
<span class="t-cats-t">库首页</span>
|
||||
<label><input type="checkbox" class="t-catc" data-k="popular"<?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="red"<?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="blue"<?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
|
||||
</span>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"<?= (int)$t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm t-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm t-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div id="toolsRows">
|
||||
<?php foreach ($tools as $t): ?>
|
||||
<div class="t-row" data-id="<?= (int) $t['id'] ?>">
|
||||
<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>
|
||||
<span class="icon-ctl">
|
||||
<span class="icon-preview"></span>
|
||||
<input type="text" class="icon-input t-icon" value="<?= he((string) $t['icon']) ?>"
|
||||
placeholder="图标">
|
||||
<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>
|
||||
<input type="file" class="icon-file" accept="image/*" hidden>
|
||||
</span>
|
||||
<input type="text" class="t-name" value="<?= he((string) $t['name']) ?>" placeholder="名称(必填)">
|
||||
<input type="text" class="t-desc" value="<?= he((string) $t['description']) ?>"
|
||||
placeholder="一句话说明">
|
||||
<input type="text" class="t-url" value="<?= he((string) $t['url']) ?>"
|
||||
placeholder="站内如 codec.php / 外链 https://…">
|
||||
<select class="t-ext">
|
||||
<option value="0" <?= (int) $t['is_external'] === 0 ? ' selected' : '' ?>>站内页面</option>
|
||||
<option value="1" <?= (int) $t['is_external'] === 1 ? ' selected' : '' ?>>外部链接</option>
|
||||
</select>
|
||||
<?php $bcT = array_map('trim', explode(',', trim((string) ($t['board_cats'] ?? '')))); ?>
|
||||
<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">
|
||||
<span class="t-cats-t">库首页</span>
|
||||
<label><input type="checkbox" class="t-catc" data-k="popular" <?= in_array('popular', $bcT, true) ? ' checked' : '' ?>>科普</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="red" <?= in_array('red', $bcT, true) ? ' checked' : '' ?>>红队</label>
|
||||
<label><input type="checkbox" class="t-catc" data-k="blue" <?= in_array('blue', $bcT, true) ? ' checked' : '' ?>>蓝队</label>
|
||||
</span>
|
||||
<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled"
|
||||
<?= (int) $t['enabled'] === 1 ? ' checked' : '' ?>><span class="sw-b"><span
|
||||
class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>
|
||||
<span class="ops">
|
||||
<button type="button" class="btn btn-sm t-up" title="上移">↑</button>
|
||||
<button type="button" class="btn btn-sm t-down" title="下移">↓</button>
|
||||
<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<button type="button" class="btn btn-sm" id="toolsAdd">+ 添加工具</button>
|
||||
<button type="submit" class="btn btn-primary">保存功能区</button>
|
||||
<span class="tip">工具数量与展示顺序实时反映到前台功能区首页</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="field-row" style="margin:12px 0">
|
||||
<button type="button" class="btn btn-sm" id="toolsAdd">+ 添加工具</button>
|
||||
<button type="submit" class="btn btn-primary">保存功能区</button>
|
||||
<span class="tip">工具数量与展示顺序实时反映到前台功能区首页</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('toolsRows');
|
||||
(function () {
|
||||
'use strict';
|
||||
var rows = document.getElementById('toolsRows');
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
function rowHTML() {
|
||||
return '<div class="t-row" data-id="0">' +
|
||||
'<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>' +
|
||||
'<span class="icon-ctl">' +
|
||||
'<span class="icon-preview"></span>' +
|
||||
'<input type="text" class="icon-input t-icon" value="" placeholder="图标">' +
|
||||
'<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>' +
|
||||
'<input type="file" class="icon-file" accept="image/*" hidden>' +
|
||||
'</span>' +
|
||||
'<input type="text" class="t-name" value="" placeholder="名称(必填)">' +
|
||||
'<input type="text" class="t-desc" value="" placeholder="一句话说明">' +
|
||||
'<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' +
|
||||
'<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' +
|
||||
'<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' +
|
||||
'<span class="t-cats-t">库首页</span>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
|
||||
'</span>' +
|
||||
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
|
||||
'</span></div>';
|
||||
}
|
||||
function toggleEnabled(input) {
|
||||
var row = input.closest('.t-row');
|
||||
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
|
||||
var csrfEl = document.querySelector('input[name="csrf"]');
|
||||
if (!csrfEl) return;
|
||||
if (!id) {
|
||||
input.checked = !input.checked;
|
||||
alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。');
|
||||
return;
|
||||
function esc(s) {
|
||||
return String(s).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'toggle');
|
||||
fd.append('csrf', csrfEl.value);
|
||||
fd.append('id', String(id));
|
||||
fd.append('enabled', input.checked ? '1' : '0');
|
||||
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
|
||||
.then(function (j) {
|
||||
if (!j || j.ok !== true) {
|
||||
input.checked = !input.checked;
|
||||
alert('启停失败:' + ((j && j.msg) || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
function rowHTML() {
|
||||
return '<div class="t-row" data-id="0">' +
|
||||
'<button type="button" class="drag-h" draggable="true" title="按住拖动调整顺序">⠿</button>' +
|
||||
'<span class="icon-ctl">' +
|
||||
'<span class="icon-preview"></span>' +
|
||||
'<input type="text" class="icon-input t-icon" value="" placeholder="图标">' +
|
||||
'<button type="button" class="btn btn-sm icon-upload" title="上传图片(≤200KB)">上传</button>' +
|
||||
'<input type="file" class="icon-file" accept="image/*" hidden>' +
|
||||
'</span>' +
|
||||
'<input type="text" class="t-name" value="" placeholder="名称(必填)">' +
|
||||
'<input type="text" class="t-desc" value="" placeholder="一句话说明">' +
|
||||
'<input type="text" class="t-url" value="" placeholder="站内如 codec.php / 外链 https://…">' +
|
||||
'<select class="t-ext"><option value="0">站内页面</option><option value="1">外部链接</option></select>' +
|
||||
'<span class="t-cats" title="在哪些库首页“内部功能区”展示(不勾选=所有库通用)">' +
|
||||
'<span class="t-cats-t">库首页</span>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="popular">科普</label>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="red">红队</label>' +
|
||||
'<label><input type="checkbox" class="t-catc" data-k="blue">蓝队</label>' +
|
||||
'</span>' +
|
||||
'<label class="sw" title="点击立即启停(即时生效,无需保存)"><input type="checkbox" class="sw-in t-enabled" checked><span class="sw-b"><span class="sw-on">ON</span><span class="sw-off">OFF</span></span></label>' +
|
||||
'<span class="ops">' +
|
||||
'<button type="button" class="btn btn-sm t-up" title="上移">↑</button>' +
|
||||
'<button type="button" class="btn btn-sm t-down" title="下移">↓</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger t-del" title="删除该工具">✕</button>' +
|
||||
'</span></div>';
|
||||
}
|
||||
function toggleEnabled(input) {
|
||||
var row = input.closest('.t-row');
|
||||
var id = row ? parseInt(row.getAttribute('data-id'), 10) : 0;
|
||||
var csrfEl = document.querySelector('input[name="csrf"]');
|
||||
if (!csrfEl) return;
|
||||
if (!id) {
|
||||
input.checked = !input.checked;
|
||||
alert('网络错误,启停未保存。');
|
||||
});
|
||||
}
|
||||
|
||||
function placeholderHint(select, urlInput) {
|
||||
urlInput.placeholder = select.value === '1'
|
||||
? '外部完整网址,如 https://example.com'
|
||||
: '站内工具页,如 codec.php';
|
||||
}
|
||||
|
||||
rows.addEventListener('click', function (ev) {
|
||||
var row = ev.target.closest('.t-row');
|
||||
if (!row) return;
|
||||
if (ev.target.classList.contains('t-del')) {
|
||||
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该工具入口?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该工具入口?')))
|
||||
.then(function (ok) { if (ok) row.remove(); });
|
||||
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
|
||||
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
|
||||
if (sib && sib.classList.contains('t-row')) {
|
||||
if (ev.target.classList.contains('t-up')) rows.insertBefore(row, sib);
|
||||
else rows.insertBefore(sib, row);
|
||||
alert('该行尚未保存过,请先点击「保存功能区」生成记录,之后即可直接拨动开关启停。');
|
||||
return;
|
||||
}
|
||||
var fd = new FormData();
|
||||
fd.append('action', 'toggle');
|
||||
fd.append('csrf', csrfEl.value);
|
||||
fd.append('id', String(id));
|
||||
fd.append('enabled', input.checked ? '1' : '0');
|
||||
fetch(window.location.href, { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||
.then(function (res) { return res.json().catch(function () { return { ok: false, msg: '服务器返回异常。' }; }); })
|
||||
.then(function (j) {
|
||||
if (!j || j.ok !== true) {
|
||||
input.checked = !input.checked;
|
||||
alert('启停失败:' + ((j && j.msg) || '未知错误'));
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
input.checked = !input.checked;
|
||||
alert('网络错误,启停未保存。');
|
||||
});
|
||||
}
|
||||
});
|
||||
rows.addEventListener('change', function (ev) {
|
||||
if (ev.target.classList.contains('t-ext')) {
|
||||
placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url'));
|
||||
} else if (ev.target.classList.contains('t-enabled')) {
|
||||
toggleEnabled(ev.target);
|
||||
|
||||
function placeholderHint(select, urlInput) {
|
||||
urlInput.placeholder = select.value === '1'
|
||||
? '外部完整网址,如 https://example.com'
|
||||
: '站内工具页,如 codec.php';
|
||||
}
|
||||
});
|
||||
rows.querySelectorAll('.t-ext').forEach(function (sel) {
|
||||
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
|
||||
});
|
||||
|
||||
// 行内拖动排序(拖动结果随「保存功能区」一并持久化)
|
||||
var dragRow = null;
|
||||
function clearRowDrop() {
|
||||
var rs = rows.querySelectorAll('.t-row');
|
||||
for (var i = 0; i < rs.length; i++) rs[i].classList.remove('drag-src', 'drop-target');
|
||||
}
|
||||
rows.addEventListener('dragstart', function (ev) {
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !rows.contains(h)) return;
|
||||
var row = h.closest('.t-row');
|
||||
if (!row) return;
|
||||
dragRow = row;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) {}
|
||||
setTimeout(function () { row.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
rows.addEventListener('dragover', function (ev) {
|
||||
if (!dragRow) return;
|
||||
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
|
||||
if (!row || row === dragRow) { clearRowDrop(); return; }
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
clearRowDrop();
|
||||
row.classList.add('drop-target');
|
||||
});
|
||||
rows.addEventListener('drop', function (ev) {
|
||||
if (!dragRow) return;
|
||||
ev.preventDefault();
|
||||
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
|
||||
if (row && row !== dragRow) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
rows.insertBefore(dragRow, after ? row.nextSibling : row);
|
||||
}
|
||||
});
|
||||
rows.addEventListener('dragend', function () {
|
||||
clearRowDrop();
|
||||
dragRow = null;
|
||||
});
|
||||
|
||||
document.getElementById('toolsAdd').addEventListener('click', function () {
|
||||
rows.insertAdjacentHTML('beforeend', rowHTML());
|
||||
});
|
||||
|
||||
// 保存:收集与前端校验
|
||||
document.getElementById('toolsForm').addEventListener('submit', function (ev) {
|
||||
var list = [], bad = false, badMsg = '';
|
||||
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
|
||||
if (bad) return;
|
||||
var icon = r.querySelector('.t-icon').value.trim();
|
||||
var name = r.querySelector('.t-name').value.trim();
|
||||
var desc = r.querySelector('.t-desc').value.trim();
|
||||
var url = r.querySelector('.t-url').value.trim();
|
||||
var ext = r.querySelector('.t-ext').value;
|
||||
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
|
||||
var cats = [];
|
||||
var catInputs = r.querySelectorAll('.t-catc');
|
||||
for (var cc = 0; cc < catInputs.length; cc++) {
|
||||
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
|
||||
rows.addEventListener('click', function (ev) {
|
||||
var row = ev.target.closest('.t-row');
|
||||
if (!row) return;
|
||||
if (ev.target.classList.contains('t-del')) {
|
||||
(window.HomePage && HomePage.ask ? HomePage.ask('确认删除该工具入口?', { okText: '删除' }) : Promise.resolve(window.confirm('确认删除该工具入口?')))
|
||||
.then(function (ok) { if (ok) row.remove(); });
|
||||
} else if (ev.target.classList.contains('t-up') || ev.target.classList.contains('t-down')) {
|
||||
var sib = ev.target.classList.contains('t-up') ? row.previousElementSibling : row.nextElementSibling;
|
||||
if (sib && sib.classList.contains('t-row')) {
|
||||
if (ev.target.classList.contains('t-up')) rows.insertBefore(row, sib);
|
||||
else rows.insertBefore(sib, row);
|
||||
}
|
||||
}
|
||||
if (!name && !url) return;
|
||||
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; }
|
||||
if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; }
|
||||
if (ext === '0' && /^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面请填相对地址(如 codec.php);如需外链请选“外部链接”。'; return; }
|
||||
if (ext === '0' && (/^[a-z][a-z0-9+.-]*:/i.test(url) || /^\/\//.test(url))) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面仅支持相对地址,不允许填写协议地址。'; return; }
|
||||
list.push({
|
||||
id: parseInt(r.getAttribute('data-id'), 10) || 0,
|
||||
icon: icon, name: name, description: desc, url: url,
|
||||
ext: ext, enabled: enabled, cats: cats
|
||||
});
|
||||
});
|
||||
if (bad) {
|
||||
ev.preventDefault();
|
||||
alert(badMsg);
|
||||
return;
|
||||
rows.addEventListener('change', function (ev) {
|
||||
if (ev.target.classList.contains('t-ext')) {
|
||||
placeholderHint(ev.target, ev.target.parentNode.querySelector('.t-url'));
|
||||
} else if (ev.target.classList.contains('t-enabled')) {
|
||||
toggleEnabled(ev.target);
|
||||
}
|
||||
});
|
||||
rows.querySelectorAll('.t-ext').forEach(function (sel) {
|
||||
placeholderHint(sel, sel.parentNode.querySelector('.t-url'));
|
||||
});
|
||||
|
||||
// 行内拖动排序(拖动结果随「保存功能区」一并持久化)
|
||||
var dragRow = null;
|
||||
function clearRowDrop() {
|
||||
var rs = rows.querySelectorAll('.t-row');
|
||||
for (var i = 0; i < rs.length; i++) rs[i].classList.remove('drag-src', 'drop-target');
|
||||
}
|
||||
if (!list.length) {
|
||||
rows.addEventListener('dragstart', function (ev) {
|
||||
var h = ev.target.closest ? ev.target.closest('.drag-h') : null;
|
||||
if (!h || !rows.contains(h)) return;
|
||||
var row = h.closest('.t-row');
|
||||
if (!row) return;
|
||||
dragRow = row;
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
try { ev.dataTransfer.setData('text/plain', row.getAttribute('data-id') || ''); } catch (e) { }
|
||||
setTimeout(function () { row.classList.add('drag-src'); }, 0);
|
||||
});
|
||||
rows.addEventListener('dragover', function (ev) {
|
||||
if (!dragRow) return;
|
||||
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
|
||||
if (!row || row === dragRow) { clearRowDrop(); return; }
|
||||
ev.preventDefault();
|
||||
alert('功能区至少保留一个工具。');
|
||||
return;
|
||||
}
|
||||
document.getElementById('toolsData').value = JSON.stringify(list);
|
||||
});
|
||||
})();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
clearRowDrop();
|
||||
row.classList.add('drop-target');
|
||||
});
|
||||
rows.addEventListener('drop', function (ev) {
|
||||
if (!dragRow) return;
|
||||
ev.preventDefault();
|
||||
var row = ev.target.closest ? ev.target.closest('.t-row') : null;
|
||||
if (row && row !== dragRow) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
var after = ev.clientY > rect.top + rect.height / 2;
|
||||
rows.insertBefore(dragRow, after ? row.nextSibling : row);
|
||||
}
|
||||
});
|
||||
rows.addEventListener('dragend', function () {
|
||||
clearRowDrop();
|
||||
dragRow = null;
|
||||
});
|
||||
|
||||
document.getElementById('toolsAdd').addEventListener('click', function () {
|
||||
rows.insertAdjacentHTML('beforeend', rowHTML());
|
||||
});
|
||||
|
||||
// 保存:收集与前端校验
|
||||
document.getElementById('toolsForm').addEventListener('submit', function (ev) {
|
||||
var list = [], bad = false, badMsg = '';
|
||||
Array.prototype.forEach.call(rows.querySelectorAll('.t-row'), function (r, i) {
|
||||
if (bad) return;
|
||||
var icon = r.querySelector('.t-icon').value.trim();
|
||||
var name = r.querySelector('.t-name').value.trim();
|
||||
var desc = r.querySelector('.t-desc').value.trim();
|
||||
var url = r.querySelector('.t-url').value.trim();
|
||||
var ext = r.querySelector('.t-ext').value;
|
||||
var enabled = r.querySelector('.t-enabled').checked ? '1' : '0';
|
||||
var cats = [];
|
||||
var catInputs = r.querySelectorAll('.t-catc');
|
||||
for (var cc = 0; cc < catInputs.length; cc++) {
|
||||
if (catInputs[cc].checked) cats.push(catInputs[cc].getAttribute('data-k'));
|
||||
}
|
||||
if (!name && !url) return;
|
||||
if (!name || !url) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:名称与地址均不能为空。'; return; }
|
||||
if (ext === '1' && !/^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:外部链接需以 http(s):// 开头。'; return; }
|
||||
if (ext === '0' && /^https?:\/\//i.test(url)) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面请填相对地址(如 codec.php);如需外链请选“外部链接”。'; return; }
|
||||
if (ext === '0' && (/^[a-z][a-z0-9+.-]*:/i.test(url) || /^\/\//.test(url))) { bad = true; badMsg = '第 ' + (i + 1) + ' 行:站内页面仅支持相对地址,不允许填写协议地址。'; return; }
|
||||
list.push({
|
||||
id: parseInt(r.getAttribute('data-id'), 10) || 0,
|
||||
icon: icon, name: name, description: desc, url: url,
|
||||
ext: ext, enabled: enabled, cats: cats
|
||||
});
|
||||
});
|
||||
if (bad) {
|
||||
ev.preventDefault();
|
||||
alert(badMsg);
|
||||
return;
|
||||
}
|
||||
if (!list.length) {
|
||||
ev.preventDefault();
|
||||
alert('功能区至少保留一个工具。');
|
||||
return;
|
||||
}
|
||||
document.getElementById('toolsData').value = JSON.stringify(list);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
625
admin/visits.php
625
admin/visits.php
@ -1,37 +1,41 @@
|
||||
<?php
|
||||
/**
|
||||
* admin/visits.php —— 访问信息
|
||||
* admin/visits.php —— 风控管理
|
||||
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
|
||||
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
|
||||
*/
|
||||
$P = '../';
|
||||
require_once __DIR__ . '/_guard.php';
|
||||
require_once __DIR__ . '/_visits_lib.php';
|
||||
|
||||
$pdo = db();
|
||||
|
||||
function settings_set(string $key, string $value): void
|
||||
{
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
$st = db()->prepare('INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
|
||||
$st->execute([$key, $value]);
|
||||
$st->execute([$key, $value]);
|
||||
}
|
||||
|
||||
/* 批量删除黑名单(按 id 列表),同步清除单 IP 规则的风控计数;返回删除条数 */
|
||||
function bl_delete_ids(PDO $pdo, array $ids): int
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) { return $v > 0; })));
|
||||
if (!$ids) return 0;
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
|
||||
$st->execute($ids);
|
||||
$rules = $st->fetchAll();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
|
||||
foreach ($rules as $r) {
|
||||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$r['rule']);
|
||||
}
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), function ($v) {
|
||||
return $v > 0;
|
||||
})));
|
||||
if (!$ids)
|
||||
return 0;
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id IN (' . $ph . ')');
|
||||
$st->execute($ids);
|
||||
$rules = $st->fetchAll();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id IN (' . $ph . ')')->execute($ids);
|
||||
foreach ($rules as $r) {
|
||||
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $r['rule']);
|
||||
}
|
||||
return count($rules);
|
||||
}
|
||||
return count($rules);
|
||||
}
|
||||
|
||||
/* ---------- POST:黑名单增删 / 清空日志 / 风控规则 ---------- */
|
||||
@ -39,235 +43,181 @@ $msgKind = '';
|
||||
$msgText = '';
|
||||
$banChanged = false;
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!csrf_verify()) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string)($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'ban_add':
|
||||
$rule = trim((string)($_POST['rule'] ?? ''));
|
||||
$note = trim((string)($_POST['note'] ?? ''));
|
||||
if ($rule === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '请填写要拦截的 IP / 网段。';
|
||||
break;
|
||||
}
|
||||
$parsed = blacklist_parse_rule($rule);
|
||||
if (!$parsed[0]) {
|
||||
$msgKind = 'err';
|
||||
$msgText = $parsed[3];
|
||||
break;
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
|
||||
$st->execute([$rule, $note, date('Y-m-d H:i:s'), 'manual']);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
|
||||
break;
|
||||
case 'ban_del':
|
||||
$id = max(0, (int)($_POST['id'] ?? 0));
|
||||
if ($id > 0) {
|
||||
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
|
||||
$bs->execute([$id]);
|
||||
$brow = $bs->fetch();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
|
||||
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
|
||||
if ($brow && filter_var((string)$brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$brow['rule']);
|
||||
}
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '黑名单规则已移除。';
|
||||
} else {
|
||||
$msgKind = 'err';
|
||||
$msgText = '无效的黑名单记录。';
|
||||
}
|
||||
break;
|
||||
case 'log_clear':
|
||||
@file_put_contents(ACCESS_LOG_FILE, '', LOCK_EX);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '访问日志已清空(data/vistors.log)。';
|
||||
break;
|
||||
case 'risk_save':
|
||||
$clip = function ($v, $d) { $raw = trim((string)($v ?? '')); $x = ($raw === '') ? (int)$d : (int)$raw; return max(1, min(1000000, $x)); };
|
||||
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
|
||||
settings_set('risk_login_n', (string)$clip($_POST['r_login_n'] ?? null, 10));
|
||||
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
|
||||
settings_set('risk_404_n', (string)$clip($_POST['r_404_n'] ?? null, 100));
|
||||
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
|
||||
settings_set('risk_rate_n', (string)$clip($_POST['r_rate_n'] ?? null, 120));
|
||||
settings_set('risk_tip_main', trim((string)($_POST['r_tip_main'] ?? '')));
|
||||
settings_set('risk_tip_sub', trim((string)($_POST['r_tip_sub'] ?? '')));
|
||||
$msgKind = 'ok';
|
||||
$msgText = '风控规则已保存并即时生效。';
|
||||
break;
|
||||
case 'risk_clear':
|
||||
risk_clear_all();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
|
||||
break;
|
||||
case 'ban_batch':
|
||||
$n = bl_delete_ids($pdo, (array)($_POST['bid'] ?? []));
|
||||
$banChanged = true;
|
||||
$msgKind = $n > 0 ? 'ok' : 'err';
|
||||
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
|
||||
break;
|
||||
case 'ban_clear_auto':
|
||||
$rows = $pdo->query("SELECT rule FROM ip_blacklist WHERE source = 'auto'")->fetchAll();
|
||||
$cnt = count($rows);
|
||||
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
|
||||
foreach ($rows as $r) {
|
||||
if (filter_var((string)$r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string)$r['rule']);
|
||||
}
|
||||
}
|
||||
blacklist_rules(true);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已一键移除全部风控自动封禁(共 ' . $cnt . ' 条),相关 IP 风控计数已同步清除。';
|
||||
break;
|
||||
case 'ban_clear_all':
|
||||
$pdo->exec('DELETE FROM ip_blacklist');
|
||||
risk_clear_all();
|
||||
blacklist_rules(true);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已清空全部黑名单,风险计数一并清零。';
|
||||
break;
|
||||
default:
|
||||
$msgKind = 'err';
|
||||
$msgText = '未知操作。';
|
||||
if (!csrf_verify()) {
|
||||
$msgKind = 'err';
|
||||
$msgText = '安全校验失败,请刷新页面重试。';
|
||||
} else {
|
||||
$act = (string) ($_POST['act'] ?? '');
|
||||
switch ($act) {
|
||||
case 'ban_add':
|
||||
$rule = trim((string) ($_POST['rule'] ?? ''));
|
||||
$note = trim((string) ($_POST['note'] ?? ''));
|
||||
if ($rule === '') {
|
||||
$msgKind = 'err';
|
||||
$msgText = '请填写要拦截的 IP / 网段。';
|
||||
break;
|
||||
}
|
||||
$parsed = blacklist_parse_rule($rule);
|
||||
if (!$parsed[0]) {
|
||||
$msgKind = 'err';
|
||||
$msgText = $parsed[3];
|
||||
break;
|
||||
}
|
||||
$st = $pdo->prepare('INSERT INTO ip_blacklist (rule, note, created_at, source) VALUES (?,?,?,?)');
|
||||
$st->execute([$rule, $note, date('Y-m-d H:i:s'), 'manual']);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '黑名单规则已添加,匹配的访问将立即被拒绝。';
|
||||
break;
|
||||
case 'ban_del':
|
||||
$id = max(0, (int) ($_POST['id'] ?? 0));
|
||||
if ($id > 0) {
|
||||
$bs = $pdo->prepare('SELECT rule FROM ip_blacklist WHERE id = ? LIMIT 1');
|
||||
$bs->execute([$id]);
|
||||
$brow = $bs->fetch();
|
||||
$pdo->prepare('DELETE FROM ip_blacklist WHERE id = ?')->execute([$id]);
|
||||
// 解封单 IP 时同步清除其风控计数,避免“刚解封又被历史计数封回”
|
||||
if ($brow && filter_var((string) $brow['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $brow['rule']);
|
||||
}
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '黑名单规则已移除。';
|
||||
} else {
|
||||
$msgKind = 'err';
|
||||
$msgText = '无效的黑名单记录。';
|
||||
}
|
||||
break;
|
||||
case 'log_clear':
|
||||
@file_put_contents(ACCESS_LOG_FILE, '', LOCK_EX);
|
||||
$msgKind = 'ok';
|
||||
$msgText = '访问日志已清空(data/vistors.log)。';
|
||||
break;
|
||||
case 'risk_save':
|
||||
$clip = function ($v, $d) {
|
||||
$raw = trim((string) ($v ?? ''));
|
||||
$x = ($raw === '') ? (int) $d : (int) $raw;
|
||||
return max(1, min(1000000, $x));
|
||||
};
|
||||
settings_set('risk_login_on', isset($_POST['r_login_on']) ? '1' : '0');
|
||||
settings_set('risk_login_n', (string) $clip($_POST['r_login_n'] ?? null, 10));
|
||||
settings_set('risk_404_on', isset($_POST['r_404_on']) ? '1' : '0');
|
||||
settings_set('risk_404_n', (string) $clip($_POST['r_404_n'] ?? null, 100));
|
||||
settings_set('risk_rate_on', isset($_POST['r_rate_on']) ? '1' : '0');
|
||||
settings_set('risk_rate_n', (string) $clip($_POST['r_rate_n'] ?? null, 120));
|
||||
settings_set('risk_tip_main', trim((string) ($_POST['r_tip_main'] ?? '')));
|
||||
settings_set('risk_tip_sub', trim((string) ($_POST['r_tip_sub'] ?? '')));
|
||||
$msgKind = 'ok';
|
||||
$msgText = '风控规则已保存并即时生效。';
|
||||
break;
|
||||
case 'risk_clear':
|
||||
risk_clear_all();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '全部风险计数已清空(黑名单本身不受影响)。';
|
||||
break;
|
||||
case 'loginlog_clear':
|
||||
login_log_clear();
|
||||
$msgKind = 'ok';
|
||||
$msgText = '登录日志已清空(含成功与失败记录)。';
|
||||
break;
|
||||
case 'ban_batch':
|
||||
$n = bl_delete_ids($pdo, (array) ($_POST['bid'] ?? []));
|
||||
$banChanged = true;
|
||||
$msgKind = $n > 0 ? 'ok' : 'err';
|
||||
$msgText = $n > 0 ? '已批量移除 ' . $n . ' 条黑名单规则(对应单 IP 计数已清除)。' : '未勾选任何需要移除的规则。';
|
||||
break;
|
||||
case 'ban_clear_auto':
|
||||
$rows = $pdo->query("SELECT rule FROM ip_blacklist WHERE source = 'auto'")->fetchAll();
|
||||
$cnt = count($rows);
|
||||
$pdo->exec("DELETE FROM ip_blacklist WHERE source = 'auto'");
|
||||
foreach ($rows as $r) {
|
||||
if (filter_var((string) $r['rule'], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
risk_clear_for_ip((string) $r['rule']);
|
||||
}
|
||||
}
|
||||
blacklist_rules(true);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已一键移除全部风控自动封禁(共 ' . $cnt . ' 条),相关 IP 风控计数已同步清除。';
|
||||
break;
|
||||
case 'ban_clear_all':
|
||||
$pdo->exec('DELETE FROM ip_blacklist');
|
||||
risk_clear_all();
|
||||
blacklist_rules(true);
|
||||
$banChanged = true;
|
||||
$msgKind = 'ok';
|
||||
$msgText = '已清空全部黑名单,风险计数一并清零。';
|
||||
break;
|
||||
default:
|
||||
$msgKind = 'err';
|
||||
$msgText = '未知操作。';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 概要统计(数据来源:data/vistors.log) ---------- */
|
||||
$entries = visitors_entries();
|
||||
$statTotal = count($entries);
|
||||
$ipSet = [];
|
||||
foreach ($entries as $e) {
|
||||
$ipSet[$e['ip']] = 1;
|
||||
}
|
||||
$statIps = count($ipSet);
|
||||
$statToday = 0;
|
||||
$todayStart = date('Y-m-d 00:00:00');
|
||||
foreach ($entries as $e) {
|
||||
if ($e['created_at'] >= $todayStart) {
|
||||
$statToday++;
|
||||
}
|
||||
}
|
||||
function vis_scalar(PDO $pdo, string $sql): int
|
||||
{
|
||||
$v = $pdo->query($sql)->fetchColumn();
|
||||
return (int)$v;
|
||||
}
|
||||
$statBan = vis_scalar($pdo, 'SELECT COUNT(*) FROM ip_blacklist');
|
||||
$visStat = vis_stat_all();
|
||||
$statTotal = $visStat['total'];
|
||||
$statIps = $visStat['ips'];
|
||||
$statToday = $visStat['today'];
|
||||
$statBan = $visStat['ban'];
|
||||
|
||||
/* ---------- 筛选与分页 ---------- */
|
||||
$mode = (string)($_GET['mode'] ?? 'full');
|
||||
/* ---------- 筛选参数(供表单回显)与访问记录查询 ---------- */
|
||||
$mode = (string) ($_GET['mode'] ?? 'full');
|
||||
if (!in_array($mode, ['full', 'compact'], true)) {
|
||||
$mode = 'full';
|
||||
$mode = 'full';
|
||||
}
|
||||
$ipF = trim((string)($_GET['ip'] ?? ''));
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$ipF = substr($ipF, 0, 64);
|
||||
$q = substr($q, 0, 100);
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$ipF = substr(trim((string) ($_GET['ip'] ?? '')), 0, 64);
|
||||
$q = substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
|
||||
$page = max(1, (int) ($_GET['page'] ?? 1));
|
||||
$perPage = 30;
|
||||
|
||||
// entries 已按新→旧排列,内存中过滤 / 聚合后分页
|
||||
$logs = [];
|
||||
$total = 0;
|
||||
$totalPages = 1;
|
||||
if ($mode === 'compact') {
|
||||
// 精简模式:按 IP 聚合(最近访问时间 / IP / 访问次数),首次出现即该 IP 最新一条
|
||||
$grp = [];
|
||||
$order = [];
|
||||
foreach ($entries as $e) {
|
||||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||||
if (!isset($grp[$e['ip']])) {
|
||||
$grp[$e['ip']] = ['ip' => $e['ip'], 'last_at' => $e['created_at'], 'cnt' => 0];
|
||||
$order[] = $e['ip'];
|
||||
}
|
||||
$grp[$e['ip']]['cnt']++;
|
||||
}
|
||||
$all = [];
|
||||
foreach ($order as $ip) {
|
||||
$all[] = $grp[$ip];
|
||||
}
|
||||
$total = count($all);
|
||||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||||
if ($page > $totalPages) {
|
||||
$page = $totalPages;
|
||||
}
|
||||
$off = ($page - 1) * $perPage;
|
||||
$logs = array_slice($all, $off, $perPage);
|
||||
} else {
|
||||
// 完整模式:逐条明细
|
||||
$all = [];
|
||||
foreach ($entries as $e) {
|
||||
if ($ipF !== '' && stripos($e['ip'], $ipF) === false) continue;
|
||||
if ($q !== '' && stripos($e['url'], $q) === false && stripos($e['method'], $q) === false) continue;
|
||||
$all[] = $e;
|
||||
}
|
||||
$total = count($all);
|
||||
$totalPages = max(1, (int)ceil($total / $perPage));
|
||||
if ($page > $totalPages) {
|
||||
$page = $totalPages;
|
||||
}
|
||||
$off = ($page - 1) * $perPage;
|
||||
$logs = array_slice($all, $off, $perPage);
|
||||
}
|
||||
|
||||
/* ---------- 链接工具(保留当前筛选参数) ---------- */
|
||||
function vis_url(array $ov): string
|
||||
{
|
||||
$p = $_GET;
|
||||
foreach ($ov as $k => $v) {
|
||||
if ($v === null || $v === '') {
|
||||
unset($p[$k]);
|
||||
} else {
|
||||
$p[$k] = $v;
|
||||
}
|
||||
}
|
||||
if (!$p) {
|
||||
return 'visits.php';
|
||||
}
|
||||
return 'visits.php?' . http_build_query($p);
|
||||
}
|
||||
$visAccess = vis_query_access($mode, $ipF, $q, $page, $perPage);
|
||||
|
||||
/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
|
||||
$curIp = client_ip();
|
||||
if ($curIp === '') {
|
||||
$curIp = '未知';
|
||||
$curIp = '未知';
|
||||
}
|
||||
$curHit = null;
|
||||
if ($curIp !== '未知') {
|
||||
if ($banChanged) {
|
||||
blacklist_rules(true);
|
||||
}
|
||||
$curHit = blacklist_ip_hit($curIp);
|
||||
if ($banChanged) {
|
||||
blacklist_rules(true);
|
||||
}
|
||||
$curHit = blacklist_ip_hit($curIp);
|
||||
}
|
||||
|
||||
/* ---------- 自动风控规则配置与当前 IP 计数 ---------- */
|
||||
$rcfg = risk_rule_cfg();
|
||||
$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null;
|
||||
|
||||
/* ---------- 登录信息:当前会话 / 历史成功登录 / 登录失败按「IP × 日期」聚合 ---------- */
|
||||
$lgStat = login_log_stat();
|
||||
$lgRecent = login_log_recent_ok(15);
|
||||
$lgFailF = substr(trim((string) ($_GET['lf'] ?? '')), 0, 64);
|
||||
$lgFailData = vis_query_loginfail($lgFailF, 200);
|
||||
$curAdmin = (string) ($_SESSION['admin'] ?? '');
|
||||
$curLoginAt = (string) ($_SESSION['login_at'] ?? '');
|
||||
$curLoginIp = (string) ($_SESSION['login_ip'] ?? ($curIp !== '未知' ? $curIp : ''));
|
||||
$curLoginUa = (string) ($_SESSION['login_ua'] ?? '');
|
||||
$curLoginDevice = (string) ($_SESSION['login_device'] ?? '');
|
||||
if ($curLoginDevice === '') {
|
||||
$curLoginDevice = login_ua_brief((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
}
|
||||
|
||||
/* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */
|
||||
$banRows = $pdo->query('SELECT id, rule, note, created_at, source FROM ip_blacklist ORDER BY id DESC')->fetchAll();
|
||||
$banAutoCount = 0;
|
||||
foreach ($banRows as $b) {
|
||||
if (((string)($b['source'] ?? 'manual')) === 'auto') $banAutoCount++;
|
||||
if (((string) ($b['source'] ?? 'manual')) === 'auto')
|
||||
$banAutoCount++;
|
||||
}
|
||||
|
||||
layout_head('访问信息');
|
||||
layout_head('风控管理');
|
||||
admin_topbar('visits');
|
||||
?>
|
||||
<main class="page-main">
|
||||
<div class="wrap page-body">
|
||||
<h2 style="margin-bottom:6px">访问信息</h2>
|
||||
<h2 style="margin-bottom:6px">风控管理</h2>
|
||||
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
|
||||
|
||||
<?php if ($msgText !== ''): ?>
|
||||
@ -286,122 +236,138 @@ admin_topbar('visits');
|
||||
<form method="post" data-confirm="确认清空全部访问日志?此操作不可恢复。" style="margin-top:14px">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="log_clear">
|
||||
<button type="submit" class="btn btn-sm btn-danger">清空访问日志</button>
|
||||
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 / URL),完整保留不设上限;可自行归档或删除该文件。</span>
|
||||
<span class="tip" style="margin-left:8px">日志按行追加写入 <code>data/vistors.log</code>(制表符分隔:时间 / IP / 请求方式 / 响应码 /
|
||||
URL),完整保留不设上限;可自行归档或删除该文件。</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 登录信息(当前会话 + 历史成功登录) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">登录信息</div>
|
||||
<div class="vis-stats">
|
||||
<div class="vis-stat"><b><?= he($curAdmin) ?></b><span>当前用户</span></div>
|
||||
<div class="vis-stat"><b
|
||||
style="font-size:15px"><?= he($curLoginAt !== '' ? $curLoginAt : '本次会话未记录') ?></b><span>本次登录时间</span></div>
|
||||
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginIp !== '' ? $curLoginIp : '未知') ?></b><span>本次登录
|
||||
IP</span></div>
|
||||
<div class="vis-stat"><b style="font-size:15px"><?= he($curLoginDevice) ?></b><span>本次登录设备</span></div>
|
||||
</div>
|
||||
<p class="tip" style="margin:12px 0 4px">历史登录记录(最近 <?= count($lgRecent) ?> 条成功登录;今日成功
|
||||
<?= (int) $lgStat['ok_today'] ?> 次)
|
||||
</p>
|
||||
<div class="v-scroll">
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>登录时间</th>
|
||||
<th>登录 IP</th>
|
||||
<th>登录设备(浏览器)</th>
|
||||
<th>用户名</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if ($lgRecent): ?>
|
||||
<?php foreach ($lgRecent as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string) $r['created_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string) $r['ip']) ?></td>
|
||||
<td><?= he(vis_device_text($r)) ?></td>
|
||||
<td class="v-nowrap"><?= he((string) $r['username']) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php else: ?>
|
||||
<tr>
|
||||
<td colspan="4"><span class="tip">暂无成功登录记录(本次登录为首次记录)。</span></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录失败日志(按 IP × 日期聚合) -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">登录失败日志</div>
|
||||
<div class="vis-stats">
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['fail_today'] ?></b><span>今日失败次数</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['fail_ips'] ?></b><span>失败涉及 IP</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['ok_today'] ?></b><span>今日成功登录</span></div>
|
||||
<div class="vis-stat"><b><?= (int) $lgStat['total'] ?></b><span>登录日志总数</span></div>
|
||||
</div>
|
||||
<form method="get" class="v-filter" style="margin-top:12px" data-vis-form="loginfail">
|
||||
<input type="text" name="lf" value="<?= he($lgFailF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0)">
|
||||
<button type="submit" class="btn btn-primary">筛选</button>
|
||||
<a class="btn" href="visits.php" data-vis-section="loginfail" data-vis-page="">重置</a>
|
||||
<span class="tip" style="align-self:center">按「IP × 日期」聚合:同一 IP 当天的失败次数与最近一次设备信息</span>
|
||||
</form>
|
||||
<div id="lfResults"><?= vis_render_loginfail($lgFailData) ?></div>
|
||||
<form method="post" data-confirm="确认清空全部登录日志(成功与失败记录)?此操作不可恢复。" style="margin-top:12px">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="loginlog_clear">
|
||||
<button type="submit" class="btn btn-sm btn-danger">清空登录日志</button>
|
||||
<span class="tip" style="margin-left:8px">记录存于数据库 <code>login_log</code> 表(含成功与失败、时间 / 用户名 / IP / 设备)。</span>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 + 日志列表 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">访问记录<?= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?></div>
|
||||
<form method="get" class="v-filter">
|
||||
<select name="mode" title="展示模式" onchange="this.form.submit()">
|
||||
<option value="full"<?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
|
||||
<option value="compact"<?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
|
||||
<div class="fs-title">访问记录</div>
|
||||
<form method="get" class="v-filter" data-vis-form="access">
|
||||
<select name="mode" title="展示模式"
|
||||
onchange="if(this.form.requestSubmit){this.form.requestSubmit();}else{this.form.submit();}">
|
||||
<option value="full" <?= $mode === 'full' ? ' selected' : '' ?>>完整模式(逐条明细)</option>
|
||||
<option value="compact" <?= $mode === 'compact' ? ' selected' : '' ?>>精简模式(仅时间 / IP / 次数)</option>
|
||||
</select>
|
||||
<input type="text" name="ip" value="<?= he($ipF) ?>" placeholder="按 IP 筛选,支持模糊(如 203.0)">
|
||||
<input type="text" name="q" class="w-s" value="<?= he($q) ?>" placeholder="关键词:地址 / 请求方式">
|
||||
<button type="submit" class="btn btn-primary">筛选</button>
|
||||
<a class="btn" href="visits.php">重置</a>
|
||||
<a class="btn" href="visits.php" data-vis-section="access" data-vis-page="">重置</a>
|
||||
</form>
|
||||
|
||||
<?php if ($logs): ?>
|
||||
<div class="tip" style="margin:10px 0 6px">
|
||||
共命中 <?= $total ?> <?= $mode === 'compact' ? '个 IP' : '条记录' ?>;
|
||||
当前第 <?= $page ?> / <?= $totalPages ?> 页,每页 <?= $perPage ?> 条。
|
||||
</div>
|
||||
<div class="v-scroll">
|
||||
<?php if ($mode === 'compact'): ?>
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr><th>最近访问时间</th><th>IP 地址</th><th>访问次数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logs as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string)$r['last_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||||
<td><b><?= (int)$r['cnt'] ?></b> 次</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php else: ?>
|
||||
<table class="v-tbl">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>访问时间</th><th>IP 地址</th><th>请求方式</th><th>访问 URL</th><th>响应码</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($logs as $r): ?>
|
||||
<tr>
|
||||
<td class="v-nowrap"><?= he((string)$r['created_at']) ?></td>
|
||||
<td class="v-nowrap"><?= he((string)$r['ip']) ?></td>
|
||||
<td><span class="v-tag"><?= he((string)$r['method']) ?></span></td>
|
||||
<td><?= he((string)$r['url']) ?></td>
|
||||
<td class="v-nowrap"><?php
|
||||
$code = (int)$r['status'];
|
||||
$cls = ($code >= 200 && $code < 300) ? 'good' : (($code >= 300 && $code < 400) ? 'info' : (($code >= 400 && $code < 500) ? 'warn' : 'bad'));
|
||||
?><span class="v-tag <?= $cls ?>"><?= $code ?></span>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($totalPages > 1): ?>
|
||||
<div class="pager">
|
||||
<span class="pg-info">共 <?= $total ?> 条</span>
|
||||
<?php if ($page > 1): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page - 1])) ?>">← 上一页</a><?php endif; ?>
|
||||
<?php
|
||||
$win = 2;
|
||||
$start = max(1, $page - $win);
|
||||
$end = min($totalPages, $page + $win);
|
||||
if ($start > 1) echo '<span class="tip">…</span>';
|
||||
for ($p = $start; $p <= $end; $p++) {
|
||||
if ($p === $page) {
|
||||
echo '<span class="btn btn-sm btn-primary">' . $p . '</span>';
|
||||
} else {
|
||||
echo '<a class="btn btn-sm" href="' . he(vis_url(['page' => $p])) . '">' . $p . '</a>';
|
||||
}
|
||||
}
|
||||
if ($end < $totalPages) echo '<span class="tip">…</span>';
|
||||
?>
|
||||
<?php if ($page < $totalPages): ?><a class="btn btn-sm" href="<?= he(vis_url(['page' => $page + 1])) ?>">下一页 →</a><?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="err-msg" style="margin-top:12px"><?= ($ipF !== '' || $q !== '') ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?></div>
|
||||
<?php endif; ?>
|
||||
<div id="visResults"><?= vis_render_access($visAccess) ?></div>
|
||||
</div>
|
||||
|
||||
<!-- 自动风控规则 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">自动风控规则<span class="tip" style="font-weight:400">(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)</span></div>
|
||||
<div class="fs-title">风控规则</div>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_save">
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
|
||||
<span class="tip">失败次数 ≥ <input type="number" name="r_login_n" min="1" max="1000000" value="<?= (int)$rcfg['login_fail']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 5-20)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_login_on" value="1"
|
||||
<?= $rcfg['login_fail']['on'] ? ' checked' : '' ?>> 后台登录失败封禁</label>
|
||||
<span class="tip">失败次数 ≥ <input type="number" name="r_login_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['login_fail']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(建议 5-20)</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
|
||||
<span class="tip">累计次数 ≥ <input type="number" name="r_404_n" min="1" max="1000000" value="<?= (int)$rcfg['not_found']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(建议 50-300)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_404_on" value="1"
|
||||
<?= $rcfg['not_found']['on'] ? ' checked' : '' ?>> 前台 404 封禁</label>
|
||||
<span class="tip">累计次数 ≥ <input type="number" name="r_404_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['not_found']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(建议 50-300)</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:9px 0">
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
|
||||
<span class="tip">60 秒内 ≥ <input type="number" name="r_rate_n" min="1" max="1000000" value="<?= (int)$rcfg['rate']['n'] ?>" style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit"> 次 → 永久封禁(多人共用出口 IP 时建议关闭)</span>
|
||||
<label class="seg-check" style="min-width:148px"><input type="checkbox" name="r_rate_on" value="1"
|
||||
<?= $rcfg['rate']['on'] ? ' checked' : '' ?>> 请求频率封禁</label>
|
||||
<span class="tip">60 秒内 ≥ <input type="number" name="r_rate_n" min="1" max="1000000"
|
||||
value="<?= (int) $rcfg['rate']['n'] ?>"
|
||||
style="width:96px;padding:4px 8px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
次 → 永久封禁(多人共用出口 IP 时建议关闭)</span>
|
||||
</div>
|
||||
<p class="tip" style="margin:8px 0 6px">计数方式:404 与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
|
||||
<p class="tip" style="margin:8px 0 6px">计数方式:404
|
||||
与登录失败只要发生即累计(规则关闭也照常计数,便于后台观察);规则启用且达到阈值时自动封禁,保存后即时生效。自动封禁在黑名单中标注“风控自动”,可随时移除解封(解封时同步清除该 IP 计数)。</p>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 8px">
|
||||
<span class="tip" style="min-width:148px">主提示(留空=默认)</span>
|
||||
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string)$rcfg['_tip']['raw_main']) ?>" placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
<input type="text" name="r_tip_main" maxlength="100" value="<?= he((string) $rcfg['_tip']['raw_main']) ?>"
|
||||
placeholder="例:您触发了本站风控,请稍后再访问" title="黑名单命中时提示页的大号主文案"
|
||||
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
</div>
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:0 0 12px">
|
||||
<span class="tip" style="min-width:148px">次行说明(留空=默认)</span>
|
||||
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string)$rcfg['_tip']['raw_sub']) ?>" placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案" style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
<input type="text" name="r_tip_sub" maxlength="200" value="<?= he((string) $rcfg['_tip']['raw_sub']) ?>"
|
||||
placeholder="例:如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。" title="主提示下方的次要说明文案"
|
||||
style="flex:1 1 300px;min-width:260px;padding:6px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--card);color:inherit">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">保存风控规则</button>
|
||||
</form>
|
||||
@ -409,27 +375,34 @@ admin_topbar('visits');
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="risk_clear">
|
||||
<button type="submit" class="btn btn-sm">清空全部风险计数</button>
|
||||
<?php if ($curRisk): ?>
|
||||
<span class="tip" style="margin-left:8px">当前访问 IP(<?= he($curIp) ?>)计数:登录失败 <?= (int)$curRisk['login_fail'] ?> 次 · 404 <?= (int)$curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求 <?= (int)$curRisk['rate_count'] ?> 次<?php endif; ?></span>
|
||||
<span class="tip" style="margin-left:8px">当前访问 IP(<?= he($curIp) ?>)计数:登录失败 <?= (int) $curRisk['login_fail'] ?>
|
||||
次
|
||||
· 404 <?= (int) $curRisk['not_found'] ?> 次<?php if ($rcfg['rate']['on']): ?> · 本分钟请求
|
||||
<?= (int) $curRisk['rate_count'] ?> 次<?php endif; ?></span>
|
||||
<?php endif; ?>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- IP 黑名单 -->
|
||||
<div class="fieldset-card">
|
||||
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</div>
|
||||
<div class="fs-title">黑名单管理</div>
|
||||
<form method="post" class="v-filter">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_add">
|
||||
<input type="text" name="rule" required placeholder="如:203.0.113.7 / 203.0.113.0/24 / 203.0.113.*" style="flex:1 1 260px;max-width:420px">
|
||||
<input type="text" name="rule" required placeholder="如:203.0.113.7 / 203.0.113.0/24 / 203.0.113.*"
|
||||
style="flex:1 1 260px;max-width:420px">
|
||||
<input type="text" name="note" placeholder="备注(可选,如:恶意扫描器)" style="flex:1 1 220px;max-width:340px">
|
||||
<button type="submit" class="btn btn-primary">加入黑名单</button>
|
||||
</form>
|
||||
<p class="tip" style="margin:10px 0 2px">
|
||||
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> 或 <code>192.168.*.*</code>);
|
||||
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的<strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db 的 ip_blacklist 表中删除记录。
|
||||
支持三种写法:单个 IP、CIDR 网段(如 <code>203.0.113.0/24</code>)、星号通配段(如 <code>203.0.113.*</code> 或
|
||||
<code>192.168.*.*</code>);
|
||||
目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的<strong>前台与后台(含登录页)访问一律被拒绝</strong>。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在
|
||||
data/homepage.db 的 ip_blacklist 表中删除记录。
|
||||
</p>
|
||||
<div style="margin:6px 0 10px">
|
||||
<?php if ($curHit): ?>
|
||||
<span class="vis-warn-tip">当前访问 IP:<?= he($curIp) ?> —— 命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string)$curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
|
||||
<span class="vis-warn-tip">当前访问 IP:<?= he($curIp) ?> ——
|
||||
命中规则「<?= he($curHit['rule']) ?>」(<?= ($curHit['note'] !== '') ? he((string) $curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截</span>
|
||||
<?php else: ?>
|
||||
<span class="vis-ok-tip">当前访问 IP:<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
|
||||
<?php endif; ?>
|
||||
@ -439,7 +412,9 @@ admin_topbar('visits');
|
||||
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin:8px 0 4px">
|
||||
<form method="post" id="blBatchForm" data-confirm="确认移除选中的黑名单规则?对应单 IP 风控计数将同步清除。">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_batch">
|
||||
<label class="tip" style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input type="checkbox" id="blCheckAll"> 全选</label>
|
||||
<label class="tip"
|
||||
style="display:inline-flex;align-items:center;gap:4px;cursor:pointer;margin:0 4px 0 0"><input
|
||||
type="checkbox" id="blCheckAll"> 全选</label>
|
||||
<button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中(0)</button>
|
||||
</form>
|
||||
<?php if ($banAutoCount > 0): ?>
|
||||
@ -455,17 +430,20 @@ admin_topbar('visits');
|
||||
</div>
|
||||
<?php foreach ($banRows as $b): ?>
|
||||
<div class="bl-row">
|
||||
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int)$b['id'] ?>"></label>
|
||||
<code class="bl-rule"><?= he((string)$b['rule']) ?></code>
|
||||
<?php if (((string)($b['source'] ?? 'manual')) === 'auto'): ?>
|
||||
<label class="tip" style="display:inline-flex;align-items:center;margin:0" title="勾选后可在上方批量移除"><input
|
||||
type="checkbox" form="blBatchForm" class="js-bl-check" name="bid[]" value="<?= (int) $b['id'] ?>"></label>
|
||||
<code class="bl-rule"><?= he((string) $b['rule']) ?></code>
|
||||
<?php if (((string) ($b['source'] ?? 'manual')) === 'auto'): ?>
|
||||
<span class="v-tag warn" style="min-width:0">风控自动</span>
|
||||
<?php else: ?>
|
||||
<span class="v-tag info" style="min-width:0">手动</span>
|
||||
<?php endif; ?>
|
||||
<span class="bl-note"><?= $b['note'] !== '' ? he((string)$b['note']) : '<span class="tip">(无备注)</span>' ?></span>
|
||||
<span class="bl-time"><?= he((string)$b['created_at']) ?></span>
|
||||
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string)$b['rule']) ?>」?">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id" value="<?= (int)$b['id'] ?>">
|
||||
<span
|
||||
class="bl-note"><?= $b['note'] !== '' ? he((string) $b['note']) : '<span class="tip">(无备注)</span>' ?></span>
|
||||
<span class="bl-time"><?= he((string) $b['created_at']) ?></span>
|
||||
<form method="post" data-confirm="确认移除黑名单规则「<?= he((string) $b['rule']) ?>」?">
|
||||
<?= csrf_field() ?><input type="hidden" name="act" value="ban_del"><input type="hidden" name="id"
|
||||
value="<?= (int) $b['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-danger">移除</button>
|
||||
</form>
|
||||
</div>
|
||||
@ -480,23 +458,24 @@ admin_topbar('visits');
|
||||
<?php layout_theme_fab(); ?>
|
||||
<script src="../assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
var all = document.getElementById('blCheckAll');
|
||||
var btn = document.getElementById('blBatchBtn');
|
||||
var boxes = Array.prototype.slice.call(document.querySelectorAll('.js-bl-check'));
|
||||
if (!all || !btn || !boxes.length) return;
|
||||
function refresh() {
|
||||
var n = boxes.filter(function (c) { return c.checked; }).length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = '移除选中(' + n + ')';
|
||||
all.checked = n > 0 && n === boxes.length;
|
||||
var n = boxes.filter(function (c) { return c.checked; }).length;
|
||||
btn.disabled = n === 0;
|
||||
btn.textContent = '移除选中(' + n + ')';
|
||||
all.checked = n > 0 && n === boxes.length;
|
||||
}
|
||||
all.addEventListener('change', function () {
|
||||
boxes.forEach(function (c) { c.checked = all.checked; });
|
||||
refresh();
|
||||
boxes.forEach(function (c) { c.checked = all.checked; });
|
||||
refresh();
|
||||
});
|
||||
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</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;
|
||||
if (el.scrollHeight <= el.clientHeight + 40) return;
|
||||
var cs;
|
||||
try { cs = window.getComputedStyle(el); } catch (e) { cs = null; }
|
||||
if (!cs) return;
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') scrollables.push(el);
|
||||
});
|
||||
function measure() {
|
||||
var v = 0;
|
||||
for (var i = 0; i < scrollables.length; i++) {
|
||||
if (scrollables[i].scrollTop > v) v = scrollables[i].scrollTop;
|
||||
var containers = [];
|
||||
var ticking = false;
|
||||
|
||||
function currentY() {
|
||||
var y = window.pageYOffset || (doc && doc.scrollTop) || 0;
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
var t = containers[i].scrollTop || 0;
|
||||
if (t > y) y = t;
|
||||
}
|
||||
btn.classList.toggle('show', v > 260);
|
||||
return y;
|
||||
}
|
||||
for (var s = 0; s < scrollables.length; s++) {
|
||||
scrollables[s].addEventListener('scroll', measure, { passive: true });
|
||||
function apply() {
|
||||
ticking = false;
|
||||
btn.classList.toggle('show', currentY() > 180);
|
||||
}
|
||||
window.addEventListener('resize', measure);
|
||||
function schedule() {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
if (window.requestAnimationFrame) window.requestAnimationFrame(apply);
|
||||
else setTimeout(apply, 30);
|
||||
}
|
||||
// 捕获阶段监听:主文档与任意内滚容器的滚动都能收到,避免漏触发
|
||||
document.addEventListener('scroll', schedule, true);
|
||||
window.addEventListener('resize', schedule, { passive: true });
|
||||
|
||||
// 采集内滚容器:DOM 就绪与资源加载后各测一次(布局完成后才能正确判断是否溢出)
|
||||
function collect() {
|
||||
var list = document.querySelectorAll('main, section, article, div, ul, ol');
|
||||
Array.prototype.forEach.call(list, function (el) {
|
||||
if (el === btn || containers.indexOf(el) >= 0) return;
|
||||
if (el.closest('.admin-side, .hp-toasts, .art-topbar')) return; // 侧栏/弹窗/顶栏自身滚动不应触发
|
||||
if (el.scrollHeight <= el.clientHeight + 40) return;
|
||||
var cs;
|
||||
try { cs = window.getComputedStyle(el); } catch (e) { return; }
|
||||
if (cs.position === 'fixed' || cs.position === 'sticky') return;
|
||||
if (cs.overflowY === 'auto' || cs.overflowY === 'scroll') containers.push(el);
|
||||
});
|
||||
apply();
|
||||
}
|
||||
collect();
|
||||
if (document.readyState !== 'complete') window.addEventListener('load', collect);
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var root = doc || document.documentElement;
|
||||
if (root && root.scrollTop > 0) root.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
else window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
btn.classList.remove('show'); // 乐观隐藏,避免到顶后仍残留
|
||||
for (var i = 0; i < containers.length; i++) {
|
||||
if (containers[i].scrollTop > 0) containers[i].scrollTop = 0;
|
||||
}
|
||||
if (doc && doc.scrollTop > 0) {
|
||||
try { doc.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { doc.scrollTop = 0; }
|
||||
} else {
|
||||
try { window.scrollTo({ top: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, 0); }
|
||||
}
|
||||
schedule();
|
||||
});
|
||||
measure();
|
||||
schedule();
|
||||
}
|
||||
|
||||
/* ---------- 后台侧边栏:收缩 / 移动端抽屉 / 本页功能锚点 ---------- */
|
||||
function initAdminSide() {
|
||||
var side = document.querySelector('.admin-side');
|
||||
if (!side) return;
|
||||
var body = document.body;
|
||||
var mq = window.matchMedia ? window.matchMedia('(max-width: 900px)') : null;
|
||||
function isMobile() { return mq ? mq.matches : window.innerWidth <= 900; }
|
||||
|
||||
// 收缩(桌面)/ 抽屉开关(小屏)
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.js-admin-side-toggle'), function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
if (isMobile()) {
|
||||
body.classList.toggle('hp-side-open');
|
||||
} else {
|
||||
var collapsed = body.classList.toggle('hp-side-collapsed');
|
||||
try { localStorage.setItem('hp-admin-side-collapsed', collapsed ? '1' : '0'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
var mask = document.getElementById('adminSideMask');
|
||||
if (mask) mask.addEventListener('click', function () { body.classList.remove('hp-side-open'); });
|
||||
window.addEventListener('resize', function () { if (!isMobile()) body.classList.remove('hp-side-open'); });
|
||||
Array.prototype.forEach.call(side.querySelectorAll('a'), function (a) {
|
||||
a.addEventListener('click', function () { if (isMobile()) body.classList.remove('hp-side-open'); });
|
||||
});
|
||||
|
||||
// 采集当前页“本页功能”:fieldset-card 的标题
|
||||
var secWrap = document.getElementById('adminSideSections');
|
||||
var secList = document.getElementById('adminSideSectionList');
|
||||
if (!secWrap || !secList) return;
|
||||
var items = [];
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.fieldset-card'), function (card) {
|
||||
var t = card.querySelector('.fs-title');
|
||||
if (!t) return;
|
||||
var clone = t.cloneNode(true);
|
||||
Array.prototype.forEach.call(clone.querySelectorAll('.tip'), function (n) { if (n.parentNode) n.parentNode.removeChild(n); });
|
||||
var text = (clone.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
if (text === '') return;
|
||||
items.push({ el: card, text: text });
|
||||
});
|
||||
if (items.length < 1) return;
|
||||
var idx = 0;
|
||||
items.forEach(function (it) {
|
||||
idx++;
|
||||
if (!it.el.id) it.el.id = 'hp-sec-' + idx;
|
||||
var a = document.createElement('a');
|
||||
a.className = 'as-sub';
|
||||
a.href = '#' + it.el.id;
|
||||
a.textContent = it.text;
|
||||
a.title = it.text;
|
||||
a.addEventListener('click', function (ev) {
|
||||
ev.preventDefault();
|
||||
try { it.el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
|
||||
catch (e) { it.el.scrollIntoView(); }
|
||||
});
|
||||
secList.appendChild(a);
|
||||
});
|
||||
secWrap.hidden = false;
|
||||
|
||||
// 高亮当前可视区块
|
||||
var subs = secList.querySelectorAll('.as-sub');
|
||||
function spy() {
|
||||
var best = -1, bestTop = -Infinity;
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var top = items[i].el.getBoundingClientRect().top - 120;
|
||||
if (top <= 0 && top > bestTop) { bestTop = top; best = i; }
|
||||
}
|
||||
for (var j = 0; j < subs.length; j++) subs[j].classList.toggle('active', j === best);
|
||||
}
|
||||
var st = false;
|
||||
function onScroll() {
|
||||
if (st) return;
|
||||
st = true;
|
||||
if (window.requestAnimationFrame) window.requestAnimationFrame(function () { st = false; spy(); });
|
||||
else { st = false; spy(); }
|
||||
}
|
||||
document.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onScroll);
|
||||
spy();
|
||||
}
|
||||
|
||||
/* ---------- 登录页:采集设备信息(系统 / 机型 / 屏幕等,能拿到什么就带什么)随表单一并提交 ---------- */
|
||||
function initLoginDevice() {
|
||||
var field = document.getElementById('devField');
|
||||
if (!field) return;
|
||||
function baseInfo() {
|
||||
var info = {};
|
||||
try {
|
||||
info.ua = navigator.userAgent || '';
|
||||
info.platform = navigator.platform || '';
|
||||
info.lang = navigator.language || '';
|
||||
info.screen = (window.screen && screen.width && screen.height) ? (screen.width + '×' + screen.height + '@' + (window.devicePixelRatio || 1)) : '';
|
||||
info.touch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||
} catch (e) { /* ignore */ }
|
||||
return info;
|
||||
}
|
||||
function fill() {
|
||||
try { field.value = JSON.stringify(baseInfo()); } catch (e) { /* ignore */ }
|
||||
}
|
||||
fill();
|
||||
// Chromium 系 UA-CH:可拿到机型(model)、系统版本、架构等高熵值
|
||||
try {
|
||||
if (navigator.userAgentData && navigator.userAgentData.getHighEntropyValues) {
|
||||
navigator.userAgentData.getHighEntropyValues(['platform', 'platformVersion', 'architecture', 'model', 'uaFullVersion'])
|
||||
.then(function (h) {
|
||||
var info = baseInfo();
|
||||
info.uch = h || {};
|
||||
field.value = JSON.stringify(info);
|
||||
})
|
||||
.catch(function () { /* ignore */ });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ---------- 风控管理页:筛选 / 翻页局部刷新(不整页跳转、不回到顶部) ---------- */
|
||||
function initVisitsAjax() {
|
||||
var boxes = {
|
||||
access: document.getElementById('visResults'),
|
||||
loginfail: document.getElementById('lfResults')
|
||||
};
|
||||
if (!boxes.access && !boxes.loginfail) return;
|
||||
|
||||
function serialize(form) {
|
||||
var parts = [];
|
||||
var els = form.elements;
|
||||
for (var i = 0; i < els.length; i++) {
|
||||
var el = els[i];
|
||||
if (!el.name || el.disabled) continue;
|
||||
var t = (el.type || '').toLowerCase();
|
||||
if ((t === 'checkbox' || t === 'radio') && !el.checked) continue;
|
||||
parts.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value));
|
||||
}
|
||||
return parts.join('&');
|
||||
}
|
||||
function load(section, query, pushUrl) {
|
||||
var box = boxes[section];
|
||||
if (!box) return;
|
||||
box.classList.add('vis-loading');
|
||||
var xhr = new XMLHttpRequest();
|
||||
try {
|
||||
xhr.open('GET', 'visits_data.php?section=' + encodeURIComponent(section) + (query ? ('&' + query) : ''), true);
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState !== 4) return;
|
||||
box.classList.remove('vis-loading');
|
||||
if (xhr.status === 200) {
|
||||
box.innerHTML = xhr.responseText;
|
||||
if (pushUrl && history.replaceState) {
|
||||
history.replaceState(null, '', 'visits.php' + (query ? ('?' + query) : ''));
|
||||
}
|
||||
} else {
|
||||
box.innerHTML = '<div class="err-msg">加载失败(可能登录状态已失效),请刷新页面后重试。</div>';
|
||||
}
|
||||
};
|
||||
xhr.send();
|
||||
} catch (e) {
|
||||
box.classList.remove('vis-loading');
|
||||
}
|
||||
}
|
||||
// 筛选表单(事件委托,局部刷新后依然有效)
|
||||
document.addEventListener('submit', function (ev) {
|
||||
var form = ev.target;
|
||||
if (!form || !form.getAttribute || !form.hasAttribute('data-vis-form')) return;
|
||||
if (!/get/i.test(form.method || '')) return;
|
||||
ev.preventDefault();
|
||||
load(form.getAttribute('data-vis-form'), serialize(form), true);
|
||||
}, true);
|
||||
// 分页链接(委托到 document,避开局部刷新后监听丢失)
|
||||
document.addEventListener('click', function (ev) {
|
||||
var a = (ev.target && ev.target.closest) ? ev.target.closest('a[data-vis-page]') : null;
|
||||
if (!a) return;
|
||||
ev.preventDefault();
|
||||
load(a.getAttribute('data-vis-section') || 'access', a.getAttribute('data-vis-page') || '', true);
|
||||
}, true);
|
||||
}
|
||||
|
||||
/* ---------- 功能区静态工具页:库卡片 ?from=nav 进入时,返回按钮改为“返回上一页” ---------- */
|
||||
@ -564,6 +768,9 @@
|
||||
initCopyButtons();
|
||||
bindConfirmForms();
|
||||
initVisitorInfo();
|
||||
initLoginDevice();
|
||||
initVisitsAjax();
|
||||
initAdminSide();
|
||||
initToTop();
|
||||
initFuncBack();
|
||||
hpShowPageToast();
|
||||
|
||||
347
assets/js/navicat.js
Normal file
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>
|
||||
|
||||
</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
|
||||
|
||||
377
includes/db.php
377
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,30 +181,30 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 文章库板块分类(第五大板块,旧库缺失时追加) ----------
|
||||
$hasArticleCat = (int)$pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
|
||||
$hasArticleCat = (int) $pdo->query("SELECT COUNT(*) FROM categories WHERE key = 'article'")->fetchColumn();
|
||||
if ($hasArticleCat === 0) {
|
||||
$stC = $pdo->prepare('INSERT INTO categories (key, name, icon, description, sort) VALUES (?,?,?,?,?)');
|
||||
$stC->execute(['article', '文章库', '📚', '站长原创与整理收录的图文与 Markdown 文章合集。', 5]);
|
||||
}
|
||||
|
||||
// ---------- 默认设置种子 ----------
|
||||
$hasSettings = (int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||
$hasSettings = (int) $pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn();
|
||||
if ($hasSettings === 0) {
|
||||
$defaults = [
|
||||
'site_name' => '知识导航站',
|
||||
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
||||
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
||||
'icp_no' => '京ICP备00000000号-1',
|
||||
'gongan_no' => '京公网安备11000000000000号',
|
||||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
||||
'footer_text' => '',
|
||||
'search_engines'=> '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||
'last_updated' => '',
|
||||
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
|
||||
'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
||||
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
|
||||
'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本)
|
||||
'site_name' => '知识导航站',
|
||||
'site_slogan' => '汇聚科普、攻防与效率工具的实用首页',
|
||||
'site_logo' => '', // 空 = 使用默认 assets/img/logo.svg
|
||||
'icp_no' => '京ICP备00000000号-1',
|
||||
'gongan_no' => '京公网安备11000000000000号',
|
||||
'gongan_link' => '', // 公安备案号链接(可空则不显示为链接)
|
||||
'copyright' => 'Copyright © 2026 知识导航站 版权所有。本站内容仅供学习研究使用。',
|
||||
'footer_text' => '',
|
||||
'search_engines' => '', // 搜索引擎列表 JSON;空 = 使用默认 4 个
|
||||
'last_updated' => '',
|
||||
'hero_bg' => '', // 首页顶部标识栏背景(纯色 / CSS 渐变),空 = 主题默认
|
||||
'hero_mode' => 'weather', // 首页顶部组件模式:weather=天气(默认) / info=访客信息 / blank=空白精简
|
||||
'cdn_ranges' => '', // CDN 段库文本;空 = 使用默认内置列表
|
||||
'article_note' => '', // 文章区首页顶部注要(Markdown 风格,纯文本)
|
||||
];
|
||||
$st = $pdo->prepare('INSERT INTO settings (key, value) VALUES (?,?)');
|
||||
foreach ($defaults as $k => $v) {
|
||||
@ -198,7 +213,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 默认管理员 ----------
|
||||
$userCount = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
$userCount = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
if ($userCount === 0) {
|
||||
// 密码算法:先 base64 再 md5(按原始需求实现)
|
||||
$st = $pdo->prepare('INSERT INTO users (username, password_hash) VALUES (?,?)');
|
||||
@ -206,7 +221,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 功能区工具默认种子 ----------
|
||||
$toolCount = (int)$pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||||
$toolCount = (int) $pdo->query('SELECT COUNT(*) FROM func_tools')->fetchColumn();
|
||||
if ($toolCount === 0) {
|
||||
$seedTools = [
|
||||
['编码 / 加解密', '🔐', 'Base64 / Base32 / URL / Unicode / MD5 / SHA 系列', 'codec.html', 0],
|
||||
@ -226,19 +241,20 @@ function db_init(PDO $pdo): void
|
||||
['国密加解密', '🔏', 'SM2 非对称加解密 / SM3 摘要 / SM4 对称加解密(SM1 为不公开硬件算法)', 'gmcodec.html'],
|
||||
['IP 地址处理', '🌐', '提取日志中的全部 IPv4 并统计出现次数,再按内网 / CDN / 公网区分展示', 'ip.html'],
|
||||
['辅助维权', '🛠️', '反弹 Shell 构建 / Windows SAM·mimikatz·票据提取 / Linux 自启服务(systemd 与 init.d)', 'persist.html'],
|
||||
['Navicat 密码解密', '🔓', 'Navicat 数据库连接密码本地解密 / 加密(11 及以前 Blowfish / 12 及以后 AES-128-CBC)', 'navicat.html'],
|
||||
];
|
||||
$chkUrl = $pdo->prepare('SELECT COUNT(*) FROM func_tools WHERE url = ?');
|
||||
$insTool = $pdo->prepare('INSERT INTO func_tools (name, icon, description, url, is_external, enabled, sort) VALUES (?,?,?,?,0,1,?)');
|
||||
foreach ($extraTools as $t) {
|
||||
$chkUrl->execute([$t[3]]);
|
||||
if ((int)$chkUrl->fetchColumn() === 0) {
|
||||
$mx = (int)$pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||||
if ((int) $chkUrl->fetchColumn() === 0) {
|
||||
$mx = (int) $pdo->query('SELECT COALESCE(MAX(sort), 0) FROM func_tools')->fetchColumn();
|
||||
$insTool->execute([$t[0], $t[1], $t[2], $t[3], $mx + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 库首页“外部工具区”默认种子(cat_keys 为空=展示于所有库首页;grp=分区/栏目,留空不分) ----------
|
||||
$extCount = (int)$pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
|
||||
$extCount = (int) $pdo->query('SELECT COUNT(*) FROM ext_links')->fetchColumn();
|
||||
if ($extCount === 0) {
|
||||
$seedExt = [
|
||||
// [分区/栏目, 名称, 网址, 简介]
|
||||
@ -257,7 +273,7 @@ function db_init(PDO $pdo): void
|
||||
}
|
||||
|
||||
// ---------- 文章库示例文章(首次建库时提供一篇使用说明) ----------
|
||||
$artCount = (int)$pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||||
$artCount = (int) $pdo->query('SELECT COUNT(*) FROM articles')->fetchColumn();
|
||||
if ($artCount === 0) {
|
||||
$sampleMd = <<<MD
|
||||
## 分区与文章
|
||||
@ -289,13 +305,13 @@ MD;
|
||||
|
||||
// ---------- 功能区工具登记:旧 PHP 外壳 → 静态 HTML(.php 已改为 302 重定向,入口自动同步迁移) ----------
|
||||
$toolHtmlMap = [
|
||||
'codec.php' => 'codec.html',
|
||||
'codec.php' => 'codec.html',
|
||||
'password.php' => 'password.html',
|
||||
'qrcode.php' => 'qrcode.html',
|
||||
'av.php' => 'av.html',
|
||||
'ip.php' => 'ip.html',
|
||||
'persist.php' => 'persist.html',
|
||||
'gmcodec.php' => 'gmcodec.html',
|
||||
'qrcode.php' => 'qrcode.html',
|
||||
'av.php' => 'av.html',
|
||||
'ip.php' => 'ip.html',
|
||||
'persist.php' => 'persist.html',
|
||||
'gmcodec.php' => 'gmcodec.html',
|
||||
];
|
||||
$updUrl = $pdo->prepare('UPDATE func_tools SET url = ? WHERE url = ?');
|
||||
foreach ($toolHtmlMap as $oldUrl => $newUrl) {
|
||||
@ -322,7 +338,7 @@ function setting_get(string $key, string $default = ''): string
|
||||
if ($cache === null) {
|
||||
$cache = [];
|
||||
foreach (db()->query('SELECT key, value FROM settings') as $row) {
|
||||
$cache[$row['key']] = (string)$row['value'];
|
||||
$cache[$row['key']] = (string) $row['value'];
|
||||
}
|
||||
}
|
||||
return array_key_exists($key, $cache) ? $cache[$key] : $default;
|
||||
@ -342,7 +358,7 @@ function client_ip(): string
|
||||
if ($cached !== null) {
|
||||
return $cached;
|
||||
}
|
||||
$ip = trim((string)($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
$ip = '';
|
||||
}
|
||||
@ -376,7 +392,7 @@ function blacklist_parse_rule(string $raw): array
|
||||
}
|
||||
// CIDR 网段
|
||||
if (preg_match('#^(\d{1,3}(?:\.\d{1,3}){3})/(\d{1,2})$#', $rule, $m)) {
|
||||
$prefix = (int)$m[2];
|
||||
$prefix = (int) $m[2];
|
||||
if ($prefix > 32 || !filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return [false, 0, 0, '网段格式不正确,掩码需在 0-32 之间。'];
|
||||
}
|
||||
@ -400,10 +416,10 @@ function blacklist_parse_rule(string $raw): array
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($seen || $p === '' || !ctype_digit($p) || (int)$p > 255) {
|
||||
if ($seen || $p === '' || !ctype_digit($p) || (int) $p > 255) {
|
||||
return [false, 0, 0, '通配段格式不正确,* 只能连续出现在末尾。'];
|
||||
}
|
||||
$bytes[$i] = (int)$p;
|
||||
$bytes[$i] = (int) $p;
|
||||
}
|
||||
if (!$seen) {
|
||||
return [false, 0, 0, '规则格式不正确。'];
|
||||
@ -424,17 +440,17 @@ function blacklist_rules(bool $refresh = false): array
|
||||
$cache = [];
|
||||
try {
|
||||
foreach (db()->query('SELECT id, rule, note, created_at FROM ip_blacklist ORDER BY id ASC') as $row) {
|
||||
[$ok, $net, $prefix] = blacklist_parse_rule((string)$row['rule']);
|
||||
[$ok, $net, $prefix] = blacklist_parse_rule((string) $row['rule']);
|
||||
if (!$ok) {
|
||||
continue;
|
||||
}
|
||||
$cache[] = [
|
||||
'id' => (int)$row['id'],
|
||||
'rule' => (string)$row['rule'],
|
||||
'note' => (string)$row['note'],
|
||||
'created_at' => (string)$row['created_at'],
|
||||
'net' => $net,
|
||||
'prefix' => $prefix,
|
||||
'id' => (int) $row['id'],
|
||||
'rule' => (string) $row['rule'],
|
||||
'note' => (string) $row['note'],
|
||||
'created_at' => (string) $row['created_at'],
|
||||
'net' => $net,
|
||||
'prefix' => $prefix,
|
||||
];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@ -473,12 +489,12 @@ function access_log_flush(): void
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
$method = (string)($_SERVER['REQUEST_METHOD'] ?? '');
|
||||
$method = (string) ($_SERVER['REQUEST_METHOD'] ?? '');
|
||||
$method = $method !== '' ? strtoupper($method) : 'GET';
|
||||
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
|
||||
$uri = (string) ($_SERVER['REQUEST_URI'] ?? '/');
|
||||
// 登录口令、CSRF 等敏感 query 参数脱敏后再写日志
|
||||
$uri = preg_replace('/([?&](?:password|passwd|old_pwd|new_pwd|new_pwd2|csrf|token|secret)[^=]*=)[^&]*/i', '$1***', $uri);
|
||||
$uri = substr((string)$uri, 0, 600);
|
||||
$uri = substr((string) $uri, 0, 600);
|
||||
$code = http_response_code();
|
||||
if (!is_int($code) || $code < 100 || $code > 599) {
|
||||
$code = 200;
|
||||
@ -487,7 +503,7 @@ function access_log_flush(): void
|
||||
$line = date('Y-m-d H:i:s') . "\t" . $ip . "\t" . $method . "\t" . $code . "\t" . $uri . "\n";
|
||||
@file_put_contents(ACCESS_LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
// 自动风控:前台 404 响应计入累计(是否达到阈值触发封禁见 risk_count_not_found / access_boot 自检)
|
||||
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
if (!$isAdmin && $code === 404) {
|
||||
risk_count_not_found($ip);
|
||||
}
|
||||
@ -496,7 +512,7 @@ function access_log_flush(): void
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“访问信息”页使用 */
|
||||
/** 读取访问日志文件,解析为记录数组(新→旧排序),供后台“风控管理”页使用 */
|
||||
function visitors_entries(): array
|
||||
{
|
||||
$out = [];
|
||||
@ -517,11 +533,11 @@ function visitors_entries(): array
|
||||
continue;
|
||||
}
|
||||
$out[] = [
|
||||
'created_at' => (string)$p[0],
|
||||
'ip' => (string)$p[1],
|
||||
'method' => (string)$p[2],
|
||||
'status' => (int)$p[3],
|
||||
'url' => (string)$p[4],
|
||||
'created_at' => (string) $p[0],
|
||||
'ip' => (string) $p[1],
|
||||
'method' => (string) $p[2],
|
||||
'status' => (int) $p[3],
|
||||
'url' => (string) $p[4],
|
||||
];
|
||||
}
|
||||
fclose($fh);
|
||||
@ -539,7 +555,7 @@ function access_boot(): void
|
||||
if ($ip === '') {
|
||||
return;
|
||||
}
|
||||
$isAdmin = strpos((string)($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
$isAdmin = strpos((string) ($_SERVER['SCRIPT_NAME'] ?? ''), '/admin/') === 0;
|
||||
// 1) 已在黑名单(手动 / 自动):前台与后台一致拦截,统一返回风控提示页
|
||||
if (blacklist_ip_hit($ip) !== null) {
|
||||
risk_block_page();
|
||||
@ -548,15 +564,15 @@ function access_boot(): void
|
||||
if (!$isAdmin) {
|
||||
$cfg = risk_rule_cfg();
|
||||
// 2a) 请求频率:先累计本请求,超过阈值即封禁
|
||||
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int)$cfg['rate']['n'])) {
|
||||
risk_ban($ip, '请求频率超过阈值(60 秒内 ' . (int)$cfg['rate']['n'] . ' 次)');
|
||||
if ($cfg['rate']['on'] && risk_bump_rate($ip, (int) $cfg['rate']['n'])) {
|
||||
risk_ban($ip, '请求频率超过阈值(60 秒内 ' . (int) $cfg['rate']['n'] . ' 次)');
|
||||
risk_block_page();
|
||||
}
|
||||
// 2b) 404 累计已达标(若上一请求刚触发封禁,这里会因命中黑名单直接拦截,此分支为兑底)
|
||||
if ($cfg['not_found']['on']) {
|
||||
$row = risk_row($ip);
|
||||
if ((int)$row['not_found'] >= (int)$cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
|
||||
if ((int) $row['not_found'] >= (int) $cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
|
||||
risk_block_page();
|
||||
}
|
||||
}
|
||||
@ -573,7 +589,7 @@ function risk_settings_map(): array
|
||||
$map = [];
|
||||
try {
|
||||
foreach (db()->query('SELECT key, value FROM settings') as $r) {
|
||||
$map[(string)$r['key']] = (string)$r['value'];
|
||||
$map[(string) $r['key']] = (string) $r['value'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$map = [];
|
||||
@ -588,10 +604,10 @@ function risk_settings_map(): array
|
||||
function risk_block_tip(): array
|
||||
{
|
||||
$map = risk_settings_map();
|
||||
$rawMain = trim((string)($map['risk_tip_main'] ?? ''));
|
||||
$rawSub = trim((string)($map['risk_tip_sub'] ?? ''));
|
||||
$rawMain = trim((string) ($map['risk_tip_main'] ?? ''));
|
||||
$rawSub = trim((string) ($map['risk_tip_sub'] ?? ''));
|
||||
$main = $rawMain !== '' ? $rawMain : '您触发了本站风控,请稍后再访问';
|
||||
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
|
||||
$sub = $rawSub !== '' ? $rawSub : '如为正常访问,请稍后重试;若频繁误判,请联系站点管理员处理。';
|
||||
return ['main' => $main, 'sub' => $sub, 'raw_main' => $rawMain, 'raw_sub' => $rawSub];
|
||||
}
|
||||
|
||||
@ -602,9 +618,9 @@ function risk_block_tip(): array
|
||||
function risk_rule_cfg(): array
|
||||
{
|
||||
$defs = [
|
||||
'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
|
||||
'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
|
||||
'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'],
|
||||
'login_fail' => [1, 10, '后台登录失败累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
|
||||
'not_found' => [1, 100, '前台访问返回 404 累计达到阈值后,该 IP 被自动加入黑名单(永久封禁)。'],
|
||||
'rate' => [0, 120, '60 秒内请求次数超过阈值后,该 IP 被自动加入黑名单(永久封禁);多人共享同一出口 IP 时建议关闭。'],
|
||||
];
|
||||
$map = risk_settings_map();
|
||||
$out = [];
|
||||
@ -613,18 +629,18 @@ function risk_rule_cfg(): array
|
||||
foreach ($defs as $k => $d) {
|
||||
$pre = $alias[$k];
|
||||
$out[$k] = [
|
||||
'on' => (($map['risk_' . $pre . '_on'] ?? (string)$d[0]) === '1'),
|
||||
'n' => max(1, min(1000000, (int)($map['risk_' . $pre . '_n'] ?? (string)$d[1]))),
|
||||
'on' => (($map['risk_' . $pre . '_on'] ?? (string) $d[0]) === '1'),
|
||||
'n' => max(1, min(1000000, (int) ($map['risk_' . $pre . '_n'] ?? (string) $d[1]))),
|
||||
'tip' => $d[2],
|
||||
];
|
||||
}
|
||||
// 拦截提示(含原始保存值,供后台编辑回显与占位提示)
|
||||
$tip = risk_block_tip();
|
||||
$out['_tip'] = [
|
||||
'main' => $tip['main'],
|
||||
'sub' => $tip['sub'],
|
||||
'main' => $tip['main'],
|
||||
'sub' => $tip['sub'],
|
||||
'raw_main' => $tip['raw_main'],
|
||||
'raw_sub' => $tip['raw_sub'],
|
||||
'raw_sub' => $tip['raw_sub'],
|
||||
];
|
||||
return $out;
|
||||
}
|
||||
@ -690,9 +706,9 @@ function risk_login_fail(string $ip): array
|
||||
}
|
||||
$cfg = risk_rule_cfg();
|
||||
$row = risk_row($ip);
|
||||
$lf = (int)$row['login_fail'] + 1;
|
||||
$n = (int)$cfg['login_fail']['n'];
|
||||
risk_upsert($ip, $lf, (int)$row['not_found'], (int)$row['rate_ts'], (int)$row['rate_count']);
|
||||
$lf = (int) $row['login_fail'] + 1;
|
||||
$n = (int) $cfg['login_fail']['n'];
|
||||
risk_upsert($ip, $lf, (int) $row['not_found'], (int) $row['rate_ts'], (int) $row['rate_count']);
|
||||
$banned = $cfg['login_fail']['on'] && $lf >= $n;
|
||||
if ($banned) {
|
||||
risk_ban($ip, '后台登录失败累计达阈值(' . $n . ' 次)');
|
||||
@ -706,10 +722,10 @@ function risk_bump_rate(string $ip, int $threshold): bool
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
return false;
|
||||
}
|
||||
$win = (int)floor(time() / 60);
|
||||
$row = risk_row($ip);
|
||||
$cnt = ((int)$row['rate_ts'] === $win) ? ((int)$row['rate_count'] + 1) : 1;
|
||||
risk_upsert($ip, (int)$row['login_fail'], (int)$row['not_found'], $win, $cnt);
|
||||
$win = (int) floor(time() / 60);
|
||||
$row = risk_row($ip);
|
||||
$cnt = ((int) $row['rate_ts'] === $win) ? ((int) $row['rate_count'] + 1) : 1;
|
||||
risk_upsert($ip, (int) $row['login_fail'], (int) $row['not_found'], $win, $cnt);
|
||||
return $cnt >= $threshold;
|
||||
}
|
||||
|
||||
@ -721,10 +737,10 @@ function risk_count_not_found(string $ip): void
|
||||
}
|
||||
$cfg = risk_rule_cfg();
|
||||
$row = risk_row($ip);
|
||||
$nf = (int)$row['not_found'] + 1;
|
||||
risk_upsert($ip, (int)$row['login_fail'], $nf, (int)$row['rate_ts'], (int)$row['rate_count']);
|
||||
if ($cfg['not_found']['on'] && $nf >= (int)$cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int)$cfg['not_found']['n'] . ' 次)');
|
||||
$nf = (int) $row['not_found'] + 1;
|
||||
risk_upsert($ip, (int) $row['login_fail'], $nf, (int) $row['rate_ts'], (int) $row['rate_count']);
|
||||
if ($cfg['not_found']['on'] && $nf >= (int) $cfg['not_found']['n']) {
|
||||
risk_ban($ip, '404 累计次数达阈值(' . (int) $cfg['not_found']['n'] . ' 次)');
|
||||
}
|
||||
}
|
||||
|
||||
@ -767,8 +783,8 @@ function risk_block_page(): void
|
||||
// ignore
|
||||
}
|
||||
$tip = risk_block_tip();
|
||||
$s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8');
|
||||
$m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8');
|
||||
$s = htmlspecialchars($site, ENT_QUOTES, 'UTF-8');
|
||||
$m = htmlspecialchars($tip['main'], ENT_QUOTES, 'UTF-8');
|
||||
$sb = htmlspecialchars($tip['sub'], ENT_QUOTES, 'UTF-8');
|
||||
echo '<!DOCTYPE html>' . "\n"
|
||||
. '<html lang="zh-CN"><head><meta charset="utf-8">'
|
||||
@ -791,4 +807,201 @@ function risk_block_page(): void
|
||||
exit;
|
||||
}
|
||||
|
||||
/** 由 UA 串解析浏览器与操作系统(内部复用):返回 [浏览器, 操作系统] */
|
||||
function ua_parts(string $ua): array
|
||||
{
|
||||
$ua = trim($ua);
|
||||
if ($ua === '') {
|
||||
return ['未知浏览器', ''];
|
||||
}
|
||||
if (strpos($ua, 'MicroMessenger') !== false) {
|
||||
$browser = '微信内置浏览器';
|
||||
} elseif (preg_match('/Edg\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Edge ' . $m[1];
|
||||
} elseif (preg_match('/OPR\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Opera ' . $m[1];
|
||||
} elseif (preg_match('/Firefox\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Firefox ' . $m[1];
|
||||
} elseif (preg_match('/(?:Chrome|CriOS)\/([\d.]+)/', $ua, $m)) {
|
||||
$browser = 'Chrome ' . $m[1];
|
||||
} elseif (preg_match('/Version\/([\d.]+).*Safari/', $ua, $m)) {
|
||||
$browser = 'Safari ' . $m[1];
|
||||
} elseif (stripos($ua, 'curl') !== false) {
|
||||
$browser = 'curl';
|
||||
} elseif (preg_match('/python|wget|Go-http|Java|okhttp/i', $ua)) {
|
||||
$browser = '脚本客户端';
|
||||
} else {
|
||||
$browser = '未知浏览器';
|
||||
}
|
||||
if (strpos($ua, 'Windows NT 10.0') !== false) {
|
||||
$os = 'Windows 10/11';
|
||||
} elseif (strpos($ua, 'Windows NT 6.3') !== false) {
|
||||
$os = 'Windows 8.1';
|
||||
} elseif (strpos($ua, 'Windows NT 6.1') !== false) {
|
||||
$os = 'Windows 7';
|
||||
} elseif (stripos($ua, 'Windows') !== false) {
|
||||
$os = 'Windows';
|
||||
} elseif (strpos($ua, 'Android') !== false) {
|
||||
$os = 'Android';
|
||||
} elseif (preg_match('/iPhone|iPad|iPod/', $ua)) {
|
||||
$os = 'iOS';
|
||||
} elseif (strpos($ua, 'Mac OS X') !== false) {
|
||||
$os = 'macOS';
|
||||
} elseif (strpos($ua, 'Linux') !== false) {
|
||||
$os = 'Linux';
|
||||
} else {
|
||||
$os = '';
|
||||
}
|
||||
return [$browser, $os];
|
||||
}
|
||||
|
||||
/** 由 UA 串解析“浏览器 / 操作系统”(简洁展示;容错返回“未知设备”) */
|
||||
function login_ua_brief(string $ua): string
|
||||
{
|
||||
if (trim($ua) === '') {
|
||||
return '未知设备';
|
||||
}
|
||||
list($browser, $os) = ua_parts($ua);
|
||||
return $os !== '' ? ($browser . ' / ' . $os) : $browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备信息描述(手机型号 / 操作系统 / 浏览器 / 设备类型 + 屏幕、语言等):
|
||||
* 客户端采集 JSON($clientJson,见 common.js collectLoginDevice)优先,服务端 UA 回退;能获取到什么就写什么。
|
||||
*/
|
||||
function login_device_brief(string $clientJson, string $ua): string
|
||||
{
|
||||
$ua = trim($ua);
|
||||
$ci = json_decode($clientJson, true);
|
||||
if (!is_array($ci)) {
|
||||
$ci = [];
|
||||
}
|
||||
$uch = (isset($ci['uch']) && is_array($ci['uch'])) ? $ci['uch'] : [];
|
||||
list($browser, $os) = ua_parts($ua);
|
||||
// 系统:客户端高熵值优先(可拿到 Windows/Android 具体版本)
|
||||
if (!empty($uch['platform'])) {
|
||||
$os2 = trim((string) $uch['platform']);
|
||||
if (!empty($uch['platformVersion'])) {
|
||||
$os2 .= ' ' . trim((string) $uch['platformVersion']);
|
||||
}
|
||||
if ($os2 !== '') {
|
||||
$os = $os2;
|
||||
}
|
||||
}
|
||||
// 型号:客户端 model 优先,其次从 Android UA 提取,iOS 只能得到机型系列
|
||||
$model = '';
|
||||
if (!empty($uch['model'])) {
|
||||
$model = trim((string) $uch['model']);
|
||||
} elseif (preg_match('/Android[\s\d.]+;\s*([^;)]+?)(?:\s+Build|\))/i', $ua, $m)) {
|
||||
$model = trim($m[1]);
|
||||
} elseif (preg_match('/(iPhone|iPad|iPod)/i', $ua, $m)) {
|
||||
$model = 'Apple ' . $m[1];
|
||||
}
|
||||
$mobile = (!empty($ci['touch'])) || (stripos($ua, 'Mobile') !== false) || (isset($uch['mobile']) && $uch['mobile']);
|
||||
$seg = [];
|
||||
if ($model !== '') {
|
||||
$seg[] = $model;
|
||||
}
|
||||
if ($os !== '') {
|
||||
$seg[] = $os;
|
||||
}
|
||||
if ($browser !== '') {
|
||||
$seg[] = $browser;
|
||||
}
|
||||
$seg[] = $mobile ? '移动设备' : '桌面设备';
|
||||
$extra = [];
|
||||
if (!empty($uch['architecture'])) {
|
||||
$extra[] = '架构 ' . trim((string) $uch['architecture']);
|
||||
}
|
||||
if (!empty($ci['screen'])) {
|
||||
$extra[] = '屏幕 ' . trim((string) $ci['screen']);
|
||||
}
|
||||
if (!empty($ci['lang'])) {
|
||||
$extra[] = trim((string) $ci['lang']);
|
||||
}
|
||||
$text = implode(' · ', $seg);
|
||||
if ($extra) {
|
||||
$text .= '(' . implode(',', $extra) . ')';
|
||||
}
|
||||
return function_exists('mb_substr') ? mb_substr($text, 0, 300, 'UTF-8') : substr($text, 0, 300);
|
||||
}
|
||||
|
||||
/** 写入一条登录日志(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();
|
||||
|
||||
545
nav.php
545
nav.php
@ -7,25 +7,26 @@
|
||||
* 说明:库间切换仅在首页板块完成,库内不提供切换入口;板块说明文案常显于首页板块卡。
|
||||
* 数据来源:
|
||||
* 导航区 = nav_blocks / nav_items(按当前库);
|
||||
* 内部功能区 = func_tools(后台“功能区管理”中 board_cats 控制按库展示,空=通用);
|
||||
* 内部功能区 = func_tools(后台“内部功能”中 board_cats 控制按库展示,空=通用);
|
||||
* 文章区 = articles(后台“文章管理”中 board_cats 勾选分发到库,未分发不显示);
|
||||
* 外部工具区 = ext_links(后台“外部工具”维护,cat_keys 控制范围,空=通用)。
|
||||
*/
|
||||
$P = '';
|
||||
require_once __DIR__ . '/includes/layout.php';
|
||||
|
||||
$catKey = isset($_GET['cat']) ? (string)$_GET['cat'] : '';
|
||||
$catKey = isset($_GET['cat']) ? (string) $_GET['cat'] : '';
|
||||
$allowed = ['popular', 'red', 'blue'];
|
||||
if (!in_array($catKey, $allowed, true)) {
|
||||
$catKey = 'popular';
|
||||
$catKey = 'popular';
|
||||
}
|
||||
|
||||
/** 范围列是否包含当前库:空字符串视为“通用(所有库)” */
|
||||
function board_cats_has(string $csv, string $key): bool
|
||||
{
|
||||
$csv = trim($csv);
|
||||
if ($csv === '') return true;
|
||||
return in_array($key, array_map('trim', explode(',', $csv)), true);
|
||||
$csv = trim($csv);
|
||||
if ($csv === '')
|
||||
return true;
|
||||
return in_array($key, array_map('trim', explode(',', $csv)), true);
|
||||
}
|
||||
|
||||
$pdo = db();
|
||||
@ -33,89 +34,99 @@ $st = $pdo->prepare('SELECT * FROM categories WHERE key = ? LIMIT 1');
|
||||
$st->execute([$catKey]);
|
||||
$cat = $st->fetch();
|
||||
if (!$cat) {
|
||||
http_response_code(404);
|
||||
layout_head('导航页不存在');
|
||||
echo '<main class="page-main"><div class="wrap"><div class="err-msg">导航分类不存在,请返回首页。</div></div></main>';
|
||||
layout_theme_fab();
|
||||
layout_footer();
|
||||
echo '<script src="assets/js/common.js"></script></body></html>';
|
||||
exit;
|
||||
http_response_code(404);
|
||||
layout_head('导航页不存在');
|
||||
echo '<main class="page-main"><div class="wrap"><div class="err-msg">导航分类不存在,请返回首页。</div></div></main>';
|
||||
layout_theme_fab();
|
||||
layout_footer();
|
||||
echo '<script src="assets/js/common.js"></script></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// —— 导航区数据 ——
|
||||
$st = $pdo->prepare('SELECT * FROM nav_blocks WHERE cat_id = ? ORDER BY sort ASC, id ASC');
|
||||
$st->execute([(int)$cat['id']]);
|
||||
$st->execute([(int) $cat['id']]);
|
||||
$blocks = $st->fetchAll();
|
||||
$itemsByBlock = [];
|
||||
if ($blocks) {
|
||||
$ids = array_map(function ($b) { return (int)$b['id']; }, $blocks);
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $item) {
|
||||
$itemsByBlock[(int)$item['block_id']][] = $item;
|
||||
}
|
||||
$ids = array_map(function ($b) {
|
||||
return (int) $b['id'];
|
||||
}, $blocks);
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = $pdo->prepare("SELECT * FROM nav_items WHERE block_id IN ($in) ORDER BY sort ASC, id ASC");
|
||||
$st->execute($ids);
|
||||
foreach ($st->fetchAll() as $item) {
|
||||
$itemsByBlock[(int) $item['block_id']][] = $item;
|
||||
}
|
||||
}
|
||||
$totalNav = 0;
|
||||
foreach ($itemsByBlock as $list) { $totalNav += count($list); }
|
||||
foreach ($itemsByBlock as $list) {
|
||||
$totalNav += count($list);
|
||||
}
|
||||
$navCols = [[], [], []];
|
||||
$idx = 0;
|
||||
foreach ($blocks as $b) { $navCols[$idx % 3][] = $b; $idx++; }
|
||||
foreach ($blocks as $b) {
|
||||
$navCols[$idx % 3][] = $b;
|
||||
$idx++;
|
||||
}
|
||||
|
||||
// —— 内部功能区数据(通用或包含当前库) ——
|
||||
$libTools = [];
|
||||
foreach ($pdo->query('SELECT * FROM func_tools WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $t) {
|
||||
if (board_cats_has((string)($t['board_cats'] ?? ''), $catKey)) {
|
||||
$libTools[] = $t;
|
||||
}
|
||||
if (board_cats_has((string) ($t['board_cats'] ?? ''), $catKey)) {
|
||||
$libTools[] = $t;
|
||||
}
|
||||
}
|
||||
|
||||
// —— 文章区数据(仅分发到当前库;按分类分组,分类内按最后更新时间倒序) ——
|
||||
$libArts = [];
|
||||
foreach ($pdo->query('SELECT id, part, title, summary, updated_at, board_cats FROM articles WHERE enabled = 1 ORDER BY updated_at DESC, id DESC')->fetchAll() as $a) {
|
||||
if (in_array($catKey, array_map('trim', explode(',', trim((string)($a['board_cats'] ?? '')))), true)) {
|
||||
$libArts[] = $a;
|
||||
}
|
||||
if (in_array($catKey, array_map('trim', explode(',', trim((string) ($a['board_cats'] ?? '')))), true)) {
|
||||
$libArts[] = $a;
|
||||
}
|
||||
}
|
||||
// 按分类(part)分组;分类顺序:后台拖出的分区顺序优先,否则按各组最新更新时间降序
|
||||
$artGroups = [];
|
||||
foreach ($libArts as $a) {
|
||||
$p = trim((string)$a['part']);
|
||||
if ($p === '') $p = '未分类';
|
||||
if (!isset($artGroups[$p])) {
|
||||
$artGroups[$p] = ['ts' => (string)$a['updated_at'], 'items' => []];
|
||||
}
|
||||
$artGroups[$p]['items'][] = $a;
|
||||
if (strcmp((string)$a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string)$a['updated_at'];
|
||||
}
|
||||
$p = trim((string) $a['part']);
|
||||
if ($p === '')
|
||||
$p = '未分类';
|
||||
if (!isset($artGroups[$p])) {
|
||||
$artGroups[$p] = ['ts' => (string) $a['updated_at'], 'items' => []];
|
||||
}
|
||||
$artGroups[$p]['items'][] = $a;
|
||||
if (strcmp((string) $a['updated_at'], $artGroups[$p]['ts']) > 0) {
|
||||
$artGroups[$p]['ts'] = (string) $a['updated_at'];
|
||||
}
|
||||
}
|
||||
$poIdx = [];
|
||||
$poCfg = trim((string)setting_get('article_part_order', ''));
|
||||
$poCfg = trim((string) setting_get('article_part_order', ''));
|
||||
if ($poCfg !== '') {
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po])) $poIdx[$po] = count($poIdx);
|
||||
}
|
||||
foreach (explode(',', $poCfg) as $po) {
|
||||
$po = trim($po);
|
||||
if ($po !== '' && isset($artGroups[$po]))
|
||||
$poIdx[$po] = count($poIdx);
|
||||
}
|
||||
}
|
||||
uksort($artGroups, static function ($a, $b) use ($poIdx, $artGroups) {
|
||||
$ia = $poIdx[$a] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$b] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib) return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($a, $b);
|
||||
$ia = $poIdx[$a] ?? PHP_INT_MAX;
|
||||
$ib = $poIdx[$b] ?? PHP_INT_MAX;
|
||||
if ($ia !== $ib)
|
||||
return $ia <=> $ib;
|
||||
$d = strcmp($artGroups[$b]['ts'], $artGroups[$a]['ts']);
|
||||
return $d !== 0 ? $d : strcmp($a, $b);
|
||||
});
|
||||
$artGroupOrder = [];
|
||||
foreach ($artGroups as $p => $g) {
|
||||
$artGroupOrder[] = ['p' => $p, 'items' => $g['items']];
|
||||
$artGroupOrder[] = ['p' => $p, 'items' => $g['items']];
|
||||
}
|
||||
|
||||
// —— 外部工具区数据(通用或包含当前库) ——
|
||||
$extLinks = [];
|
||||
foreach ($pdo->query('SELECT * FROM ext_links WHERE enabled = 1 ORDER BY sort ASC, id ASC')->fetchAll() as $e) {
|
||||
if (board_cats_has((string)($e['cat_keys'] ?? ''), $catKey)) {
|
||||
$extLinks[] = $e;
|
||||
}
|
||||
if (board_cats_has((string) ($e['cat_keys'] ?? ''), $catKey)) {
|
||||
$extLinks[] = $e;
|
||||
}
|
||||
}
|
||||
|
||||
$siteName = setting_get('site_name', '知识导航站');
|
||||
@ -123,80 +134,293 @@ $engines = search_engines();
|
||||
$defaultEngine = $engines[0]['key'] ?? 'bing';
|
||||
$enginesJson = json_encode($engines, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$domainOf = static function (string $u): string {
|
||||
$h = (string)parse_url($u, PHP_URL_HOST);
|
||||
return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u;
|
||||
$h = (string) parse_url($u, PHP_URL_HOST);
|
||||
return $h !== '' ? preg_replace('/^www\./i', '', $h) : $u;
|
||||
};
|
||||
$catName = (string)$cat['name'];
|
||||
$catIcon = trim((string)$cat['icon']);
|
||||
$catName = (string) $cat['name'];
|
||||
$catIcon = trim((string) $cat['icon']);
|
||||
|
||||
layout_head($catName);
|
||||
?>
|
||||
<style>
|
||||
/* ===== 库首页分区导航:文本居中 + 下划线滑动指示 ===== */
|
||||
.lnx-nav {
|
||||
position: relative; display: flex; justify-content: center; align-items: center;
|
||||
gap: 0 38px; margin: 6px 0 16px; border-bottom: 1px solid var(--line); padding: 0 4px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0 38px;
|
||||
margin: 6px 0 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding: 0 4px;
|
||||
}
|
||||
.lnx-tab { position: relative; z-index: 1; border: none; background: none; cursor: pointer;
|
||||
padding: 12px 6px 13px; font-size: 15px; color: var(--text-3);
|
||||
transition: color .2s ease; white-space: nowrap; }
|
||||
.lnx-tab:hover { color: var(--text); }
|
||||
.lnx-tab.active { color: var(--brand); font-weight: 700; }
|
||||
|
||||
.lnx-tab {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 12px 6px 13px;
|
||||
font-size: 15px;
|
||||
color: var(--text-3);
|
||||
transition: color .2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lnx-tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lnx-tab.active {
|
||||
color: var(--brand);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.lnx-ink {
|
||||
position: absolute; left: 0; bottom: -1px; width: 0; height: 3px; border-radius: 3px;
|
||||
background: var(--brand); box-shadow: 0 0 8px var(--accent-soft);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -1px;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
border-radius: 3px;
|
||||
background: var(--brand);
|
||||
box-shadow: 0 0 8px var(--accent-soft);
|
||||
transition: left .28s cubic-bezier(.45, 0, .25, 1), width .28s cubic-bezier(.45, 0, .25, 1);
|
||||
}
|
||||
.lnx-panel { display: none; }
|
||||
.lnx-panel.active { display: block; }
|
||||
.lnx-phead { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 4px 0 14px; }
|
||||
.lnx-phead h3 { margin: 0; font-size: 18px; }
|
||||
.lnx-phead .lnx-go { font-size: 13px; color: var(--text-3); text-decoration: none; }
|
||||
.lnx-note { font-size: 12.5px; color: var(--text-3); background: var(--card); border: 1px dashed var(--line-2);
|
||||
border-radius: 10px; padding: 8px 12px; margin-bottom: 14px; }
|
||||
|
||||
.lnx-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lnx-panel.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lnx-phead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin: 4px 0 14px;
|
||||
}
|
||||
|
||||
.lnx-phead h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lnx-phead .lnx-go {
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.lnx-note {
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
background: var(--card);
|
||||
border: 1px dashed var(--line-2);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 外部工具区分区标题(按 grp 分组的小节) */
|
||||
.lnx-eg { display: flex; align-items: center; gap: 10px; margin: 18px 0 8px; font-size: 14.5px; font-weight: 600; color: var(--brand); }
|
||||
.lnx-eg::after { content: ''; flex: 1; height: 1px; background: var(--line); }
|
||||
.lnx-exts + .lnx-eg { margin-top: 24px; }
|
||||
.lnx-empty { color: var(--text-3); font-size: 13.5px; background: var(--card); border: 1px solid var(--line);
|
||||
border-radius: 12px; padding: 22px 16px; text-align: center; }
|
||||
.lnx-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
|
||||
.lnx-tool { display: flex; gap: 12px; align-items: flex-start; text-decoration: none; color: var(--text);
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-tool:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-tool .lnx-ti { font-size: 24px; }
|
||||
.lnx-tool b { display: block; font-size: 15px; margin-bottom: 4px; }
|
||||
.lnx-tool span.tip { margin: 0; line-height: 1.5; display: block; }
|
||||
.lnx-arts { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.lnx-art { display: block; text-decoration: none; color: var(--text); background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-art:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-art .lnx-ab { display: inline-block; font-size: 12px; color: var(--brand); background: var(--accent-soft);
|
||||
border-radius: 6px; padding: 2px 8px; margin-bottom: 8px; }
|
||||
.lnx-art h4 { margin: 0 0 6px; font-size: 15.5px; }
|
||||
.lnx-art p { margin: 0 0 8px; font-size: 13px; color: var(--text-3); line-height: 1.6; }
|
||||
.lnx-art time { font-size: 12px; color: var(--text-3); }
|
||||
.lnx-exts { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
|
||||
.lnx-ext { display: block; text-decoration: none; color: var(--text); background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 14px; padding: 12px 14px; box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease; }
|
||||
.lnx-ext:hover { transform: translateY(-2px); border-color: var(--brand); }
|
||||
.lnx-ext .lnx-eh { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
|
||||
.lnx-ext .lnx-en { font-weight: 600; font-size: 14.5px; }
|
||||
.lnx-ext .lnx-ed { font-size: 12px; color: var(--text-3); word-break: break-all; }
|
||||
.lnx-ext p { margin: 6px 0 0; font-size: 12.5px; color: var(--text-3); line-height: 1.5; }
|
||||
.lnx-eg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 18px 0 8px;
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-eg::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.lnx-exts+.lnx-eg {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.lnx-empty {
|
||||
color: var(--text-3);
|
||||
font-size: 13.5px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 22px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lnx-tools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-tool {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-tool:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-tool .lnx-ti {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.lnx-tool b {
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.lnx-tool span.tip {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.lnx-arts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-art {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-art:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-art .lnx-ab {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--brand);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.lnx-art h4 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 15.5px;
|
||||
}
|
||||
|
||||
.lnx-art p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.lnx-art time {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.lnx-exts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lnx-ext {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform .15s ease, border-color .15s ease;
|
||||
}
|
||||
|
||||
.lnx-ext:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-eh {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-en {
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.lnx-ext .lnx-ed {
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.lnx-ext p {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-3);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.lnx-nav { gap: 0 10px; }
|
||||
.lnx-tab { flex: 1 1 0; min-width: 0; font-size: 14px; padding: 10px 2px 12px; text-align: center; }
|
||||
.nav-cols { grid-template-columns: 1fr; }
|
||||
.lnx-nav {
|
||||
gap: 0 10px;
|
||||
}
|
||||
|
||||
.lnx-tab {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
padding: 10px 2px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-cols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<main>
|
||||
<!-- 顶部留白 + 品牌条(上半区不变) -->
|
||||
<div class="nav-top">
|
||||
<div class="wrap" style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<div class="wrap"
|
||||
style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;flex-wrap:wrap;">
|
||||
<a class="brand brand-sm" href="index.php" title="返回首页">
|
||||
<?php layout_logo_img('logo', $siteName); ?>
|
||||
<span class="site-name"><?= he($siteName) ?></span>
|
||||
@ -241,26 +465,26 @@ layout_head($catName);
|
||||
<span class="lnx-go">共 <?= count($blocks) ?> 个导航块 / <?= $totalNav ?> 个链接 · 悬停按钮可看备注</span>
|
||||
</div>
|
||||
<?php if (!$blocks): ?>
|
||||
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航内容管理」中添加</span>。</div>
|
||||
<div class="lnx-empty">该库还没有导航内容<span class="st-tip">,可在后台「导航管理」中添加</span>。</div>
|
||||
<?php else: ?>
|
||||
<div class="nav-cols" style="margin:0">
|
||||
<?php foreach ($navCols as $colBlocks): ?>
|
||||
<div class="nav-col">
|
||||
<?php foreach ($colBlocks as $b):
|
||||
$items = $itemsByBlock[(int)$b['id']] ?? [];
|
||||
$items = $itemsByBlock[(int) $b['id']] ?? [];
|
||||
$show = array_slice($items, 0, 15);
|
||||
?>
|
||||
<div class="nav-block">
|
||||
<div class="nav-block-title"><?= he((string)$b['title']) ?></div>
|
||||
<div class="nav-block-title"><?= he((string) $b['title']) ?></div>
|
||||
<div class="nav-block-grid">
|
||||
<?php for ($i = 0; $i < 15; $i++): ?>
|
||||
<?php if (isset($show[$i])):
|
||||
$it = $show[$i];
|
||||
$label = (string)$it['label'];
|
||||
$url = trim((string)$it['url']);
|
||||
$note = trim((string)$it['note']);
|
||||
$label = (string) $it['label'];
|
||||
$url = trim((string) $it['url']);
|
||||
$note = trim((string) $it['note']);
|
||||
if ($url !== ''): ?>
|
||||
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer"<?php if ($note !== ''): ?> data-note="<?= he($note) ?>"<?php endif; ?>><?= he($label ?: $url) ?></a>
|
||||
<a class="nav-btn" href="<?= he($url) ?>" target="_blank" rel="noopener noreferrer" <?php if ($note !== ''): ?> data-note="<?= he($note) ?>" <?php endif; ?>><?= he($label ?: $url) ?></a>
|
||||
<?php else: ?>
|
||||
<span class="nav-btn is-off" title="链接未配置"><?= he($label) ?></span>
|
||||
<?php endif; ?>
|
||||
@ -283,23 +507,24 @@ layout_head($catName);
|
||||
<h3>内部功能区 · <?= he($catName) ?>常用工具</h3>
|
||||
<a class="lnx-go" href="func/index.php">前往完整功能区 ›</a>
|
||||
</div>
|
||||
<div class="lnx-note st-tip">由后台「功能区管理」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
|
||||
<div class="lnx-note st-tip">由后台「内部功能」为每个工具勾选“展示库”,未勾选默认出现在所有库。点击工具进入详情页(返回按钮已适配为返回上一页)。</div>
|
||||
<?php if (!$libTools): ?>
|
||||
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「功能区管理」开启</span>。</div>
|
||||
<div class="lnx-empty">尚未把工具分配到本库<span class="st-tip">,可到后台「内部功能」开启</span>。</div>
|
||||
<?php else: ?>
|
||||
<div class="lnx-tools">
|
||||
<?php foreach ($libTools as $tk):
|
||||
$isExt = (int)$tk['is_external'] === 1;
|
||||
$url = trim((string)$tk['url']);
|
||||
$isExt = (int) $tk['is_external'] === 1;
|
||||
$url = trim((string) $tk['url']);
|
||||
$href = $isExt ? $url : 'func/' . $url . '?from=nav';
|
||||
$tIcon = trim((string)($tk['icon'] ?? ''));
|
||||
$tIcon = trim((string) ($tk['icon'] ?? ''));
|
||||
$tIcon = $tIcon !== '' ? $tIcon : '🧩';
|
||||
?>
|
||||
<a class="lnx-tool" href="<?= he($href) ?>"<?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"<?php endif; ?>>
|
||||
<a class="lnx-tool" href="<?= he($href) ?>" <?php if ($isExt): ?> target="_blank" rel="noopener noreferrer"
|
||||
<?php endif; ?>>
|
||||
<span class="lnx-ti"><?= site_icon($tIcon, '') ?></span>
|
||||
<span>
|
||||
<b><?= he((string)$tk['name']) ?></b>
|
||||
<span class="tip"><?= he((string)$tk['description']) ?></span>
|
||||
<b><?= he((string) $tk['name']) ?></b>
|
||||
<span class="tip"><?= he((string) $tk['description']) ?></span>
|
||||
</span>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
@ -321,10 +546,10 @@ layout_head($catName);
|
||||
<h4 class="lnx-eg"><?= he($ag['p']) ?></h4>
|
||||
<div class="lnx-arts">
|
||||
<?php foreach ($ag['items'] as $ak): ?>
|
||||
<a class="lnx-art" href="article/index.php?id=<?= (int)$ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
|
||||
<h4><?= he((string)$ak['title']) ?></h4>
|
||||
<p><?= he(trim((string)$ak['summary']) !== '' ? (string)$ak['summary'] : '(无摘要)') ?></p>
|
||||
<time>更新于 <?= he((string)$ak['updated_at']) ?></time>
|
||||
<a class="lnx-art" href="article/index.php?id=<?= (int) $ak['id'] ?>&from=nav&cat=<?= he($catKey) ?>">
|
||||
<h4><?= he((string) $ak['title']) ?></h4>
|
||||
<p><?= he(trim((string) $ak['summary']) !== '' ? (string) $ak['summary'] : '(无摘要)') ?></p>
|
||||
<time>更新于 <?= he((string) $ak['updated_at']) ?></time>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -344,22 +569,23 @@ layout_head($catName);
|
||||
<?php else:
|
||||
$extGroups = [];
|
||||
foreach ($extLinks as $ek) {
|
||||
$eg = trim((string)($ek['grp'] ?? ''));
|
||||
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
|
||||
$eg = trim((string) ($ek['grp'] ?? ''));
|
||||
$extGroups[$eg === '' ? "\x00" : $eg][] = $ek;
|
||||
}
|
||||
?>
|
||||
?>
|
||||
<?php foreach ($extGroups as $eg => $gItems): ?>
|
||||
<?php if ($eg !== "\x00"): ?><h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
|
||||
<?php if ($eg !== "\x00"): ?>
|
||||
<h4 class="lnx-eg"><?= he($eg) ?></h4><?php endif; ?>
|
||||
<div class="lnx-exts">
|
||||
<?php foreach ($gItems as $ek):
|
||||
$eUrl = trim((string)$ek['url']);
|
||||
?>
|
||||
$eUrl = trim((string) $ek['url']);
|
||||
?>
|
||||
<a class="lnx-ext" href="<?= he($eUrl) ?>" target="_blank" rel="noopener noreferrer">
|
||||
<span class="lnx-eh">
|
||||
<span class="lnx-en"><?= he((string)$ek['name']) ?></span>
|
||||
<span class="lnx-en"><?= he((string) $ek['name']) ?></span>
|
||||
<span class="lnx-ed"><?= he($domainOf($eUrl)) ?></span>
|
||||
</span>
|
||||
<p><?= he((string)($ek['description'] ?? '')) ?></p>
|
||||
<p><?= he((string) ($ek['description'] ?? '')) ?></p>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@ -373,7 +599,7 @@ layout_head($catName);
|
||||
<?php layout_footer(); ?>
|
||||
<script src="assets/js/common.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
(function () {
|
||||
'use strict';
|
||||
// 下划线滑动指示导航:切换分区时激活项高亮,指示条平滑滑动到该项下方
|
||||
var nav = document.getElementById('lnxNav');
|
||||
@ -383,25 +609,25 @@ layout_head($catName);
|
||||
if (!nav || !ink || !tabs.length) return;
|
||||
|
||||
function moveInk() {
|
||||
var cur = nav.querySelector('.lnx-tab.active');
|
||||
if (!cur) { ink.style.left = '0px'; ink.style.width = '0px'; return; }
|
||||
ink.style.left = cur.offsetLeft + 'px';
|
||||
ink.style.width = cur.offsetWidth + 'px';
|
||||
var cur = nav.querySelector('.lnx-tab.active');
|
||||
if (!cur) { ink.style.left = '0px'; ink.style.width = '0px'; return; }
|
||||
ink.style.left = cur.offsetLeft + 'px';
|
||||
ink.style.width = cur.offsetWidth + 'px';
|
||||
}
|
||||
function show(btn, fromHash) {
|
||||
var t = btn.getAttribute('data-t');
|
||||
tabs.forEach(function (x) { x.classList.toggle('active', x === btn); });
|
||||
panels.forEach(function (p) { p.classList.toggle('active', p.getAttribute('data-panel') === t); });
|
||||
moveInk();
|
||||
if (!fromHash) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// 把当前分区写入 URL #hash(replaceState 不产生额外历史记录),
|
||||
// 这样从本页跳去文章/工具再“返回上一页”或刷新时,仍能回到原来的分区。
|
||||
if (location.hash !== '#' + t) {
|
||||
history.replaceState(null, '', '#' + t);
|
||||
}
|
||||
var t = btn.getAttribute('data-t');
|
||||
tabs.forEach(function (x) { x.classList.toggle('active', x === btn); });
|
||||
panels.forEach(function (p) { p.classList.toggle('active', p.getAttribute('data-panel') === t); });
|
||||
moveInk();
|
||||
if (!fromHash) window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
// 把当前分区写入 URL #hash(replaceState 不产生额外历史记录),
|
||||
// 这样从本页跳去文章/工具再“返回上一页”或刷新时,仍能回到原来的分区。
|
||||
if (location.hash !== '#' + t) {
|
||||
history.replaceState(null, '', '#' + t);
|
||||
}
|
||||
}
|
||||
tabs.forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { show(btn); });
|
||||
btn.addEventListener('click', function () { show(btn); });
|
||||
});
|
||||
window.addEventListener('resize', function () { moveInk(); });
|
||||
// 初始恢复:URL 中带 #分区(返回上一页 / 手动分享)时,激活对应分区
|
||||
@ -409,15 +635,16 @@ layout_head($catName);
|
||||
var foundTab = null;
|
||||
tabs.forEach(function (b) { if (!foundTab && b.getAttribute('data-t') === wantTab) foundTab = b; });
|
||||
if (foundTab) {
|
||||
show(foundTab, true);
|
||||
show(foundTab, true);
|
||||
} else {
|
||||
moveInk();
|
||||
// 无分区 hash 时清掉可能遗留的无效 hash(如 #footer),保持地址干净
|
||||
if (location.hash !== '') {
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
moveInk();
|
||||
// 无分区 hash 时清掉可能遗留的无效 hash(如 #footer),保持地址干净
|
||||
if (location.hash !== '') {
|
||||
history.replaceState(null, '', location.pathname + location.search);
|
||||
}
|
||||
}
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user