diff --git a/GUIDE.md b/GUIDE.md
index a96eefe..0a6119c 100644
--- a/GUIDE.md
+++ b/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
diff --git a/README.md b/README.md
index 8279039..3a5344b 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,8 @@ Markdown 写作与三栏阅读(标题栏 / 段落目录 / 正文),分区
### 访问审计与风控
- 每次访问自动追加写入 `data/vistors.log`(完整保留、可归档)
-- **访问信息页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页
+- **风控管理页**:完整 / 精简(按 IP 聚合)双模式,IP 筛选与关键词搜索、分页(页面内局部刷新,不跳回顶部)
+- **登录日志**:成功登录记录(时间 / IP / 设备)与失败日志(按 IP × 日期聚合次数与设备);设备信息含系统 / 手机型号 / 屏幕等,由浏览器采集
- **IP 黑名单**:支持单 IP、CIDR 网段、星号通配;命中后**全站统一拦截**(含后台与登录页)
- **自动风控**:登录失败、404 次数、请求频率三条规则,阈值可配、即时启停;自动封禁支持一键 / 批量移除
- 拦截提示文案可自定义;后台操作结果以右下角 Toast 反馈
diff --git a/admin/_guard.php b/admin/_guard.php
index 1a19aca..df58cca 100644
--- a/admin/_guard.php
+++ b/admin/_guard.php
@@ -1,37 +1,65 @@
';
- echo '
';
- layout_logo_img('logo', $site);
- echo '' . he($site) . ' · 后台';
+ $admin = (string) ($_SESSION['admin'] ?? '');
+ // 立即执行:标记 body 供侧栏布局使用,并恢复折叠状态(避免首帧闪动)
+ echo '' . "\n";
+
+ // 小屏顶部条(仅移动端显示:菜单按钮 + 站名 + 退出)
+ echo '
' . "\n";
+
+ echo '
' . "\n";
+ echo '';
+ // 本页功能(由 common.js 依据当前页区块自动生成)
+ echo '';
+ echo '
本页功能
';
+ echo '
';
+ echo '
';
+ // 底部:前台入口 / 退出 / 当前管理员
+ echo '';
+ echo '' . "\n";
+ echo '' . "\n";
}
diff --git a/admin/_visits_lib.php b/admin/_visits_lib.php
new file mode 100644
index 0000000..e6ecc80
--- /dev/null
+++ b/admin/_visits_lib.php
@@ -0,0 +1,274 @@
+= $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) {
+ ?>
+
+ 共命中 = (int) $d['total'] ?> = $mode === 'compact' ? '个 IP' : '条记录' ?>;
+ 当前第 = $page ?> / = $totalPages ?> 页,每页 = (int) $d['perPage'] ?> 条。
+
+
+
+ 1): ?>
+
+
+
+
+ = $d['hasFilter'] ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?>
+
+ $lf,
+ 'rows' => login_fail_daily_stats($lf, $limit),
+ ];
+}
+
+/** 登录失败日志片段渲染(表格 / 空态) */
+function vis_render_loginfail(array $d): string
+{
+ $rows = $d['rows'];
+ ob_start();
+ if ($rows) {
+ ?>
+
+
+
+ = $d['filter'] !== '' ? '该 IP 暂无登录失败记录。' : '暂无登录失败记录。' ?>
+
+ 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 '';
+ // 改为右下角 Toast:注入 hpToastMsg,由 common.js 在页面加载后自动弹出
+ return '';
}
$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');
-
+
(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); }
?>
= $cur['id'] ? '编辑文章 #' . $cur['id'] : '新增文章' ?>