NewHome/admin/visits.php

503 lines
26 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* admin/visits.php —— 访问信息
* 访问日志查询(精简 / 完整两种模式 + IP / 关键词筛选 + 分页)与 IP 黑名单管理。
* 黑名单命中规则后前台与后台(含登录页)请求均被直接拒绝(与前台同一风控提示页)。
*/
$P = '../';
require_once __DIR__ . '/_guard.php';
$pdo = db();
function settings_set(string $key, string $value): void
{
$st = db()->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');
?>
<main class="page-main">
<div class="wrap page-body">
<h2 style="margin-bottom:6px">访问信息</h2>
<p class="tip">记录访客对本站的动态请求(含后台),完整模式逐条展示访问明细,精简模式按 IP 汇总次数;黑名单 / 自动风控对所有访问统一生效,命中即返回 403 风控提示页,后台与登录页不例外。</p>
<?php if ($msgText !== ''): ?>
<script>window.hpToastMsg = <?= json_encode(['kind' => $msgKind === 'ok' ? 'ok' : 'err', 'text' => $msgText], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;</script>
<?php endif; ?>
<!-- 概要统计 -->
<div class="fieldset-card">
<div class="fs-title">访问概况</div>
<div class="vis-stats">
<div class="vis-stat"><b><?= $statTotal ?></b><span>累计访问</span></div>
<div class="vis-stat"><b><?= $statIps ?></b><span>独立 IP</span></div>
<div class="vis-stat"><b><?= $statToday ?></b><span>今日访问</span></div>
<div class="vis-stat"><b><?= $statBan ?></b><span>黑名单规则</span></div>
</div>
<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>
</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>
</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>
</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>
<!-- 自动风控规则 -->
<div class="fieldset-card">
<div class="fs-title">自动风控规则<span class="tip" style="font-weight:400">(达到阈值自动加入黑名单;命中前台与后台均返回 403 风控提示页,提示文案可在下方自定义)</span></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>
</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>
</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>
</div>
<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">
</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">
</div>
<button type="submit" class="btn btn-primary">保存风控规则</button>
</form>
<form method="post" data-confirm="确认清空全部风险计数?各 IP 将从 0 重新累计,不影响已封禁的黑名单。" style="margin-top:14px">
<?= 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>
<?php endif; ?>
</form>
</div>
<!-- IP 黑名单 -->
<div class="fieldset-card">
<div class="fs-title">IP 黑名单(全站拦截前台与后台,命中统一返回 403 风控提示页)</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="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 表中删除记录。
</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>
<?php else: ?>
<span class="vis-ok-tip">当前访问 IP<?= he($curIp) ?> —— 未命中任何黑名单规则</span>
<?php endif; ?>
</div>
<?php if ($banRows): ?>
<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>
<button type="submit" class="btn btn-sm btn-danger" id="blBatchBtn" disabled>移除选中0</button>
</form>
<?php if ($banAutoCount > 0): ?>
<form method="post" data-confirm="确认一键移除全部风控自动封禁(共 <?= $banAutoCount ?> 条)?相关 IP 风控计数将同步清除。">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_clear_auto">
<button type="submit" class="btn btn-sm">一键移除风控自动(<?= $banAutoCount ?></button>
</form>
<?php endif; ?>
<form method="post" data-confirm="确认清空全部黑名单?全部风险计数将一并清零。">
<?= csrf_field() ?><input type="hidden" name="act" value="ban_clear_all">
<button type="submit" class="btn btn-sm btn-danger">清空全部</button>
</form>
</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'): ?>
<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'] ?>">
<button type="submit" class="btn btn-sm btn-danger">移除</button>
</form>
</div>
<?php endforeach; ?>
<?php else: ?>
<p class="tip" style="margin:6px 0 0">尚未配置黑名单规则。启用上方「自动风控」后,异常 IP 会自动记录到这里;也可在下方手动添加规则。</p>
<?php endif; ?>
</div>
</div>
</main>
<?php layout_theme_fab(); ?>
<script src="../assets/js/common.js"></script>
<script>
(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;
}
all.addEventListener('change', function () {
boxes.forEach(function (c) { c.checked = all.checked; });
refresh();
});
boxes.forEach(function (c) { c.addEventListener('change', refresh); });
})();
</script>
</body>
</html>