Files
MeshSate/app/templates/dashboard.html
zhaolei 16f134e328 feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交
核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
2026-10-28 19:07:11 +08:00

504 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<link rel="icon" href="/static/logo.png">
<title>MeshSafe 控制台</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
margin: 0; background: #f4f6fa; color: #1e293b; }
{% include "_nav_style.html" %}
main { padding: 24px; max-width: 1320px; margin: 0 auto; }
/* ===== 顶部标题栏 ===== */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 20px; }
.page-head h1 { margin: 0 0 6px; font-size: 21px; font-weight: 700; letter-spacing: .3px; }
.page-head .sub { font-size: 13px; color: #64748b; }
.policy-chip {
display: inline-flex; align-items: center; gap: 8px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 8px 14px; font-size: 12.5px; color: #475569;
box-shadow: 0 1px 3px rgba(0,0,0,.04);
}
.policy-chip b { color: #0f172a; }
.policy-chip code { color: #64748b; font-size: 11.5px; }
/* ===== 统计卡片行 ===== */
.stat-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin-bottom: 22px; }
.stat {
background: #fff; border-radius: 12px; padding: 18px 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; position: relative; overflow: hidden;
transition: transform .18s, box-shadow .18s;
}
.stat:hover { transform: translateY(-2px); box-shadow: 0 4px 10px rgba(15,23,42,.09), 0 12px 30px rgba(15,23,42,.07); }
.stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; }
.stat.s1::before { background: #3b82f6; } .stat.s2::before { background: #ef4444; }
.stat.s3::before { background: #f59e0b; } .stat.s4::before { background: #8b5cf6; }
.stat.s5::before { background: #10b981; }
.stat .s-label { font-size: 12.5px; color: #64748b; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
.stat .s-value { font-size: 28px; font-weight: 700; color: #0f172a; line-height: 1.1; font-variant-numeric: tabular-nums; }
.stat .s-value .unit { font-size: 14px; font-weight: 500; color: #94a3b8; margin-left: 3px; }
.stat .s-foot { font-size: 11.5px; color: #94a3b8; margin-top: 7px; }
/* ===== 主内容两栏 ===== */
.cols { display: grid; grid-template-columns: 1fr 320px; gap: 18px; align-items: start; }
.panel {
background: #fff; border-radius: 12px; padding: 20px;
box-shadow: 0 1px 3px rgba(15,23,42,.07), 0 6px 20px rgba(15,23,42,.04);
border: 1px solid #eef2f7; margin-bottom: 18px;
}
.panel-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.panel-head h2 { margin: 0; font-size: 15px; font-weight: 600; color: #0f172a; display: flex; align-items: center; gap: 8px; }
.panel-head .hint { font-size: 12px; color: #94a3b8; }
/* ===== 引擎卡片 ===== */
.engine-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.ecard {
border: 1px solid #e8edf4; border-radius: 10px; padding: 14px;
background: #fcfdff; transition: all .18s;
}
.ecard:hover { border-color: #c7d2fe; box-shadow: 0 4px 14px rgba(99,102,241,.1); }
.ecard .e-top { display: flex; align-items: center; gap: 9px; margin-bottom: 10px; }
.ecard .e-icon {
width: 30px; height: 30px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 15px;
background: #eff6ff; color: #3b82f6;
}
.ecard .e-name { font-size: 13.5px; font-weight: 600; color: #1e293b; line-height: 1.25; }
.ecard .e-ver { font-size: 11px; color: #94a3b8; font-family: monospace; }
.badge {
margin-left: auto; font-size: 11px; padding: 3px 9px; border-radius: 20px;
font-weight: 500; white-space: nowrap; flex-shrink: 0;
}
.badge.ok { background: #dcfce7; color: #15803d; }
.badge.error { background: #fee2e2; color: #b91c1c; }
.badge.degraded { background: #fef3c7; color: #b45309; }
.ecard .e-meta { font-size: 11.5px; color: #64748b; line-height: 1.7; }
.ecard .e-meta .k { color: #94a3b8; }
.ecard .e-meta .v { color: #334155; font-weight: 500; }
.ecard .e-err { margin-top: 8px; font-size: 11.5px; color: #b91c1c; background: #fef2f2;
padding: 6px 9px; border-radius: 6px; line-height: 1.5; }
.clamav-actions { margin-top: 11px; display: flex; gap: 7px; }
.clamav-actions button {
flex: 1; padding: 6px 8px; border: none; border-radius: 6px; cursor: pointer;
font-size: 11.5px; font-weight: 500; transition: all .18s;
}
.btn-online { background: #e0f2fe; color: #0369a1; }
.btn-online:hover { background: #bae6fd; }
.btn-upload { background: #dcfce7; color: #15803d; }
.btn-upload:hover { background: #bbf7d0; }
/* ===== 结论分布条 ===== */
.dist-bar { display: flex; height: 10px; border-radius: 6px; overflow: hidden; background: #f1f5f9; margin-bottom: 12px; }
.dist-bar span { display: block; height: 100%; }
.dist-bar .d-ok { background: #10b981; }
.dist-bar .d-warn { background: #f59e0b; }
.dist-bar .d-bad { background: #ef4444; }
.dist-bar .d-err { background: #8b5cf6; }
.dist-bar .d-muted { background: #94a3b8; }
.dist-legend { display: flex; flex-wrap: wrap; gap: 14px; }
.dist-legend .li { display: flex; align-items: center; gap: 6px; font-size: 12px; color: #64748b; }
.dist-legend .li .dot { width: 8px; height: 8px; border-radius: 3px; }
.dist-legend .li b { color: #1e293b; font-variant-numeric: tabular-nums; }
.dist-legend .li .pct { color: #94a3b8; font-size: 11px; }
/* ===== 渠道排行 ===== */
.rank-item { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px dashed #eef2f7; }
.rank-item:last-child { border-bottom: none; }
.rank-item .r-no {
width: 20px; height: 20px; border-radius: 5px; background: #eff6ff; color: #3b82f6;
font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.rank-item .r-name { font-size: 12.5px; color: #1e293b; font-family: monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-item .r-name small { display: block; font-family: -apple-system, "PingFang SC", sans-serif; color: #94a3b8; font-size: 11px; }
.rank-item .r-count { margin-left: auto; font-size: 13px; font-weight: 600; color: #334155; font-variant-numeric: tabular-nums; }
/* ===== 任务表 ===== */
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; padding: 9px 12px; text-align: left; font-size: 11.5px;
color: #64748b; font-weight: 600; border-bottom: 1px solid #e8edf4; white-space: nowrap; }
td { padding: 10px 12px; font-size: 12.5px; border-bottom: 1px solid #f4f7fb; color: #334155; }
tr:last-child td { border-bottom: none; }
tbody tr:hover { background: #fafbfd; }
td.mono { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 11.5px; color: #64748b; }
td.fname { font-weight: 500; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { color: #3b82f6; text-decoration: none; font-size: 12px; }
.link:hover { text-decoration: underline; }
.empty { text-align: center; color: #94a3b8; padding: 32px 0; font-size: 13px; }
/* ===== 模态框 ===== */
.modal-mask { display: none; position: fixed; inset: 0; background: rgba(15,23,42,.5);
z-index: 100; align-items: center; justify-content: center; }
.modal-mask.show { display: flex; }
.modal { background: white; border-radius: 14px; width: 480px; max-width: 90vw;
box-shadow: 0 24px 64px rgba(0,0,0,.28); overflow: hidden; }
.modal-header { background: linear-gradient(135deg, #0f172a, #1e3a5f); color: white; padding: 15px 20px;
font-size: 15px; font-weight: 500; display: flex; justify-content: space-between; align-items: center; }
.modal-header .close { cursor: pointer; opacity: .7; font-size: 22px; line-height: 1; }
.modal-header .close:hover { opacity: 1; }
.modal-body { padding: 20px; font-size: 13px; }
.modal-body p { margin: 8px 0; color: #555; }
.modal-body .hint { background: #fffbeb; border-left: 3px solid #f59e0b; padding: 11px 14px;
border-radius: 0 6px 6px 0; margin: 13px 0; font-size: 12px; color: #78350f; line-height: 1.7; }
.modal-body label { display: block; margin: 13px 0 6px; font-weight: 500; color: #333; }
.modal-body input[type="file"] { width: 100%; padding: 9px; border: 1.5px dashed #cbd5e1; border-radius: 8px; background: #f8fafc; }
.modal-body .result-box { margin-top: 13px; padding: 11px 14px; border-radius: 8px; font-size: 12px; display: none; white-space: pre-wrap; line-height: 1.7; }
.modal-body .result-box.show { display: block; }
.result-box.ok { background: #f0fdf4; color: #15803d; border: 1px solid #bbf7d0; }
.result-box.err { background: #fef2f2; color: #b91c1c; border: 1px solid #fecaca; }
.modal-footer { padding: 14px 20px; border-top: 1px solid #eef2f7; display: flex; justify-content: flex-end; gap: 8px; }
.modal-footer button { padding: 8px 20px; border: none; border-radius: 7px; cursor: pointer; font-size: 13px; font-weight: 500; }
.btn-primary { background: #1e3a5f; color: white; }
.btn-primary:hover { background: #152060; }
.btn-primary:disabled { background: #cbd5e1; cursor: not-allowed; }
.btn-secondary { background: #f1f5f9; color: #475569; }
.btn-secondary:hover { background: #e2e8f0; }
@media (max-width: 1150px) {
.stat-row { grid-template-columns: repeat(3, 1fr); }
.cols { grid-template-columns: 1fr; }
.engine-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 700px) {
.stat-row { grid-template-columns: repeat(2, 1fr); }
.engine-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
{% set nav_active = 'overview' %}{% include "_nav.html" %}
<main>
<!-- ===== 顶部标题 + 策略 ===== -->
<div class="page-head">
<div>
<h1>系统概览</h1>
<div class="sub">光闸摆渡文件安全检测 · 实时运行状态与统计</div>
</div>
<div class="policy-chip">
当前策略 <b>{{ policy_store.current().version }}</b>
<span>·</span>
<code>{{ policy_store.current().sha256[:16] }}</code>
</div>
</div>
<!-- ===== 统计卡片 ===== -->
<div class="stat-row">
<div class="stat s1">
<div class="s-label">📊 累计扫描</div>
<div class="s-value">{{ stats.total_scans }}<span class="unit">个文件</span></div>
<div class="s-foot">检测引擎处理总量</div>
</div>
<div class="stat s2">
<div class="s-label">🛑 拦截次数</div>
<div class="s-value">{{ stats.total_blocked }}<span class="unit">次</span></div>
<div class="s-foot">零容忍命中直接阻断</div>
</div>
<div class="stat s3">
<div class="s-label">⚠️ 检出问题</div>
<div class="s-value">{{ stats.total_findings }}<span class="unit">项</span></div>
<div class="s-foot">规则命中 findings 总数</div>
</div>
<div class="stat s4">
<div class="s-label">📋 规则总数</div>
<div class="s-value">{{ stats.total_rules }}<span class="unit">条</span></div>
<div class="s-foot">零容忍 {{ stats.zero_tolerance }} · YARA {{ stats.yara_rules }}</div>
</div>
<div class="stat s5">
<div class="s-label">⚡ 引擎在线</div>
<div class="s-value">{{ stats.engines_ok }}<span class="unit">/ {{ stats.engines_total }}</span></div>
<div class="s-foot">全部引擎可用则正常检测</div>
</div>
</div>
<!-- ===== 两栏主区 ===== -->
<div class="cols">
<div>
<!-- ===== 引擎状态 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🧩 引擎状态</h2>
<span class="hint">{{ stats.engines_ok }} / {{ stats.engines_total }} 引擎在线</span>
</div>
<div class="engine-grid">
{% for h in engine_health %}
<div class="ecard">
<div class="e-top">
<div class="e-icon">{% if h.engine == 'structure' %}🗂️{% elif h.engine == 'clamav' %}🦠{% elif h.engine == 'document' %}📄{% elif h.engine == 'keywords' %}🔑{% elif h.engine == 'yara' %}🎯{% elif h.engine == 'binary' %}⚙️{% else %}🔧{% endif %}</div>
<div>
<div class="e-name">{{ h.display_name }}</div>
{% if h.engine_version_label %}<div class="e-ver">{{ h.engine_version_label }}</div>{% endif %}
</div>
<span class="badge {{ 'ok' if h.ok else ('error' if h.error else 'degraded') }}">{{ h.status_label or h.status }}</span>
</div>
<div class="e-meta">
{% if h.engine == 'clamav' %}
<div><span class="k">病毒库</span> <span class="v" id="clamav-sig-date">加载中...</span></div>
{% elif h.engine == 'yara' %}
<div><span class="k">规则库</span> <span class="v">{{ stats.yara_rules }} 条规则已加载</span></div>
{% elif h.engine == 'keywords' %}
<div><span class="k">特征词表</span> <span class="v">{{ policy_store.current().keyword_rules|length }} 条规则</span></div>
{% else %}
<div><span class="k">层级</span> <span class="v">{{ h.layer_label }}</span></div>
{% endif %}
</div>
{% if h.friendly_error %}
<div class="e-err">{{ h.friendly_error }}</div>
{% endif %}
{% if h.engine == 'clamav' %}
<div class="clamav-actions">
<button class="btn-online" onclick="clamavUpdate(this)">🔄 在线更新</button>
<button class="btn-upload" onclick="openUploadModal()">📤 上传签名库</button>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- ===== 检测结论分布 ===== -->
<div class="panel">
<div class="panel-head">
<h2>📈 检测结论分布</h2>
<span class="hint">共 {{ stats.total_scans }} 次扫描</span>
</div>
{% if verdict_items %}
<div class="dist-bar">
{% for v in verdict_items %}
<span class="d-{{ v.cls }}" style="width: {{ v.pct }}%" title="{{ v.label }} {{ v.pct }}%"></span>
{% endfor %}
</div>
<div class="dist-legend">
{% for v in verdict_items %}
<div class="li">
<span class="dot d-{{ v.cls }}"></span>
<span>{{ v.label }}</span>
<b>{{ v.count }}</b>
<span class="pct">{{ v.pct }}%</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty">暂无扫描数据</div>
{% endif %}
</div>
<!-- ===== 最近扫描任务 ===== -->
<div class="panel">
<div class="panel-head">
<h2>🕐 最近扫描任务</h2>
<a class="link" href="/console/tasks">查看全部 →</a>
</div>
{% if recent_tasks %}
<table>
<thead>
<tr>
<th>时间</th><th>文件名</th><th>来源渠道</th><th>大小</th><th>结论</th><th>动作</th><th>分数</th><th></th>
</tr>
</thead>
<tbody>
{% for t in recent_tasks %}
<tr>
<td class="mono">{{ t.created_at|bj_clock if t.created_at else '' }}</td>
<td class="fname" title="{{ t.filename }}">{{ t.filename }}</td>
<td class="mono" title="{{ t.client_id }} / {{ t.channel }}">{{ t.channel|cn_channel or '-' }}</td>
<td class="mono">{{ (t.size / 1024)|round(1) if t.size else 0 }} KB</td>
<td><span class="tag {{ t.verdict|cn_verdict_badge }}" title="{{ t.verdict }}">{{ t.verdict|cn_verdict }}</span></td>
<td><span class="tag plain" title="{{ t.action }}">{{ t.action|cn_action }}</span></td>
<td class="mono">{{ t.score }}</td>
<td><a class="link" href="/console/tasks#task-{{ t.id }}">详情</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty">暂无扫描记录,先去 <a class="link" href="/console/test">测试靶场</a> 跑一批样本吧</div>
{% endif %}
</div>
</div>
<!-- ===== 右栏 ===== -->
<div>
<!-- 策略要点 -->
<div class="panel">
<div class="panel-head"><h2>🛡️ 策略要点</h2></div>
<div class="e-meta" style="font-size:12.5px; line-height:2;">
<div><span class="k">零容忍规则</span> <span class="v">{{ stats.zero_tolerance }} 条</span></div>
<div><span class="k">YARA 规则</span> <span class="v">{{ stats.yara_rules }} 条</span></div>
<div><span class="k">规则覆盖</span> <span class="v">{{ policy_store.current().rule_overrides|length }} 条</span></div>
<div><span class="k">拦截阈值</span> <span class="v">≥ {{ policy_store.current().block_score }} 分</span></div>
<div><span class="k">文件大小上限</span> <span class="v">{{ (policy_store.current().max_file_size / 1024 / 1024 / 1024)|round(2) }} GB</span></div>
</div>
</div>
<!-- 渠道排行 -->
<div class="panel">
<div class="panel-head">
<h2>🔗 摆渡来源 TOP</h2>
<span class="hint">按任务数</span>
</div>
{% if channel_rank %}
{% for c in channel_rank %}
<div class="rank-item">
<div class="r-no">{{ loop.index }}</div>
<div class="r-name">{{ c.channel|cn_channel }}<small>{{ c.client_id }}</small></div>
<div class="r-count">{{ c.count }}</div>
</div>
{% endfor %}
{% else %}
<div class="empty">暂无渠道数据</div>
{% endif %}
</div>
</div>
</div>
</main>
<!-- ========== 上传签名库模态框 ========== -->
<div class="modal-mask" id="uploadModal">
<div class="modal">
<div class="modal-header">
📤 离线导入 ClamAV 签名库
<span class="close" onclick="closeUploadModal()">×</span>
</div>
<div class="modal-body">
<p>选择需要更新的 ClamAV 签名库文件(可多选):</p>
<div class="hint">
💡 光闸内网无法联网,请先在<strong>外网电脑</strong>下载最新签名库,通过 U 盘/摆渡带进内网后上传。<br>
下载地址:<code>https://database.clamav.net/main.cvd</code> / <code>daily.cvd</code> / <code>bytecode.cvd</code><br>
clamd 会自动热加载新签名,无需重启。
</div>
<label>选择文件(.cvd / .cld):</label>
<input type="file" id="uploadFiles" multiple accept=".cvd,.cld,.ign2">
<div class="result-box" id="uploadResult"></div>
</div>
<div class="modal-footer">
<button class="btn-secondary" onclick="closeUploadModal()">取消</button>
<button class="btn-primary" id="uploadSubmitBtn" onclick="submitUpload()">确认上传</button>
</div>
</div>
</div>
<script>
// ========== ClamAV 病毒库信息加载 ==========
async function loadClamavInfo() {
const el = document.getElementById('clamav-sig-date');
if (!el) return;
try {
const resp = await fetch('/console/clamav/info', { credentials: 'same-origin' });
const data = await resp.json();
if (!data.daemon_ok) {
el.textContent = '守护进程不可用';
el.style.color = '#b91c1c';
return;
}
// 只显示到日期,不显示时分秒
const d = (data.signature_date || '').slice(0, 10);
el.textContent = d || '未知';
el.style.color = '';
if (data.signature_days_old !== null && data.signature_days_old > 7) {
el.textContent = d + '(已 ' + data.signature_days_old + ' 天)';
el.style.color = '#b45309';
}
} catch (e) {
el.textContent = '加载失败';
el.style.color = '#b91c1c';
}
}
// ========== 在线更新 ==========
async function clamavUpdate(btn) {
if (!confirm('确认尝试在线更新病毒库?\n光闸内网环境大概率拉不到(断网隔离)。')) return;
const old = btn.textContent;
btn.disabled = true; btn.textContent = '更新中...';
try {
const resp = await fetch('/console/clamav/update', { method: 'POST', credentials: 'same-origin' });
const data = await resp.json();
if (data.ok) {
alert('✅ ' + data.message + '\n\n' + (data.output || ''));
} else {
alert('⚠️ ' + data.message + '\n\n请改用「上传签名库」离线导入。');
}
loadClamavInfo();
} catch (e) {
alert('❌ 请求失败: ' + e);
} finally {
btn.disabled = false; btn.textContent = old;
}
}
// ========== 上传模态框 ==========
function openUploadModal() {
document.getElementById('uploadModal').classList.add('show');
document.getElementById('uploadResult').className = 'result-box';
document.getElementById('uploadResult').textContent = '';
document.getElementById('uploadFiles').value = '';
}
function closeUploadModal() {
document.getElementById('uploadModal').classList.remove('show');
}
async function submitUpload() {
const input = document.getElementById('uploadFiles');
const result = document.getElementById('uploadResult');
const btn = document.getElementById('uploadSubmitBtn');
if (!input.files || input.files.length === 0) {
result.className = 'result-box err show';
result.textContent = '❌ 请先选择要上传的文件';
return;
}
const fd = new FormData();
for (let i = 0; i < input.files.length; i++) {
fd.append('files', input.files[i]);
}
btn.disabled = true; btn.textContent = '上传中...';
result.className = 'result-box';
result.textContent = '';
try {
const resp = await fetch('/console/clamav/upload', {
method: 'POST', body: fd, credentials: 'same-origin',
});
const data = await resp.json();
result.className = 'result-box ' + (data.ok ? 'ok' : 'err') + ' show';
let txt = '';
if (data.saved && data.saved.length > 0) {
txt += '✅ 成功导入 ' + data.saved.length + ' 个文件:\n';
data.saved.forEach(s => txt += ' · ' + s.name + ' (' + s.size_mb + ' MB)\n');
txt += '\n' + (data.reload_message || '');
}
if (data.errors && data.errors.length > 0) {
txt += '\n❌ 失败:\n';
data.errors.forEach(e => txt += ' · ' + e + '\n');
}
if (data.skipped && data.skipped.length > 0) {
txt += '\n⏭️ 跳过:\n';
data.skipped.forEach(s => txt += ' · ' + s + '\n');
}
result.textContent = txt || data.message || '完成';
loadClamavInfo();
} catch (e) {
result.className = 'result-box err show';
result.textContent = '❌ 请求失败: ' + e;
} finally {
btn.disabled = false; btn.textContent = '确认上传';
}
}
loadClamavInfo();
</script>
</body>
</html>