diff --git a/admin/tools.php b/admin/tools.php
index ba17919..851ed14 100644
--- a/admin/tools.php
+++ b/admin/tools.php
@@ -148,10 +148,10 @@ admin_topbar('tools');
-
diff --git a/admin/visits.php b/admin/visits.php
new file mode 100644
index 0000000..2d4f145
--- /dev/null
+++ b/admin/visits.php
@@ -0,0 +1,502 @@
+prepare('INSERT INTO settings (key, value) VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.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']);
+ }
+ }
+ return count($rules);
+}
+
+/* ---------- POST:黑名单增删 / 清空日志 / 风控规则 ---------- */
+$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 = '未知操作。';
+ }
+ }
+}
+
+/* ---------- 概要统计(数据来源: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');
+
+/* ---------- 筛选与分页 ---------- */
+$mode = (string)($_GET['mode'] ?? 'full');
+if (!in_array($mode, ['full', 'compact'], true)) {
+ $mode = 'full';
+}
+$ipF = trim((string)($_GET['ip'] ?? ''));
+$q = trim((string)($_GET['q'] ?? ''));
+$ipF = substr($ipF, 0, 64);
+$q = substr($q, 0, 100);
+$page = max(1, (int)($_GET['page'] ?? 1));
+$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);
+}
+
+/* ---------- 当前访问者信息(用于黑名单自查提示) ---------- */
+$curIp = client_ip();
+if ($curIp === '') {
+ $curIp = '未知';
+}
+$curHit = null;
+if ($curIp !== '未知') {
+ if ($banChanged) {
+ blacklist_rules(true);
+ }
+ $curHit = blacklist_ip_hit($curIp);
+}
+
+/* ---------- 自动风控规则配置与当前 IP 计数 ---------- */
+$rcfg = risk_rule_cfg();
+$curRisk = ($curIp !== '未知') ? risk_row($curIp) : null;
+
+/* ---------- 黑名单规则(直接读表展示,便于核对备注与来源) ---------- */
+$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++;
+}
+
+layout_head('访问信息');
+admin_topbar('visits');
+?>
+
+
+
访问信息
+
记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。
+
+
+
+
+
+
+
+
访问概况
+
+
= $statTotal ?>累计访问
+
= $statIps ?>独立 IP
+
= $statToday ?>今日访问
+
= $statBan ?>黑名单规则
+
+
+
+
+
+
+
访问记录= $mode === 'compact' ? '(精简模式:按 IP 汇总)' : '(完整模式:逐条明细)' ?>
+
+
+
+
+ 共命中 = $total ?> = $mode === 'compact' ? '个 IP' : '条记录' ?>;
+ 当前第 = $page ?> / = $totalPages ?> 页,每页 = $perPage ?> 条。
+
+
+
+ 1): ?>
+
+
+
+
= ($ipF !== '' || $q !== '') ? '该筛选条件下暂无访问记录,可点击「重置」查看全部。' : '还没有访问记录,访问站点前台页面后将在这里实时展示。' ?>
+
+
+
+
+
+
自动风控规则(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)
+
+
+
+
+
+
+
IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)
+
+
+ 支持三种写法:单个 IP、CIDR 网段(如 203.0.113.0/24)、星号通配段(如 203.0.113.* 或 192.168.*.*);
+ 目前仅按 IPv4 匹配。黑名单即时生效且不受「站长提示」开关影响,命中 IP 的前台与后台(含登录页)访问一律被拒绝。如需解除,请从未被封禁的网络进入后台移除该规则,或直接在 data/homepage.db 的 ip_blacklist 表中删除记录。
+
+
+
+ 当前访问 IP:= he($curIp) ?> —— 命中规则「= he($curHit['rule']) ?>」(= ($curHit['note'] !== '') ? he((string)$curHit['note']) : '无备注' ?>),全站(前台与后台)访问将被拦截
+
+ 当前访问 IP:= he($curIp) ?> —— 未命中任何黑名单规则
+
+
+
+
+
+
+ 0): ?>
+
+
+
+
+
+
+
+ = he((string)$b['rule']) ?>
+
+ 风控自动
+
+ 手动
+
+ = $b['note'] !== '' ? he((string)$b['note']) : '(无备注)' ?>
+ = he((string)$b['created_at']) ?>
+
+
+
+
+
尚未配置黑名单规则。启用上方「自动风控」后,异常 IP 会自动记录到这里;也可在下方手动添加规则。
+
+
+
+
+
+
+
+
+