Просмотр исходного кода

feat: add complete deployment package

- Add assets/web/ (all frontend HTML pages)
- Add scripts/ (reset_passwords.py, setup_frpc_sudoers.sh)
- Add deploy.sh (deployment script)
- Add .gitignore (exclude binaries, configs with credentials, large files)
- All previous bug fixes retained (19 bugs from code review)
- Documentation: TECH_DOC.md, INSTALL_RPI5.md, CHANGELOG.md, Q&A archive
steven_roc 7 часов назад
Родитель
Сommit
db052aa98c

+ 38 - 0
.gitignore

@@ -0,0 +1,38 @@
+# 编译产物
+build/
+*.o
+*.obj
+*.so
+*.a
+PlateRecApp
+
+# 运行时数据
+data/
+PlateJPG/
+*.db
+*.db-journal
+offset.dat
+*.log
+
+# 敏感配置(含凭证)
+config.ini
+ssl/*.pem
+ssl/*.key
+mqtt.https.config
+
+# SDK 和模型(大文件)
+hyperlpr3/
+simsun.ttc
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*~
+
+# 系统文件
+.DS_Store
+Thumbs.db
+
+# 旧版本备份
+src/*1.cpp

+ 205 - 0
assets/web/frpc.html

@@ -0,0 +1,205 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>frpc 管理 - PlateRecApp</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{background:#0f172a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
+.header{background:#1e293b;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155}
+.header h1{font-size:20px;font-weight:600;color:#f8fafc}
+.nav{display:flex;gap:16px;align-items:center}
+.nav a{color:#60a5fa;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px;transition:background .2s}
+.nav a:hover,.nav a.active{background:#334155}
+.container{max-width:1200px;margin:0 auto;padding:20px}
+.tabs{display:flex;gap:8px;margin-bottom:20px}
+.tab{padding:8px 16px;background:#1e293b;border:1px solid #334155;border-radius:8px;cursor:pointer;color:#94a3b8;font-size:14px;transition:all .2s}
+.tab.active{background:#3b82f6;color:#fff;border-color:#3b82f6}
+.panel{display:none}.panel.active{display:block}
+.card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155;margin-bottom:16px}
+.card-title{font-size:15px;font-weight:600;margin-bottom:12px;color:#f8fafc}
+.status-dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:8px}
+.status-dot.online{background:#4ade80}.status-dot.offline{background:#f87171}
+table{width:100%;border-collapse:collapse;font-size:14px}
+th{text-align:left;padding:8px 12px;color:#94a3b8;border-bottom:1px solid #334155;font-weight:500}
+td{padding:8px 12px;border-bottom:1px solid #1e293b}
+.btn{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:14px;font-weight:500;transition:all .2s}
+.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
+.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
+.btn-success{background:#16a34a;color:#fff}.btn-success:hover{background:#15803d}
+.btn-group{display:flex;gap:8px;margin-top:12px}
+textarea{width:100%;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:12px;font-family:'Fira Code',monospace;font-size:13px;resize:vertical;min-height:400px;line-height:1.5}
+textarea:focus{outline:none;border-color:#3b82f6}
+.ssh-frame{width:100%;height:500px;border:1px solid #334155;border-radius:8px}
+.msg{padding:10px 14px;border-radius:8px;margin-top:10px;font-size:14px;display:none}
+.msg.success{display:block;background:#14532d;color:#86efac;border:1px solid #16a34a}
+.msg.error{display:block;background:#7f1d1d;color:#fca5a5;border:1px solid #dc2626}
+.ssh-link{display:inline-flex;align-items:center;gap:8px;padding:10px 20px;background:#1e293b;border:1px solid #334155;border-radius:8px;color:#60a5fa;text-decoration:none;font-size:14px;transition:all .2s}
+.ssh-link:hover{background:#334155;border-color:#60a5fa}
+
+    @media (max-width: 768px) {
+        .header { flex-direction: column; gap: 8px; padding: 12px 16px; }
+        .header h1 { font-size: 16px; }
+        .nav { flex-wrap: wrap; gap: 8px; justify-content: center; }
+        .nav a { font-size: 12px; padding: 4px 8px; }
+        .container { padding: 12px; }
+        .tabs { flex-wrap: wrap; }
+        .tab { flex: 1; min-width: 80px; text-align: center; font-size: 13px; padding: 6px 10px; }
+        .card { padding: 14px; }
+        table { font-size: 12px; display: block; overflow-x: auto; white-space: nowrap; }
+        th, td { padding: 6px 8px; }
+        .btn-group { flex-wrap: wrap; }
+        .btn { flex: 1; min-width: 100px; text-align: center; }
+        textarea { min-height: 200px; font-size: 12px; }
+        .ssh-frame { height: 350px; }
+    }
+    @media (max-width: 480px) {
+        .card-title { font-size: 13px; }
+        .btn { font-size: 12px; padding: 6px 12px; }
+    }
+    </style>
+</head>
+<body>
+<div class="header">
+    <h1>🔧 frpc 管理</h1>
+    <div class="nav">
+        <a href="/">监控台</a>
+        <a href="/video">视频</a>
+        <a href="/config">配置</a>
+        <a href="/system">系统</a>
+        <a href="/frpc" class="active">frpc</a>
+        <a href="/ssh-keys">SSH</a>
+        <a href="/logs">日志</a>
+        <a href="/help">帮助</a>
+    </div>
+</div>
+<div class="container">
+    <div class="tabs">
+        <div class="tab active" onclick="switchTab('status')">隧道状态</div>
+        <div class="tab" onclick="switchTab('config')">配置编辑</div>
+        <div class="tab" onclick="switchTab('ssh')">SSH 终端</div>
+    </div>
+
+    <!-- 隧道状态 -->
+    <div id="panel-status" class="panel active">
+        <div class="card">
+            <div class="card-title">
+                <span class="status-dot" id="svc-status"></span>
+                frpc 服务状态
+                <button class="btn btn-primary" style="float:right" onclick="refreshStatus()">🔄 刷新</button>
+            </div>
+            <table>
+                <thead><tr><th>名称</th><th>类型</th><th>本地地址</th><th>远程地址</th><th>状态</th></tr></thead>
+                <tbody id="tunnel-list"><tr><td colspan="5" style="color:#64748b">加载中...</td></tr></tbody>
+            </table>
+        </div>
+        <div class="btn-group">
+            <button class="btn btn-danger" onclick="restartFrpc()">⚡ 重启 frpc</button>
+        </div>
+        <div id="status-msg" class="msg"></div>
+    </div>
+
+    <!-- 配置编辑 -->
+    <div id="panel-config" class="panel">
+        <div class="card">
+            <div class="card-title">frpc.toml 配置文件
+                <button class="btn btn-primary" style="float:right" onclick="saveConfig()">💾 保存配置</button>
+            </div>
+            <textarea id="config-editor" spellcheck="false"></textarea>
+        </div>
+        <div class="btn-group">
+            <button class="btn btn-success" onclick="saveAndRestart()">💾 保存并重启</button>
+            <button class="btn btn-primary" onclick="loadConfig()">🔄 重新加载</button>
+        </div>
+        <div id="config-msg" class="msg"></div>
+    </div>
+
+    <!-- SSH 终端 -->
+    <div id="panel-ssh" class="panel">
+        <div class="card">
+            <div class="card-title">Web SSH 终端 (ttyd)</div>
+            <p style="color:#94a3b8;font-size:14px;margin-bottom:12px">通过 ttyd 提供的 Web SSH 终端直接连接到服务器。</p>
+            <a class="ssh-link" id="ssh-external-link" href="#" target="_blank">
+                🖥️ 在新窗口打开 SSH 终端
+            </a>
+            <div style="margin-top:16px">
+                <iframe id="ssh-frame" class="ssh-frame" src="" style="display:none"></iframe>
+                <button class="btn btn-primary" style="margin-top:8px" onclick="toggleSSH()">🔌 连接 SSH 终端</button>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+function getCookie(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}function authFetch(u,o){o=o||{};var h=o.headers||{};if(!(h instanceof Headers)){var nh=new Headers();for(var k in h)nh.set(k,h[k]);h=nh;}if(!h.has("X-CSRF-Token")){var t=getCookie("csrf_token");if(t)h.set("X-CSRF-Token",t);}o.headers=h;return fetch(u,o).then(function(r){if(r.status===401){window.location.href="/login?redirect="+encodeURIComponent(window.location.pathname);return new Promise(function(){});}return r;});}
+let sshConnected = false;
+function switchTab(name){
+    document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',['status','config','ssh'][i]===name));
+    document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
+    document.getElementById('panel-'+name).classList.add('active');
+    if(name==='config'&&!document.getElementById('config-editor').value) loadConfig();
+    if(name==='status') refreshStatus();
+}
+function showMsg(id,text,ok){
+    let el=document.getElementById(id);
+    el.className='msg '+(ok?'success':'error');
+    el.textContent=text;
+    setTimeout(()=>el.className='msg',5000);
+}
+function refreshStatus(){
+    authFetch('/api/frpc/status').then(r=>r.json()).then(d=>{
+        let dot=document.getElementById('svc-status');
+        dot.className='status-dot '+(d.running?'online':'offline');
+        let tbody=document.getElementById('tunnel-list');
+        if(!d.tunnels||d.tunnels.length===0){
+            tbody.innerHTML='<tr><td colspan="5" style="color:#64748b">'+(d.error||'无隧道')+'</td></tr>';
+            return;
+        }
+        tbody.innerHTML=d.tunnels.map(t=>'<tr><td>'+t.name+'</td><td>'+t.type+'</td><td>'+t.local_addr+'</td><td>'+t.remote_addr+'</td><td><span class="status-dot '+(t.status==='online'?'online':'offline')+'"></span>'+t.status+'</td></tr>').join('');
+    }).catch(e=>console.error(e));
+}
+function loadConfig(){
+    authFetch('/api/frpc/config').then(r=>r.text()).then(t=>{
+        document.getElementById('config-editor').value=t;
+    });
+}
+function saveConfig(){
+    let content=document.getElementById('config-editor').value;
+    authFetch('/api/frpc/config',{method:'POST',body:content,headers:{'Content-Type':'text/plain'}})
+    .then(r=>r.json()).then(d=>{
+        showMsg('config-msg',d.message||d.error,d.success);
+    });
+}
+function saveAndRestart(){
+    saveConfig();
+    setTimeout(restartFrpc,500);
+}
+function restartFrpc(){
+    if(!confirm('确定要重启 frpc 服务吗?配置变更后重启会生效。')) return;
+    authFetch('/api/frpc/restart',{method:'POST'}).then(r=>r.json()).then(d=>{
+        showMsg('status-msg',d.message||d.error,d.success);
+        setTimeout(refreshStatus,2000);
+    });
+}
+function toggleSSH(){
+    let frame=document.getElementById('ssh-frame');
+    if(!sshConnected){
+        let proto=location.protocol==='https:'?'wss':'ws';
+        let host=location.hostname;
+        let port=location.port;
+        // ttyd runs on separate port, use external URL
+        frame.src='http://'+host+':40964';
+        frame.style.display='block';
+        sshConnected=true;
+    }else{
+        frame.style.display='none';
+        frame.src='';
+        sshConnected=false;
+    }
+}
+// Init
+refreshStatus();
+document.getElementById('ssh-external-link').href='http://'+location.hostname+':40964';
+</script>
+</body>
+</html>

+ 728 - 0
assets/web/help.bak1.html

@@ -0,0 +1,728 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>帮助手册 - 车牌识别系统</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
+.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
+.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
+.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
+.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
+.container{max-width:960px;margin:20px auto;padding:0 16px}
+.card{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
+.card-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
+.card-title:hover{background:#37474f}
+.card-title .arrow{transition:transform 0.2s;font-size:12px}
+.card-title.collapsed .arrow{transform:rotate(-90deg)}
+.card-body{padding:16px 20px;line-height:1.8;font-size:14px}
+.card-body.hidden{display:none}
+h3{color:#1a1a2e;margin:16px 0 8px;font-size:15px;border-left:3px solid #667eea;padding-left:10px}
+h4{color:#333;margin:12px 0 6px;font-size:14px}
+table{width:100%;border-collapse:collapse;margin:8px 0 16px;font-size:13px}
+th{background:#f5f5f5;padding:8px 10px;text-align:left;font-weight:600;border-bottom:2px solid #e0e0e0;white-space:nowrap}
+td{padding:8px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top}
+td:first-child{font-family:monospace;color:#d32f2f;white-space:nowrap;font-size:12px}
+code{background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:12px;color:#d32f2f;font-family:monospace}
+.tip{background:#e8f5e9;border-left:4px solid #4caf50;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.warn{background:#fff3e0;border-left:4px solid #ff9800;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.note{background:#e3f2fd;border-left:4px solid #2196f3;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+ul{padding-left:20px;margin:6px 0}
+li{margin:4px 0;font-size:13px;line-height:1.7}
+.toc{background:#fff;border-radius:8px;padding:16px 24px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
+.toc h2{font-size:16px;color:#1a1a2e;margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:6px}
+.toc ol{padding-left:20px;columns:2;column-gap:30px}
+.toc li{margin:4px 0;font-size:13px;break-inside:avoid}
+.toc a{color:#667eea;text-decoration:none}
+.toc a:hover{text-decoration:underline}
+@media(max-width:768px){
+.navbar{flex-wrap:wrap;gap:8px;padding:10px 14px}
+.navbar .brand{font-size:14px;width:100%}
+.navbar a{font-size:12px;padding:4px 8px}
+.container{padding:0 10px;margin:10px auto}
+.toc ol{columns:1}
+table{font-size:12px;display:block;overflow-x:auto}
+th,td{padding:6px 8px}
+td:first-child{font-size:11px}
+}
+@media(max-width:480px){
+.navbar .brand{font-size:13px}
+.navbar a{font-size:11px;padding:3px 6px}
+.card-body{padding:12px 14px}
+}
+</style>
+</head>
+<body>
+<nav class="navbar">
+  <span class="brand">🚛 车牌识别系统</span>
+  <a href="/">监控首页</a>
+  <a href="/video">视频预览</a>
+  <a href="/locks">锁定管理</a>
+  <a href="/help" class="active">帮助手册</a>
+</nav>
+
+<div class="container">
+
+<div class="toc">
+<h2>📋 目录</h2>
+<ol>
+  <li><a href="#sec1">系统概述</a></li>
+  <li><a href="#sec2">快速部署指南</a></li>
+  <li><a href="#sec3">config.ini 完整配置说明</a></li>
+  <li><a href="#sec4">RTSP 摄像头配置</a></li>
+  <li><a href="#sec5">车牌识别上传流程</a></li>
+  <li><a href="#sec6">MQTT 数据上报配置</a></li>
+  <li><a href="#sec7">称重系统对接</a></li>
+  <li><a href="#sec8">ROI 区域识别配置</a></li>
+  <li><a href="#sec9">交替锁定机制</a></li>
+  <li><a href="#sec10">登录认证与权限</a></li>
+  <li><a href="#sec11">frpc 内网穿透</a></li>
+  <li><a href="#sec12">SSL/HTTPS 配置</a></li>
+  <li><a href="#sec13">系统监控与日志</a></li>
+  <li><a href="#sec14">Web 管理功能</a></li>
+  <li><a href="#sec15">数据库与备份</a></li>
+  <li><a href="#sec16">常见问题 FAQ</a></li>
+</ol>
+</div>
+
+<!-- 1. 系统概述 -->
+<div class="card" id="sec1">
+<div class="card-title" onclick="toggleCard(this)">1. 系统概述 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统简介</h3>
+<p>车牌识别系统(PlateRecApp)是一款基于 HyperLPR3 的实时车牌识别应用,运行在树莓派 5 (aarch64) 上,支持:</p>
+<ul>
+  <li>多路 RTSP 摄像头实时抓拍识别(进场/出场/侧面进/侧面出)</li>
+  <li>车牌识别结果自动上传至云端 API</li>
+  <li>称重系统对接(TCP 协议),自动关联车牌与称重数据</li>
+  <li>MQTT 协议实时推送识别结果</li>
+  <li>飞书群消息通知(异常报警)</li>
+  <li>Web 管理界面(监控首页、视频预览、系统配置、锁定管理等)</li>
+  <li>frpc 内网穿透管理</li>
+  <li>登录认证与角色权限控制</li>
+</ul>
+
+<h3>运行环境</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>硬件</td><td>树莓派 5 8GB(推荐)</td></tr>
+  <tr><td>系统</td><td>Debian 13 (trixie) / Ubuntu 24.04 aarch64</td></tr>
+  <tr><td>依赖</td><td>HyperLPR3 SDK、FFmpeg、libcurl、OpenSSL、cJSON、Mosquitto</td></tr>
+  <tr><td>反向代理</td><td>Nginx(HTTPS 对外服务)</td></tr>
+</table>
+
+<h3>目录结构</h3>
+<table>
+  <tr><th>路径</th><th>说明</th></tr>
+  <tr><td>PlateRecApp</td><td>主程序二进制</td></tr>
+  <tr><td>config.ini</td><td>主配置文件</td></tr>
+  <tr><td>config.txt</td><td>MD5 校验配置</td></tr>
+  <tr><td>hyperlpr3/</td><td>HyperLPR3 SDK 模型文件</td></tr>
+  <tr><td>assets/web/</td><td>Web 页面文件(HTML/CSS/JS)</td></tr>
+  <tr><td>data/</td><td>数据库目录(auth.db、upload_records.db)</td></tr>
+  <tr><td>photos/</td><td>抓拍图片存储目录</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 2. 快速部署指南 -->
+<div class="card" id="sec2">
+<div class="card-title" onclick="toggleCard(this)">2. 快速部署指南 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>编译部署</h3>
+<ol>
+  <li>解压代码包:<code>tar xzf fix24_vXX.tar.gz</code></li>
+  <li>编译:<code>cd fix24/build && cmake .. && make -j4</code></li>
+  <li>部署文件到安装目录(如 <code>/opt/openAI/project/003.PlateRecAPP/</code>)</li>
+  <li>复制 HTML 文件:<code>cp -r assets/web/ /安装目录/assets/web/</code></li>
+  <li>配置 sudoers(frpc 管理需要):<code>sudo bash scripts/setup_frpc_sudoers.sh</code></li>
+  <li>修改 <code>config.ini</code> 配置(参考下文各节)</li>
+  <li>启动服务:<code>sudo systemctl restart PlateRecApp</code></li>
+</ol>
+
+<h3>systemd 服务配置</h3>
+<p>服务文件路径:<code>/etc/systemd/system/PlateRecApp.service</code></p>
+<div class="tip">关键配置:User=stevenroc(非 root 运行),需要 sudoers 授权 frpc 管理权限。WorkingDirectory 应指向安装目录。</div>
+
+<h3>密码重置</h3>
+<p>如忘记管理员密码,运行:<code>python3 reset_passwords.py</code></p>
+</div>
+</div>
+
+<!-- 3. config.ini 完整配置 -->
+<div class="card" id="sec3">
+<div class="card-title" onclick="toggleCard(this)">3. config.ini 完整配置说明 <span class="arrow">▼</span></div>
+<div class="card-body">
+<p>配置文件路径:<code>&lt;安装目录&gt;/config.ini</code>,ini 格式,section 和 key 均不区分大小写。</p>
+
+<h3>[config] 核心识别配置</h3>
+<table>
+  <tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>project_name</td><td>string</td><td>-</td><td>项目名称</td></tr>
+  <tr><td>point_number</td><td>string</td><td>-</td><td>站点编号</td></tr>
+  <tr><td>throughway</td><td>string</td><td>-</td><td>通道名称</td></tr>
+  <tr><td>rtsp_url_front_in</td><td>string</td><td>-</td><td>前侧进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>string</td><td>-</td><td>前侧出场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>string</td><td>-</td><td>侧面进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>string</td><td>-</td><td>侧面出场摄像头 RTSP 地址</td></tr>
+  <tr><td>app_api</td><td>string</td><td>-</td><td>云端上传 API 地址</td></tr>
+  <tr><td>app_key</td><td>string</td><td>-</td><td>API 认证 Key</td></tr>
+  <tr><td>app_secret</td><td>string</td><td>-</td><td>API 认证 Secret</td></tr>
+  <tr><td>DEBUG_LOG</td><td>int</td><td>0</td><td>调试日志开关(0=关闭,1=开启)</td></tr>
+  <tr><td>TestFlag</td><td>int</td><td>0</td><td>测试模式开关</td></tr>
+  <tr><td>wType</td><td>int</td><td>0</td><td>称重类型(0=禁用,1=启用)</td></tr>
+  <tr><td>TIME_WINDOW</td><td>int</td><td>5</td><td>识别时间窗口(分钟),同一车牌在窗口内不重复上传</td></tr>
+  <tr><td>MAX_PHOTO_GROUPS</td><td>int</td><td>100</td><td>最大照片组数缓存</td></tr>
+  <tr><td>in_out_interval</td><td>int</td><td>5</td><td>⚠️ 单位是<strong>分钟</strong>!交替锁定间隔(同一车牌进出最短时间),内部转为秒存储</td></tr>
+  <tr><td>PLATE_CONFIDENCE_THRESHOLD</td><td>float</td><td>0.7</td><td>车牌识别置信度阈值(0~1),低于此值不上传</td></tr>
+  <tr><td>PLATE_LOG_THRESHOLD</td><td>float</td><td>0.5</td><td>日志记录置信度阈值</td></tr>
+  <tr><td>AlternatingMerge</td><td>int</td><td>0</td><td>交替合并开关(0=关闭,1=开启)</td></tr>
+  <tr><td>hw_decode_mode</td><td>string</td><td>auto</td><td>硬件解码模式:auto/soft/drm</td></tr>
+  <tr><td>hw_decode_device</td><td>string</td><td>/dev/dri/renderD128</td><td>硬件解码设备路径</td></tr>
+  <tr><td>rtsp_transport</td><td>string</td><td>tcp</td><td>RTSP 传输协议:tcp/udp</td></tr>
+  <tr><td>PhotoMaxCapacityMB</td><td>int</td><td>1024</td><td>照片目录最大容量(MB),超限自动清理旧照片</td></tr>
+  <tr><td>LogRetentionDays</td><td>int</td><td>30</td><td>日志保留天数</td></tr>
+</table>
+
+<h3>[config] ROI 区域配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>roi_in_x / roi_in_y</td><td>进场 ROI 区域左上角坐标</td></tr>
+  <tr><td>roi_in_w / roi_in_h</td><td>进场 ROI 区域宽高</td></tr>
+  <tr><td>roi_in_enabled</td><td>进场 ROI 开关(0/1)</td></tr>
+  <tr><td>roi_out_x / roi_out_y</td><td>出场 ROI 区域左上角坐标</td></tr>
+  <tr><td>roi_out_w / roi_out_h</td><td>出场 ROI 区域宽高</td></tr>
+  <tr><td>roi_out_enabled</td><td>出场 ROI 开关(0/1)</td></tr>
+  <tr><td>roi_debug_enabled</td><td>ROI 调试模式开关(0/1)</td></tr>
+</table>
+<div class="tip">ROI 可通过 Web 首页的可视化工具拖拽设置,也可在 config.ini 中手动填写坐标值。</div>
+
+<h3>[server] Web 服务配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>ip</td><td>Web 服务监听地址(含端口),如 <code>0.0.0.0:8080</code></td></tr>
+</table>
+
+<h3>[mqtt] MQTT 上报配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>MQTT_HOST</td><td>-</td><td>MQTT Broker 地址(填写后自动启用 MQTT)</td></tr>
+  <tr><td>MQTT_PORT</td><td>1883</td><td>MQTT Broker 端口</td></tr>
+  <tr><td>MQTT_USER</td><td>-</td><td>MQTT 用户名</td></tr>
+  <tr><td>MQTT_PASS</td><td>-</td><td>MQTT 密码</td></tr>
+  <tr><td>MQTT_TOPIC</td><td>-</td><td>推送主题</td></tr>
+  <tr><td>MQTT_CLIENT_ID</td><td>-</td><td>客户端 ID</td></tr>
+  <tr><td>PLATE_COLOR</td><td>-</td><td>车牌颜色标识</td></tr>
+  <tr><td>VEHICLE_TYPE</td><td>-</td><td>车辆类型标识</td></tr>
+</table>
+
+<h3>[feishu] 飞书通知配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>APP_ID</td><td>飞书应用 App ID</td></tr>
+  <tr><td>APP_SECRET</td><td>飞书应用 App Secret</td></tr>
+  <tr><td>CHAT_ID</td><td>飞书群聊 ID</td></tr>
+  <tr><td>TIMEOUT_SECOND</td><td>请求超时时间(秒)</td></tr>
+  <tr><td>WarningSigns</td><td>抑制紧急报警(1=抑制,0=正常)</td></tr>
+</table>
+
+<h3>[weight] 称重系统配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>flagWeight</td><td>0</td><td>称重系统开关(0/1)</td></tr>
+  <tr><td>weight_server_ip</td><td>-</td><td>称重服务器 IP</td></tr>
+  <tr><td>weight_server_port</td><td>-</td><td>称重服务器端口</td></tr>
+  <tr><td>weight_threshold_in</td><td>0</td><td>进场称重阈值(吨)</td></tr>
+  <tr><td>weight_threshold_out</td><td>0</td><td>出场称重阈值(吨)</td></tr>
+  <tr><td>default_entry_weight</td><td>0</td><td>默认进场重量</td></tr>
+  <tr><td>default_exit_weight</td><td>0</td><td>默认出场重量</td></tr>
+  <tr><td>weight_detection_time</td><td>5000</td><td>称重检测时间(毫秒)</td></tr>
+  <tr><td>tcp_connect_timeout</td><td>3000</td><td>TCP 连接超时(毫秒)</td></tr>
+  <tr><td>MAX_WEIGHT</td><td>100</td><td>最大称重值(吨)</td></tr>
+  <tr><td>STABLE_SAMPLE_COUNT</td><td>5</td><td>稳定采样次数</td></tr>
+  <tr><td>STABLE_THRESHOLD_KG</td><td>50</td><td>稳定判定阈值(公斤)</td></tr>
+  <tr><td>CANDIDATE_DATA_COUNT</td><td>3</td><td>候选数据次数</td></tr>
+  <tr><td>MAX_UPLOAD_RETRIES</td><td>3</td><td>上传最大重试次数</td></tr>
+  <tr><td>RETRY_DELAY_MS</td><td>1000</td><td>重试延迟(毫秒)</td></tr>
+</table>
+
+<h3>[auth] 登录认证配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>认证开关(0=关闭,1=开启)</td></tr>
+  <tr><td>db_path</td><td>data/auth.db</td><td>认证数据库路径(相对安装目录)</td></tr>
+  <tr><td>session_idle_timeout</td><td>1800</td><td>Session 空闲超时(秒)</td></tr>
+  <tr><td>session_max_timeout</td><td>28800</td><td>Session 最大超时(秒)</td></tr>
+  <tr><td>session_remember_timeout</td><td>604800</td><td>记住登录超时(秒)</td></tr>
+  <tr><td>bcrypt_cost</td><td>12</td><td>bcrypt 加密轮数(10~14)</td></tr>
+  <tr><td>max_failed_attempts</td><td>5</td><td>最大登录失败次数</td></tr>
+  <tr><td>lockout_duration</td><td>900</td><td>锁定时长(秒)</td></tr>
+  <tr><td>rate_limit_max</td><td>5</td><td>速率限制最大请求</td></tr>
+  <tr><td>rate_limit_window</td><td>300</td><td>速率限制窗口(秒)</td></tr>
+</table>
+
+<h3>[ssl] HTTPS 配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>SSL 开关(0/1)</td></tr>
+  <tr><td>cert_path</td><td>SSL 证书路径</td></tr>
+  <tr><td>key_path</td><td>SSL 私钥路径</td></tr>
+</table>
+
+<h3>[system] 系统监控配置(v37 启用历史存储)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>monitor_interval</td><td>2</td><td>监控采集间隔(秒),通过 delta 法计算 CPU 使用率</td></tr>
+  <tr><td>temp_alert_threshold</td><td>70</td><td>温度告警阈值(°C)</td></tr>
+  <tr><td>cpu_alert_threshold</td><td>90</td><td>CPU 告警阈值(%)</td></tr>
+  <tr><td>db_path</td><td>data/system_metrics.db</td><td>监控数据库路径(SQLite)</td></tr>
+  <tr><td>history_retention_days</td><td>7</td><td>原始数据保留天数</td></tr>
+  <tr><td>aggregation_retention_days</td><td>365</td><td>聚合数据(小时/日)保留天数</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM),内存监控数据写入数据库</td></tr>
+</table>
+
+<h3>[frpc] 内网穿透配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>config_path</td><td>-</td><td>frpc.toml 配置文件路径</td></tr>
+  <tr><td>backup_path</td><td>-</td><td>配置备份路径</td></tr>
+  <tr><td>protected_tunnels</td><td>-</td><td>受保护隧道名称(逗号分隔)</td></tr>
+  <tr><td>restart_check_timeout</td><td>3</td><td>重启后检查超时(秒)</td></tr>
+  <tr><td>auto_rollback</td><td>1</td><td>自动回滚开关(0/1)</td></tr>
+  <tr><td>web_enabled</td><td>1</td><td>frpc Web 管理开关</td></tr>
+  <tr><td>admin_addr</td><td>127.0.0.1</td><td>frpc Admin API 地址</td></tr>
+  <tr><td>admin_port</td><td>7400</td><td>frpc Admin API 端口</td></tr>
+  <tr><td>admin_user</td><td>-</td><td>frpc Admin 用户名</td></tr>
+  <tr><td>admin_password</td><td>-</td><td>frpc Admin 密码</td></tr>
+</table>
+
+<h3>[terminal] 终端/SSH 配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>ttyd_port</td><td>7681</td><td>ttyd Web 终端端口</td></tr>
+  <tr><td>ttyd_credential</td><td>-</td><td>ttyd 登录凭据(user:password)</td></tr>
+  <tr><td>ttyd_max_clients</td><td>5</td><td>ttyd 最大客户端数</td></tr>
+  <tr><td>ssh_keys_path</td><td>-</td><td>SSH 公钥存储路径</td></tr>
+</table>
+
+<h3>[log] 日志配置(v36 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>内存缓冲模式开关(1=内存缓冲,0=直写文件)</td></tr>
+  <tr><td>log_file</td><td>PlateRecApp.log</td><td>日志文件路径</td></tr>
+  <tr><td>buffer_size_mb</td><td>20</td><td>内存缓冲区上限(MB),满时丢弃最旧日志</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM),内存日志写入磁盘文件</td></tr>
+  <tr><td>retention_days</td><td>30</td><td>日志文件保留天数,超期自动清理</td></tr>
+  <tr><td>redirect_stdout</td><td>1</td><td>捕获 stdout/stderr 到内存缓冲区(0/1)</td></tr>
+  <tr><td>auto_flush_on_exit</td><td>1</td><td>程序退出(SIGTERM/SIGINT)时立即刷盘(0/1)</td></tr>
+</table>
+<p><strong>v36 说明:</strong>启用内存缓冲后,所有日志先写入内存环形缓冲区,Web 日志页面直接从内存读取(零磁盘 I/O)。每日到达 <code>flush_time</code> 时批量写入 <code>PlateRecApp_YYYYMMDD.log</code>。设 <code>enabled=0</code> 可回退到传统直写文件模式。</p>
+</div>
+</div>
+
+<!-- 4. RTSP 摄像头配置 -->
+<div class="card" id="sec4">
+<div class="card-title" onclick="toggleCard(this)">4. RTSP 摄像头配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>摄像头地址格式</h3>
+<p>标准 RTSP 地址格式:</p>
+<code>rtsp://用户名:密码@摄像头IP:554/stream</code>
+
+<h3>四路摄像头说明</h3>
+<table>
+  <tr><th>参数</th><th>方向</th><th>说明</th></tr>
+  <tr><td>rtsp_url_front_in</td><td>前→进</td><td>前方摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>前→出</td><td>前方摄像头,捕捉出场车辆</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>侧→进</td><td>侧面摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>侧→出</td><td>侧面摄像头,捕捉出场车辆</td></tr>
+</table>
+
+<h3>共享捕获模式</h3>
+<p>当 <code>rtsp_url_front_in</code> 与 <code>rtsp_url_front_out</code> 指向同一摄像头时,系统自动启用共享捕获模式,节省解码资源。</p>
+
+<h3>硬件解码</h3>
+<table>
+  <tr><th>模式</th><th>值</th><th>说明</th></tr>
+  <tr><td>自动</td><td>auto</td><td>优先硬件解码,不可用则回退软件</td></tr>
+  <tr><td>软件解码</td><td>soft</td><td>CPU 解码,兼容性最好</td></tr>
+  <tr><td>DRM 硬件</td><td>drm</td><td>使用 DRM 硬件加速解码(树莓派5推荐)</td></tr>
+</table>
+<div class="tip">树莓派 5 推荐配置:<code>hw_decode_mode=drm</code>,<code>hw_decode_device=/dev/dri/renderD128</code>,<code>rtsp_transport=tcp</code></div>
+</div>
+</div>
+
+<!-- 5. 车牌识别上传流程 -->
+<div class="card" id="sec5">
+<div class="card-title" onclick="toggleCard(this)">5. 车牌识别上传完整流程 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>识别流程</h3>
+<ol>
+  <li><strong>摄像头抓拍</strong>:RTSP 视频流实时解码,检测到车辆运动时触发抓拍</li>
+  <li><strong>车牌识别</strong>:HyperLPR3 引擎识别车牌号,返回车牌文本 + 置信度</li>
+  <li><strong>置信度过滤</strong>:识别结果低于 <code>PLATE_CONFIDENCE_THRESHOLD</code> 则丢弃</li>
+  <li><strong>时间窗口去重</strong>:同一车牌在 <code>TIME_WINDOW</code> 分钟内不重复上传</li>
+  <li><strong>交替锁定检查</strong>:同一车牌进出间隔低于 <code>in_out_interval</code> 分钟则锁定</li>
+  <li><strong>ROI 过滤</strong>:如启用 ROI,只识别指定区域内的车牌</li>
+  <li><strong>数据上传</strong>:通过 HTTP POST 将识别结果 + 抓拍照片上传至 <code>app_api</code></li>
+  <li><strong>MQTT 推送</strong>:如启用 MQTT,同步推送识别结果</li>
+  <li><strong>飞书通知</strong>:异常情况(识别失败、系统异常)发送飞书群消息</li>
+</ol>
+
+<h3>上传 API 请求格式</h3>
+<p>POST <code>app_api</code>,Headers 包含 <code>app_key</code> 和 <code>app_secret</code> 认证。</p>
+<div class="note">上传数据包含:车牌号、置信度、车牌颜色、抓拍时间、摄像头方向、照片文件(Base64 或 multipart)。</div>
+
+<h3>照片存储与清理</h3>
+<ul>
+  <li>照片保存在 <code>photos/</code> 目录下,按日期分目录</li>
+  <li>当照片总容量超过 <code>PhotoMaxCapacityMB</code> 时,自动清理最旧的照片</li>
+  <li>清理时优先保留最近的照片,从最旧的日期目录开始删除</li>
+</ul>
+</div>
+</div>
+
+<!-- 6. MQTT 数据上报 -->
+<div class="card" id="sec6">
+<div class="card-title" onclick="toggleCard(this)">6. MQTT 数据上报配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>启用条件</h3>
+<p>在 <code>[mqtt]</code> 段中填写 <code>MQTT_HOST</code> 即自动启用 MQTT 推送。</p>
+
+<h3>推送内容</h3>
+<p>每次车牌识别成功后,MQTT 推送 JSON 格式数据,包含:</p>
+<ul>
+  <li>车牌号、车牌颜色、车辆类型</li>
+  <li>识别置信度</li>
+  <li>摄像头方向(进/出)</li>
+  <li>识别时间戳</li>
+  <li>站点编号</li>
+</ul>
+
+<h3>配置示例</h3>
+<pre style="background:#f5f5f5;padding:10px;border-radius:4px;font-size:12px;overflow-x:auto">[mqtt]
+MQTT_HOST=mqtt.example.com
+MQTT_PORT=1883
+MQTT_USER=username
+MQTT_PASS=password
+MQTT_TOPIC=plate/recognition
+MQTT_CLIENT_ID=plate_rec_station_01
+PLATE_COLOR=yellow
+VEHICLE_TYPE=truck</pre>
+
+<div class="warn">注意:MQTT 连接失败时系统会自动重连,不影响识别和上传功能。</div>
+</div>
+</div>
+
+<!-- 7. 称重系统 -->
+<div class="card" id="sec7">
+<div class="card-title" onclick="toggleCard(this)">7. 称重系统对接 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>工作原理</h3>
+<p>称重系统通过 TCP 协议与称重地磅通信:</p>
+<ol>
+  <li>车牌识别成功后,系统连接称重服务器</li>
+  <li>读取当前称重数据</li>
+  <li>将车牌与重量数据关联后一起上传</li>
+</ol>
+
+<h3>重量判定逻辑</h3>
+<ul>
+  <li>连续 <code>STABLE_SAMPLE_COUNT</code> 次读数变化 < <code>STABLE_THRESHOLD_KG</code> 公斤,视为稳定</li>
+  <li>稳定后取 <code>CANDIDATE_DATA_COUNT</code> 次读数的中值作为最终重量</li>
+  <li>重量超过 <code>MAX_WEIGHT</code> 吨视为异常,丢弃</li>
+</ul>
+
+<h3>阈值说明</h3>
+<ul>
+  <li><code>weight_threshold_in</code>:进场最低重量(吨),低于此值可能表示空车</li>
+  <li><code>weight_threshold_out</code>:出场最低重量(吨)</li>
+  <li><code>default_entry_weight</code>:无法获取称重数据时的默认进场重量</li>
+  <li><code>default_exit_weight</code>:无法获取称重数据时的默认出场重量</li>
+</ul>
+</div>
+</div>
+
+<!-- 8. ROI -->
+<div class="card" id="sec8">
+<div class="card-title" onclick="toggleCard(this)">8. ROI 区域识别配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>ROI 概念</h3>
+<p>ROI(Region of Interest)即感兴趣区域,用于限定车牌识别的有效区域,减少误识别。</p>
+
+<h3>配置方式</h3>
+<ul>
+  <li><strong>Web 可视化</strong>:首页连接状态面板中可拖拽设置 ROI 区域</li>
+  <li><strong>手动配置</strong>:在 config.ini 的 <code>[config]</code> 段填写坐标值</li>
+</ul>
+
+<h3>参数说明</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>roi_in_x, roi_in_y</td><td>进场区域左上角坐标(像素)</td></tr>
+  <tr><td>roi_in_w, roi_in_h</td><td>进场区域宽高(像素)</td></tr>
+  <tr><td>roi_in_enabled</td><td>1=启用进场 ROI,0=关闭</td></tr>
+  <tr><td>roi_out_x, roi_out_y</td><td>出场区域左上角坐标</td></tr>
+  <tr><td>roi_out_w, roi_out_h</td><td>出场区域宽高</td></tr>
+  <tr><td>roi_out_enabled</td><td>1=启用出场 ROI</td></tr>
+  <tr><td>roi_debug_enabled</td><td>1=在抓拍图上绘制 ROI 框(调试用)</td></tr>
+</table>
+<div class="tip">开启 <code>roi_debug_enabled=1</code> 后,抓拍照片上会显示红色 ROI 框线,便于确认区域位置是否准确。</div>
+</div>
+</div>
+
+<!-- 9. 交替锁定 -->
+<div class="card" id="sec9">
+<div class="card-title" onclick="toggleCard(this)">9. 交替锁定机制 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>机制说明</h3>
+<p>交替锁定防止同一车牌短时间内进出频繁触发(如车辆在门口调头)。同一车牌从"进"切换到"出"(或反之),必须间隔 <code>in_out_interval</code> 分钟以上。</p>
+
+<h3>工作流程</h3>
+<ol>
+  <li>车牌 A 被识别为"进"方向</li>
+  <li>5 分钟内(默认)车牌 A 再次被识别为"出"方向</li>
+  <li>系统判定为异常,创建交替锁定记录</li>
+  <li>锁定期间该车牌不会被上传</li>
+  <li>超过 2 小时锁定自动清零,或手动在"锁定管理"页面清除</li>
+</ol>
+
+<h3>配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>in_out_interval</td><td>5</td><td>交替锁定间隔(⚠️ 单位分钟),小于此间隔的进/出切换会被锁定</td></tr>
+  <tr><td>AlternatingMerge</td><td>0</td><td>交替合并开关</td></tr>
+</table>
+<div class="warn"><code>in_out_interval</code> 单位是<strong>分钟</strong>,不是秒!config.ini 填 5 表示 5 分钟 = 300 秒。系统内部会自动 ×60 转换。</div>
+</div>
+</div>
+
+<!-- 10. 认证与权限 -->
+<div class="card" id="sec10">
+<div class="card-title" onclick="toggleCard(this)">10. 登录认证与权限 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>角色定义</h3>
+<table>
+  <tr><th>角色</th><th>Role值</th><th>可访问页面</th></tr>
+  <tr><td>普通用户</td><td>0</td><td>首页、视频、称重、锁定管理</td></tr>
+  <tr><td>管理员</td><td>1</td><td>普通用户 + 系统配置</td></tr>
+  <tr><td>超级管理员</td><td>2</td><td>所有页面(含系统监控、frpc管理、SSH密钥、日志)</td></tr>
+</table>
+
+<h3>默认账号</h3>
+<table>
+  <tr><th>用户名</th><th>密码</th><th>角色</th></tr>
+  <tr><td>admin</td><td>zhongjin188A</td><td>超级管理员</td></tr>
+  <tr><td>zhonjin</td><td>zhonjin</td><td>普通用户</td></tr>
+</table>
+
+<h3>安全特性</h3>
+<ul>
+  <li>密码使用 bcrypt 加密存储(cost=12)</li>
+  <li>连续 <code>max_failed_attempts</code> 次登录失败后锁定账号</li>
+  <li>CSRF Token 防护所有状态变更请求</li>
+  <li>Session 超时自动失效</li>
+  <li>登录速率限制</li>
+</ul>
+
+<h3>密码重置</h3>
+<p>运行 <code>python3 reset_passwords.py</code>,脚本会自动查找 <code>data/auth.db</code> 并重置所有用户密码为默认值。</p>
+</div>
+</div>
+
+<!-- 11. frpc -->
+<div class="card" id="sec11">
+<div class="card-title" onclick="toggleCard(this)">11. frpc 内网穿透 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>功能说明</h3>
+<p>系统内置 frpc 管理,支持:</p>
+<ul>
+  <li>查看 frpc 运行状态和隧道列表</li>
+  <li>在线编辑 frpc.toml 配置(自动备份)</li>
+  <li>一键重启 frpc 服务(需 sudoers 配置)</li>
+  <li>重启失败自动回滚配置</li>
+  <li>受保护隧道标记,防止误删</li>
+</ul>
+
+<h3>sudoers 配置</h3>
+<p>PlateRecApp 以非 root 用户运行时,需要配置 sudoers 授权 frpc 管理权限:</p>
+<pre style="background:#f5f5f5;padding:10px;border-radius:4px;font-size:12px">sudo bash scripts/setup_frpc_sudoers.sh [运行用户名]</pre>
+</div>
+</div>
+
+<!-- 12. SSL -->
+<div class="card" id="sec12">
+<div class="card-title" onclick="toggleCard(this)">12. SSL/HTTPS 配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>两种方式</h3>
+<ul>
+  <li><strong>应用内置 SSL</strong>:在 <code>[ssl]</code> 段配置证书和私钥路径,PlateRecApp 直接提供 HTTPS</li>
+  <li><strong>Nginx 反向代理</strong>(推荐):PlateRecApp 监听 HTTP,Nginx 处理 SSL 终止</li>
+</ul>
+
+<h3>config.ini 配置</h3>
+<pre style="background:#f5f5f5;padding:10px;border-radius:4px;font-size:12px">[ssl]
+enabled=1
+cert_path=/path/to/cert.pem
+key_path=/path/to/key.pem</pre>
+</div>
+</div>
+
+<!-- 13. 监控与日志 -->
+<div class="card" id="sec13">
+<div class="card-title" onclick="toggleCard(this)">13. 系统监控与日志 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统监控(实时)</h3>
+<ul>
+  <li>CPU 使用率(含每核使用率、1/5/15分钟负载均值)</li>
+  <li>内存使用率(总量/已用/可用)</li>
+  <li>磁盘使用率(根分区容量/已用/可用)</li>
+  <li>CPU 温度监控,超阈值告警(阈值由 <code>temp_alert_threshold</code> 配置)</li>
+  <li>CPU 使用率告警(阈值由 <code>cpu_alert_threshold</code> 配置)</li>
+  <li>PlateRecApp 进程资源占用(RSS 内存、CPU 占用)</li>
+  <li>frpc 运行状态</li>
+  <li>MQTT 连接状态</li>
+  <li>称重系统连接状态</li>
+</ul>
+<p>数据来源:通过读取 <code>/proc</code>、<code>/sys</code>、<code>statvfs</code> 等系统接口实时采集,页面每 2 秒自动刷新。</p>
+
+<h3>历史监控数据(v37 新增)</h3>
+<p>系统自动采集并存储监控数据到 SQLite 数据库(<code>data/system_metrics.db</code>),支持历史趋势查询。</p>
+<ul>
+  <li><strong>采集间隔</strong>:<code>monitor_interval</code>(默认 2 秒),通过 delta 法计算 CPU 使用率,不阻塞采集线程</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)将内存监控数据批量写入 <code>system_metrics.db</code>,减少 SD 卡写入次数(v38 起)</li>
+  <li><strong>三级存储</strong>:原始数据(2秒间隔)→ 小时聚合 → 日聚合,自动降采样</li>
+  <li><strong>自动清理</strong>:全量数据保留 <code>history_retention_days</code> 天(默认 7 天),聚合数据保留 <code>aggregation_retention_days</code> 天(默认 365 天)</li>
+  <li><strong>历史查询 API</strong>:<code>/api/monitor/history?range=1h|6h|24h|7d|30d</code>,自动选择合适的数据粒度</li>
+  <li><strong>统计接口</strong>:<code>/api/monitor/stats</code> 返回数据库记录数和运行状态</li>
+</ul>
+<div class="tip">💡 历史数据页面路径:<code>/history</code>,可查看 CPU、内存、温度、磁盘等指标的趋势图表。</div>
+
+<h3>SD 卡写入保护策略(v38)</h3>
+<p>系统针对树莓派 SD 卡/eMMC 写入寿命问题,统一采用<strong>"内存缓冲 + 每日定时写盘"</strong>策略:</p>
+<ul>
+  <li><strong>系统监控</strong>:采集数据全部缓存在内存(~5MB),每天 23:20 一次性写入 <code>system_metrics.db</code>。相比每 30 秒写盘,每日写入从 <strong>2880 次降至 1 次</strong></li>
+  <li><strong>运行日志</strong>:stdout/stderr 通过 pipe 重定向到内存环形缓冲区,Web 日志页面实时从内存读取。每天 23:20 写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>旧版日志清理自动禁用</strong>:内存模式启用时,凌晨 2 点的旧版 <code>daily_log_cleanup()</code>(copytruncate)自动跳过,避免无意义的 SD 卡读写</li>
+  <li><strong>退出安全刷盘</strong>:程序退出(SIGTERM/SIGINT)时自动执行最后一次刷盘,防止数据丢失</li>
+</ul>
+<div class="note">💡 两个子系统的 <code>flush_time</code> 均配置为 <code>23:20</code>,分别在 <code>[log]</code> 和 <code>[system]</code> 段中设置,可独立调整。</div>
+
+<h3>日志管理(v36 内存缓冲)</h3>
+<ul>
+  <li><strong>内存缓冲模式</strong>(<code>enabled=1</code>,默认):所有日志写入内存环形缓冲区,Web 日志页面直接从内存读取,零磁盘 I/O</li>
+  <li><strong>直写文件模式</strong>(<code>enabled=0</code>):传统模式,日志直接写入文件</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)自动将内存日志批量写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>退出刷盘</strong>:<code>auto_flush_on_exit=1</code> 时,收到 SIGTERM/SIGINT 信号立即刷盘,防止日志丢失</li>
+  <li><strong>stdout 捕获</strong>:<code>redirect_stdout=1</code> 时,stdout/stderr 通过 pipe 重定向到内存缓冲区,统一收集</li>
+  <li>Web 界面实时查看日志(支持 Tail 模式),显示内存使用率和缓冲状态</li>
+  <li>超期日志文件自动清理(由 <code>retention_days</code> 控制)</li>
+  <li>支持手动触发刷盘(Web 页面操作)</li>
+</ul>
+</div>
+</div>
+
+<!-- 14. Web管理 -->
+<div class="card" id="sec14">
+<div class="card-title" onclick="toggleCard(this)">14. Web 管理功能 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>页面列表</h3>
+<table>
+  <tr><th>页面</th><th>路径</th><th>权限</th><th>说明</th></tr>
+  <tr><td>监控首页</td><td>/</td><td>所有用户</td><td>实时监控、识别记录、连接状态、ROI设置</td></tr>
+  <tr><td>视频预览</td><td>/video</td><td>所有用户</td><td>实时视频流预览</td></tr>
+  <tr><td>称重记录</td><td>/weight</td><td>所有用户</td><td>称重数据查看</td></tr>
+  <tr><td>锁定管理</td><td>/locks</td><td>所有用户</td><td>交替锁定记录查看和清除</td></tr>
+  <tr><td>帮助手册</td><td>/help</td><td>超级管理员</td><td>系统配置文档</td></tr>
+  <tr><td>系统配置</td><td>/config</td><td>管理员+</td><td>config.ini 在线编辑</td></tr>
+  <tr><td>系统监控</td><td>/system</td><td>超级管理员</td><td>CPU/内存/温度/磁盘监控</td></tr>
+  <tr><td>frpc 管理</td><td>/frpc</td><td>超级管理员</td><td>内网穿透状态和配置</td></tr>
+  <tr><td>SSH 密钥</td><td>/ssh-keys</td><td>超级管理员</td><td>SSH 公钥管理</td></tr>
+  <tr><td>日志管理</td><td>/logs</td><td>超级管理员</td><td>系统日志查看</td></tr>
+  <tr><td>登录</td><td>/login</td><td>公开</td><td>用户登录页面</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 15. 数据库 -->
+<div class="card" id="sec15">
+<div class="card-title" onclick="toggleCard(this)">15. 数据库与备份 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>数据库文件</h3>
+<table>
+  <tr><th>文件</th><th>路径</th><th>说明</th></tr>
+  <tr><td>认证数据库</td><td>data/auth.db</td><td>用户账号、密码、Session</td></tr>
+  <tr><td>识别记录</td><td>data/upload_records.db</td><td>车牌识别上传记录</td></tr>
+  <tr><td>系统监控</td><td>data/system_metrics.db</td><td>性能监控时序数据(原始+小时聚合+日聚合)</td></tr>
+</table>
+<div class="tip">v29 版本起,所有数据库统一存放在 data/ 目录下。系统首次启动时会自动将旧路径数据库迁移到新位置(旧文件保留为备份)。</div>
+
+<h3>备份建议</h3>
+<ul>
+  <li>定期备份 <code>data/</code> 目录和 <code>config.ini</code></li>
+  <li>frpc 配置修改时会自动备份到 <code>backup_path</code> 指定位置</li>
+  <li>照片数据在 <code>PhotoMaxCapacityMB</code> 限制内自动管理</li>
+</ul>
+</div>
+</div>
+
+<!-- 16. FAQ -->
+<div class="card" id="sec16">
+<div class="card-title" onclick="toggleCard(this)">16. 常见问题 FAQ <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>Q: 摄像头连接失败怎么办?</h3>
+<p>A: 检查以下几点:</p>
+<ul>
+  <li>确认 RTSP 地址格式正确,在 VLC 中测试能否播放</li>
+  <li>检查网络连通性:<code>ping 摄像头IP</code></li>
+  <li>尝试修改 <code>rtsp_transport=tcp</code>(比 UDP 更稳定)</li>
+  <li>查看日志:<code>tail -f PlateRecApp.log</code></li>
+</ul>
+
+<h3>Q: 识别率低怎么办?</h3>
+<p>A: 优化建议:</p>
+<ul>
+  <li>启用 ROI 区域,缩小识别范围到车道区域</li>
+  <li>调整 <code>PLATE_CONFIDENCE_THRESHOLD</code>(适当降低可增加识别率,但可能增加误识别)</li>
+  <li>确保摄像头角度和光照条件良好</li>
+  <li>开启 <code>roi_debug_enabled=1</code> 确认 ROI 区域位置</li>
+</ul>
+
+<h3>Q: frpc 重启失败?</h3>
+<p>A: 检查:</p>
+<ul>
+  <li>确认已配置 sudoers:<code>sudo bash scripts/setup_frpc_sudoers.sh</code></li>
+  <li>检查 frpc.toml 配置语法是否正确</li>
+  <li>查看 frpc 服务状态:<code>systemctl status frpc</code></li>
+  <li>查看 frpc 日志:<code>journalctl -u frpc -n 50</code></li>
+</ul>
+
+<h3>Q: 登录后页面显示 403?</h3>
+<p>A: 这是因为当前用户角色没有该页面的访问权限。普通用户无法访问配置和管理页面,需要管理员或超级管理员账号。</p>
+
+<h3>Q: 如何修改密码?</h3>
+<p>A: 目前通过命令行重置:</p>
+<pre style="background:#f5f5f5;padding:10px;border-radius:4px;font-size:12px">python3 reset_passwords.py</pre>
+
+<h3>Q: 数据库路径错误怎么办?</h3>
+<p>A: v29 版本起数据库统一在 <code>data/</code> 目录。如果是从旧版升级,启动时会自动迁移。如迁移失败,手动将 <code>upload_records.db</code> 和 <code>upload_records_auth.db</code> 复制到 <code>data/</code> 目录并重命名。</p>
+
+<h3>Q: in_out_interval 填 5 是 5 秒还是 5 分钟?</h3>
+<p>A: <strong>5 分钟</strong>。这个参数的单位是分钟,不是秒。系统内部会自动乘以 60 转换为秒。填 5 = 300 秒。</p>
+
+</div>
+</div>
+
+</div>
+
+<script>
+function toggleCard(el) {
+  el.classList.toggle('collapsed');
+  var body = el.nextElementSibling;
+  body.classList.toggle('hidden');
+}
+</script>
+</body>
+</html>

+ 1185 - 0
assets/web/help.html

@@ -0,0 +1,1185 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>帮助手册 - 车牌识别系统</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
+.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
+.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
+.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
+.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
+.container{max-width:960px;margin:20px auto;padding:0 16px}
+.card{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
+.card-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
+.card-title:hover{background:#37474f}
+.card-title .arrow{transition:transform 0.2s;font-size:12px}
+.card-title.collapsed .arrow{transform:rotate(-90deg)}
+.card-body{padding:16px 20px;line-height:1.8;font-size:14px}
+.card-body.hidden{display:none}
+h3{color:#1a1a2e;margin:16px 0 8px;font-size:15px;border-left:3px solid #667eea;padding-left:10px}
+h4{color:#333;margin:12px 0 6px;font-size:14px}
+table{width:100%;border-collapse:collapse;margin:8px 0 16px;font-size:13px}
+th{background:#f5f5f5;padding:8px 10px;text-align:left;font-weight:600;border-bottom:2px solid #e0e0e0;white-space:nowrap}
+td{padding:8px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top}
+td:first-child{font-family:monospace;color:#d32f2f;white-space:nowrap;font-size:12px}
+code{background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:12px;color:#d32f2f;font-family:monospace}
+pre{background:#1e1e2e;color:#cdd6f4;padding:14px 18px;border-radius:6px;font-size:12px;overflow-x:auto;margin:8px 0 16px;line-height:1.6}
+pre code{background:none;color:#cdd6f4;padding:0;font-size:12px}
+.tip{background:#e8f5e9;border-left:4px solid #4caf50;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.warn{background:#fff3e0;border-left:4px solid #ff9800;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.note{background:#e3f2fd;border-left:4px solid #2196f3;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+ul{padding-left:20px;margin:6px 0}
+li{margin:4px 0;font-size:13px;line-height:1.7}
+.toc{background:#fff;border-radius:8px;padding:16px 24px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
+.toc h2{font-size:16px;color:#1a1a2e;margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:6px}
+.toc ol{padding-left:20px;columns:2;column-gap:30px}
+.toc li{margin:4px 0;font-size:13px;break-inside:avoid}
+.toc a{color:#667eea;text-decoration:none}
+.toc a:hover{text-decoration:underline}
+@media(max-width:768px){
+.navbar{flex-wrap:wrap;gap:8px;padding:10px 14px}
+.navbar .brand{font-size:14px;width:100%}
+.navbar a{font-size:12px;padding:4px 8px}
+.container{padding:0 10px;margin:10px auto}
+.toc ol{columns:1}
+table{font-size:12px;display:block;overflow-x:auto}
+th,td{padding:6px 8px}
+td:first-child{font-size:11px}
+}
+@media(max-width:480px){
+.navbar .brand{font-size:13px}
+.navbar a{font-size:11px;padding:3px 6px}
+.card-body{padding:12px 14px}
+}
+</style>
+</head>
+<body>
+<nav class="navbar">
+  <span class="brand">🚛 车牌识别系统</span>
+  <a href="/">监控首页</a>
+  <a href="/video">视频预览</a>
+  <a href="/locks">锁定管理</a>
+  <a href="/help" class="active">帮助手册</a>
+</nav>
+
+<div class="container">
+
+<div class="toc">
+<h2>📋 目录</h2>
+<ol>
+  <li><a href="#sec1">系统概述</a></li>
+  <li><a href="#sec2">快速部署指南</a></li>
+  <li><a href="#sec3">config.ini 完整配置说明</a></li>
+  <li><a href="#sec4">RTSP 摄像头配置</a></li>
+  <li><a href="#sec5">车牌识别上传流程</a></li>
+  <li><a href="#sec6">MQTT 数据上报配置</a></li>
+  <li><a href="#sec7">称重系统对接</a></li>
+  <li><a href="#sec8">ROI 区域识别配置</a></li>
+  <li><a href="#sec9">交替锁定机制</a></li>
+  <li><a href="#sec10">登录认证与权限</a></li>
+  <li><a href="#sec11">frpc.toml 内网穿透配置详解</a></li>
+  <li><a href="#sec12">Nginx 反向代理配置详解</a></li>
+  <li><a href="#sec13">SSL/HTTPS 证书配置</a></li>
+  <li><a href="#sec14">系统监控与日志</a></li>
+  <li><a href="#sec15">Web 管理功能</a></li>
+  <li><a href="#sec16">数据库与备份</a></li>
+  <li><a href="#sec17">常见问题 FAQ</a></li>
+</ol>
+</div>
+
+<!-- 1. 系统概述 -->
+<div class="card" id="sec1">
+<div class="card-title" onclick="toggleCard(this)">1. 系统概述 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统简介</h3>
+<p>车牌识别系统(PlateRecApp)是一款基于 HyperLPR3 的实时车牌识别应用,运行在树莓派 5 (aarch64) 上,支持:</p>
+<ul>
+  <li>多路 RTSP 摄像头实时抓拍识别(进场/出场/侧面进/侧面出)</li>
+  <li>车牌识别结果自动上传至云端 API</li>
+  <li>称重系统对接(TCP 协议),自动关联车牌与称重数据</li>
+  <li>MQTT 协议实时推送识别结果</li>
+  <li>飞书群消息通知(异常报警)</li>
+  <li>Web 管理界面(监控首页、视频预览、系统配置、锁定管理等)</li>
+  <li>frpc 内网穿透管理 + Nginx stream + ssl_preread 协议分流(HTTP 自动跳 HTTPS)</li>
+  <li>登录认证与角色权限控制</li>
+  <li>SD 卡写入寿命保护(内存缓冲 + 每日定时写盘)</li>
+</ul>
+
+<h3>运行环境</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>硬件</td><td>树莓派 5 8GB(推荐)</td></tr>
+  <tr><td>系统</td><td>Debian 13 (trixie) / Ubuntu 24.04 aarch64</td></tr>
+  <tr><td>依赖</td><td>HyperLPR3 SDK、FFmpeg、libcurl、OpenSSL、cJSON、Mosquitto、Nginx</td></tr>
+  <tr><td>内网穿透</td><td>frpc(通过 systemctl 管理)</td></tr>
+  <tr><td>协议分流</td><td>Nginx stream + ssl_preread(TCP 层 HTTP/HTTPS 自动检测分流)</td></tr>
+</table>
+
+<h3>网络架构总览(v2 — nginx stream + ssl_preread 方案)</h3>
+<pre>外网客户端                        frps 服务器                    本机(树莓派5)
+─────────                        ─────────                    ─────────────
+                                                               ┌─ TLS → nginx stream → C++ HTTPS:8080 (透传)
+https://mqtt.zhonjin.com:40963/  ─TCP─→  discover.zhonjin.com:9443  ─TCP─→  frpc ─→ nginx:40963 (stream+ssl_preread)
+http://mqtt.zhonjin.com:40963/   ─TCP─→            ↑                ─TCP─→  frpc ─→└─ HTTP → nginx:8081 (301→HTTPS)
+https://mqtt.zhonjin.com:40964/  ─TCP─→            ↑                ─TCP─→  frpc ─→ nginx:8082 (HTTPS代理) → ttyd:7681</pre>
+<div class="tip"><strong>v2 方案优势:</strong>公网只需一个端口 40963,nginx stream 在 TCP 层自动检测协议类型并分流。HTTP 自动跳 HTTPS,HTTPS 透传到 C++ 应用处理。用户只需记住一个地址。</div>
+
+<h3>目录结构</h3>
+<table>
+  <tr><th>路径</th><th>说明</th></tr>
+  <tr><td>PlateRecApp</td><td>主程序二进制</td></tr>
+  <tr><td>config.ini</td><td>主配置文件</td></tr>
+  <tr><td>config.txt</td><td>MD5 校验配置</td></tr>
+  <tr><td>hyperlpr3/</td><td>HyperLPR3 SDK 模型文件</td></tr>
+  <tr><td>assets/web/</td><td>Web 页面文件(HTML/CSS/JS)</td></tr>
+  <tr><td>data/</td><td>数据库目录(auth.db、upload_records.db、system_metrics.db)</td></tr>
+  <tr><td>photos/</td><td>抓拍图片存储目录</td></tr>
+  <tr><td>scripts/</td><td>运维脚本(sudoers 配置、密码重置等)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 2. 快速部署指南 -->
+<div class="card" id="sec2">
+<div class="card-title" onclick="toggleCard(this)">2. 快速部署指南 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>编译部署</h3>
+<ol>
+  <li>解压代码包:<code>tar xzf fix24_vXX.tar.gz</code></li>
+  <li>编译:<code>cd fix24/build && cmake .. && make -j4</code></li>
+  <li>部署文件到安装目录(如 <code>/opt/openAI/project/003.PlateRecAPP/</code>)</li>
+  <li>复制 HTML 文件:<code>cp -r assets/web/ /安装目录/assets/web/</code></li>
+  <li>配置 sudoers(frpc 管理需要):<code>sudo bash scripts/setup_frpc_sudoers.sh</code></li>
+  <li>安装 nginx stream 模块:<code>sudo apt install libnginx-mod-stream</code></li>
+  <li>部署 nginx stream 配置文件(详见第 12 章)</li>
+  <li>部署 SSL 证书(C++ 应用自管理,详见第 13 章)</li>
+  <li>修改 <code>config.ini</code> 配置(参考下文各节)</li>
+  <li>启动服务:<code>sudo systemctl restart nginx && sudo systemctl restart PlateRecApp</code></li>
+</ol>
+
+<h3>一键部署脚本(推荐)</h3>
+<p>代码包内置 <code>scripts/deploy.sh</code>,覆盖编译→备份→部署→服务安装→启动→健康检查全流程,默认部署目录 <code>/opt/openAI/project/003.PlateRecAPP</code>、运行用户 <code>stevenroc</code>、服务名 <code>PlateRecApp</code>(均可用环境变量 <code>APP_DIR</code>/<code>RUN_USER</code>/<code>SVC_NAME</code> 覆盖):</p>
+<pre># 首次:安装编译/运行依赖(需 root)
+sudo bash scripts/deploy.sh deps
+
+# 一键编译+部署+重启(部署前自动备份当前版本到 backup/,保留最近 5 个)
+sudo bash scripts/deploy.sh deploy      # 或直接 sudo bash scripts/deploy.sh
+
+# 日常运维
+bash scripts/deploy.sh status           # 服务状态 + 8080/8079 端口 + 进程 + 磁盘
+bash scripts/deploy.sh logs             # 跟踪 PlateRecApp.log
+bash scripts/deploy.sh journal          # 跟踪 systemd journal
+bash scripts/deploy.sh restart          # 重启服务
+sudo bash scripts/deploy.sh rollback    # 回滚到上一次备份
+sudo bash scripts/deploy.sh sudoers     # 配置 frpc 管理 sudoers 权限</pre>
+<div class="tip">脚本安全策略:<b>config.ini 已存在时永不覆盖</b>(首次部署才从模板生成);每次部署自动备份二进制+配置+Web资源+SDK;服务文件由脚本自动安装到 /etc/systemd/system/ 并设置开机自启。</div>
+
+<h3>systemd 服务配置</h3>
+<p>PlateRecApp 服务文件路径:<code>/etc/systemd/system/PlateRecApp.service</code>(由 deploy.sh 自动生成,内容等价于):</p>
+<pre>Unit]
+Description=PlateRecApp License Plate Recognition System
+After=network.target frpc.service
+
+[Service]
+User=stevenroc
+Group=stevenroc
+WorkingDirectory=/opt/openAI/project/003.PlateRecAPP
+ExecStartPre=/bin/sleep 10
+ExecStart=/opt/openAI/project/003.PlateRecAPP/PlateRecApp
+TimeoutStartSec=30
+TimeoutStopSec=10
+Restart=on-failure
+RestartSec=5
+StandardOutput=append:/opt/openAI/project/003.PlateRecAPP/PlateRecApp.log
+LimitNOFILE=1048576
+
+[Install]
+WantedBy=multi-user.target</pre>
+<div class="tip">PlateRecApp 以非 root 用户运行(stevenroc),frpc 管理通过 sudoers 授权。日志输出由 v36 内存缓冲系统接管,systemd 的 StandardOutput 仅作备份。</div>
+
+<h3>密码重置</h3>
+<p>如忘记管理员密码,运行:<code>python3 reset_passwords.py</code></p>
+
+<h3>Nginx 服务配置(v2 — stream 方案)</h3>
+<p>安装 stream 模块并部署配置:</p>
+<pre># 安装 stream 模块(必须)
+sudo apt install -y libnginx-mod-stream
+
+# 验证模块
+nginx -V 2>&amp;1 | grep stream
+
+# 替换 nginx.conf(详见第 12 章)
+sudo cp nginx-stream.conf /etc/nginx/nginx.conf
+
+# 验证配置
+sudo nginx -t
+
+# 启动/重载
+sudo systemctl enable nginx
+sudo systemctl restart nginx</pre>
+</div>
+</div>
+
+<!-- 3. config.ini 完整配置 -->
+<div class="card" id="sec3">
+<div class="card-title" onclick="toggleCard(this)">3. config.ini 完整配置说明 <span class="arrow">▼</span></div>
+<div class="card-body">
+<p>配置文件路径:<code>&lt;安装目录&gt;/config.ini</code>,ini 格式,section 和 key 均不区分大小写。</p>
+
+<h3>[config] 核心识别配置</h3>
+<table>
+  <tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>project_name</td><td>string</td><td>-</td><td>项目名称</td></tr>
+  <tr><td>point_number</td><td>string</td><td>-</td><td>站点编号</td></tr>
+  <tr><td>throughway</td><td>string</td><td>-</td><td>通道名称</td></tr>
+  <tr><td>rtsp_url_front_in</td><td>string</td><td>-</td><td>前侧进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>string</td><td>-</td><td>前侧出场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>string</td><td>-</td><td>侧面进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>string</td><td>-</td><td>侧面出场摄像头 RTSP 地址</td></tr>
+  <tr><td>app_api</td><td>string</td><td>-</td><td>云端上传 API 地址</td></tr>
+  <tr><td>app_key</td><td>string</td><td>-</td><td>API 认证 Key</td></tr>
+  <tr><td>app_secret</td><td>string</td><td>-</td><td>API 认证 Secret</td></tr>
+  <tr><td>DEBUG_LOG</td><td>int</td><td>0</td><td>调试日志开关(0=关闭,1=开启)</td></tr>
+  <tr><td>TestFlag</td><td>int</td><td>0</td><td>测试模式开关</td></tr>
+  <tr><td>wType</td><td>int</td><td>0</td><td>称重类型(0=禁用,1=启用)</td></tr>
+  <tr><td>TIME_WINDOW</td><td>int</td><td>5</td><td>识别时间窗口(分钟),同一车牌在窗口内不重复上传</td></tr>
+  <tr><td>MAX_PHOTO_GROUPS</td><td>int</td><td>100</td><td>最大照片组数缓存</td></tr>
+  <tr><td>in_out_interval</td><td>int</td><td>5</td><td>⚠️ 单位是<strong>分钟</strong>!交替锁定间隔(同一车牌进出最短时间),内部转为秒存储</td></tr>
+  <tr><td>PLATE_CONFIDENCE_THRESHOLD</td><td>float</td><td>0.7</td><td>车牌识别置信度阈值(0~1),低于此值不上传</td></tr>
+  <tr><td>PLATE_LOG_THRESHOLD</td><td>float</td><td>0.5</td><td>日志记录置信度阈值</td></tr>
+  <tr><td>AlternatingMerge</td><td>int</td><td>0</td><td>交替合并开关(0=关闭,1=开启)</td></tr>
+  <tr><td>hw_decode_mode</td><td>string</td><td>auto</td><td>硬件解码模式:auto/soft/drm</td></tr>
+  <tr><td>hw_decode_device</td><td>string</td><td>/dev/dri/renderD128</td><td>硬件解码设备路径</td></tr>
+  <tr><td>rtsp_transport</td><td>string</td><td>tcp</td><td>RTSP 传输协议:tcp/udp</td></tr>
+  <tr><td>PhotoMaxCapacityMB</td><td>int</td><td>1024</td><td>照片目录最大容量(MB),超限自动清理旧照片</td></tr>
+  <tr><td>LogRetentionDays</td><td>int</td><td>30</td><td>日志保留天数</td></tr>
+</table>
+
+<h3>[server] Web 服务配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>ip</td><td>Web 服务监听地址(含端口),如 <code>0.0.0.0:8080</code>。v2 方案下 PlateRecApp 提供 HTTPS 服务(SSL 自管理),nginx stream 透传 TLS 流量到此处</td></tr>
+</table>
+
+<h3>[mqtt] MQTT 上报配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>MQTT_HOST</td><td>-</td><td>MQTT Broker 地址(填写后自动启用 MQTT)</td></tr>
+  <tr><td>MQTT_PORT</td><td>1883</td><td>MQTT Broker 端口</td></tr>
+  <tr><td>MQTT_USER</td><td>-</td><td>MQTT 用户名</td></tr>
+  <tr><td>MQTT_PASS</td><td>-</td><td>MQTT 密码</td></tr>
+  <tr><td>MQTT_TOPIC</td><td>-</td><td>推送主题</td></tr>
+  <tr><td>MQTT_CLIENT_ID</td><td>-</td><td>客户端 ID</td></tr>
+  <tr><td>PLATE_COLOR</td><td>-</td><td>车牌颜色标识(随 MQTT 消息上报)</td></tr>
+  <tr><td>VEHICLE_TYPE</td><td>-</td><td>车辆类型标识(随 MQTT 消息上报)</td></tr>
+</table>
+
+<h3>[auth] 登录认证配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>认证开关(0=关闭,1=开启)</td></tr>
+  <tr><td>db_path</td><td>data/auth.db</td><td>认证数据库路径</td></tr>
+  <tr><td>session_idle_timeout</td><td>1800</td><td>Session 空闲超时(秒)</td></tr>
+  <tr><td>session_max_timeout</td><td>28800</td><td>Session 最大超时(秒)</td></tr>
+  <tr><td>session_remember_timeout</td><td>604800</td><td>记住登录超时(秒)</td></tr>
+  <tr><td>max_failed_attempts</td><td>5</td><td>最大登录失败次数</td></tr>
+  <tr><td>lockout_duration</td><td>900</td><td>锁定时长(秒)</td></tr>
+</table>
+
+<h3>[ssl] HTTPS 配置(v2 — C++ 应用自管理)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>HTTPS 开关。v2 方案下必须设为 1(C++ 应用直接处理 TLS)</td></tr>
+  <tr><td>cert_path</td><td>ssl/plate_fullchain.pem</td><td>SSL 证书路径(相对于安装目录)</td></tr>
+  <tr><td>key_path</td><td>ssl/plate_privkey.pem</td><td>SSL 私钥路径(相对于安装目录)</td></tr>
+</table>
+<div class="note">v2 方案变更:nginx stream 只做 TCP 分流,不解密 TLS。SSL 证书由 C++ 应用自己管理,<code>enabled</code> 必须为 1。</div>
+
+<h3>[sync] 车牌称重并行同步配置(v43 新增)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>0</td><td>并行同步开关。<code>0</code>=串行v41模式(重量在照片上传成功后入队);<code>1</code>=并行v43模式(tb_num确定后立即并行触发重量读取+上传)</td></tr>
+  <tr><td>reporter_timeout_sec</td><td>30</td><td>重量上报API超时(秒),最小5</td></tr>
+  <tr><td>weight_retry_max</td><td>5</td><td>重量上传最大重试次数,最小0</td></tr>
+  <tr><td>weight_retry_interval_sec</td><td>10</td><td>重量上传重试间隔(秒),最小1</td></tr>
+  <tr><td>tb_num_dedup_sec</td><td>300</td><td>同联单编号去重窗口(秒),最小10。同一 tb_num 在此时间内只触发一次重量读取</td></tr>
+</table>
+<div class="tip"><b>v43 核心变更</b>:并行模式下,车牌识别成功并获取到 tb_num 后,照片上传和重量读取<strong>同时进行</strong>,互不阻塞。重量上报延迟从 v41 的 25~50s 降至 ≤5s,捕获率从 72% 提升至 ≥98%。</div>
+<div class="warn"><b>灰度策略</b>:首次部署建议保持 <code>enabled=0</code>(完全兼容v41行为),验证系统稳定后再改为 <code>enabled=1</code> 开启并行模式。修改 enabled 无需重启,秒级生效。</div>
+
+<h3>[system] 系统监控配置(v38 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>monitor_interval</td><td>2</td><td>监控采集间隔(秒),delta 法计算 CPU 使用率</td></tr>
+  <tr><td>temp_alert_threshold</td><td>70</td><td>温度告警阈值(°C)</td></tr>
+  <tr><td>cpu_alert_threshold</td><td>90</td><td>CPU 告警阈值(%)</td></tr>
+  <tr><td>db_path</td><td>data/system_metrics.db</td><td>监控数据库路径</td></tr>
+  <tr><td>history_retention_days</td><td>7</td><td>原始数据保留天数</td></tr>
+  <tr><td>aggregation_retention_days</td><td>365</td><td>聚合数据保留天数</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+</table>
+
+<h3>[frpc] 内网穿透配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>config_path</td><td>-</td><td>frpc.toml 配置文件路径</td></tr>
+  <tr><td>protected_tunnels</td><td>-</td><td>受保护隧道名称(逗号分隔)</td></tr>
+  <tr><td>auto_rollback</td><td>1</td><td>重启失败自动回滚(0/1)</td></tr>
+  <tr><td>admin_addr</td><td>127.0.0.1</td><td>frpc Admin API 地址</td></tr>
+  <tr><td>admin_port</td><td>7400</td><td>frpc Admin API 端口</td></tr>
+  <tr><td>admin_user</td><td>-</td><td>frpc Admin 用户名</td></tr>
+  <tr><td>admin_password</td><td>-</td><td>frpc Admin 密码</td></tr>
+</table>
+
+<h3>[log] 日志配置(v36 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>内存缓冲模式(1=内存缓冲,0=直写文件)</td></tr>
+  <tr><td>log_file</td><td>PlateRecApp.log</td><td>日志文件路径</td></tr>
+  <tr><td>buffer_size_mb</td><td>20</td><td>内存缓冲区上限(MB)</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+  <tr><td>retention_days</td><td>30</td><td>日志文件保留天数</td></tr>
+  <tr><td>redirect_stdout</td><td>1</td><td>捕获 stdout/stderr(0/1)</td></tr>
+  <tr><td>auto_flush_on_exit</td><td>1</td><td>退出时立即刷盘(0/1)</td></tr>
+</table>
+
+<h3>[feishu] 飞书通知配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>APP_ID</td><td>飞书应用 App ID</td></tr>
+  <tr><td>APP_SECRET</td><td>飞书应用 App Secret</td></tr>
+  <tr><td>CHAT_ID</td><td>飞书群聊 ID</td></tr>
+  <tr><td>TIMEOUT_SECOND</td><td>请求超时时间(秒)</td></tr>
+  <tr><td>WarningSigns</td><td>抑制紧急报警(1=抑制,0=正常)</td></tr>
+</table>
+
+<h3>[weight] 称重系统配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>flagWeight</td><td>0</td><td>称重系统开关(0/1)</td></tr>
+  <tr><td>weight_server_ip</td><td>-</td><td>称重服务器 IP</td></tr>
+  <tr><td>weight_server_port</td><td>-</td><td>称重服务器端口</td></tr>
+  <tr><td>weight_threshold_in</td><td>0</td><td>进场称重阈值(吨)</td></tr>
+  <tr><td>weight_threshold_out</td><td>0</td><td>出场称重阈值(吨)</td></tr>
+  <tr><td>default_entry_weight</td><td>0</td><td>默认进场重量</td></tr>
+  <tr><td>default_exit_weight</td><td>0</td><td>默认出场重量</td></tr>
+  <tr><td>weight_detection_time</td><td>5000</td><td>称重检测时间(毫秒)</td></tr>
+  <tr><td>tcp_connect_timeout</td><td>3000</td><td>TCP 连接超时(毫秒)</td></tr>
+  <tr><td>MAX_WEIGHT</td><td>100</td><td>最大称重值(吨)</td></tr>
+  <tr><td>STABLE_SAMPLE_COUNT</td><td>5</td><td>稳定采样次数</td></tr>
+  <tr><td>STABLE_THRESHOLD_KG</td><td>50</td><td>稳定判定阈值(公斤)</td></tr>
+  <tr><td>CANDIDATE_DATA_COUNT</td><td>3</td><td>候选数据次数</td></tr>
+  <tr><td>MAX_UPLOAD_RETRIES</td><td>3</td><td>上传最大重试次数</td></tr>
+  <tr><td>RETRY_DELAY_MS</td><td>1000</td><td>重试延迟(毫秒)</td></tr>
+</table>
+
+<h3>[terminal] 终端/SSH 配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>ttyd_port</td><td>7681</td><td>ttyd Web 终端端口</td></tr>
+  <tr><td>ttyd_credential</td><td>-</td><td>ttyd 登录凭据(user:password)</td></tr>
+  <tr><td>ttyd_max_clients</td><td>5</td><td>ttyd 最大客户端数</td></tr>
+  <tr><td>ssh_keys_path</td><td>-</td><td>SSH 公钥存储路径</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 4. RTSP 摄像头配置 -->
+<div class="card" id="sec4">
+<div class="card-title" onclick="toggleCard(this)">4. RTSP 摄像头配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>摄像头地址格式</h3>
+<p>标准 RTSP 地址格式:</p>
+<pre>rtsp://用户名:密码@摄像头IP:554/stream</pre>
+
+<h3>四路摄像头说明</h3>
+<table>
+  <tr><th>参数</th><th>方向</th><th>说明</th></tr>
+  <tr><td>rtsp_url_front_in</td><td>前→进</td><td>前方摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>前→出</td><td>前方摄像头,捕捉出场车辆</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>侧→进</td><td>侧面摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>侧→出</td><td>侧面摄像头,捕捉出场车辆</td></tr>
+</table>
+
+<h3>硬件解码(树莓派5推荐配置)</h3>
+<table>
+  <tr><th>模式</th><th>值</th><th>说明</th></tr>
+  <tr><td>自动</td><td>auto</td><td>优先硬件解码,不可用则回退软件</td></tr>
+  <tr><td>软件解码</td><td>soft</td><td>CPU 解码,兼容性最好</td></tr>
+  <tr><td>DRM 硬件</td><td>drm</td><td>DRM 硬件加速(树莓派5推荐)</td></tr>
+</table>
+<div class="tip">树莓派 5 推荐:<code>hw_decode_mode=drm</code>,<code>hw_decode_device=/dev/dri/renderD128</code>,<code>rtsp_transport=tcp</code></div>
+</div>
+</div>
+
+<!-- 5. 车牌识别上传流程 -->
+<div class="card" id="sec5">
+<div class="card-title" onclick="toggleCard(this)">5. 车牌识别上传完整流程 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>识别流程</h3>
+<ol>
+  <li><strong>摄像头抓拍</strong>:RTSP 视频流实时解码,检测到车辆运动时触发抓拍</li>
+  <li><strong>车牌识别</strong>:HyperLPR3 引擎识别车牌号,返回车牌文本 + 置信度</li>
+  <li><strong>置信度过滤</strong>:低于 <code>PLATE_CONFIDENCE_THRESHOLD</code> 则丢弃</li>
+  <li><strong>时间窗口去重</strong>:同一车牌在 <code>TIME_WINDOW</code> 分钟内不重复上传</li>
+  <li><strong>交替锁定检查</strong>:同一车牌进出间隔低于 <code>in_out_interval</code> 分钟则锁定</li>
+  <li><strong>ROI 过滤</strong>:如启用 ROI,只识别指定区域内的车牌</li>
+  <li><strong>数据上传</strong>:HTTP POST 将识别结果 + 照片上传至 <code>app_api</code></li>
+  <li><strong>MQTT 推送</strong>:如启用 MQTT,同步推送识别结果</li>
+  <li><strong>飞书通知</strong>:异常报警发送飞书群消息</li>
+</ol>
+
+<h3>照片存储与自动清理</h3>
+<ul>
+  <li>照片保存在 <code>PlateJPG/</code> 目录下,文件名格式:<code>车牌号_时间戳_方向.jpg</code>(如 <code>沪FQ7108_1787365677_side_out.jpg</code>)</li>
+  <li>系统每日凌晨 <strong>2:00</strong> 自动执行照片清理任务(间隔 24 小时)</li>
+  <li>清理策略:当 <code>PlateJPG/</code> 目录下所有 .jpg 文件总大小超过 <code>PhotoMaxCapacityMB</code>(默认 1024MB)时,按文件修改时间从旧到新逐个删除,直到总大小降至阈值以下</li>
+  <li>删除照片后递归清理空子目录</li>
+  <li>清理日志输出示例:
+<pre>[照片清理] PlateJPG/ 当前 1156 MB / 1024 MB 阈值,共 8234 个文件
+[照片清理] 删除 1523 张照片(按时间从旧到新),释放 156 MB,当前 1000 MB
+[照片清理] 同步清空DB路径字段: 2847 个字段已更新</pre>
+  </li>
+</ul>
+
+<h4>照片清理与数据库同步(v41 修复)</h4>
+<p>照片被物理删除后,系统同步清空 <code>upload_records</code> 表中对应的 <code>lo_photo_path</code> / <code>hi_photo_path</code> 字段(设为 NULL):</p>
+<ul>
+  <li>业务记录(车牌号、联单编号、抓拍时间、上传状态等)<strong>完整保留</strong>,仅清空路径字段</li>
+  <li>Web 前端路径为 NULL 时显示灰色斜体「已清理」占位,不再显示 broken image</li>
+  <li>即使 DB 清理与页面加载存在竞态,<code>&lt;img onerror&gt;</code> 也会自动替换为「已清理」</li>
+  <li>程序启动时自动执行一次孤儿路径扫描(<code>db_purge_missing_photo_paths</code>),修复历史遗留数据</li>
+</ul>
+<div class="tip">💡 调整容量阈值:修改 <code>config.ini</code> 中 <code>PhotoMaxCapacityMB</code> 的值并重启服务。例如设为 2048 表示允许 2GB 照片存储。</div>
+</div>
+</div>
+
+<!-- 6. MQTT -->
+<div class="card" id="sec6">
+<div class="card-title" onclick="toggleCard(this)">6. MQTT 数据上报配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>启用条件</h3>
+<p>在 <code>[mqtt]</code> 段中填写 <code>MQTT_HOST</code> 即自动启用 MQTT 推送。</p>
+
+<h3>配置示例</h3>
+<pre>[mqtt]
+MQTT_HOST=mqtt.example.com
+MQTT_PORT=1883
+MQTT_USER=username
+MQTT_PASS=password
+MQTT_TOPIC=plate/recognition
+MQTT_CLIENT_ID=plate_rec_station_01</pre>
+<div class="warn">MQTT 连接失败时系统会自动重连,不影响识别和上传功能。</div>
+</div>
+</div>
+
+<!-- 7. 称重系统 -->
+<div class="card" id="sec7">
+<div class="card-title" onclick="toggleCard(this)">7. 称重系统对接 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>工作原理</h3>
+<p>称重系统通过 TCP 协议与称重地磅通信,车牌识别成功后自动读取称重数据并关联上传。</p>
+
+<h3>重量判定逻辑</h3>
+<ul>
+  <li>连续 <code>STABLE_SAMPLE_COUNT</code> 次读数变化 &lt; <code>STABLE_THRESHOLD_KG</code> 公斤,视为稳定</li>
+  <li>稳定后取 <code>CANDIDATE_DATA_COUNT</code> 次读数的中值作为最终重量</li>
+  <li>重量超过 <code>MAX_WEIGHT</code> 吨视为异常,丢弃</li>
+</ul>
+
+<h3>阈值说明</h3>
+<ul>
+  <li><code>weight_threshold_in</code>:进场最低重量(吨),低于此值可能表示空车</li>
+  <li><code>weight_threshold_out</code>:出场最低重量(吨)</li>
+  <li><code>default_entry_weight</code>:无法获取称重数据时的默认进场重量</li>
+  <li><code>default_exit_weight</code>:无法获取称重数据时的默认出场重量</li>
+  <li><code>weight_detection_time</code>:称重检测时间(毫秒,默认 5000)</li>
+  <li><code>tcp_connect_timeout</code>:TCP 连接超时(毫秒,默认 3000)</li>
+  <li><code>MAX_UPLOAD_RETRIES</code>:上传最大重试次数(默认 3)</li>
+  <li><code>RETRY_DELAY_MS</code>:重试延迟(毫秒,默认 1000)</li>
+</ul>
+</div>
+</div>
+
+<!-- 8. ROI -->
+<div class="card" id="sec8">
+<div class="card-title" onclick="toggleCard(this)">8. ROI 区域识别配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>配置方式</h3>
+<ul>
+  <li><strong>Web 可视化</strong>:首页连接状态面板中拖拽设置 ROI 区域</li>
+  <li><strong>手动配置</strong>:在 config.ini 的 <code>[config]</code> 段填写坐标值</li>
+</ul>
+<h3>参数说明</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>roi_in_x/y/w/h</td><td>进场 ROI 区域坐标和尺寸(像素)</td></tr>
+  <tr><td>roi_in_enabled</td><td>1=启用进场 ROI</td></tr>
+  <tr><td>roi_out_x/y/w/h</td><td>出场 ROI 区域坐标和尺寸</td></tr>
+  <tr><td>roi_out_enabled</td><td>1=启用出场 ROI</td></tr>
+  <tr><td>roi_debug_enabled</td><td>1=在抓拍图上绘制 ROI 框(调试用)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 9. 交替锁定 -->
+<div class="card" id="sec9">
+<div class="card-title" onclick="toggleCard(this)">9. 交替锁定机制 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>机制说明</h3>
+<p>防止同一车牌短时间内进出频繁触发(如车辆在门口调头)。同一车牌从"进"切换到"出"必须间隔 <code>in_out_interval</code> 分钟以上。</p>
+
+<h3>工作流程</h3>
+<ol>
+  <li>车牌 A 被识别为"进"方向</li>
+  <li>5 分钟内(默认)车牌 A 再次被识别为"出"方向</li>
+  <li>系统判定为异常,创建交替锁定记录</li>
+  <li>锁定期间该车牌不会被上传</li>
+  <li>超过 2 小时锁定自动清零,或手动在"锁定管理"页面清除</li>
+</ol>
+
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>in_out_interval</td><td>5</td><td>交替锁定间隔(⚠️ 单位分钟),小于此间隔的进/出切换会被锁定</td></tr>
+  <tr><td>AlternatingMerge</td><td>0</td><td>交替合并开关</td></tr>
+</table>
+<div class="warn"><code>in_out_interval</code> 单位是<strong>分钟</strong>,不是秒!填 5 表示 5 分钟 = 300 秒。</div>
+</div>
+</div>
+
+<!-- 10. 认证与权限 -->
+<div class="card" id="sec10">
+<div class="card-title" onclick="toggleCard(this)">10. 登录认证与权限 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>角色定义</h3>
+<table>
+  <tr><th>角色</th><th>Role值</th><th>可访问页面</th></tr>
+  <tr><td>普通用户</td><td>0</td><td>首页、视频、称重、锁定管理</td></tr>
+  <tr><td>管理员</td><td>1</td><td>普通用户 + 系统配置</td></tr>
+  <tr><td>超级管理员</td><td>2</td><td>所有页面(含系统监控、frpc管理、SSH密钥、日志、帮助)</td></tr>
+</table>
+
+<h3>默认账号</h3>
+<table>
+  <tr><th>用户名</th><th>密码</th><th>角色</th></tr>
+  <tr><td>admin</td><td>zhongjin188A</td><td>超级管理员</td></tr>
+  <tr><td>zhonjin</td><td>zhonjin</td><td>普通用户</td></tr>
+</table>
+
+<h3>安全特性</h3>
+<ul>
+  <li>密码 bcrypt 加密存储(cost=12)</li>
+  <li>连续登录失败锁定账号</li>
+  <li>CSRF Token 防护</li>
+  <li>Session 超时自动失效</li>
+  <li>登录速率限制</li>
+</ul>
+</div>
+</div>
+
+<!-- 11. frpc.toml 详解 -->
+<div class="card" id="sec11">
+<div class="card-title" onclick="toggleCard(this)">11. frpc.toml 内网穿透配置详解 <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>配置文件位置</h3>
+<p>frpc 配置文件路径由 <code>config.ini</code> 的 <code>[frpc] config_path</code> 指定,通常为安装目录下的 <code>frpc.toml</code>。</p>
+
+<h3>v2 方案 — nginx stream 分流(推荐)</h3>
+<p>使用 nginx stream + ssl_preread 方案,<strong>只需一个公网端口 40963</strong> 即可同时处理 HTTP 和 HTTPS:</p>
+<pre># frpc.toml — v2 方案(nginx stream 分流)
+
+serverAddr = "discover.zhonjin.com"
+serverPort = 9443
+auth.method = "token"
+auth.token  = "你的frps认证Token"
+transport.tls.enable = true
+clientID = "ssd_40960"
+
+# frpc Admin HTTP API
+webServer.addr = "127.0.0.1"
+webServer.port = 7400
+webServer.user = "admin"
+webServer.password = "你的管理密码"
+
+# 隧道1:主服务(外网40963→本机nginx:40963,stream自动分流HTTP/HTTPS)
+[[proxies]]
+name       = "plate_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 40963    # ← 转发到 nginx stream,不是 C++ 的 8080
+remotePort = 40963
+
+# 隧道2:ttyd终端(外网40964→本机nginx:8082→ttyd:7681)
+[[proxies]]
+name       = "ttyd_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 8082     # ← nginx HTTPS 反向代理(SSL 终止 + WebSocket)
+remotePort = 40964</pre>
+
+<h3>隧道说明(v2)</h3>
+<table>
+  <tr><th>隧道名</th><th>外网端口</th><th>本机端口</th><th>用途</th></tr>
+  <tr><td>plate_web</td><td>40963</td><td>nginx stream:40963</td><td>HTTP/HTTPS 合一,stream 自动分流</td></tr>
+  <tr><td>ttyd_web</td><td>40964</td><td>nginx HTTPS:8082</td><td>Web 终端(nginx SSL→ttyd:7681)</td></tr>
+</table>
+
+<h3>v2 数据流向</h3>
+<pre>HTTP  流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread=""  → 127.0.0.1:8081 (nginx HTTP) → 301 重定向到 HTTPS
+HTTPS 流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread≠"" → 127.0.0.1:8080 (C++ HTTPS) → 直接处理
+Web SSH:  公网:40964 → frpc → nginx:8082 (HTTPS反向代理, SSL终止) → 127.0.0.1:7681 (ttyd HTTP)</pre>
+
+<div class="tip"><strong>v1→v2 迁移注意:</strong>不再需要 40965 端口。HTTPS 主服务统一使用 <code>https://mqtt.zhonjin.com:40963/</code>。</div>
+
+<h3>参数详解</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>serverAddr</td><td>frps 服务器域名或 IP</td></tr>
+  <tr><td>serverPort</td><td>frps 服务端口(默认 7000,本项目用 9443)</td></tr>
+  <tr><td>auth.method</td><td>认证方式,固定为 <code>token</code></td></tr>
+  <tr><td>auth.token</td><td>与 frps 约定的认证 Token</td></tr>
+  <tr><td>webServer.addr/port</td><td>frpc Admin API 监听地址,PlateRecApp 通过此接口获取隧道状态</td></tr>
+  <tr><td>webServer.user/password</td><td>Admin API 认证凭据(需与 config.ini [frpc] 段一致)</td></tr>
+  <tr><td>[[proxies]] name</td><td>隧道唯一名称,PlateRecApp Web 界面按名称识别</td></tr>
+  <tr><td>[[proxies]] type</td><td>隧道类型,本项目全部使用 <code>tcp</code>(透传 TCP 流)</td></tr>
+  <tr><td>[[proxies]] localPort</td><td>本机目标端口,frpc 将外网流量转发到此端口</td></tr>
+  <tr><td>[[proxies]] remotePort</td><td>frps 服务器暴露的外网端口,客户端通过此端口访问</td></tr>
+</table>
+
+<h3>隧道保护</h3>
+<p>在 <code>config.ini [frpc]</code> 段配置受保护隧道,防止 Web 界面误删关键隧道:</p>
+<pre>protected_tunnels = plate_web,ttyd_web</pre>
+<div class="warn">受保护的隧道无法通过 Web 管理界面删除。如需删除,必须先修改 config.ini 中的 <code>protected_tunnels</code> 字段。</div>
+
+<h3>frpc 管理命令</h3>
+<pre># 查看 frpc 状态
+systemctl status frpc
+
+# 重启 frpc
+sudo systemctl restart frpc
+
+# 查看 frpc 日志
+tail -f /var/log/frpc.log
+
+# 通过 Admin API 查看隧道状态
+curl -u admin:密码 http://127.0.0.1:7400/api/status/tcp</pre>
+</div>
+</div>
+
+<!-- 12. Nginx 反向代理详解 -->
+<div class="card" id="sec12">
+<div class="card-title" onclick="toggleCard(this)">12. Nginx stream + ssl_preread 配置详解(v2) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>架构说明</h3>
+<p>v2 方案使用 nginx 的 <code>stream</code> 模块 + <code>ssl_preread</code> 功能,在 <strong>TCP 层面</strong>检测流量类型并自动分流:</p>
+<ul>
+  <li><strong>端口 40963</strong>:nginx stream 监听,检测协议类型(TLS / HTTP)</li>
+  <li><strong>TLS 流量</strong> → 透传到 C++ HTTPS 服务(127.0.0.1:8080)</li>
+  <li><strong>HTTP 流量</strong> → nginx HTTP 模块(127.0.0.1:8081)→ 301 重定向到 HTTPS</li>
+</ul>
+<div class="tip"><strong>关键区别:</strong>stream 模块是 TCP 层代理,不解密 TLS 流量。SSL 证书由 C++ 应用自己管理,nginx 只做协议检测和分流。</div>
+
+<h3>nginx.conf 完整配置</h3>
+<p>文件路径:<code>/etc/nginx/nginx.conf</code>(替换整个文件)</p>
+<pre># 加载 stream 动态模块
+load_module modules/ngx_stream_module.so;
+
+events {
+    worker_connections 1024;
+}
+
+# ============================================================
+# stream 块 — TCP 层协议分流(在 http 块外面)
+# ============================================================
+stream {
+    # 根据 ssl_preread 检测结果选择后端
+    # $ssl_preread_protocol 的可能值:
+    #   ""          → HTTP 流量(非 TLS)
+    #   "TLSv1.2"   → TLS 1.2
+    #   "TLSv1.3"   → TLS 1.3
+    map $ssl_preread_protocol $backend {
+        ""        http_backend;    # HTTP → nginx HTTP 8081
+        default   tls_backend;     # TLS → C++ HTTPS 8080
+    }
+
+    # TLS 后端 — C++ HTTPS 服务
+    upstream tls_backend {
+        server 127.0.0.1:8080;
+    }
+
+    # HTTP 后端 — nginx HTTP 301 重定向
+    upstream http_backend {
+        server 127.0.0.1:8081;
+    }
+
+    # 主服务器 — 监听公网端口
+    server {
+        listen 40963;
+        proxy_pass $backend;
+        ssl_preread on;          # 启用 SSL 预读检测协议
+    }
+}
+
+# ============================================================
+# http 块 — HTTP → HTTPS 301 重定向
+# ============================================================
+http {
+    sendfile on;
+    tcp_nopush on;
+    tcp_nodelay on;
+    keepalive_timeout 65;
+
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
+
+    access_log /var/log/nginx/access.log;
+    error_log  /var/log/nginx/error.log;
+
+    # HTTP → HTTPS 301 重定向
+    server {
+        listen 8081;
+        server_name _;
+        return 301 https://$host:40963$request_uri;
+    }
+
+    # ============================================================
+    # ttyd Web 终端 HTTPS 反向代理(端口 8082)
+    # ============================================================
+    # frpc 隧道:外网 40964 → nginx:8082 → ttyd:7681
+    # nginx 做 SSL 终止,再通过 HTTP 代理到 ttyd(含 WebSocket 支持)
+    server {
+        listen 8082 ssl;
+        server_name _;
+
+        ssl_certificate     /etc/nginx/ssl/plate_fullchain.pem;
+        ssl_certificate_key /etc/nginx/ssl/plate_privkey.pem;
+        ssl_protocols TLSv1.2 TLSv1.3;
+        ssl_ciphers HIGH:!aNULL:!MD5:!RC4;
+        ssl_prefer_server_ciphers on;
+        ssl_session_cache shared:SSL_Ttyd:5m;
+        ssl_session_timeout 1h;
+
+        location / {
+            proxy_pass http://127.0.0.1:7681;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+
+            # WebSocket 支持(ttyd 核心通信方式)
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+
+            # ttyd 长连接超时
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+    }
+}</pre>
+
+<h3>核心原理</h3>
+<table>
+  <tr><th>组件</th><th>作用</th><th>说明</th></tr>
+  <tr><td>stream 块</td><td>TCP 层代理</td><td>与 http 块平级,不处理 HTTP 协议,只做 TCP 转发</td></tr>
+  <tr><td>ssl_preread on</td><td>SSL 预读</td><td>在 TLS 握手阶段读取 ClientHello,获取协议版本,不解密</td></tr>
+  <tr><td>map $ssl_preread_protocol</td><td>协议分流</td><td>空字符串 = HTTP,非空 = TLS</td></tr>
+  <tr><td>upstream tls_backend</td><td>TLS 后端</td><td>透传到 C++ 应用(8080),C++ 自己处理 TLS 握手</td></tr>
+  <tr><td>upstream http_backend</td><td>HTTP 后端</td><td>转发到 nginx HTTP 8081,返回 301 重定向</td></tr>
+  <tr><td>server 8082 ssl</td><td>ttyd HTTPS 代理</td><td>SSL 终止后代理到 ttyd:7681,支持 WebSocket</td></tr>
+</table>
+
+<h3>部署步骤</h3>
+<ol>
+  <li>安装 stream 模块:<code>sudo apt install -y libnginx-mod-stream</code></li>
+  <li>验证模块:<code>ls -l /usr/lib/nginx/modules/ngx_stream_module.so</code></li>
+  <li>备份原配置:<code>sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak</code></li>
+  <li>替换配置:<code>sudo cp nginx-stream.conf /etc/nginx/nginx.conf</code></li>
+  <li>删除默认站点(如有):<code>sudo rm -f /etc/nginx/sites-enabled/default</code></li>
+  <li>验证配置:<code>sudo nginx -t</code></li>
+  <li>启动 nginx:<code>sudo systemctl enable nginx && sudo systemctl restart nginx</code></li>
+</ol>
+
+<h3>前置条件</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>nginx 版本</td><td>≥ 1.11.5(支持 stream 模块)</td></tr>
+  <tr><td>stream 模块</td><td>libnginx-mod-stream(Debian)或 nginx-mod-stream(CentOS)</td></tr>
+  <tr><td>C++ 应用</td><td>必须启用 HTTPS,监听 127.0.0.1:8080</td></tr>
+  <tr><td>SSL 证书</td><td>C++ 应用自行管理(config.ini 的 [ssl] 段)</td></tr>
+</table>
+
+<h3>验证方法</h3>
+<pre># 检查端口监听
+ss -tlnp | grep -E '40963|8080|8081'
+# 预期输出:
+# LISTEN  0.0.0.0:40963  nginx (stream)
+# LISTEN  127.0.0.1:8081  nginx (http 301)
+# LISTEN  0.0.0.0:8080  PlateRecApp (HTTPS)
+
+# 测试 HTTP 重定向(应返回 301)
+curl -I http://localhost:40963/
+# 预期:HTTP/1.1 301 Moved Permanently
+#       Location: https://localhost:40963/
+
+# 测试 HTTPS(应返回 200)
+curl -kI https://localhost:40963/
+# 预期:HTTP/1.1 200 OK
+
+# 浏览器测试(清除缓存或用无痕模式)
+# 访问 http://mqtt.zhonjin.com:40963/ → 自动跳转到 https://mqtt.zhonjin.com:40963/</pre>
+
+<h3>端口规划总结</h3>
+<table>
+  <tr><th>端口</th><th>服务</th><th>说明</th></tr>
+  <tr><td><strong>40963</strong></td><td>nginx stream</td><td>公网入口,TLS/HTTP 分流</td></tr>
+  <tr><td>8081</td><td>nginx http</td><td>HTTP 301 重定向(仅内部)</td></tr>
+  <tr><td>8080</td><td>C++ HTTPS</td><td>PlateRecApp Web 服务(SSL 自管理)</td></tr>
+  <tr><td><strong>40964</strong></td><td>nginx HTTPS→ttyd</td><td>Web 终端(nginx:8082 SSL终止→ttyd:7681)</td></tr>
+  <tr><td>8082</td><td>nginx HTTPS 代理</td><td>ttyd Web 终端 HTTPS 反向代理(仅内部)</td></tr>
+  <tr><td>7400</td><td>frpc Admin</td><td>隧道状态查询(仅 127.0.0.1)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 13. SSL/HTTPS 证书配置 -->
+<div class="card" id="sec13">
+<div class="card-title" onclick="toggleCard(this)">13. SSL/HTTPS 证书配置(v2 — C++ 应用自管理) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<div class="note"><strong>v2 方案变更:</strong>nginx stream 只做 TCP 层协议分流,不解密 TLS 流量。SSL 证书由 C++ 应用(PlateRecApp)自己管理,通过 config.ini 的 <code>[ssl]</code> 段配置。nginx 不再需要 SSL 证书。</div>
+
+<h3>config.ini [ssl] 段配置</h3>
+<pre>[ssl]
+enabled=1
+cert_path=ssl/plate_fullchain.pem
+key_path=ssl/plate_privkey.pem</pre>
+<div class="tip">v2 方案下 SSL 证书文件放在安装目录的 <code>ssl/</code> 子目录下(或任意自定义路径),不再需要 <code>/etc/nginx/ssl/</code>。</div>
+
+<h3>方式一:Let's Encrypt 证书 — 阿里云 DNS 验证(推荐)</h3>
+<p>本项目使用 frpc 内网穿透,<strong>无公网 80 端口</strong>,因此不能使用传统的 HTTP-01 验证。推荐使用 <strong>DNS-01 验证</strong>,通过阿里云 DNS API 自动完成,<strong>不需要任何端口</strong>。</p>
+
+<h4>方案对比</h4>
+<table>
+  <tr><th>验证方式</th><th>端口要求</th><th>通配符</th><th>自动续期</th><th>本项目适用</th></tr>
+  <tr><td>HTTP-01 (standalone)</td><td>公网 80</td><td>❌</td><td>✅</td><td>❌ 80 端口不可用</td></tr>
+  <tr><td>TLS-ALPN-01</td><td>公网 443</td><td>❌</td><td>✅</td><td>❌ 443 端口不可用</td></tr>
+  <tr><td><strong>DNS-01 (dns-aliyun)</strong></td><td><strong>无</strong></td><td><strong>✅</strong></td><td><strong>✅</strong></td><td><strong>✅ 推荐</strong></td></tr>
+  <tr><td>手动 DNS</td><td>无</td><td>✅</td><td>❌ 手动</td><td>⚠️ 每 90 天手动操作</td></tr>
+</table>
+
+<h4>一键部署</h4>
+<pre># 1. 安装 certbot + 阿里云 DNS 插件
+sudo apt update
+sudo apt install -y certbot python3-pip
+sudo pip3 install certbot-dns-aliyun
+
+# 2. 配置阿里云 AccessKey(RAM 子账号,需 AliyunDNSFullAccess 权限)
+sudo mkdir -p /etc/letsencrypt
+sudo vim /etc/letsencrypt/aliyun.ini
+# 内容:
+#   dns_aliyun_access_key = 你的AccessKeyID
+#   dns_aliyun_access_key_secret = 你的AccessKeySecret
+sudo chmod 600 /etc/letsencrypt/aliyun.ini
+
+# 3. 一键申请通配符证书(自动通过 DNS API 验证)
+sudo certbot certonly \
+    --authenticator dns-aliyun \
+    --dns-aliyun-credentials /etc/letsencrypt/aliyun.ini \
+    --dns-aliyun-propagation-seconds 30 \
+    --server https://acme-v02.api.letsencrypt.org/directory \
+    --agree-tos --no-eff-email \
+    --email admin@zhonjin.com \
+    -d mqtt.zhonjin.com \
+    -d "*.zhonjin.com"
+
+# 4. 部署证书到 PlateRecApp
+mkdir -p ssl/
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/fullchain.pem ssl/plate_fullchain.pem
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/privkey.pem   ssl/plate_privkey.pem
+sudo chown stevenroc:stevenroc ssl/*.pem
+chmod 644 ssl/plate_fullchain.pem
+chmod 600 ssl/plate_privkey.pem
+
+# 5. 配置自动续期(每天 3:00 检查,续期后自动部署)
+(crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --deploy-hook /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh") | crontab -
+
+# 6. 重启 PlateRecApp
+sudo systemctl restart PlateRecApp</pre>
+
+<h4>续期部署脚本 renew_ssl.sh</h4>
+<pre>#!/bin/bash
+# /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh
+DOMAIN="mqtt.zhonjin.com"
+SSL_DIR="/opt/openAI/project/003.PlateRecAPP/ssl"
+
+cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${SSL_DIR}/plate_fullchain.pem
+cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem   ${SSL_DIR}/plate_privkey.pem
+chown stevenroc:stevenroc ${SSL_DIR}/*.pem
+chmod 644 ${SSL_DIR}/plate_fullchain.pem
+chmod 600 ${SSL_DIR}/plate_privkey.pem
+systemctl restart PlateRecApp
+echo "$(date) SSL 证书续期部署成功" >> /opt/openAI/project/003.PlateRecAPP/ssl_renew.log</pre>
+
+<h4>阿里云 RAM 权限配置</h4>
+<ol>
+  <li>登录 <a href="https://ram.console.aliyun.com/" target="_blank">RAM 控制台</a> → 用户 → 创建用户</li>
+  <li>勾选 <strong>OpenAPI 调用访问</strong>,保存 AccessKey ID 和 Secret</li>
+  <li>添加权限:<code>AliyunDNSFullAccess</code>(云解析 DNS 管理)</li>
+</ol>
+<div class="tip">DNS 验证原理:certbot 通过阿里云 API 自动添加 <code>_acme-challenge.mqtt.zhonjin.com</code> TXT 记录,Let's Encrypt CA 查询 DNS 验证域名所有权,全程无需开放任何端口。</div>
+<div class="warn"><strong>前提:</strong>域名 mqtt.zhonjin.com 的 DNS 解析必须使用阿里云(<code>dns9.hichina.com</code>)。如果 DNS 在其他服务商,需先迁移到阿里云,或改用对应插件(如 <code>certbot-dns-cloudflare</code>、<code>certbot-dns-dnspod</code>)。</div>
+
+<h3>方式二:自签名证书</h3>
+<p>无公网域名时使用自签名证书,浏览器会显示警告但加密通信正常:</p>
+<pre># 创建证书目录
+mkdir -p ssl/
+
+# 生成自签名证书(10年有效期)
+openssl req -x509 -newkey rsa:2048 -nodes \
+  -keyout ssl/plate_privkey.pem \
+  -out    ssl/plate_fullchain.pem \
+  -days   3650 \
+  -subj   "/CN=mqtt.zhonjin.com/O=PlateRecApp/C=CN"
+
+# 设置权限
+chmod 600 ssl/plate_privkey.pem
+chmod 644 ssl/plate_fullchain.pem
+
+# 重启 PlateRecApp 加载证书
+sudo systemctl restart PlateRecApp</pre>
+
+<h3>证书文件说明</h3>
+<table>
+  <tr><th>文件</th><th>用途</th><th>config.ini 参数</th></tr>
+  <tr><td>plate_fullchain.pem</td><td>完整证书链(含中间 CA)</td><td>cert_path</td></tr>
+  <tr><td>plate_privkey.pem</td><td>私钥文件(严禁泄露)</td><td>key_path</td></tr>
+</table>
+
+<h3>v1→v2 迁移说明</h3>
+<div class="warn"><strong>v1 方案:</strong>SSL 证书由 nginx 管理(<code>/etc/nginx/ssl/</code>),nginx 做 HTTPS 终止后转发 HTTP 到 C++。<br>
+<strong>v2 方案:</strong>SSL 证书由 C++ 应用管理,nginx stream 只做 TCP 分流,不解密 TLS。不再需要 <code>/etc/nginx/ssl/</code> 目录。</div>
+</div>
+</div>
+
+<!-- 14. 系统监控与日志 -->
+<div class="card" id="sec14">
+<div class="card-title" onclick="toggleCard(this)">14. 系统监控与日志 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统监控(实时)</h3>
+<ul>
+  <li>CPU 使用率(delta 法计算,含每核使用率、负载均值)</li>
+  <li>内存使用率(总量/已用/可用)</li>
+  <li>磁盘使用率(根分区容量/已用/可用)</li>
+  <li>CPU 温度监控,超阈值告警(阈值由 <code>temp_alert_threshold</code> 配置)</li>
+  <li>CPU 使用率告警(阈值由 <code>cpu_alert_threshold</code> 配置)</li>
+  <li>PlateRecApp 进程资源占用(RSS 内存、CPU 占用)</li>
+  <li>frpc 运行状态、MQTT 连接状态、称重系统连接状态</li>
+</ul>
+<p>数据来源:通过读取 <code>/proc</code>、<code>/sys</code>、<code>statvfs</code> 等系统接口实时采集,页面每 2 秒自动刷新。</p>
+
+<h3>历史监控数据(v37+)</h3>
+<p>系统自动采集并存储监控数据到 SQLite 数据库(<code>data/system_metrics.db</code>),支持历史趋势查询:</p>
+<ul>
+  <li><strong>采集间隔</strong>:<code>monitor_interval</code>(默认 2 秒),delta 法计算 CPU 使用率,不阻塞采集线程</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)将内存监控数据批量写入 <code>system_metrics.db</code>,减少 SD 卡写入次数(v38 起)</li>
+  <li><strong>三级存储</strong>:原始数据(2秒间隔)→ 小时聚合 → 日聚合,自动降采样</li>
+  <li><strong>自动清理</strong>:原始数据保留 <code>history_retention_days</code> 天(默认 7 天),聚合数据保留 <code>aggregation_retention_days</code> 天(默认 365 天)</li>
+  <li><strong>历史查询 API</strong>:<code>/api/monitor/history?range=1h|6h|24h|7d|30d</code>,自动选择合适的数据粒度</li>
+  <li><strong>统计接口</strong>:<code>/api/monitor/stats</code> 返回数据库记录数和运行状态</li>
+</ul>
+
+<h3>SD 卡写入保护策略(v38)</h3>
+<ul>
+  <li><strong>系统监控</strong>:内存缓冲,每日 23:20 一次写入,SD 卡写入从 2880 次/天降至 1 次</li>
+  <li><strong>运行日志</strong>:内存环形缓冲区,Web 日志页面零磁盘 I/O,每日 23:20 写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>旧版日志清理</strong>:内存模式启用时自动跳过旧版 copytruncate,避免无意义读写</li>
+  <li><strong>退出安全刷盘</strong>:SIGTERM/SIGINT 时立即执行最后一次刷盘</li>
+</ul>
+
+<h3>日志管理(v36 内存缓冲)</h3>
+<ul>
+  <li><strong>内存缓冲模式</strong>(<code>enabled=1</code>,默认):所有日志写入内存环形缓冲区,Web 日志页面直接从内存读取,零磁盘 I/O</li>
+  <li><strong>直写文件模式</strong>(<code>enabled=0</code>):传统模式,日志直接写入文件</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)自动将内存日志批量写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>退出刷盘</strong>:<code>auto_flush_on_exit=1</code> 时,收到 SIGTERM/SIGINT 信号立即刷盘,防止日志丢失</li>
+  <li><strong>stdout 捕获</strong>:<code>redirect_stdout=1</code> 时,stdout/stderr 通过 pipe 重定向到内存缓冲区统一收集</li>
+  <li>Web 界面实时查看日志(支持 Tail 模式),显示内存使用率和缓冲状态</li>
+  <li>超期日志文件自动清理(由 <code>retention_days</code> 控制),支持手动触发刷盘</li>
+</ul>
+<div class="note">两个子系统的 <code>flush_time</code> 均配置为 23:20,分别在 <code>[log]</code> 和 <code>[system]</code> 段中设置,可独立调整。</div>
+</div>
+</div>
+
+<!-- 15. Web管理 -->
+<div class="card" id="sec15">
+<div class="card-title" onclick="toggleCard(this)">15. Web 管理功能 <span class="arrow">▼</span></div>
+<div class="card-body">
+<table>
+  <tr><th>页面</th><th>路径</th><th>权限</th><th>说明</th></tr>
+  <tr><td>监控首页</td><td>/</td><td>所有用户</td><td>实时监控、识别记录、连接状态、ROI设置</td></tr>
+  <tr><td>视频预览</td><td>/video</td><td>所有用户</td><td>实时视频流</td></tr>
+  <tr><td>称重记录</td><td>/weight</td><td>所有用户</td><td>称重数据</td></tr>
+  <tr><td>锁定管理</td><td>/locks</td><td>所有用户</td><td>交替锁定记录</td></tr>
+  <tr><td>帮助手册</td><td>/help</td><td>超级管理员</td><td>本文档</td></tr>
+  <tr><td>系统配置</td><td>/config</td><td>管理员+</td><td>config.ini 在线编辑</td></tr>
+  <tr><td>系统监控</td><td>/system</td><td>超级管理员</td><td>CPU/内存/温度/磁盘</td></tr>
+  <tr><td>frpc 管理</td><td>/frpc</td><td>超级管理员</td><td>内网穿透状态和配置</td></tr>
+  <tr><td>SSH 密钥</td><td>/ssh-keys</td><td>超级管理员</td><td>SSH 公钥管理</td></tr>
+  <tr><td>日志管理</td><td>/logs</td><td>超级管理员</td><td>系统日志查看</td></tr>
+  <tr><td>登录</td><td>/login</td><td>公开</td><td>用户登录页面</td></tr>
+</table>
+
+<h3>Tab 分离:实时数据 + 历史查询(v41 新增)</h3>
+<p>首页和称重页均改为 Tab 分离结构,自动刷新与手动查询完全独立:</p>
+<table>
+  <tr><th>Tab</th><th>数据来源</th><th>刷新方式</th></tr>
+  <tr><td>📡 实时数据 / 实时称重</td><td>固定最新 20 条,无条件加载</td><td>每 30 秒自动刷新</td></tr>
+  <tr><td>🔍 历史查询</td><td>按筛选条件查询,支持完整分页</td><td>手动触发,不自动刷新</td></tr>
+</table>
+<p>两个 Tab 使用独立的状态和函数,互不干扰。切换 Tab 时自动加载对应数据;重试操作后根据当前 Tab 智能刷新对应数据源。</p>
+
+<h3>分类查询筛选条件(v41)</h3>
+<p>历史查询 Tab 支持多维度组合筛选(所有条件 AND 组合):</p>
+<table>
+  <tr><th>筛选条件</th><th>首页</th><th>称重页</th><th>说明</th></tr>
+  <tr><td>车牌号</td><td>✅</td><td>✅</td><td>模糊匹配,如"沪A"</td></tr>
+  <tr><td>联单编号</td><td>✅</td><td>✅</td><td>模糊匹配</td></tr>
+  <tr><td>方向</td><td>✅</td><td>✅</td><td>全部 / 进站 / 出站</td></tr>
+  <tr><td>上传状态</td><td>✅</td><td>✅</td><td>首页:成功/失败;称重页多一个"待上传"(status=0)</td></tr>
+  <tr><td>飞书状态</td><td>✅</td><td>—</td><td>全部 / 已发送 / 失败 / 未发送</td></tr>
+  <tr><td>日期范围</td><td>✅</td><td>✅</td><td>按抓拍时间过滤(YYYY-MM-DD)</td></tr>
+</table>
+
+<h3>分页控件(v41)</h3>
+<ul>
+  <li><strong>每页条数</strong>:可选 20 / 50 / 100 条</li>
+  <li><strong>页码导航</strong>:首页 / 上一页 / 页码(当前页前后各 2 页,含首末页和省略号)/ 下一页 / 末页</li>
+  <li><strong>跳转</strong>:输入页码 + GO 按钮快速跳转</li>
+  <li><strong>信息栏</strong>:显示"第 X/Y 页 · 共 Z 条"</li>
+</ul>
+</div>
+</div>
+
+<!-- 16. 数据库 -->
+<div class="card" id="sec16">
+<div class="card-title" onclick="toggleCard(this)">16. 数据库与备份 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>数据库文件</h3>
+<table>
+  <tr><th>文件</th><th>路径</th><th>说明</th></tr>
+  <tr><td>认证数据库</td><td>data/auth.db</td><td>用户账号、密码、Session</td></tr>
+  <tr><td>识别记录</td><td>data/upload_records.db</td><td>车牌识别上传记录</td></tr>
+  <tr><td>系统监控</td><td>data/system_metrics.db</td><td>性能监控时序数据(原始+小时聚合+日聚合)</td></tr>
+</table>
+<div class="tip">v29 版本起,所有数据库统一存放在 <code>data/</code> 目录下。系统首次启动时自动将旧路径数据库迁移到新位置(旧文件保留为备份)。</div>
+
+<h3>upload_records 表结构</h3>
+<table>
+  <tr><th>字段</th><th>说明</th></tr>
+  <tr><td>id</td><td>自增主键</td></tr>
+  <tr><td>create_time</td><td>记录创建时间</td></tr>
+  <tr><td>capture_time</td><td>抓拍时间(用于时间对齐)</td></tr>
+  <tr><td>plate_number</td><td>车牌号</td></tr>
+  <tr><td>tb_num</td><td>联单编号</td></tr>
+  <tr><td>station_type</td><td>方向:1=进站,2=出站</td></tr>
+  <tr><td>lo_photo_path / hi_photo_path</td><td>照片文件名(相对 PlateJPG/),清理后为 NULL</td></tr>
+  <tr><td>lo_upload_status / hi_upload_status</td><td>上传状态:0=待传,1=成功,2=失败</td></tr>
+  <tr><td>feishu_status</td><td>飞书通知:1=已发送,2=失败,3=未发送</td></tr>
+  <tr><td>retry_count</td><td>重试次数(上限 5 次)</td></tr>
+</table>
+
+<h3>备份建议</h3>
+<ul>
+  <li>定期备份 <code>data/</code> 目录和 <code>config.ini</code></li>
+  <li>nginx SSL 证书和配置文件备份:<code>/etc/nginx/ssl/</code>、<code>/etc/nginx/conf.d/</code></li>
+  <li>frpc 配置修改时系统自动备份</li>
+  <li>照片数据在 <code>PhotoMaxCapacityMB</code> 限制内自动管理,重要照片建议另行备份</li>
+</ul>
+</div>
+</div>
+
+<!-- 17. FAQ -->
+<div class="card" id="sec17">
+<div class="card-title" onclick="toggleCard(this)">17. 常见问题 FAQ <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>Q: HTTP 访问没有跳转到 HTTPS?</h3>
+<p>A: 检查以下几点:</p>
+<ul>
+  <li>确认 nginx stream 模块已安装:<code>nginx -V 2>&amp;1 | grep stream</code></li>
+  <li>确认 nginx 配置正确:<code>sudo nginx -t</code></li>
+  <li>确认 8081 端口正在监听:<code>ss -tlnp | grep 8081</code></li>
+  <li>确认 frpc 隧道 <code>plate_web</code> 正常运行(localPort 为 40963)</li>
+  <li>浏览器可能缓存了旧的重定向,尝试清除缓存或无痕模式</li>
+  <li>测试本机重定向:<code>curl -I http://localhost:40963/</code>,应返回 301</li>
+</ul>
+
+<h3>Q: HTTPS 访问返回错误?</h3>
+<p>A: v2 方案下 TLS 由 C++ 应用处理,检查:</p>
+<ul>
+  <li>PlateRecApp 是否正在运行:<code>systemctl status PlateRecApp</code></li>
+  <li>PlateRecApp 是否启用了 HTTPS:<code>config.ini [ssl] enabled=1</code></li>
+  <li>SSL 证书文件是否存在:<code>ls -l ssl/plate_fullchain.pem ssl/plate_privkey.pem</code></li>
+  <li>nginx stream 是否正常监听:<code>ss -tlnp | grep 40963</code></li>
+  <li>nginx 错误日志:<code>sudo tail -f /var/log/nginx/error.log</code></li>
+</ul>
+
+<h3>Q: <code>unknown directive "stream"</code>?</h3>
+<p>A: stream 模块未加载。执行:<code>sudo apt install -y libnginx-mod-stream</code>,确认 nginx.conf 顶部有 <code>load_module modules/ngx_stream_module.so;</code></p>
+
+<h3>Q: frpc 重启后隧道不生效?</h3>
+<p>A: 检查 frpc.toml 配置语法:</p>
+<pre>frpc verify -c /path/to/frpc.toml</pre>
+<p>常见错误:TOML 格式问题(缺少引号、缩进错误)。通过 Web 管理界面编辑配置时系统会自动校验。</p>
+
+<h3>Q: 自签名证书浏览器警告怎么消除?</h3>
+<p>A: 自签名证书无法消除浏览器警告(这是正常安全行为)。解决方案:</p>
+<ul>
+  <li>方式1:使用 Let's Encrypt 免费证书(需域名解析到公网 IP)</li>
+  <li>方式2:在客户端将自签名 CA 证书添加到受信任的根证书列表</li>
+  <li>方式3:忽略警告,继续使用(加密通信正常,仅证书不受信任)</li>
+</ul>
+
+<h3>Q: 摄像头连接失败怎么办?</h3>
+<p>A: 确认 RTSP 地址格式正确,在 VLC 中测试播放。尝试 <code>rtsp_transport=tcp</code>。</p>
+
+<h3>Q: in_out_interval 填 5 是 5 秒还是 5 分钟?</h3>
+<p>A: <strong>5 分钟</strong>。单位是分钟,系统内部自动 ×60 转为秒。</p>
+
+<h3>Q: 照片被自动清理后,Web 页面看到"已清理"怎么办?</h3>
+<p>A: 这是正常现象。当 <code>PlateJPG/</code> 目录总大小超过 <code>PhotoMaxCapacityMB</code>(默认 1024MB)时,系统每日凌晨 2:00 自动删除最旧的照片释放空间。被删除照片对应的数据库记录仍保留(业务数据不丢失),Web 页面显示灰色「已清理」占位。如需保留更多照片,调大该配置值。</p>
+
+<h3>Q: 日志中出现 <code>[路径校验] 转换绝对路径失败</code> 错误?</h3>
+<p>A: 此错误在旧版本(v41 修复前)出现,原因是照片被物理删除后 DB 路径字段未同步清空,前端仍请求已删除文件。v41 已修复:删除照片时通过 basename 匹配同步清空 DB 路径字段,启动时自动扫描孤儿路径,且 <code>/photo</code> 接口对缺失文件静默返回 404 不再打 ERROR 日志。升级后此错误不再出现。</p>
+
+<h3>Q: 如何调整照片存储上限?</h3>
+<p>A: 修改 <code>config.ini</code> 的 <code>[config]</code> 段中 <code>PhotoMaxCapacityMB</code> 值并重启服务。例如:</p>
+<pre>[config]
+PhotoMaxCapacityMB=2048   # 调整为 2GB</pre>
+
+<h3>Q: 称重页的"待上传"状态是什么意思?</h3>
+<p>A: 称重记录上传状态筛选比首页多一个「待上传」选项,对应数据库中 <code>upload_status = 0</code> 的记录,即称重数据尚未成功上传到云端 API,可通过此筛选快速定位需重试的记录。</p>
+
+<h3>Q: 如何重置管理员密码?</h3>
+<pre>cd /opt/openAI/project/003.PlateRecAPP
+python3 reset_passwords.py</pre>
+</div>
+</div>
+
+</div>
+
+<script>
+function toggleCard(el) {
+  el.classList.toggle('collapsed');
+  var body = el.nextElementSibling;
+  body.classList.toggle('hidden');
+}
+</script>
+</body>
+</html>

+ 995 - 0
assets/web/help20200706.html

@@ -0,0 +1,995 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>帮助手册 - 车牌识别系统</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
+.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
+.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
+.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
+.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
+.container{max-width:960px;margin:20px auto;padding:0 16px}
+.card{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
+.card-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
+.card-title:hover{background:#37474f}
+.card-title .arrow{transition:transform 0.2s;font-size:12px}
+.card-title.collapsed .arrow{transform:rotate(-90deg)}
+.card-body{padding:16px 20px;line-height:1.8;font-size:14px}
+.card-body.hidden{display:none}
+h3{color:#1a1a2e;margin:16px 0 8px;font-size:15px;border-left:3px solid #667eea;padding-left:10px}
+h4{color:#333;margin:12px 0 6px;font-size:14px}
+table{width:100%;border-collapse:collapse;margin:8px 0 16px;font-size:13px}
+th{background:#f5f5f5;padding:8px 10px;text-align:left;font-weight:600;border-bottom:2px solid #e0e0e0;white-space:nowrap}
+td{padding:8px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top}
+td:first-child{font-family:monospace;color:#d32f2f;white-space:nowrap;font-size:12px}
+code{background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:12px;color:#d32f2f;font-family:monospace}
+pre{background:#1e1e2e;color:#cdd6f4;padding:14px 18px;border-radius:6px;font-size:12px;overflow-x:auto;margin:8px 0 16px;line-height:1.6}
+pre code{background:none;color:#cdd6f4;padding:0;font-size:12px}
+.tip{background:#e8f5e9;border-left:4px solid #4caf50;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.warn{background:#fff3e0;border-left:4px solid #ff9800;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.note{background:#e3f2fd;border-left:4px solid #2196f3;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+ul{padding-left:20px;margin:6px 0}
+li{margin:4px 0;font-size:13px;line-height:1.7}
+.toc{background:#fff;border-radius:8px;padding:16px 24px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
+.toc h2{font-size:16px;color:#1a1a2e;margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:6px}
+.toc ol{padding-left:20px;columns:2;column-gap:30px}
+.toc li{margin:4px 0;font-size:13px;break-inside:avoid}
+.toc a{color:#667eea;text-decoration:none}
+.toc a:hover{text-decoration:underline}
+@media(max-width:768px){
+.navbar{flex-wrap:wrap;gap:8px;padding:10px 14px}
+.navbar .brand{font-size:14px;width:100%}
+.navbar a{font-size:12px;padding:4px 8px}
+.container{padding:0 10px;margin:10px auto}
+.toc ol{columns:1}
+table{font-size:12px;display:block;overflow-x:auto}
+th,td{padding:6px 8px}
+td:first-child{font-size:11px}
+}
+@media(max-width:480px){
+.navbar .brand{font-size:13px}
+.navbar a{font-size:11px;padding:3px 6px}
+.card-body{padding:12px 14px}
+}
+</style>
+</head>
+<body>
+<nav class="navbar">
+  <span class="brand">🚛 车牌识别系统</span>
+  <a href="/">监控首页</a>
+  <a href="/video">视频预览</a>
+  <a href="/locks">锁定管理</a>
+  <a href="/help" class="active">帮助手册</a>
+</nav>
+
+<div class="container">
+
+<div class="toc">
+<h2>📋 目录</h2>
+<ol>
+  <li><a href="#sec1">系统概述</a></li>
+  <li><a href="#sec2">快速部署指南</a></li>
+  <li><a href="#sec3">config.ini 完整配置说明</a></li>
+  <li><a href="#sec4">RTSP 摄像头配置</a></li>
+  <li><a href="#sec5">车牌识别上传流程</a></li>
+  <li><a href="#sec6">MQTT 数据上报配置</a></li>
+  <li><a href="#sec7">称重系统对接</a></li>
+  <li><a href="#sec8">ROI 区域识别配置</a></li>
+  <li><a href="#sec9">交替锁定机制</a></li>
+  <li><a href="#sec10">登录认证与权限</a></li>
+  <li><a href="#sec11">frpc.toml 内网穿透配置详解</a></li>
+  <li><a href="#sec12">Nginx 反向代理配置详解</a></li>
+  <li><a href="#sec13">SSL/HTTPS 证书配置</a></li>
+  <li><a href="#sec14">系统监控与日志</a></li>
+  <li><a href="#sec15">Web 管理功能</a></li>
+  <li><a href="#sec16">数据库与备份</a></li>
+  <li><a href="#sec17">常见问题 FAQ</a></li>
+</ol>
+</div>
+
+<!-- 1. 系统概述 -->
+<div class="card" id="sec1">
+<div class="card-title" onclick="toggleCard(this)">1. 系统概述 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统简介</h3>
+<p>车牌识别系统(PlateRecApp)是一款基于 HyperLPR3 的实时车牌识别应用,运行在树莓派 5 (aarch64) 上,支持:</p>
+<ul>
+  <li>多路 RTSP 摄像头实时抓拍识别(进场/出场/侧面进/侧面出)</li>
+  <li>车牌识别结果自动上传至云端 API</li>
+  <li>称重系统对接(TCP 协议),自动关联车牌与称重数据</li>
+  <li>MQTT 协议实时推送识别结果</li>
+  <li>飞书群消息通知(异常报警)</li>
+  <li>Web 管理界面(监控首页、视频预览、系统配置、锁定管理等)</li>
+  <li>frpc 内网穿透管理 + Nginx stream + ssl_preread 协议分流(HTTP 自动跳 HTTPS)</li>
+  <li>登录认证与角色权限控制</li>
+  <li>SD 卡写入寿命保护(内存缓冲 + 每日定时写盘)</li>
+</ul>
+
+<h3>运行环境</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>硬件</td><td>树莓派 5 8GB(推荐)</td></tr>
+  <tr><td>系统</td><td>Debian 13 (trixie) / Ubuntu 24.04 aarch64</td></tr>
+  <tr><td>依赖</td><td>HyperLPR3 SDK、FFmpeg、libcurl、OpenSSL、cJSON、Mosquitto、Nginx</td></tr>
+  <tr><td>内网穿透</td><td>frpc(通过 systemctl 管理)</td></tr>
+  <tr><td>协议分流</td><td>Nginx stream + ssl_preread(TCP 层 HTTP/HTTPS 自动检测分流)</td></tr>
+</table>
+
+<h3>网络架构总览(v2 — nginx stream + ssl_preread 方案)</h3>
+<pre>外网客户端                        frps 服务器                    本机(树莓派5)
+─────────                        ─────────                    ─────────────
+                                                               ┌─ TLS → nginx stream → C++ HTTPS:8080 (透传)
+https://mqtt.zhonjin.com:40963/  ─TCP─→  discover.zhonjin.com:9443  ─TCP─→  frpc ─→ nginx:40963 (stream+ssl_preread)
+http://mqtt.zhonjin.com:40963/   ─TCP─→            ↑                ─TCP─→  frpc ─→└─ HTTP → nginx:8081 (301→HTTPS)
+https://mqtt.zhonjin.com:40964/  ─TCP─→            ↑                ─TCP─→  frpc ─→ nginx:8082 (HTTPS代理) → ttyd:7681</pre>
+<div class="tip"><strong>v2 方案优势:</strong>公网只需一个端口 40963,nginx stream 在 TCP 层自动检测协议类型并分流。HTTP 自动跳 HTTPS,HTTPS 透传到 C++ 应用处理。用户只需记住一个地址。</div>
+
+<h3>目录结构</h3>
+<table>
+  <tr><th>路径</th><th>说明</th></tr>
+  <tr><td>PlateRecApp</td><td>主程序二进制</td></tr>
+  <tr><td>config.ini</td><td>主配置文件</td></tr>
+  <tr><td>config.txt</td><td>MD5 校验配置</td></tr>
+  <tr><td>hyperlpr3/</td><td>HyperLPR3 SDK 模型文件</td></tr>
+  <tr><td>assets/web/</td><td>Web 页面文件(HTML/CSS/JS)</td></tr>
+  <tr><td>data/</td><td>数据库目录(auth.db、upload_records.db、system_metrics.db)</td></tr>
+  <tr><td>photos/</td><td>抓拍图片存储目录</td></tr>
+  <tr><td>scripts/</td><td>运维脚本(sudoers 配置、密码重置等)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 2. 快速部署指南 -->
+<div class="card" id="sec2">
+<div class="card-title" onclick="toggleCard(this)">2. 快速部署指南 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>编译部署</h3>
+<ol>
+  <li>解压代码包:<code>tar xzf fix24_vXX.tar.gz</code></li>
+  <li>编译:<code>cd fix24/build && cmake .. && make -j4</code></li>
+  <li>部署文件到安装目录(如 <code>/opt/openAI/project/003.PlateRecAPP/</code>)</li>
+  <li>复制 HTML 文件:<code>cp -r assets/web/ /安装目录/assets/web/</code></li>
+  <li>配置 sudoers(frpc 管理需要):<code>sudo bash scripts/setup_frpc_sudoers.sh</code></li>
+  <li>安装 nginx stream 模块:<code>sudo apt install libnginx-mod-stream</code></li>
+  <li>部署 nginx stream 配置文件(详见第 12 章)</li>
+  <li>部署 SSL 证书(C++ 应用自管理,详见第 13 章)</li>
+  <li>修改 <code>config.ini</code> 配置(参考下文各节)</li>
+  <li>启动服务:<code>sudo systemctl restart nginx && sudo systemctl restart PlateRecApp</code></li>
+</ol>
+
+<h3>systemd 服务配置</h3>
+<p>PlateRecApp 服务文件路径:<code>/etc/systemd/system/PlateRecApp.service</code></p>
+<pre>Unit]
+Description=PlateRecApp License Plate Recognition System
+After=network.target frpc.service
+
+[Service]
+User=stevenroc
+Group=stevenroc
+WorkingDirectory=/opt/openAI/project/003.PlateRecAPP
+ExecStartPre=/bin/sleep 10
+ExecStart=/opt/openAI/project/003.PlateRecAPP/PlateRecApp
+TimeoutStartSec=30
+TimeoutStopSec=10
+Restart=on-failure
+RestartSec=5
+StandardOutput=append:/opt/openAI/project/003.PlateRecAPP/PlateRecApp.log
+LimitNOFILE=1048576
+
+[Install]
+WantedBy=multi-user.target</pre>
+<div class="tip">PlateRecApp 以非 root 用户运行(stevenroc),frpc 管理通过 sudoers 授权。日志输出由 v36 内存缓冲系统接管,systemd 的 StandardOutput 仅作备份。</div>
+
+<h3>密码重置</h3>
+<p>如忘记管理员密码,运行:<code>python3 reset_passwords.py</code></p>
+
+<h3>Nginx 服务配置(v2 — stream 方案)</h3>
+<p>安装 stream 模块并部署配置:</p>
+<pre># 安装 stream 模块(必须)
+sudo apt install -y libnginx-mod-stream
+
+# 验证模块
+nginx -V 2>&amp;1 | grep stream
+
+# 替换 nginx.conf(详见第 12 章)
+sudo cp nginx-stream.conf /etc/nginx/nginx.conf
+
+# 验证配置
+sudo nginx -t
+
+# 启动/重载
+sudo systemctl enable nginx
+sudo systemctl restart nginx</pre>
+</div>
+</div>
+
+<!-- 3. config.ini 完整配置 -->
+<div class="card" id="sec3">
+<div class="card-title" onclick="toggleCard(this)">3. config.ini 完整配置说明 <span class="arrow">▼</span></div>
+<div class="card-body">
+<p>配置文件路径:<code>&lt;安装目录&gt;/config.ini</code>,ini 格式,section 和 key 均不区分大小写。</p>
+
+<h3>[config] 核心识别配置</h3>
+<table>
+  <tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>project_name</td><td>string</td><td>-</td><td>项目名称</td></tr>
+  <tr><td>point_number</td><td>string</td><td>-</td><td>站点编号</td></tr>
+  <tr><td>throughway</td><td>string</td><td>-</td><td>通道名称</td></tr>
+  <tr><td>rtsp_url_front_in</td><td>string</td><td>-</td><td>前侧进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>string</td><td>-</td><td>前侧出场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>string</td><td>-</td><td>侧面进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>string</td><td>-</td><td>侧面出场摄像头 RTSP 地址</td></tr>
+  <tr><td>app_api</td><td>string</td><td>-</td><td>云端上传 API 地址</td></tr>
+  <tr><td>app_key</td><td>string</td><td>-</td><td>API 认证 Key</td></tr>
+  <tr><td>app_secret</td><td>string</td><td>-</td><td>API 认证 Secret</td></tr>
+  <tr><td>DEBUG_LOG</td><td>int</td><td>0</td><td>调试日志开关(0=关闭,1=开启)</td></tr>
+  <tr><td>TestFlag</td><td>int</td><td>0</td><td>测试模式开关</td></tr>
+  <tr><td>wType</td><td>int</td><td>0</td><td>称重类型(0=禁用,1=启用)</td></tr>
+  <tr><td>TIME_WINDOW</td><td>int</td><td>5</td><td>识别时间窗口(分钟),同一车牌在窗口内不重复上传</td></tr>
+  <tr><td>MAX_PHOTO_GROUPS</td><td>int</td><td>100</td><td>最大照片组数缓存</td></tr>
+  <tr><td>in_out_interval</td><td>int</td><td>5</td><td>⚠️ 单位是<strong>分钟</strong>!交替锁定间隔(同一车牌进出最短时间),内部转为秒存储</td></tr>
+  <tr><td>PLATE_CONFIDENCE_THRESHOLD</td><td>float</td><td>0.7</td><td>车牌识别置信度阈值(0~1),低于此值不上传</td></tr>
+  <tr><td>PLATE_LOG_THRESHOLD</td><td>float</td><td>0.5</td><td>日志记录置信度阈值</td></tr>
+  <tr><td>AlternatingMerge</td><td>int</td><td>0</td><td>交替合并开关(0=关闭,1=开启)</td></tr>
+  <tr><td>hw_decode_mode</td><td>string</td><td>auto</td><td>硬件解码模式:auto/soft/drm</td></tr>
+  <tr><td>hw_decode_device</td><td>string</td><td>/dev/dri/renderD128</td><td>硬件解码设备路径</td></tr>
+  <tr><td>rtsp_transport</td><td>string</td><td>tcp</td><td>RTSP 传输协议:tcp/udp</td></tr>
+  <tr><td>PhotoMaxCapacityMB</td><td>int</td><td>1024</td><td>照片目录最大容量(MB),超限自动清理旧照片</td></tr>
+  <tr><td>LogRetentionDays</td><td>int</td><td>30</td><td>日志保留天数</td></tr>
+</table>
+
+<h3>[server] Web 服务配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>ip</td><td>Web 服务监听地址(含端口),如 <code>0.0.0.0:8080</code>。v2 方案下 PlateRecApp 提供 HTTPS 服务(SSL 自管理),nginx stream 透传 TLS 流量到此处</td></tr>
+</table>
+
+<h3>[mqtt] MQTT 上报配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>MQTT_HOST</td><td>-</td><td>MQTT Broker 地址(填写后自动启用 MQTT)</td></tr>
+  <tr><td>MQTT_PORT</td><td>1883</td><td>MQTT Broker 端口</td></tr>
+  <tr><td>MQTT_USER</td><td>-</td><td>MQTT 用户名</td></tr>
+  <tr><td>MQTT_PASS</td><td>-</td><td>MQTT 密码</td></tr>
+  <tr><td>MQTT_TOPIC</td><td>-</td><td>推送主题</td></tr>
+  <tr><td>MQTT_CLIENT_ID</td><td>-</td><td>客户端 ID</td></tr>
+</table>
+
+<h3>[auth] 登录认证配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>认证开关(0=关闭,1=开启)</td></tr>
+  <tr><td>db_path</td><td>data/auth.db</td><td>认证数据库路径</td></tr>
+  <tr><td>session_idle_timeout</td><td>1800</td><td>Session 空闲超时(秒)</td></tr>
+  <tr><td>session_max_timeout</td><td>28800</td><td>Session 最大超时(秒)</td></tr>
+  <tr><td>session_remember_timeout</td><td>604800</td><td>记住登录超时(秒)</td></tr>
+  <tr><td>max_failed_attempts</td><td>5</td><td>最大登录失败次数</td></tr>
+  <tr><td>lockout_duration</td><td>900</td><td>锁定时长(秒)</td></tr>
+</table>
+
+<h3>[ssl] HTTPS 配置(v2 — C++ 应用自管理)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>HTTPS 开关。v2 方案下必须设为 1(C++ 应用直接处理 TLS)</td></tr>
+  <tr><td>cert_path</td><td>ssl/plate_fullchain.pem</td><td>SSL 证书路径(相对于安装目录)</td></tr>
+  <tr><td>key_path</td><td>ssl/plate_privkey.pem</td><td>SSL 私钥路径(相对于安装目录)</td></tr>
+</table>
+<div class="note">v2 方案变更:nginx stream 只做 TCP 分流,不解密 TLS。SSL 证书由 C++ 应用自己管理,<code>enabled</code> 必须为 1。</div>
+
+<h3>[system] 系统监控配置(v38 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>monitor_interval</td><td>2</td><td>监控采集间隔(秒),delta 法计算 CPU 使用率</td></tr>
+  <tr><td>temp_alert_threshold</td><td>70</td><td>温度告警阈值(°C)</td></tr>
+  <tr><td>cpu_alert_threshold</td><td>90</td><td>CPU 告警阈值(%)</td></tr>
+  <tr><td>db_path</td><td>data/system_metrics.db</td><td>监控数据库路径</td></tr>
+  <tr><td>history_retention_days</td><td>7</td><td>原始数据保留天数</td></tr>
+  <tr><td>aggregation_retention_days</td><td>365</td><td>聚合数据保留天数</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+</table>
+
+<h3>[frpc] 内网穿透配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>config_path</td><td>-</td><td>frpc.toml 配置文件路径</td></tr>
+  <tr><td>protected_tunnels</td><td>-</td><td>受保护隧道名称(逗号分隔)</td></tr>
+  <tr><td>auto_rollback</td><td>1</td><td>重启失败自动回滚(0/1)</td></tr>
+  <tr><td>admin_addr</td><td>127.0.0.1</td><td>frpc Admin API 地址</td></tr>
+  <tr><td>admin_port</td><td>7400</td><td>frpc Admin API 端口</td></tr>
+  <tr><td>admin_user</td><td>-</td><td>frpc Admin 用户名</td></tr>
+  <tr><td>admin_password</td><td>-</td><td>frpc Admin 密码</td></tr>
+</table>
+
+<h3>[log] 日志配置(v36 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>内存缓冲模式(1=内存缓冲,0=直写文件)</td></tr>
+  <tr><td>log_file</td><td>PlateRecApp.log</td><td>日志文件路径</td></tr>
+  <tr><td>buffer_size_mb</td><td>20</td><td>内存缓冲区上限(MB)</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+  <tr><td>retention_days</td><td>30</td><td>日志文件保留天数</td></tr>
+  <tr><td>redirect_stdout</td><td>1</td><td>捕获 stdout/stderr(0/1)</td></tr>
+  <tr><td>auto_flush_on_exit</td><td>1</td><td>退出时立即刷盘(0/1)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 4. RTSP 摄像头配置 -->
+<div class="card" id="sec4">
+<div class="card-title" onclick="toggleCard(this)">4. RTSP 摄像头配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>摄像头地址格式</h3>
+<p>标准 RTSP 地址格式:</p>
+<pre>rtsp://用户名:密码@摄像头IP:554/stream</pre>
+
+<h3>四路摄像头说明</h3>
+<table>
+  <tr><th>参数</th><th>方向</th><th>说明</th></tr>
+  <tr><td>rtsp_url_front_in</td><td>前→进</td><td>前方摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>前→出</td><td>前方摄像头,捕捉出场车辆</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>侧→进</td><td>侧面摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>侧→出</td><td>侧面摄像头,捕捉出场车辆</td></tr>
+</table>
+
+<h3>硬件解码(树莓派5推荐配置)</h3>
+<table>
+  <tr><th>模式</th><th>值</th><th>说明</th></tr>
+  <tr><td>自动</td><td>auto</td><td>优先硬件解码,不可用则回退软件</td></tr>
+  <tr><td>软件解码</td><td>soft</td><td>CPU 解码,兼容性最好</td></tr>
+  <tr><td>DRM 硬件</td><td>drm</td><td>DRM 硬件加速(树莓派5推荐)</td></tr>
+</table>
+<div class="tip">树莓派 5 推荐:<code>hw_decode_mode=drm</code>,<code>hw_decode_device=/dev/dri/renderD128</code>,<code>rtsp_transport=tcp</code></div>
+</div>
+</div>
+
+<!-- 5. 车牌识别上传流程 -->
+<div class="card" id="sec5">
+<div class="card-title" onclick="toggleCard(this)">5. 车牌识别上传完整流程 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>识别流程</h3>
+<ol>
+  <li><strong>摄像头抓拍</strong>:RTSP 视频流实时解码,检测到车辆运动时触发抓拍</li>
+  <li><strong>车牌识别</strong>:HyperLPR3 引擎识别车牌号,返回车牌文本 + 置信度</li>
+  <li><strong>置信度过滤</strong>:低于 <code>PLATE_CONFIDENCE_THRESHOLD</code> 则丢弃</li>
+  <li><strong>时间窗口去重</strong>:同一车牌在 <code>TIME_WINDOW</code> 分钟内不重复上传</li>
+  <li><strong>交替锁定检查</strong>:同一车牌进出间隔低于 <code>in_out_interval</code> 分钟则锁定</li>
+  <li><strong>ROI 过滤</strong>:如启用 ROI,只识别指定区域内的车牌</li>
+  <li><strong>数据上传</strong>:HTTP POST 将识别结果 + 照片上传至 <code>app_api</code></li>
+  <li><strong>MQTT 推送</strong>:如启用 MQTT,同步推送识别结果</li>
+  <li><strong>飞书通知</strong>:异常报警发送飞书群消息</li>
+</ol>
+</div>
+</div>
+
+<!-- 6. MQTT -->
+<div class="card" id="sec6">
+<div class="card-title" onclick="toggleCard(this)">6. MQTT 数据上报配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>启用条件</h3>
+<p>在 <code>[mqtt]</code> 段中填写 <code>MQTT_HOST</code> 即自动启用 MQTT 推送。</p>
+
+<h3>配置示例</h3>
+<pre>[mqtt]
+MQTT_HOST=mqtt.example.com
+MQTT_PORT=1883
+MQTT_USER=username
+MQTT_PASS=password
+MQTT_TOPIC=plate/recognition
+MQTT_CLIENT_ID=plate_rec_station_01</pre>
+<div class="warn">MQTT 连接失败时系统会自动重连,不影响识别和上传功能。</div>
+</div>
+</div>
+
+<!-- 7. 称重系统 -->
+<div class="card" id="sec7">
+<div class="card-title" onclick="toggleCard(this)">7. 称重系统对接 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>工作原理</h3>
+<p>称重系统通过 TCP 协议与称重地磅通信,车牌识别成功后自动读取称重数据并关联上传。</p>
+
+<h3>重量判定逻辑</h3>
+<ul>
+  <li>连续 <code>STABLE_SAMPLE_COUNT</code> 次读数变化 &lt; <code>STABLE_THRESHOLD_KG</code> 公斤,视为稳定</li>
+  <li>稳定后取 <code>CANDIDATE_DATA_COUNT</code> 次读数的中值作为最终重量</li>
+  <li>重量超过 <code>MAX_WEIGHT</code> 吨视为异常,丢弃</li>
+</ul>
+</div>
+</div>
+
+<!-- 8. ROI -->
+<div class="card" id="sec8">
+<div class="card-title" onclick="toggleCard(this)">8. ROI 区域识别配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>配置方式</h3>
+<ul>
+  <li><strong>Web 可视化</strong>:首页连接状态面板中拖拽设置 ROI 区域</li>
+  <li><strong>手动配置</strong>:在 config.ini 的 <code>[config]</code> 段填写坐标值</li>
+</ul>
+<h3>参数说明</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>roi_in_x/y/w/h</td><td>进场 ROI 区域坐标和尺寸(像素)</td></tr>
+  <tr><td>roi_in_enabled</td><td>1=启用进场 ROI</td></tr>
+  <tr><td>roi_out_x/y/w/h</td><td>出场 ROI 区域坐标和尺寸</td></tr>
+  <tr><td>roi_out_enabled</td><td>1=启用出场 ROI</td></tr>
+  <tr><td>roi_debug_enabled</td><td>1=在抓拍图上绘制 ROI 框(调试用)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 9. 交替锁定 -->
+<div class="card" id="sec9">
+<div class="card-title" onclick="toggleCard(this)">9. 交替锁定机制 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>机制说明</h3>
+<p>防止同一车牌短时间内进出频繁触发(如车辆在门口调头)。同一车牌从"进"切换到"出"必须间隔 <code>in_out_interval</code> 分钟以上。</p>
+<div class="warn"><code>in_out_interval</code> 单位是<strong>分钟</strong>,不是秒!填 5 表示 5 分钟 = 300 秒。</div>
+</div>
+</div>
+
+<!-- 10. 认证与权限 -->
+<div class="card" id="sec10">
+<div class="card-title" onclick="toggleCard(this)">10. 登录认证与权限 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>角色定义</h3>
+<table>
+  <tr><th>角色</th><th>Role值</th><th>可访问页面</th></tr>
+  <tr><td>普通用户</td><td>0</td><td>首页、视频、称重、锁定管理</td></tr>
+  <tr><td>管理员</td><td>1</td><td>普通用户 + 系统配置</td></tr>
+  <tr><td>超级管理员</td><td>2</td><td>所有页面(含系统监控、frpc管理、SSH密钥、日志、帮助)</td></tr>
+</table>
+
+<h3>默认账号</h3>
+<table>
+  <tr><th>用户名</th><th>密码</th><th>角色</th></tr>
+  <tr><td>admin</td><td>zhongjin188A</td><td>超级管理员</td></tr>
+  <tr><td>zhonjin</td><td>zhonjin</td><td>普通用户</td></tr>
+</table>
+
+<h3>安全特性</h3>
+<ul>
+  <li>密码 bcrypt 加密存储(cost=12)</li>
+  <li>连续登录失败锁定账号</li>
+  <li>CSRF Token 防护</li>
+  <li>Session 超时自动失效</li>
+  <li>登录速率限制</li>
+</ul>
+</div>
+</div>
+
+<!-- 11. frpc.toml 详解 -->
+<div class="card" id="sec11">
+<div class="card-title" onclick="toggleCard(this)">11. frpc.toml 内网穿透配置详解 <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>配置文件位置</h3>
+<p>frpc 配置文件路径由 <code>config.ini</code> 的 <code>[frpc] config_path</code> 指定,通常为安装目录下的 <code>frpc.toml</code>。</p>
+
+<h3>v2 方案 — nginx stream 分流(推荐)</h3>
+<p>使用 nginx stream + ssl_preread 方案,<strong>只需一个公网端口 40963</strong> 即可同时处理 HTTP 和 HTTPS:</p>
+<pre># frpc.toml — v2 方案(nginx stream 分流)
+
+serverAddr = "discover.zhonjin.com"
+serverPort = 9443
+auth.method = "token"
+auth.token  = "你的frps认证Token"
+transport.tls.enable = true
+clientID = "ssd_40960"
+
+# frpc Admin HTTP API
+webServer.addr = "127.0.0.1"
+webServer.port = 7400
+webServer.user = "admin"
+webServer.password = "你的管理密码"
+
+# 隧道1:主服务(外网40963→本机nginx:40963,stream自动分流HTTP/HTTPS)
+[[proxies]]
+name       = "plate_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 40963    # ← 转发到 nginx stream,不是 C++ 的 8080
+remotePort = 40963
+
+# 隧道2:ttyd终端(外网40964→本机nginx:8082→ttyd:7681)
+[[proxies]]
+name       = "ttyd_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 8082     # ← nginx HTTPS 反向代理(SSL 终止 + WebSocket)
+remotePort = 40964</pre>
+
+<h3>隧道说明(v2)</h3>
+<table>
+  <tr><th>隧道名</th><th>外网端口</th><th>本机端口</th><th>用途</th></tr>
+  <tr><td>plate_web</td><td>40963</td><td>nginx stream:40963</td><td>HTTP/HTTPS 合一,stream 自动分流</td></tr>
+  <tr><td>ttyd_web</td><td>40964</td><td>nginx HTTPS:8082</td><td>Web 终端(nginx SSL→ttyd:7681)</td></tr>
+</table>
+
+<h3>v2 数据流向</h3>
+<pre>HTTP  流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread=""  → 127.0.0.1:8081 (nginx HTTP) → 301 重定向到 HTTPS
+HTTPS 流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread≠"" → 127.0.0.1:8080 (C++ HTTPS) → 直接处理
+Web SSH:  公网:40964 → frpc → nginx:8082 (HTTPS反向代理, SSL终止) → 127.0.0.1:7681 (ttyd HTTP)</pre>
+
+<div class="tip"><strong>v1→v2 迁移注意:</strong>不再需要 40965 端口。HTTPS 主服务统一使用 <code>https://mqtt.zhonjin.com:40963/</code>。</div>
+
+<h3>参数详解</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>serverAddr</td><td>frps 服务器域名或 IP</td></tr>
+  <tr><td>serverPort</td><td>frps 服务端口(默认 7000,本项目用 9443)</td></tr>
+  <tr><td>auth.method</td><td>认证方式,固定为 <code>token</code></td></tr>
+  <tr><td>auth.token</td><td>与 frps 约定的认证 Token</td></tr>
+  <tr><td>webServer.addr/port</td><td>frpc Admin API 监听地址,PlateRecApp 通过此接口获取隧道状态</td></tr>
+  <tr><td>webServer.user/password</td><td>Admin API 认证凭据(需与 config.ini [frpc] 段一致)</td></tr>
+  <tr><td>[[proxies]] name</td><td>隧道唯一名称,PlateRecApp Web 界面按名称识别</td></tr>
+  <tr><td>[[proxies]] type</td><td>隧道类型,本项目全部使用 <code>tcp</code>(透传 TCP 流)</td></tr>
+  <tr><td>[[proxies]] localPort</td><td>本机目标端口,frpc 将外网流量转发到此端口</td></tr>
+  <tr><td>[[proxies]] remotePort</td><td>frps 服务器暴露的外网端口,客户端通过此端口访问</td></tr>
+</table>
+
+<h3>隧道保护</h3>
+<p>在 <code>config.ini [frpc]</code> 段配置受保护隧道,防止 Web 界面误删关键隧道:</p>
+<pre>protected_tunnels = plate_web,ttyd_web</pre>
+<div class="warn">受保护的隧道无法通过 Web 管理界面删除。如需删除,必须先修改 config.ini 中的 <code>protected_tunnels</code> 字段。</div>
+
+<h3>frpc 管理命令</h3>
+<pre># 查看 frpc 状态
+systemctl status frpc
+
+# 重启 frpc
+sudo systemctl restart frpc
+
+# 查看 frpc 日志
+tail -f /var/log/frpc.log
+
+# 通过 Admin API 查看隧道状态
+curl -u admin:密码 http://127.0.0.1:7400/api/status/tcp</pre>
+</div>
+</div>
+
+<!-- 12. Nginx 反向代理详解 -->
+<div class="card" id="sec12">
+<div class="card-title" onclick="toggleCard(this)">12. Nginx stream + ssl_preread 配置详解(v2) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>架构说明</h3>
+<p>v2 方案使用 nginx 的 <code>stream</code> 模块 + <code>ssl_preread</code> 功能,在 <strong>TCP 层面</strong>检测流量类型并自动分流:</p>
+<ul>
+  <li><strong>端口 40963</strong>:nginx stream 监听,检测协议类型(TLS / HTTP)</li>
+  <li><strong>TLS 流量</strong> → 透传到 C++ HTTPS 服务(127.0.0.1:8080)</li>
+  <li><strong>HTTP 流量</strong> → nginx HTTP 模块(127.0.0.1:8081)→ 301 重定向到 HTTPS</li>
+</ul>
+<div class="tip"><strong>关键区别:</strong>stream 模块是 TCP 层代理,不解密 TLS 流量。SSL 证书由 C++ 应用自己管理,nginx 只做协议检测和分流。</div>
+
+<h3>nginx.conf 完整配置</h3>
+<p>文件路径:<code>/etc/nginx/nginx.conf</code>(替换整个文件)</p>
+<pre># 加载 stream 动态模块
+load_module modules/ngx_stream_module.so;
+
+events {
+    worker_connections 1024;
+}
+
+# ============================================================
+# stream 块 — TCP 层协议分流(在 http 块外面)
+# ============================================================
+stream {
+    # 根据 ssl_preread 检测结果选择后端
+    # $ssl_preread_protocol 的可能值:
+    #   ""          → HTTP 流量(非 TLS)
+    #   "TLSv1.2"   → TLS 1.2
+    #   "TLSv1.3"   → TLS 1.3
+    map $ssl_preread_protocol $backend {
+        ""        http_backend;    # HTTP → nginx HTTP 8081
+        default   tls_backend;     # TLS → C++ HTTPS 8080
+    }
+
+    # TLS 后端 — C++ HTTPS 服务
+    upstream tls_backend {
+        server 127.0.0.1:8080;
+    }
+
+    # HTTP 后端 — nginx HTTP 301 重定向
+    upstream http_backend {
+        server 127.0.0.1:8081;
+    }
+
+    # 主服务器 — 监听公网端口
+    server {
+        listen 40963;
+        proxy_pass $backend;
+        ssl_preread on;          # 启用 SSL 预读检测协议
+    }
+}
+
+# ============================================================
+# http 块 — HTTP → HTTPS 301 重定向
+# ============================================================
+http {
+    sendfile on;
+    tcp_nopush on;
+    tcp_nodelay on;
+    keepalive_timeout 65;
+
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
+
+    access_log /var/log/nginx/access.log;
+    error_log  /var/log/nginx/error.log;
+
+    # HTTP → HTTPS 301 重定向
+    server {
+        listen 8081;
+        server_name _;
+        return 301 https://$host:40963$request_uri;
+    }
+
+    # ============================================================
+    # ttyd Web 终端 HTTPS 反向代理(端口 8082)
+    # ============================================================
+    # frpc 隧道:外网 40964 → nginx:8082 → ttyd:7681
+    # nginx 做 SSL 终止,再通过 HTTP 代理到 ttyd(含 WebSocket 支持)
+    server {
+        listen 8082 ssl;
+        server_name _;
+
+        ssl_certificate     /etc/nginx/ssl/plate_fullchain.pem;
+        ssl_certificate_key /etc/nginx/ssl/plate_privkey.pem;
+        ssl_protocols TLSv1.2 TLSv1.3;
+        ssl_ciphers HIGH:!aNULL:!MD5:!RC4;
+        ssl_prefer_server_ciphers on;
+        ssl_session_cache shared:SSL_Ttyd:5m;
+        ssl_session_timeout 1h;
+
+        location / {
+            proxy_pass http://127.0.0.1:7681;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+
+            # WebSocket 支持(ttyd 核心通信方式)
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+
+            # ttyd 长连接超时
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+    }
+}</pre>
+
+<h3>核心原理</h3>
+<table>
+  <tr><th>组件</th><th>作用</th><th>说明</th></tr>
+  <tr><td>stream 块</td><td>TCP 层代理</td><td>与 http 块平级,不处理 HTTP 协议,只做 TCP 转发</td></tr>
+  <tr><td>ssl_preread on</td><td>SSL 预读</td><td>在 TLS 握手阶段读取 ClientHello,获取协议版本,不解密</td></tr>
+  <tr><td>map $ssl_preread_protocol</td><td>协议分流</td><td>空字符串 = HTTP,非空 = TLS</td></tr>
+  <tr><td>upstream tls_backend</td><td>TLS 后端</td><td>透传到 C++ 应用(8080),C++ 自己处理 TLS 握手</td></tr>
+  <tr><td>upstream http_backend</td><td>HTTP 后端</td><td>转发到 nginx HTTP 8081,返回 301 重定向</td></tr>
+  <tr><td>server 8082 ssl</td><td>ttyd HTTPS 代理</td><td>SSL 终止后代理到 ttyd:7681,支持 WebSocket</td></tr>
+</table>
+
+<h3>部署步骤</h3>
+<ol>
+  <li>安装 stream 模块:<code>sudo apt install -y libnginx-mod-stream</code></li>
+  <li>验证模块:<code>ls -l /usr/lib/nginx/modules/ngx_stream_module.so</code></li>
+  <li>备份原配置:<code>sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak</code></li>
+  <li>替换配置:<code>sudo cp nginx-stream.conf /etc/nginx/nginx.conf</code></li>
+  <li>删除默认站点(如有):<code>sudo rm -f /etc/nginx/sites-enabled/default</code></li>
+  <li>验证配置:<code>sudo nginx -t</code></li>
+  <li>启动 nginx:<code>sudo systemctl enable nginx && sudo systemctl restart nginx</code></li>
+</ol>
+
+<h3>前置条件</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>nginx 版本</td><td>≥ 1.11.5(支持 stream 模块)</td></tr>
+  <tr><td>stream 模块</td><td>libnginx-mod-stream(Debian)或 nginx-mod-stream(CentOS)</td></tr>
+  <tr><td>C++ 应用</td><td>必须启用 HTTPS,监听 127.0.0.1:8080</td></tr>
+  <tr><td>SSL 证书</td><td>C++ 应用自行管理(config.ini 的 [ssl] 段)</td></tr>
+</table>
+
+<h3>验证方法</h3>
+<pre># 检查端口监听
+ss -tlnp | grep -E '40963|8080|8081'
+# 预期输出:
+# LISTEN  0.0.0.0:40963  nginx (stream)
+# LISTEN  127.0.0.1:8081  nginx (http 301)
+# LISTEN  0.0.0.0:8080  PlateRecApp (HTTPS)
+
+# 测试 HTTP 重定向(应返回 301)
+curl -I http://localhost:40963/
+# 预期:HTTP/1.1 301 Moved Permanently
+#       Location: https://localhost:40963/
+
+# 测试 HTTPS(应返回 200)
+curl -kI https://localhost:40963/
+# 预期:HTTP/1.1 200 OK
+
+# 浏览器测试(清除缓存或用无痕模式)
+# 访问 http://mqtt.zhonjin.com:40963/ → 自动跳转到 https://mqtt.zhonjin.com:40963/</pre>
+
+<h3>端口规划总结</h3>
+<table>
+  <tr><th>端口</th><th>服务</th><th>说明</th></tr>
+  <tr><td><strong>40963</strong></td><td>nginx stream</td><td>公网入口,TLS/HTTP 分流</td></tr>
+  <tr><td>8081</td><td>nginx http</td><td>HTTP 301 重定向(仅内部)</td></tr>
+  <tr><td>8080</td><td>C++ HTTPS</td><td>PlateRecApp Web 服务(SSL 自管理)</td></tr>
+  <tr><td><strong>40964</strong></td><td>nginx HTTPS→ttyd</td><td>Web 终端(nginx:8082 SSL终止→ttyd:7681)</td></tr>
+  <tr><td>8082</td><td>nginx HTTPS 代理</td><td>ttyd Web 终端 HTTPS 反向代理(仅内部)</td></tr>
+  <tr><td>7400</td><td>frpc Admin</td><td>隧道状态查询(仅 127.0.0.1)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 13. SSL/HTTPS 证书配置 -->
+<div class="card" id="sec13">
+<div class="card-title" onclick="toggleCard(this)">13. SSL/HTTPS 证书配置(v2 — C++ 应用自管理) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<div class="note"><strong>v2 方案变更:</strong>nginx stream 只做 TCP 层协议分流,不解密 TLS 流量。SSL 证书由 C++ 应用(PlateRecApp)自己管理,通过 config.ini 的 <code>[ssl]</code> 段配置。nginx 不再需要 SSL 证书。</div>
+
+<h3>config.ini [ssl] 段配置</h3>
+<pre>[ssl]
+enabled=1
+cert_path=ssl/plate_fullchain.pem
+key_path=ssl/plate_privkey.pem</pre>
+<div class="tip">v2 方案下 SSL 证书文件放在安装目录的 <code>ssl/</code> 子目录下(或任意自定义路径),不再需要 <code>/etc/nginx/ssl/</code>。</div>
+
+<h3>方式一:Let's Encrypt 证书 — 阿里云 DNS 验证(推荐)</h3>
+<p>本项目使用 frpc 内网穿透,<strong>无公网 80 端口</strong>,因此不能使用传统的 HTTP-01 验证。推荐使用 <strong>DNS-01 验证</strong>,通过阿里云 DNS API 自动完成,<strong>不需要任何端口</strong>。</p>
+
+<h4>方案对比</h4>
+<table>
+  <tr><th>验证方式</th><th>端口要求</th><th>通配符</th><th>自动续期</th><th>本项目适用</th></tr>
+  <tr><td>HTTP-01 (standalone)</td><td>公网 80</td><td>❌</td><td>✅</td><td>❌ 80 端口不可用</td></tr>
+  <tr><td>TLS-ALPN-01</td><td>公网 443</td><td>❌</td><td>✅</td><td>❌ 443 端口不可用</td></tr>
+  <tr><td><strong>DNS-01 (dns-aliyun)</strong></td><td><strong>无</strong></td><td><strong>✅</strong></td><td><strong>✅</strong></td><td><strong>✅ 推荐</strong></td></tr>
+  <tr><td>手动 DNS</td><td>无</td><td>✅</td><td>❌ 手动</td><td>⚠️ 每 90 天手动操作</td></tr>
+</table>
+
+<h4>一键部署</h4>
+<pre># 1. 安装 certbot + 阿里云 DNS 插件
+sudo apt update
+sudo apt install -y certbot python3-pip
+sudo pip3 install certbot-dns-aliyun
+
+# 2. 配置阿里云 AccessKey(RAM 子账号,需 AliyunDNSFullAccess 权限)
+sudo mkdir -p /etc/letsencrypt
+sudo vim /etc/letsencrypt/aliyun.ini
+# 内容:
+#   dns_aliyun_access_key = 你的AccessKeyID
+#   dns_aliyun_access_key_secret = 你的AccessKeySecret
+sudo chmod 600 /etc/letsencrypt/aliyun.ini
+
+# 3. 一键申请通配符证书(自动通过 DNS API 验证)
+sudo certbot certonly \
+    --authenticator dns-aliyun \
+    --dns-aliyun-credentials /etc/letsencrypt/aliyun.ini \
+    --dns-aliyun-propagation-seconds 30 \
+    --server https://acme-v02.api.letsencrypt.org/directory \
+    --agree-tos --no-eff-email \
+    --email admin@zhonjin.com \
+    -d mqtt.zhonjin.com \
+    -d "*.zhonjin.com"
+
+# 4. 部署证书到 PlateRecApp
+mkdir -p ssl/
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/fullchain.pem ssl/plate_fullchain.pem
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/privkey.pem   ssl/plate_privkey.pem
+sudo chown stevenroc:stevenroc ssl/*.pem
+chmod 644 ssl/plate_fullchain.pem
+chmod 600 ssl/plate_privkey.pem
+
+# 5. 配置自动续期(每天 3:00 检查,续期后自动部署)
+(crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --deploy-hook /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh") | crontab -
+
+# 6. 重启 PlateRecApp
+sudo systemctl restart PlateRecApp</pre>
+
+<h4>续期部署脚本 renew_ssl.sh</h4>
+<pre>#!/bin/bash
+# /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh
+DOMAIN="mqtt.zhonjin.com"
+SSL_DIR="/opt/openAI/project/003.PlateRecAPP/ssl"
+
+cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${SSL_DIR}/plate_fullchain.pem
+cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem   ${SSL_DIR}/plate_privkey.pem
+chown stevenroc:stevenroc ${SSL_DIR}/*.pem
+chmod 644 ${SSL_DIR}/plate_fullchain.pem
+chmod 600 ${SSL_DIR}/plate_privkey.pem
+systemctl restart PlateRecApp
+echo "$(date) SSL 证书续期部署成功" >> /opt/openAI/project/003.PlateRecAPP/ssl_renew.log</pre>
+
+<h4>阿里云 RAM 权限配置</h4>
+<ol>
+  <li>登录 <a href="https://ram.console.aliyun.com/" target="_blank">RAM 控制台</a> → 用户 → 创建用户</li>
+  <li>勾选 <strong>OpenAPI 调用访问</strong>,保存 AccessKey ID 和 Secret</li>
+  <li>添加权限:<code>AliyunDNSFullAccess</code>(云解析 DNS 管理)</li>
+</ol>
+<div class="tip">DNS 验证原理:certbot 通过阿里云 API 自动添加 <code>_acme-challenge.mqtt.zhonjin.com</code> TXT 记录,Let's Encrypt CA 查询 DNS 验证域名所有权,全程无需开放任何端口。</div>
+<div class="warn"><strong>前提:</strong>域名 mqtt.zhonjin.com 的 DNS 解析必须使用阿里云(<code>dns9.hichina.com</code>)。如果 DNS 在其他服务商,需先迁移到阿里云,或改用对应插件(如 <code>certbot-dns-cloudflare</code>、<code>certbot-dns-dnspod</code>)。</div>
+
+<h3>方式二:自签名证书</h3>
+<p>无公网域名时使用自签名证书,浏览器会显示警告但加密通信正常:</p>
+<pre># 创建证书目录
+mkdir -p ssl/
+
+# 生成自签名证书(10年有效期)
+openssl req -x509 -newkey rsa:2048 -nodes \
+  -keyout ssl/plate_privkey.pem \
+  -out    ssl/plate_fullchain.pem \
+  -days   3650 \
+  -subj   "/CN=mqtt.zhonjin.com/O=PlateRecApp/C=CN"
+
+# 设置权限
+chmod 600 ssl/plate_privkey.pem
+chmod 644 ssl/plate_fullchain.pem
+
+# 重启 PlateRecApp 加载证书
+sudo systemctl restart PlateRecApp</pre>
+
+<h3>证书文件说明</h3>
+<table>
+  <tr><th>文件</th><th>用途</th><th>config.ini 参数</th></tr>
+  <tr><td>plate_fullchain.pem</td><td>完整证书链(含中间 CA)</td><td>cert_path</td></tr>
+  <tr><td>plate_privkey.pem</td><td>私钥文件(严禁泄露)</td><td>key_path</td></tr>
+</table>
+
+<h3>v1→v2 迁移说明</h3>
+<div class="warn"><strong>v1 方案:</strong>SSL 证书由 nginx 管理(<code>/etc/nginx/ssl/</code>),nginx 做 HTTPS 终止后转发 HTTP 到 C++。<br>
+<strong>v2 方案:</strong>SSL 证书由 C++ 应用管理,nginx stream 只做 TCP 分流,不解密 TLS。不再需要 <code>/etc/nginx/ssl/</code> 目录。</div>
+</div>
+</div>
+
+<!-- 14. 系统监控与日志 -->
+<div class="card" id="sec14">
+<div class="card-title" onclick="toggleCard(this)">14. 系统监控与日志 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统监控(实时)</h3>
+<ul>
+  <li>CPU 使用率(delta 法,含每核使用率、负载均值)</li>
+  <li>内存使用率(总量/已用/可用)</li>
+  <li>磁盘使用率(根分区)</li>
+  <li>CPU 温度,超阈值告警</li>
+  <li>PlateRecApp 进程资源占用</li>
+  <li>frpc/MQTT/称重连接状态</li>
+</ul>
+
+<h3>历史监控数据(v37+)</h3>
+<ul>
+  <li>采集间隔:<code>monitor_interval</code>(默认 2 秒)</li>
+  <li>定时刷盘:每日 <code>flush_time</code>(默认 23:20)写入 <code>system_metrics.db</code></li>
+  <li>三级存储:原始数据 → 小时聚合 → 日聚合</li>
+  <li>自动清理:原始数据保留 7 天,聚合数据保留 365 天</li>
+  <li>历史查询 API:<code>/api/monitor/history?range=1h|6h|24h|7d|30d</code></li>
+</ul>
+
+<h3>SD 卡写入保护策略(v38)</h3>
+<ul>
+  <li><strong>系统监控</strong>:内存缓冲,每日 23:20 一次写入,SD 卡写入从 2880 次/天降至 1 次</li>
+  <li><strong>运行日志</strong>:内存环形缓冲区,Web 日志页面零磁盘 I/O,每日 23:20 写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>旧版日志清理</strong>:内存模式启用时自动跳过旧版 copytruncate,避免无意义读写</li>
+  <li><strong>退出安全刷盘</strong>:SIGTERM/SIGINT 时立即执行最后一次刷盘</li>
+</ul>
+
+<h3>日志管理(v36 内存缓冲)</h3>
+<ul>
+  <li>内存缓冲模式(默认):所有日志写入内存,Web 页面实时读取</li>
+  <li>直写文件模式(<code>enabled=0</code>):传统模式回退</li>
+  <li>支持手动触发刷盘(Web 页面操作)</li>
+</ul>
+</div>
+</div>
+
+<!-- 15. Web管理 -->
+<div class="card" id="sec15">
+<div class="card-title" onclick="toggleCard(this)">15. Web 管理功能 <span class="arrow">▼</span></div>
+<div class="card-body">
+<table>
+  <tr><th>页面</th><th>路径</th><th>权限</th><th>说明</th></tr>
+  <tr><td>监控首页</td><td>/</td><td>所有用户</td><td>实时监控、识别记录、连接状态</td></tr>
+  <tr><td>视频预览</td><td>/video</td><td>所有用户</td><td>实时视频流</td></tr>
+  <tr><td>称重记录</td><td>/weight</td><td>所有用户</td><td>称重数据</td></tr>
+  <tr><td>锁定管理</td><td>/locks</td><td>所有用户</td><td>交替锁定记录</td></tr>
+  <tr><td>帮助手册</td><td>/help</td><td>超级管理员</td><td>本文档</td></tr>
+  <tr><td>系统配置</td><td>/config</td><td>管理员+</td><td>config.ini 在线编辑</td></tr>
+  <tr><td>系统监控</td><td>/system</td><td>超级管理员</td><td>CPU/内存/温度/磁盘</td></tr>
+  <tr><td>frpc 管理</td><td>/frpc</td><td>超级管理员</td><td>内网穿透状态和配置</td></tr>
+  <tr><td>SSH 密钥</td><td>/ssh-keys</td><td>超级管理员</td><td>SSH 公钥管理</td></tr>
+  <tr><td>日志管理</td><td>/logs</td><td>超级管理员</td><td>系统日志查看</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 16. 数据库 -->
+<div class="card" id="sec16">
+<div class="card-title" onclick="toggleCard(this)">16. 数据库与备份 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>数据库文件</h3>
+<table>
+  <tr><th>文件</th><th>路径</th><th>说明</th></tr>
+  <tr><td>认证数据库</td><td>data/auth.db</td><td>用户账号、密码、Session</td></tr>
+  <tr><td>识别记录</td><td>data/upload_records.db</td><td>车牌识别上传记录</td></tr>
+  <tr><td>系统监控</td><td>data/system_metrics.db</td><td>性能监控时序数据</td></tr>
+</table>
+
+<h3>备份建议</h3>
+<ul>
+  <li>定期备份 <code>data/</code> 目录和 <code>config.ini</code></li>
+  <li>nginx SSL 证书和配置文件备份:<code>/etc/nginx/ssl/</code>、<code>/etc/nginx/conf.d/</code></li>
+  <li>frpc 配置修改时系统自动备份</li>
+</ul>
+</div>
+</div>
+
+<!-- 17. FAQ -->
+<div class="card" id="sec17">
+<div class="card-title" onclick="toggleCard(this)">17. 常见问题 FAQ <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>Q: HTTP 访问没有跳转到 HTTPS?</h3>
+<p>A: 检查以下几点:</p>
+<ul>
+  <li>确认 nginx stream 模块已安装:<code>nginx -V 2>&amp;1 | grep stream</code></li>
+  <li>确认 nginx 配置正确:<code>sudo nginx -t</code></li>
+  <li>确认 8081 端口正在监听:<code>ss -tlnp | grep 8081</code></li>
+  <li>确认 frpc 隧道 <code>plate_web</code> 正常运行(localPort 为 40963)</li>
+  <li>浏览器可能缓存了旧的重定向,尝试清除缓存或无痕模式</li>
+  <li>测试本机重定向:<code>curl -I http://localhost:40963/</code>,应返回 301</li>
+</ul>
+
+<h3>Q: HTTPS 访问返回错误?</h3>
+<p>A: v2 方案下 TLS 由 C++ 应用处理,检查:</p>
+<ul>
+  <li>PlateRecApp 是否正在运行:<code>systemctl status PlateRecApp</code></li>
+  <li>PlateRecApp 是否启用了 HTTPS:<code>config.ini [ssl] enabled=1</code></li>
+  <li>SSL 证书文件是否存在:<code>ls -l ssl/plate_fullchain.pem ssl/plate_privkey.pem</code></li>
+  <li>nginx stream 是否正常监听:<code>ss -tlnp | grep 40963</code></li>
+  <li>nginx 错误日志:<code>sudo tail -f /var/log/nginx/error.log</code></li>
+</ul>
+
+<h3>Q: <code>unknown directive "stream"</code>?</h3>
+<p>A: stream 模块未加载。执行:<code>sudo apt install -y libnginx-mod-stream</code>,确认 nginx.conf 顶部有 <code>load_module modules/ngx_stream_module.so;</code></p>
+
+<h3>Q: frpc 重启后隧道不生效?</h3>
+<p>A: 检查 frpc.toml 配置语法:</p>
+<pre>frpc verify -c /path/to/frpc.toml</pre>
+<p>常见错误:TOML 格式问题(缺少引号、缩进错误)。通过 Web 管理界面编辑配置时系统会自动校验。</p>
+
+<h3>Q: 自签名证书浏览器警告怎么消除?</h3>
+<p>A: 自签名证书无法消除浏览器警告(这是正常安全行为)。解决方案:</p>
+<ul>
+  <li>方式1:使用 Let's Encrypt 免费证书(需域名解析到公网 IP)</li>
+  <li>方式2:在客户端将自签名 CA 证书添加到受信任的根证书列表</li>
+  <li>方式3:忽略警告,继续使用(加密通信正常,仅证书不受信任)</li>
+</ul>
+
+<h3>Q: 摄像头连接失败怎么办?</h3>
+<p>A: 确认 RTSP 地址格式正确,在 VLC 中测试播放。尝试 <code>rtsp_transport=tcp</code>。</p>
+
+<h3>Q: in_out_interval 填 5 是 5 秒还是 5 分钟?</h3>
+<p>A: <strong>5 分钟</strong>。单位是分钟,系统内部自动 ×60 转为秒。</p>
+
+<h3>Q: 如何重置管理员密码?</h3>
+<pre>cd /opt/openAI/project/003.PlateRecAPP
+python3 reset_passwords.py</pre>
+</div>
+</div>
+
+</div>
+
+<script>
+function toggleCard(el) {
+  el.classList.toggle('collapsed');
+  var body = el.nextElementSibling;
+  body.classList.toggle('hidden');
+}
+</script>
+</body>
+</html>

+ 1156 - 0
assets/web/help20260828.html

@@ -0,0 +1,1156 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>帮助手册 - 车牌识别系统</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f0f2f5;color:#333}
+.navbar{background:#1a1a2e;color:#fff;padding:12px 20px;display:flex;align-items:center;gap:20px;box-shadow:0 2px 8px rgba(0,0,0,0.15)}
+.navbar a{color:#ccc;text-decoration:none;padding:6px 12px;border-radius:4px;font-size:14px}
+.navbar a:hover,.navbar a.active{background:rgba(255,255,255,0.15);color:#fff}
+.navbar .brand{font-size:16px;font-weight:bold;color:#4fc3f7;margin-right:10px}
+.container{max-width:960px;margin:20px auto;padding:0 16px}
+.card{background:#fff;border-radius:8px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08);overflow:hidden}
+.card-title{background:#263238;color:#fff;padding:10px 16px;font-size:14px;font-weight:600;display:flex;align-items:center;justify-content:space-between;cursor:pointer;user-select:none}
+.card-title:hover{background:#37474f}
+.card-title .arrow{transition:transform 0.2s;font-size:12px}
+.card-title.collapsed .arrow{transform:rotate(-90deg)}
+.card-body{padding:16px 20px;line-height:1.8;font-size:14px}
+.card-body.hidden{display:none}
+h3{color:#1a1a2e;margin:16px 0 8px;font-size:15px;border-left:3px solid #667eea;padding-left:10px}
+h4{color:#333;margin:12px 0 6px;font-size:14px}
+table{width:100%;border-collapse:collapse;margin:8px 0 16px;font-size:13px}
+th{background:#f5f5f5;padding:8px 10px;text-align:left;font-weight:600;border-bottom:2px solid #e0e0e0;white-space:nowrap}
+td{padding:8px 10px;border-bottom:1px solid #f0f0f0;vertical-align:top}
+td:first-child{font-family:monospace;color:#d32f2f;white-space:nowrap;font-size:12px}
+code{background:#f5f5f5;padding:2px 6px;border-radius:3px;font-size:12px;color:#d32f2f;font-family:monospace}
+pre{background:#1e1e2e;color:#cdd6f4;padding:14px 18px;border-radius:6px;font-size:12px;overflow-x:auto;margin:8px 0 16px;line-height:1.6}
+pre code{background:none;color:#cdd6f4;padding:0;font-size:12px}
+.tip{background:#e8f5e9;border-left:4px solid #4caf50;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.warn{background:#fff3e0;border-left:4px solid #ff9800;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+.note{background:#e3f2fd;border-left:4px solid #2196f3;padding:10px 14px;margin:8px 0;border-radius:0 4px 4px 0;font-size:13px}
+ul{padding-left:20px;margin:6px 0}
+li{margin:4px 0;font-size:13px;line-height:1.7}
+.toc{background:#fff;border-radius:8px;padding:16px 24px;margin-bottom:16px;box-shadow:0 1px 4px rgba(0,0,0,0.08)}
+.toc h2{font-size:16px;color:#1a1a2e;margin-bottom:10px;border-bottom:2px solid #667eea;padding-bottom:6px}
+.toc ol{padding-left:20px;columns:2;column-gap:30px}
+.toc li{margin:4px 0;font-size:13px;break-inside:avoid}
+.toc a{color:#667eea;text-decoration:none}
+.toc a:hover{text-decoration:underline}
+@media(max-width:768px){
+.navbar{flex-wrap:wrap;gap:8px;padding:10px 14px}
+.navbar .brand{font-size:14px;width:100%}
+.navbar a{font-size:12px;padding:4px 8px}
+.container{padding:0 10px;margin:10px auto}
+.toc ol{columns:1}
+table{font-size:12px;display:block;overflow-x:auto}
+th,td{padding:6px 8px}
+td:first-child{font-size:11px}
+}
+@media(max-width:480px){
+.navbar .brand{font-size:13px}
+.navbar a{font-size:11px;padding:3px 6px}
+.card-body{padding:12px 14px}
+}
+</style>
+</head>
+<body>
+<nav class="navbar">
+  <span class="brand">🚛 车牌识别系统</span>
+  <a href="/">监控首页</a>
+  <a href="/video">视频预览</a>
+  <a href="/locks">锁定管理</a>
+  <a href="/help" class="active">帮助手册</a>
+</nav>
+
+<div class="container">
+
+<div class="toc">
+<h2>📋 目录</h2>
+<ol>
+  <li><a href="#sec1">系统概述</a></li>
+  <li><a href="#sec2">快速部署指南</a></li>
+  <li><a href="#sec3">config.ini 完整配置说明</a></li>
+  <li><a href="#sec4">RTSP 摄像头配置</a></li>
+  <li><a href="#sec5">车牌识别上传流程</a></li>
+  <li><a href="#sec6">MQTT 数据上报配置</a></li>
+  <li><a href="#sec7">称重系统对接</a></li>
+  <li><a href="#sec8">ROI 区域识别配置</a></li>
+  <li><a href="#sec9">交替锁定机制</a></li>
+  <li><a href="#sec10">登录认证与权限</a></li>
+  <li><a href="#sec11">frpc.toml 内网穿透配置详解</a></li>
+  <li><a href="#sec12">Nginx 反向代理配置详解</a></li>
+  <li><a href="#sec13">SSL/HTTPS 证书配置</a></li>
+  <li><a href="#sec14">系统监控与日志</a></li>
+  <li><a href="#sec15">Web 管理功能</a></li>
+  <li><a href="#sec16">数据库与备份</a></li>
+  <li><a href="#sec17">常见问题 FAQ</a></li>
+</ol>
+</div>
+
+<!-- 1. 系统概述 -->
+<div class="card" id="sec1">
+<div class="card-title" onclick="toggleCard(this)">1. 系统概述 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统简介</h3>
+<p>车牌识别系统(PlateRecApp)是一款基于 HyperLPR3 的实时车牌识别应用,运行在树莓派 5 (aarch64) 上,支持:</p>
+<ul>
+  <li>多路 RTSP 摄像头实时抓拍识别(进场/出场/侧面进/侧面出)</li>
+  <li>车牌识别结果自动上传至云端 API</li>
+  <li>称重系统对接(TCP 协议),自动关联车牌与称重数据</li>
+  <li>MQTT 协议实时推送识别结果</li>
+  <li>飞书群消息通知(异常报警)</li>
+  <li>Web 管理界面(监控首页、视频预览、系统配置、锁定管理等)</li>
+  <li>frpc 内网穿透管理 + Nginx stream + ssl_preread 协议分流(HTTP 自动跳 HTTPS)</li>
+  <li>登录认证与角色权限控制</li>
+  <li>SD 卡写入寿命保护(内存缓冲 + 每日定时写盘)</li>
+</ul>
+
+<h3>运行环境</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>硬件</td><td>树莓派 5 8GB(推荐)</td></tr>
+  <tr><td>系统</td><td>Debian 13 (trixie) / Ubuntu 24.04 aarch64</td></tr>
+  <tr><td>依赖</td><td>HyperLPR3 SDK、FFmpeg、libcurl、OpenSSL、cJSON、Mosquitto、Nginx</td></tr>
+  <tr><td>内网穿透</td><td>frpc(通过 systemctl 管理)</td></tr>
+  <tr><td>协议分流</td><td>Nginx stream + ssl_preread(TCP 层 HTTP/HTTPS 自动检测分流)</td></tr>
+</table>
+
+<h3>网络架构总览(v2 — nginx stream + ssl_preread 方案)</h3>
+<pre>外网客户端                        frps 服务器                    本机(树莓派5)
+─────────                        ─────────                    ─────────────
+                                                               ┌─ TLS → nginx stream → C++ HTTPS:8080 (透传)
+https://mqtt.zhonjin.com:40963/  ─TCP─→  discover.zhonjin.com:9443  ─TCP─→  frpc ─→ nginx:40963 (stream+ssl_preread)
+http://mqtt.zhonjin.com:40963/   ─TCP─→            ↑                ─TCP─→  frpc ─→└─ HTTP → nginx:8081 (301→HTTPS)
+https://mqtt.zhonjin.com:40964/  ─TCP─→            ↑                ─TCP─→  frpc ─→ nginx:8082 (HTTPS代理) → ttyd:7681</pre>
+<div class="tip"><strong>v2 方案优势:</strong>公网只需一个端口 40963,nginx stream 在 TCP 层自动检测协议类型并分流。HTTP 自动跳 HTTPS,HTTPS 透传到 C++ 应用处理。用户只需记住一个地址。</div>
+
+<h3>目录结构</h3>
+<table>
+  <tr><th>路径</th><th>说明</th></tr>
+  <tr><td>PlateRecApp</td><td>主程序二进制</td></tr>
+  <tr><td>config.ini</td><td>主配置文件</td></tr>
+  <tr><td>config.txt</td><td>MD5 校验配置</td></tr>
+  <tr><td>hyperlpr3/</td><td>HyperLPR3 SDK 模型文件</td></tr>
+  <tr><td>assets/web/</td><td>Web 页面文件(HTML/CSS/JS)</td></tr>
+  <tr><td>data/</td><td>数据库目录(auth.db、upload_records.db、system_metrics.db)</td></tr>
+  <tr><td>photos/</td><td>抓拍图片存储目录</td></tr>
+  <tr><td>scripts/</td><td>运维脚本(sudoers 配置、密码重置等)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 2. 快速部署指南 -->
+<div class="card" id="sec2">
+<div class="card-title" onclick="toggleCard(this)">2. 快速部署指南 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>编译部署</h3>
+<ol>
+  <li>解压代码包:<code>tar xzf fix24_vXX.tar.gz</code></li>
+  <li>编译:<code>cd fix24/build && cmake .. && make -j4</code></li>
+  <li>部署文件到安装目录(如 <code>/opt/openAI/project/003.PlateRecAPP/</code>)</li>
+  <li>复制 HTML 文件:<code>cp -r assets/web/ /安装目录/assets/web/</code></li>
+  <li>配置 sudoers(frpc 管理需要):<code>sudo bash scripts/setup_frpc_sudoers.sh</code></li>
+  <li>安装 nginx stream 模块:<code>sudo apt install libnginx-mod-stream</code></li>
+  <li>部署 nginx stream 配置文件(详见第 12 章)</li>
+  <li>部署 SSL 证书(C++ 应用自管理,详见第 13 章)</li>
+  <li>修改 <code>config.ini</code> 配置(参考下文各节)</li>
+  <li>启动服务:<code>sudo systemctl restart nginx && sudo systemctl restart PlateRecApp</code></li>
+</ol>
+
+<h3>systemd 服务配置</h3>
+<p>PlateRecApp 服务文件路径:<code>/etc/systemd/system/PlateRecApp.service</code></p>
+<pre>Unit]
+Description=PlateRecApp License Plate Recognition System
+After=network.target frpc.service
+
+[Service]
+User=stevenroc
+Group=stevenroc
+WorkingDirectory=/opt/openAI/project/003.PlateRecAPP
+ExecStartPre=/bin/sleep 10
+ExecStart=/opt/openAI/project/003.PlateRecAPP/PlateRecApp
+TimeoutStartSec=30
+TimeoutStopSec=10
+Restart=on-failure
+RestartSec=5
+StandardOutput=append:/opt/openAI/project/003.PlateRecAPP/PlateRecApp.log
+LimitNOFILE=1048576
+
+[Install]
+WantedBy=multi-user.target</pre>
+<div class="tip">PlateRecApp 以非 root 用户运行(stevenroc),frpc 管理通过 sudoers 授权。日志输出由 v36 内存缓冲系统接管,systemd 的 StandardOutput 仅作备份。</div>
+
+<h3>密码重置</h3>
+<p>如忘记管理员密码,运行:<code>python3 reset_passwords.py</code></p>
+
+<h3>Nginx 服务配置(v2 — stream 方案)</h3>
+<p>安装 stream 模块并部署配置:</p>
+<pre># 安装 stream 模块(必须)
+sudo apt install -y libnginx-mod-stream
+
+# 验证模块
+nginx -V 2>&amp;1 | grep stream
+
+# 替换 nginx.conf(详见第 12 章)
+sudo cp nginx-stream.conf /etc/nginx/nginx.conf
+
+# 验证配置
+sudo nginx -t
+
+# 启动/重载
+sudo systemctl enable nginx
+sudo systemctl restart nginx</pre>
+</div>
+</div>
+
+<!-- 3. config.ini 完整配置 -->
+<div class="card" id="sec3">
+<div class="card-title" onclick="toggleCard(this)">3. config.ini 完整配置说明 <span class="arrow">▼</span></div>
+<div class="card-body">
+<p>配置文件路径:<code>&lt;安装目录&gt;/config.ini</code>,ini 格式,section 和 key 均不区分大小写。</p>
+
+<h3>[config] 核心识别配置</h3>
+<table>
+  <tr><th>参数</th><th>类型</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>project_name</td><td>string</td><td>-</td><td>项目名称</td></tr>
+  <tr><td>point_number</td><td>string</td><td>-</td><td>站点编号</td></tr>
+  <tr><td>throughway</td><td>string</td><td>-</td><td>通道名称</td></tr>
+  <tr><td>rtsp_url_front_in</td><td>string</td><td>-</td><td>前侧进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>string</td><td>-</td><td>前侧出场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>string</td><td>-</td><td>侧面进场摄像头 RTSP 地址</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>string</td><td>-</td><td>侧面出场摄像头 RTSP 地址</td></tr>
+  <tr><td>app_api</td><td>string</td><td>-</td><td>云端上传 API 地址</td></tr>
+  <tr><td>app_key</td><td>string</td><td>-</td><td>API 认证 Key</td></tr>
+  <tr><td>app_secret</td><td>string</td><td>-</td><td>API 认证 Secret</td></tr>
+  <tr><td>DEBUG_LOG</td><td>int</td><td>0</td><td>调试日志开关(0=关闭,1=开启)</td></tr>
+  <tr><td>TestFlag</td><td>int</td><td>0</td><td>测试模式开关</td></tr>
+  <tr><td>wType</td><td>int</td><td>0</td><td>称重类型(0=禁用,1=启用)</td></tr>
+  <tr><td>TIME_WINDOW</td><td>int</td><td>5</td><td>识别时间窗口(分钟),同一车牌在窗口内不重复上传</td></tr>
+  <tr><td>MAX_PHOTO_GROUPS</td><td>int</td><td>100</td><td>最大照片组数缓存</td></tr>
+  <tr><td>in_out_interval</td><td>int</td><td>5</td><td>⚠️ 单位是<strong>分钟</strong>!交替锁定间隔(同一车牌进出最短时间),内部转为秒存储</td></tr>
+  <tr><td>PLATE_CONFIDENCE_THRESHOLD</td><td>float</td><td>0.7</td><td>车牌识别置信度阈值(0~1),低于此值不上传</td></tr>
+  <tr><td>PLATE_LOG_THRESHOLD</td><td>float</td><td>0.5</td><td>日志记录置信度阈值</td></tr>
+  <tr><td>AlternatingMerge</td><td>int</td><td>0</td><td>交替合并开关(0=关闭,1=开启)</td></tr>
+  <tr><td>hw_decode_mode</td><td>string</td><td>auto</td><td>硬件解码模式:auto/soft/drm</td></tr>
+  <tr><td>hw_decode_device</td><td>string</td><td>/dev/dri/renderD128</td><td>硬件解码设备路径</td></tr>
+  <tr><td>rtsp_transport</td><td>string</td><td>tcp</td><td>RTSP 传输协议:tcp/udp</td></tr>
+  <tr><td>PhotoMaxCapacityMB</td><td>int</td><td>1024</td><td>照片目录最大容量(MB),超限自动清理旧照片</td></tr>
+  <tr><td>LogRetentionDays</td><td>int</td><td>30</td><td>日志保留天数</td></tr>
+</table>
+
+<h3>[server] Web 服务配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>ip</td><td>Web 服务监听地址(含端口),如 <code>0.0.0.0:8080</code>。v2 方案下 PlateRecApp 提供 HTTPS 服务(SSL 自管理),nginx stream 透传 TLS 流量到此处</td></tr>
+</table>
+
+<h3>[mqtt] MQTT 上报配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>MQTT_HOST</td><td>-</td><td>MQTT Broker 地址(填写后自动启用 MQTT)</td></tr>
+  <tr><td>MQTT_PORT</td><td>1883</td><td>MQTT Broker 端口</td></tr>
+  <tr><td>MQTT_USER</td><td>-</td><td>MQTT 用户名</td></tr>
+  <tr><td>MQTT_PASS</td><td>-</td><td>MQTT 密码</td></tr>
+  <tr><td>MQTT_TOPIC</td><td>-</td><td>推送主题</td></tr>
+  <tr><td>MQTT_CLIENT_ID</td><td>-</td><td>客户端 ID</td></tr>
+  <tr><td>PLATE_COLOR</td><td>-</td><td>车牌颜色标识(随 MQTT 消息上报)</td></tr>
+  <tr><td>VEHICLE_TYPE</td><td>-</td><td>车辆类型标识(随 MQTT 消息上报)</td></tr>
+</table>
+
+<h3>[auth] 登录认证配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>认证开关(0=关闭,1=开启)</td></tr>
+  <tr><td>db_path</td><td>data/auth.db</td><td>认证数据库路径</td></tr>
+  <tr><td>session_idle_timeout</td><td>1800</td><td>Session 空闲超时(秒)</td></tr>
+  <tr><td>session_max_timeout</td><td>28800</td><td>Session 最大超时(秒)</td></tr>
+  <tr><td>session_remember_timeout</td><td>604800</td><td>记住登录超时(秒)</td></tr>
+  <tr><td>max_failed_attempts</td><td>5</td><td>最大登录失败次数</td></tr>
+  <tr><td>lockout_duration</td><td>900</td><td>锁定时长(秒)</td></tr>
+</table>
+
+<h3>[ssl] HTTPS 配置(v2 — C++ 应用自管理)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>HTTPS 开关。v2 方案下必须设为 1(C++ 应用直接处理 TLS)</td></tr>
+  <tr><td>cert_path</td><td>ssl/plate_fullchain.pem</td><td>SSL 证书路径(相对于安装目录)</td></tr>
+  <tr><td>key_path</td><td>ssl/plate_privkey.pem</td><td>SSL 私钥路径(相对于安装目录)</td></tr>
+</table>
+<div class="note">v2 方案变更:nginx stream 只做 TCP 分流,不解密 TLS。SSL 证书由 C++ 应用自己管理,<code>enabled</code> 必须为 1。</div>
+
+<h3>[system] 系统监控配置(v38 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>monitor_interval</td><td>2</td><td>监控采集间隔(秒),delta 法计算 CPU 使用率</td></tr>
+  <tr><td>temp_alert_threshold</td><td>70</td><td>温度告警阈值(°C)</td></tr>
+  <tr><td>cpu_alert_threshold</td><td>90</td><td>CPU 告警阈值(%)</td></tr>
+  <tr><td>db_path</td><td>data/system_metrics.db</td><td>监控数据库路径</td></tr>
+  <tr><td>history_retention_days</td><td>7</td><td>原始数据保留天数</td></tr>
+  <tr><td>aggregation_retention_days</td><td>365</td><td>聚合数据保留天数</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+</table>
+
+<h3>[frpc] 内网穿透配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>config_path</td><td>-</td><td>frpc.toml 配置文件路径</td></tr>
+  <tr><td>protected_tunnels</td><td>-</td><td>受保护隧道名称(逗号分隔)</td></tr>
+  <tr><td>auto_rollback</td><td>1</td><td>重启失败自动回滚(0/1)</td></tr>
+  <tr><td>admin_addr</td><td>127.0.0.1</td><td>frpc Admin API 地址</td></tr>
+  <tr><td>admin_port</td><td>7400</td><td>frpc Admin API 端口</td></tr>
+  <tr><td>admin_user</td><td>-</td><td>frpc Admin 用户名</td></tr>
+  <tr><td>admin_password</td><td>-</td><td>frpc Admin 密码</td></tr>
+</table>
+
+<h3>[log] 日志配置(v36 内存缓冲)</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>enabled</td><td>1</td><td>内存缓冲模式(1=内存缓冲,0=直写文件)</td></tr>
+  <tr><td>log_file</td><td>PlateRecApp.log</td><td>日志文件路径</td></tr>
+  <tr><td>buffer_size_mb</td><td>20</td><td>内存缓冲区上限(MB)</td></tr>
+  <tr><td>flush_time</td><td>23:20</td><td>每日定时刷盘时间(HH:MM)</td></tr>
+  <tr><td>retention_days</td><td>30</td><td>日志文件保留天数</td></tr>
+  <tr><td>redirect_stdout</td><td>1</td><td>捕获 stdout/stderr(0/1)</td></tr>
+  <tr><td>auto_flush_on_exit</td><td>1</td><td>退出时立即刷盘(0/1)</td></tr>
+</table>
+
+<h3>[feishu] 飞书通知配置</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>APP_ID</td><td>飞书应用 App ID</td></tr>
+  <tr><td>APP_SECRET</td><td>飞书应用 App Secret</td></tr>
+  <tr><td>CHAT_ID</td><td>飞书群聊 ID</td></tr>
+  <tr><td>TIMEOUT_SECOND</td><td>请求超时时间(秒)</td></tr>
+  <tr><td>WarningSigns</td><td>抑制紧急报警(1=抑制,0=正常)</td></tr>
+</table>
+
+<h3>[weight] 称重系统配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>flagWeight</td><td>0</td><td>称重系统开关(0/1)</td></tr>
+  <tr><td>weight_server_ip</td><td>-</td><td>称重服务器 IP</td></tr>
+  <tr><td>weight_server_port</td><td>-</td><td>称重服务器端口</td></tr>
+  <tr><td>weight_threshold_in</td><td>0</td><td>进场称重阈值(吨)</td></tr>
+  <tr><td>weight_threshold_out</td><td>0</td><td>出场称重阈值(吨)</td></tr>
+  <tr><td>default_entry_weight</td><td>0</td><td>默认进场重量</td></tr>
+  <tr><td>default_exit_weight</td><td>0</td><td>默认出场重量</td></tr>
+  <tr><td>weight_detection_time</td><td>5000</td><td>称重检测时间(毫秒)</td></tr>
+  <tr><td>tcp_connect_timeout</td><td>3000</td><td>TCP 连接超时(毫秒)</td></tr>
+  <tr><td>MAX_WEIGHT</td><td>100</td><td>最大称重值(吨)</td></tr>
+  <tr><td>STABLE_SAMPLE_COUNT</td><td>5</td><td>稳定采样次数</td></tr>
+  <tr><td>STABLE_THRESHOLD_KG</td><td>50</td><td>稳定判定阈值(公斤)</td></tr>
+  <tr><td>CANDIDATE_DATA_COUNT</td><td>3</td><td>候选数据次数</td></tr>
+  <tr><td>MAX_UPLOAD_RETRIES</td><td>3</td><td>上传最大重试次数</td></tr>
+  <tr><td>RETRY_DELAY_MS</td><td>1000</td><td>重试延迟(毫秒)</td></tr>
+</table>
+
+<h3>[terminal] 终端/SSH 配置</h3>
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>ttyd_port</td><td>7681</td><td>ttyd Web 终端端口</td></tr>
+  <tr><td>ttyd_credential</td><td>-</td><td>ttyd 登录凭据(user:password)</td></tr>
+  <tr><td>ttyd_max_clients</td><td>5</td><td>ttyd 最大客户端数</td></tr>
+  <tr><td>ssh_keys_path</td><td>-</td><td>SSH 公钥存储路径</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 4. RTSP 摄像头配置 -->
+<div class="card" id="sec4">
+<div class="card-title" onclick="toggleCard(this)">4. RTSP 摄像头配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>摄像头地址格式</h3>
+<p>标准 RTSP 地址格式:</p>
+<pre>rtsp://用户名:密码@摄像头IP:554/stream</pre>
+
+<h3>四路摄像头说明</h3>
+<table>
+  <tr><th>参数</th><th>方向</th><th>说明</th></tr>
+  <tr><td>rtsp_url_front_in</td><td>前→进</td><td>前方摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_front_out</td><td>前→出</td><td>前方摄像头,捕捉出场车辆</td></tr>
+  <tr><td>rtsp_url_side_in</td><td>侧→进</td><td>侧面摄像头,捕捉进场车辆</td></tr>
+  <tr><td>rtsp_url_side_out</td><td>侧→出</td><td>侧面摄像头,捕捉出场车辆</td></tr>
+</table>
+
+<h3>硬件解码(树莓派5推荐配置)</h3>
+<table>
+  <tr><th>模式</th><th>值</th><th>说明</th></tr>
+  <tr><td>自动</td><td>auto</td><td>优先硬件解码,不可用则回退软件</td></tr>
+  <tr><td>软件解码</td><td>soft</td><td>CPU 解码,兼容性最好</td></tr>
+  <tr><td>DRM 硬件</td><td>drm</td><td>DRM 硬件加速(树莓派5推荐)</td></tr>
+</table>
+<div class="tip">树莓派 5 推荐:<code>hw_decode_mode=drm</code>,<code>hw_decode_device=/dev/dri/renderD128</code>,<code>rtsp_transport=tcp</code></div>
+</div>
+</div>
+
+<!-- 5. 车牌识别上传流程 -->
+<div class="card" id="sec5">
+<div class="card-title" onclick="toggleCard(this)">5. 车牌识别上传完整流程 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>识别流程</h3>
+<ol>
+  <li><strong>摄像头抓拍</strong>:RTSP 视频流实时解码,检测到车辆运动时触发抓拍</li>
+  <li><strong>车牌识别</strong>:HyperLPR3 引擎识别车牌号,返回车牌文本 + 置信度</li>
+  <li><strong>置信度过滤</strong>:低于 <code>PLATE_CONFIDENCE_THRESHOLD</code> 则丢弃</li>
+  <li><strong>时间窗口去重</strong>:同一车牌在 <code>TIME_WINDOW</code> 分钟内不重复上传</li>
+  <li><strong>交替锁定检查</strong>:同一车牌进出间隔低于 <code>in_out_interval</code> 分钟则锁定</li>
+  <li><strong>ROI 过滤</strong>:如启用 ROI,只识别指定区域内的车牌</li>
+  <li><strong>数据上传</strong>:HTTP POST 将识别结果 + 照片上传至 <code>app_api</code></li>
+  <li><strong>MQTT 推送</strong>:如启用 MQTT,同步推送识别结果</li>
+  <li><strong>飞书通知</strong>:异常报警发送飞书群消息</li>
+</ol>
+
+<h3>照片存储与自动清理</h3>
+<ul>
+  <li>照片保存在 <code>PlateJPG/</code> 目录下,文件名格式:<code>车牌号_时间戳_方向.jpg</code>(如 <code>沪FQ7108_1787365677_side_out.jpg</code>)</li>
+  <li>系统每日凌晨 <strong>2:00</strong> 自动执行照片清理任务(间隔 24 小时)</li>
+  <li>清理策略:当 <code>PlateJPG/</code> 目录下所有 .jpg 文件总大小超过 <code>PhotoMaxCapacityMB</code>(默认 1024MB)时,按文件修改时间从旧到新逐个删除,直到总大小降至阈值以下</li>
+  <li>删除照片后递归清理空子目录</li>
+  <li>清理日志输出示例:
+<pre>[照片清理] PlateJPG/ 当前 1156 MB / 1024 MB 阈值,共 8234 个文件
+[照片清理] 删除 1523 张照片(按时间从旧到新),释放 156 MB,当前 1000 MB
+[照片清理] 同步清空DB路径字段: 2847 个字段已更新</pre>
+  </li>
+</ul>
+
+<h4>照片清理与数据库同步(v41 修复)</h4>
+<p>照片被物理删除后,系统同步清空 <code>upload_records</code> 表中对应的 <code>lo_photo_path</code> / <code>hi_photo_path</code> 字段(设为 NULL):</p>
+<ul>
+  <li>业务记录(车牌号、联单编号、抓拍时间、上传状态等)<strong>完整保留</strong>,仅清空路径字段</li>
+  <li>Web 前端路径为 NULL 时显示灰色斜体「已清理」占位,不再显示 broken image</li>
+  <li>即使 DB 清理与页面加载存在竞态,<code>&lt;img onerror&gt;</code> 也会自动替换为「已清理」</li>
+  <li>程序启动时自动执行一次孤儿路径扫描(<code>db_purge_missing_photo_paths</code>),修复历史遗留数据</li>
+</ul>
+<div class="tip">💡 调整容量阈值:修改 <code>config.ini</code> 中 <code>PhotoMaxCapacityMB</code> 的值并重启服务。例如设为 2048 表示允许 2GB 照片存储。</div>
+</div>
+</div>
+
+<!-- 6. MQTT -->
+<div class="card" id="sec6">
+<div class="card-title" onclick="toggleCard(this)">6. MQTT 数据上报配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>启用条件</h3>
+<p>在 <code>[mqtt]</code> 段中填写 <code>MQTT_HOST</code> 即自动启用 MQTT 推送。</p>
+
+<h3>配置示例</h3>
+<pre>[mqtt]
+MQTT_HOST=mqtt.example.com
+MQTT_PORT=1883
+MQTT_USER=username
+MQTT_PASS=password
+MQTT_TOPIC=plate/recognition
+MQTT_CLIENT_ID=plate_rec_station_01</pre>
+<div class="warn">MQTT 连接失败时系统会自动重连,不影响识别和上传功能。</div>
+</div>
+</div>
+
+<!-- 7. 称重系统 -->
+<div class="card" id="sec7">
+<div class="card-title" onclick="toggleCard(this)">7. 称重系统对接 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>工作原理</h3>
+<p>称重系统通过 TCP 协议与称重地磅通信,车牌识别成功后自动读取称重数据并关联上传。</p>
+
+<h3>重量判定逻辑</h3>
+<ul>
+  <li>连续 <code>STABLE_SAMPLE_COUNT</code> 次读数变化 &lt; <code>STABLE_THRESHOLD_KG</code> 公斤,视为稳定</li>
+  <li>稳定后取 <code>CANDIDATE_DATA_COUNT</code> 次读数的中值作为最终重量</li>
+  <li>重量超过 <code>MAX_WEIGHT</code> 吨视为异常,丢弃</li>
+</ul>
+
+<h3>阈值说明</h3>
+<ul>
+  <li><code>weight_threshold_in</code>:进场最低重量(吨),低于此值可能表示空车</li>
+  <li><code>weight_threshold_out</code>:出场最低重量(吨)</li>
+  <li><code>default_entry_weight</code>:无法获取称重数据时的默认进场重量</li>
+  <li><code>default_exit_weight</code>:无法获取称重数据时的默认出场重量</li>
+  <li><code>weight_detection_time</code>:称重检测时间(毫秒,默认 5000)</li>
+  <li><code>tcp_connect_timeout</code>:TCP 连接超时(毫秒,默认 3000)</li>
+  <li><code>MAX_UPLOAD_RETRIES</code>:上传最大重试次数(默认 3)</li>
+  <li><code>RETRY_DELAY_MS</code>:重试延迟(毫秒,默认 1000)</li>
+</ul>
+</div>
+</div>
+
+<!-- 8. ROI -->
+<div class="card" id="sec8">
+<div class="card-title" onclick="toggleCard(this)">8. ROI 区域识别配置 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>配置方式</h3>
+<ul>
+  <li><strong>Web 可视化</strong>:首页连接状态面板中拖拽设置 ROI 区域</li>
+  <li><strong>手动配置</strong>:在 config.ini 的 <code>[config]</code> 段填写坐标值</li>
+</ul>
+<h3>参数说明</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>roi_in_x/y/w/h</td><td>进场 ROI 区域坐标和尺寸(像素)</td></tr>
+  <tr><td>roi_in_enabled</td><td>1=启用进场 ROI</td></tr>
+  <tr><td>roi_out_x/y/w/h</td><td>出场 ROI 区域坐标和尺寸</td></tr>
+  <tr><td>roi_out_enabled</td><td>1=启用出场 ROI</td></tr>
+  <tr><td>roi_debug_enabled</td><td>1=在抓拍图上绘制 ROI 框(调试用)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 9. 交替锁定 -->
+<div class="card" id="sec9">
+<div class="card-title" onclick="toggleCard(this)">9. 交替锁定机制 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>机制说明</h3>
+<p>防止同一车牌短时间内进出频繁触发(如车辆在门口调头)。同一车牌从"进"切换到"出"必须间隔 <code>in_out_interval</code> 分钟以上。</p>
+
+<h3>工作流程</h3>
+<ol>
+  <li>车牌 A 被识别为"进"方向</li>
+  <li>5 分钟内(默认)车牌 A 再次被识别为"出"方向</li>
+  <li>系统判定为异常,创建交替锁定记录</li>
+  <li>锁定期间该车牌不会被上传</li>
+  <li>超过 2 小时锁定自动清零,或手动在"锁定管理"页面清除</li>
+</ol>
+
+<table>
+  <tr><th>参数</th><th>默认值</th><th>说明</th></tr>
+  <tr><td>in_out_interval</td><td>5</td><td>交替锁定间隔(⚠️ 单位分钟),小于此间隔的进/出切换会被锁定</td></tr>
+  <tr><td>AlternatingMerge</td><td>0</td><td>交替合并开关</td></tr>
+</table>
+<div class="warn"><code>in_out_interval</code> 单位是<strong>分钟</strong>,不是秒!填 5 表示 5 分钟 = 300 秒。</div>
+</div>
+</div>
+
+<!-- 10. 认证与权限 -->
+<div class="card" id="sec10">
+<div class="card-title" onclick="toggleCard(this)">10. 登录认证与权限 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>角色定义</h3>
+<table>
+  <tr><th>角色</th><th>Role值</th><th>可访问页面</th></tr>
+  <tr><td>普通用户</td><td>0</td><td>首页、视频、称重、锁定管理</td></tr>
+  <tr><td>管理员</td><td>1</td><td>普通用户 + 系统配置</td></tr>
+  <tr><td>超级管理员</td><td>2</td><td>所有页面(含系统监控、frpc管理、SSH密钥、日志、帮助)</td></tr>
+</table>
+
+<h3>默认账号</h3>
+<table>
+  <tr><th>用户名</th><th>密码</th><th>角色</th></tr>
+  <tr><td>admin</td><td>zhongjin188A</td><td>超级管理员</td></tr>
+  <tr><td>zhonjin</td><td>zhonjin</td><td>普通用户</td></tr>
+</table>
+
+<h3>安全特性</h3>
+<ul>
+  <li>密码 bcrypt 加密存储(cost=12)</li>
+  <li>连续登录失败锁定账号</li>
+  <li>CSRF Token 防护</li>
+  <li>Session 超时自动失效</li>
+  <li>登录速率限制</li>
+</ul>
+</div>
+</div>
+
+<!-- 11. frpc.toml 详解 -->
+<div class="card" id="sec11">
+<div class="card-title" onclick="toggleCard(this)">11. frpc.toml 内网穿透配置详解 <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>配置文件位置</h3>
+<p>frpc 配置文件路径由 <code>config.ini</code> 的 <code>[frpc] config_path</code> 指定,通常为安装目录下的 <code>frpc.toml</code>。</p>
+
+<h3>v2 方案 — nginx stream 分流(推荐)</h3>
+<p>使用 nginx stream + ssl_preread 方案,<strong>只需一个公网端口 40963</strong> 即可同时处理 HTTP 和 HTTPS:</p>
+<pre># frpc.toml — v2 方案(nginx stream 分流)
+
+serverAddr = "discover.zhonjin.com"
+serverPort = 9443
+auth.method = "token"
+auth.token  = "你的frps认证Token"
+transport.tls.enable = true
+clientID = "ssd_40960"
+
+# frpc Admin HTTP API
+webServer.addr = "127.0.0.1"
+webServer.port = 7400
+webServer.user = "admin"
+webServer.password = "你的管理密码"
+
+# 隧道1:主服务(外网40963→本机nginx:40963,stream自动分流HTTP/HTTPS)
+[[proxies]]
+name       = "plate_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 40963    # ← 转发到 nginx stream,不是 C++ 的 8080
+remotePort = 40963
+
+# 隧道2:ttyd终端(外网40964→本机nginx:8082→ttyd:7681)
+[[proxies]]
+name       = "ttyd_web"
+type       = "tcp"
+localIP    = "127.0.0.1"
+localPort  = 8082     # ← nginx HTTPS 反向代理(SSL 终止 + WebSocket)
+remotePort = 40964</pre>
+
+<h3>隧道说明(v2)</h3>
+<table>
+  <tr><th>隧道名</th><th>外网端口</th><th>本机端口</th><th>用途</th></tr>
+  <tr><td>plate_web</td><td>40963</td><td>nginx stream:40963</td><td>HTTP/HTTPS 合一,stream 自动分流</td></tr>
+  <tr><td>ttyd_web</td><td>40964</td><td>nginx HTTPS:8082</td><td>Web 终端(nginx SSL→ttyd:7681)</td></tr>
+</table>
+
+<h3>v2 数据流向</h3>
+<pre>HTTP  流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread=""  → 127.0.0.1:8081 (nginx HTTP) → 301 重定向到 HTTPS
+HTTPS 流量: 公网:40963 → frpc → nginx:40963 (stream) → $ssl_preread≠"" → 127.0.0.1:8080 (C++ HTTPS) → 直接处理
+Web SSH:  公网:40964 → frpc → nginx:8082 (HTTPS反向代理, SSL终止) → 127.0.0.1:7681 (ttyd HTTP)</pre>
+
+<div class="tip"><strong>v1→v2 迁移注意:</strong>不再需要 40965 端口。HTTPS 主服务统一使用 <code>https://mqtt.zhonjin.com:40963/</code>。</div>
+
+<h3>参数详解</h3>
+<table>
+  <tr><th>参数</th><th>说明</th></tr>
+  <tr><td>serverAddr</td><td>frps 服务器域名或 IP</td></tr>
+  <tr><td>serverPort</td><td>frps 服务端口(默认 7000,本项目用 9443)</td></tr>
+  <tr><td>auth.method</td><td>认证方式,固定为 <code>token</code></td></tr>
+  <tr><td>auth.token</td><td>与 frps 约定的认证 Token</td></tr>
+  <tr><td>webServer.addr/port</td><td>frpc Admin API 监听地址,PlateRecApp 通过此接口获取隧道状态</td></tr>
+  <tr><td>webServer.user/password</td><td>Admin API 认证凭据(需与 config.ini [frpc] 段一致)</td></tr>
+  <tr><td>[[proxies]] name</td><td>隧道唯一名称,PlateRecApp Web 界面按名称识别</td></tr>
+  <tr><td>[[proxies]] type</td><td>隧道类型,本项目全部使用 <code>tcp</code>(透传 TCP 流)</td></tr>
+  <tr><td>[[proxies]] localPort</td><td>本机目标端口,frpc 将外网流量转发到此端口</td></tr>
+  <tr><td>[[proxies]] remotePort</td><td>frps 服务器暴露的外网端口,客户端通过此端口访问</td></tr>
+</table>
+
+<h3>隧道保护</h3>
+<p>在 <code>config.ini [frpc]</code> 段配置受保护隧道,防止 Web 界面误删关键隧道:</p>
+<pre>protected_tunnels = plate_web,ttyd_web</pre>
+<div class="warn">受保护的隧道无法通过 Web 管理界面删除。如需删除,必须先修改 config.ini 中的 <code>protected_tunnels</code> 字段。</div>
+
+<h3>frpc 管理命令</h3>
+<pre># 查看 frpc 状态
+systemctl status frpc
+
+# 重启 frpc
+sudo systemctl restart frpc
+
+# 查看 frpc 日志
+tail -f /var/log/frpc.log
+
+# 通过 Admin API 查看隧道状态
+curl -u admin:密码 http://127.0.0.1:7400/api/status/tcp</pre>
+</div>
+</div>
+
+<!-- 12. Nginx 反向代理详解 -->
+<div class="card" id="sec12">
+<div class="card-title" onclick="toggleCard(this)">12. Nginx stream + ssl_preread 配置详解(v2) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>架构说明</h3>
+<p>v2 方案使用 nginx 的 <code>stream</code> 模块 + <code>ssl_preread</code> 功能,在 <strong>TCP 层面</strong>检测流量类型并自动分流:</p>
+<ul>
+  <li><strong>端口 40963</strong>:nginx stream 监听,检测协议类型(TLS / HTTP)</li>
+  <li><strong>TLS 流量</strong> → 透传到 C++ HTTPS 服务(127.0.0.1:8080)</li>
+  <li><strong>HTTP 流量</strong> → nginx HTTP 模块(127.0.0.1:8081)→ 301 重定向到 HTTPS</li>
+</ul>
+<div class="tip"><strong>关键区别:</strong>stream 模块是 TCP 层代理,不解密 TLS 流量。SSL 证书由 C++ 应用自己管理,nginx 只做协议检测和分流。</div>
+
+<h3>nginx.conf 完整配置</h3>
+<p>文件路径:<code>/etc/nginx/nginx.conf</code>(替换整个文件)</p>
+<pre># 加载 stream 动态模块
+load_module modules/ngx_stream_module.so;
+
+events {
+    worker_connections 1024;
+}
+
+# ============================================================
+# stream 块 — TCP 层协议分流(在 http 块外面)
+# ============================================================
+stream {
+    # 根据 ssl_preread 检测结果选择后端
+    # $ssl_preread_protocol 的可能值:
+    #   ""          → HTTP 流量(非 TLS)
+    #   "TLSv1.2"   → TLS 1.2
+    #   "TLSv1.3"   → TLS 1.3
+    map $ssl_preread_protocol $backend {
+        ""        http_backend;    # HTTP → nginx HTTP 8081
+        default   tls_backend;     # TLS → C++ HTTPS 8080
+    }
+
+    # TLS 后端 — C++ HTTPS 服务
+    upstream tls_backend {
+        server 127.0.0.1:8080;
+    }
+
+    # HTTP 后端 — nginx HTTP 301 重定向
+    upstream http_backend {
+        server 127.0.0.1:8081;
+    }
+
+    # 主服务器 — 监听公网端口
+    server {
+        listen 40963;
+        proxy_pass $backend;
+        ssl_preread on;          # 启用 SSL 预读检测协议
+    }
+}
+
+# ============================================================
+# http 块 — HTTP → HTTPS 301 重定向
+# ============================================================
+http {
+    sendfile on;
+    tcp_nopush on;
+    tcp_nodelay on;
+    keepalive_timeout 65;
+
+    include /etc/nginx/mime.types;
+    default_type application/octet-stream;
+
+    access_log /var/log/nginx/access.log;
+    error_log  /var/log/nginx/error.log;
+
+    # HTTP → HTTPS 301 重定向
+    server {
+        listen 8081;
+        server_name _;
+        return 301 https://$host:40963$request_uri;
+    }
+
+    # ============================================================
+    # ttyd Web 终端 HTTPS 反向代理(端口 8082)
+    # ============================================================
+    # frpc 隧道:外网 40964 → nginx:8082 → ttyd:7681
+    # nginx 做 SSL 终止,再通过 HTTP 代理到 ttyd(含 WebSocket 支持)
+    server {
+        listen 8082 ssl;
+        server_name _;
+
+        ssl_certificate     /etc/nginx/ssl/plate_fullchain.pem;
+        ssl_certificate_key /etc/nginx/ssl/plate_privkey.pem;
+        ssl_protocols TLSv1.2 TLSv1.3;
+        ssl_ciphers HIGH:!aNULL:!MD5:!RC4;
+        ssl_prefer_server_ciphers on;
+        ssl_session_cache shared:SSL_Ttyd:5m;
+        ssl_session_timeout 1h;
+
+        location / {
+            proxy_pass http://127.0.0.1:7681;
+            proxy_set_header Host $host;
+            proxy_set_header X-Real-IP $remote_addr;
+            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+            proxy_set_header X-Forwarded-Proto $scheme;
+
+            # WebSocket 支持(ttyd 核心通信方式)
+            proxy_http_version 1.1;
+            proxy_set_header Upgrade $http_upgrade;
+            proxy_set_header Connection "upgrade";
+
+            # ttyd 长连接超时
+            proxy_read_timeout 86400s;
+            proxy_send_timeout 86400s;
+        }
+    }
+}</pre>
+
+<h3>核心原理</h3>
+<table>
+  <tr><th>组件</th><th>作用</th><th>说明</th></tr>
+  <tr><td>stream 块</td><td>TCP 层代理</td><td>与 http 块平级,不处理 HTTP 协议,只做 TCP 转发</td></tr>
+  <tr><td>ssl_preread on</td><td>SSL 预读</td><td>在 TLS 握手阶段读取 ClientHello,获取协议版本,不解密</td></tr>
+  <tr><td>map $ssl_preread_protocol</td><td>协议分流</td><td>空字符串 = HTTP,非空 = TLS</td></tr>
+  <tr><td>upstream tls_backend</td><td>TLS 后端</td><td>透传到 C++ 应用(8080),C++ 自己处理 TLS 握手</td></tr>
+  <tr><td>upstream http_backend</td><td>HTTP 后端</td><td>转发到 nginx HTTP 8081,返回 301 重定向</td></tr>
+  <tr><td>server 8082 ssl</td><td>ttyd HTTPS 代理</td><td>SSL 终止后代理到 ttyd:7681,支持 WebSocket</td></tr>
+</table>
+
+<h3>部署步骤</h3>
+<ol>
+  <li>安装 stream 模块:<code>sudo apt install -y libnginx-mod-stream</code></li>
+  <li>验证模块:<code>ls -l /usr/lib/nginx/modules/ngx_stream_module.so</code></li>
+  <li>备份原配置:<code>sudo cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak</code></li>
+  <li>替换配置:<code>sudo cp nginx-stream.conf /etc/nginx/nginx.conf</code></li>
+  <li>删除默认站点(如有):<code>sudo rm -f /etc/nginx/sites-enabled/default</code></li>
+  <li>验证配置:<code>sudo nginx -t</code></li>
+  <li>启动 nginx:<code>sudo systemctl enable nginx && sudo systemctl restart nginx</code></li>
+</ol>
+
+<h3>前置条件</h3>
+<table>
+  <tr><th>项目</th><th>要求</th></tr>
+  <tr><td>nginx 版本</td><td>≥ 1.11.5(支持 stream 模块)</td></tr>
+  <tr><td>stream 模块</td><td>libnginx-mod-stream(Debian)或 nginx-mod-stream(CentOS)</td></tr>
+  <tr><td>C++ 应用</td><td>必须启用 HTTPS,监听 127.0.0.1:8080</td></tr>
+  <tr><td>SSL 证书</td><td>C++ 应用自行管理(config.ini 的 [ssl] 段)</td></tr>
+</table>
+
+<h3>验证方法</h3>
+<pre># 检查端口监听
+ss -tlnp | grep -E '40963|8080|8081'
+# 预期输出:
+# LISTEN  0.0.0.0:40963  nginx (stream)
+# LISTEN  127.0.0.1:8081  nginx (http 301)
+# LISTEN  0.0.0.0:8080  PlateRecApp (HTTPS)
+
+# 测试 HTTP 重定向(应返回 301)
+curl -I http://localhost:40963/
+# 预期:HTTP/1.1 301 Moved Permanently
+#       Location: https://localhost:40963/
+
+# 测试 HTTPS(应返回 200)
+curl -kI https://localhost:40963/
+# 预期:HTTP/1.1 200 OK
+
+# 浏览器测试(清除缓存或用无痕模式)
+# 访问 http://mqtt.zhonjin.com:40963/ → 自动跳转到 https://mqtt.zhonjin.com:40963/</pre>
+
+<h3>端口规划总结</h3>
+<table>
+  <tr><th>端口</th><th>服务</th><th>说明</th></tr>
+  <tr><td><strong>40963</strong></td><td>nginx stream</td><td>公网入口,TLS/HTTP 分流</td></tr>
+  <tr><td>8081</td><td>nginx http</td><td>HTTP 301 重定向(仅内部)</td></tr>
+  <tr><td>8080</td><td>C++ HTTPS</td><td>PlateRecApp Web 服务(SSL 自管理)</td></tr>
+  <tr><td><strong>40964</strong></td><td>nginx HTTPS→ttyd</td><td>Web 终端(nginx:8082 SSL终止→ttyd:7681)</td></tr>
+  <tr><td>8082</td><td>nginx HTTPS 代理</td><td>ttyd Web 终端 HTTPS 反向代理(仅内部)</td></tr>
+  <tr><td>7400</td><td>frpc Admin</td><td>隧道状态查询(仅 127.0.0.1)</td></tr>
+</table>
+</div>
+</div>
+
+<!-- 13. SSL/HTTPS 证书配置 -->
+<div class="card" id="sec13">
+<div class="card-title" onclick="toggleCard(this)">13. SSL/HTTPS 证书配置(v2 — C++ 应用自管理) <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<div class="note"><strong>v2 方案变更:</strong>nginx stream 只做 TCP 层协议分流,不解密 TLS 流量。SSL 证书由 C++ 应用(PlateRecApp)自己管理,通过 config.ini 的 <code>[ssl]</code> 段配置。nginx 不再需要 SSL 证书。</div>
+
+<h3>config.ini [ssl] 段配置</h3>
+<pre>[ssl]
+enabled=1
+cert_path=ssl/plate_fullchain.pem
+key_path=ssl/plate_privkey.pem</pre>
+<div class="tip">v2 方案下 SSL 证书文件放在安装目录的 <code>ssl/</code> 子目录下(或任意自定义路径),不再需要 <code>/etc/nginx/ssl/</code>。</div>
+
+<h3>方式一:Let's Encrypt 证书 — 阿里云 DNS 验证(推荐)</h3>
+<p>本项目使用 frpc 内网穿透,<strong>无公网 80 端口</strong>,因此不能使用传统的 HTTP-01 验证。推荐使用 <strong>DNS-01 验证</strong>,通过阿里云 DNS API 自动完成,<strong>不需要任何端口</strong>。</p>
+
+<h4>方案对比</h4>
+<table>
+  <tr><th>验证方式</th><th>端口要求</th><th>通配符</th><th>自动续期</th><th>本项目适用</th></tr>
+  <tr><td>HTTP-01 (standalone)</td><td>公网 80</td><td>❌</td><td>✅</td><td>❌ 80 端口不可用</td></tr>
+  <tr><td>TLS-ALPN-01</td><td>公网 443</td><td>❌</td><td>✅</td><td>❌ 443 端口不可用</td></tr>
+  <tr><td><strong>DNS-01 (dns-aliyun)</strong></td><td><strong>无</strong></td><td><strong>✅</strong></td><td><strong>✅</strong></td><td><strong>✅ 推荐</strong></td></tr>
+  <tr><td>手动 DNS</td><td>无</td><td>✅</td><td>❌ 手动</td><td>⚠️ 每 90 天手动操作</td></tr>
+</table>
+
+<h4>一键部署</h4>
+<pre># 1. 安装 certbot + 阿里云 DNS 插件
+sudo apt update
+sudo apt install -y certbot python3-pip
+sudo pip3 install certbot-dns-aliyun
+
+# 2. 配置阿里云 AccessKey(RAM 子账号,需 AliyunDNSFullAccess 权限)
+sudo mkdir -p /etc/letsencrypt
+sudo vim /etc/letsencrypt/aliyun.ini
+# 内容:
+#   dns_aliyun_access_key = 你的AccessKeyID
+#   dns_aliyun_access_key_secret = 你的AccessKeySecret
+sudo chmod 600 /etc/letsencrypt/aliyun.ini
+
+# 3. 一键申请通配符证书(自动通过 DNS API 验证)
+sudo certbot certonly \
+    --authenticator dns-aliyun \
+    --dns-aliyun-credentials /etc/letsencrypt/aliyun.ini \
+    --dns-aliyun-propagation-seconds 30 \
+    --server https://acme-v02.api.letsencrypt.org/directory \
+    --agree-tos --no-eff-email \
+    --email admin@zhonjin.com \
+    -d mqtt.zhonjin.com \
+    -d "*.zhonjin.com"
+
+# 4. 部署证书到 PlateRecApp
+mkdir -p ssl/
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/fullchain.pem ssl/plate_fullchain.pem
+sudo cp /etc/letsencrypt/live/mqtt.zhonjin.com/privkey.pem   ssl/plate_privkey.pem
+sudo chown stevenroc:stevenroc ssl/*.pem
+chmod 644 ssl/plate_fullchain.pem
+chmod 600 ssl/plate_privkey.pem
+
+# 5. 配置自动续期(每天 3:00 检查,续期后自动部署)
+(crontab -l 2>/dev/null; echo "0 3 * * * certbot renew --quiet --deploy-hook /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh") | crontab -
+
+# 6. 重启 PlateRecApp
+sudo systemctl restart PlateRecApp</pre>
+
+<h4>续期部署脚本 renew_ssl.sh</h4>
+<pre>#!/bin/bash
+# /opt/openAI/project/003.PlateRecAPP/scripts/renew_ssl.sh
+DOMAIN="mqtt.zhonjin.com"
+SSL_DIR="/opt/openAI/project/003.PlateRecAPP/ssl"
+
+cp /etc/letsencrypt/live/${DOMAIN}/fullchain.pem ${SSL_DIR}/plate_fullchain.pem
+cp /etc/letsencrypt/live/${DOMAIN}/privkey.pem   ${SSL_DIR}/plate_privkey.pem
+chown stevenroc:stevenroc ${SSL_DIR}/*.pem
+chmod 644 ${SSL_DIR}/plate_fullchain.pem
+chmod 600 ${SSL_DIR}/plate_privkey.pem
+systemctl restart PlateRecApp
+echo "$(date) SSL 证书续期部署成功" >> /opt/openAI/project/003.PlateRecAPP/ssl_renew.log</pre>
+
+<h4>阿里云 RAM 权限配置</h4>
+<ol>
+  <li>登录 <a href="https://ram.console.aliyun.com/" target="_blank">RAM 控制台</a> → 用户 → 创建用户</li>
+  <li>勾选 <strong>OpenAPI 调用访问</strong>,保存 AccessKey ID 和 Secret</li>
+  <li>添加权限:<code>AliyunDNSFullAccess</code>(云解析 DNS 管理)</li>
+</ol>
+<div class="tip">DNS 验证原理:certbot 通过阿里云 API 自动添加 <code>_acme-challenge.mqtt.zhonjin.com</code> TXT 记录,Let's Encrypt CA 查询 DNS 验证域名所有权,全程无需开放任何端口。</div>
+<div class="warn"><strong>前提:</strong>域名 mqtt.zhonjin.com 的 DNS 解析必须使用阿里云(<code>dns9.hichina.com</code>)。如果 DNS 在其他服务商,需先迁移到阿里云,或改用对应插件(如 <code>certbot-dns-cloudflare</code>、<code>certbot-dns-dnspod</code>)。</div>
+
+<h3>方式二:自签名证书</h3>
+<p>无公网域名时使用自签名证书,浏览器会显示警告但加密通信正常:</p>
+<pre># 创建证书目录
+mkdir -p ssl/
+
+# 生成自签名证书(10年有效期)
+openssl req -x509 -newkey rsa:2048 -nodes \
+  -keyout ssl/plate_privkey.pem \
+  -out    ssl/plate_fullchain.pem \
+  -days   3650 \
+  -subj   "/CN=mqtt.zhonjin.com/O=PlateRecApp/C=CN"
+
+# 设置权限
+chmod 600 ssl/plate_privkey.pem
+chmod 644 ssl/plate_fullchain.pem
+
+# 重启 PlateRecApp 加载证书
+sudo systemctl restart PlateRecApp</pre>
+
+<h3>证书文件说明</h3>
+<table>
+  <tr><th>文件</th><th>用途</th><th>config.ini 参数</th></tr>
+  <tr><td>plate_fullchain.pem</td><td>完整证书链(含中间 CA)</td><td>cert_path</td></tr>
+  <tr><td>plate_privkey.pem</td><td>私钥文件(严禁泄露)</td><td>key_path</td></tr>
+</table>
+
+<h3>v1→v2 迁移说明</h3>
+<div class="warn"><strong>v1 方案:</strong>SSL 证书由 nginx 管理(<code>/etc/nginx/ssl/</code>),nginx 做 HTTPS 终止后转发 HTTP 到 C++。<br>
+<strong>v2 方案:</strong>SSL 证书由 C++ 应用管理,nginx stream 只做 TCP 分流,不解密 TLS。不再需要 <code>/etc/nginx/ssl/</code> 目录。</div>
+</div>
+</div>
+
+<!-- 14. 系统监控与日志 -->
+<div class="card" id="sec14">
+<div class="card-title" onclick="toggleCard(this)">14. 系统监控与日志 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>系统监控(实时)</h3>
+<ul>
+  <li>CPU 使用率(delta 法计算,含每核使用率、负载均值)</li>
+  <li>内存使用率(总量/已用/可用)</li>
+  <li>磁盘使用率(根分区容量/已用/可用)</li>
+  <li>CPU 温度监控,超阈值告警(阈值由 <code>temp_alert_threshold</code> 配置)</li>
+  <li>CPU 使用率告警(阈值由 <code>cpu_alert_threshold</code> 配置)</li>
+  <li>PlateRecApp 进程资源占用(RSS 内存、CPU 占用)</li>
+  <li>frpc 运行状态、MQTT 连接状态、称重系统连接状态</li>
+</ul>
+<p>数据来源:通过读取 <code>/proc</code>、<code>/sys</code>、<code>statvfs</code> 等系统接口实时采集,页面每 2 秒自动刷新。</p>
+
+<h3>历史监控数据(v37+)</h3>
+<p>系统自动采集并存储监控数据到 SQLite 数据库(<code>data/system_metrics.db</code>),支持历史趋势查询:</p>
+<ul>
+  <li><strong>采集间隔</strong>:<code>monitor_interval</code>(默认 2 秒),delta 法计算 CPU 使用率,不阻塞采集线程</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)将内存监控数据批量写入 <code>system_metrics.db</code>,减少 SD 卡写入次数(v38 起)</li>
+  <li><strong>三级存储</strong>:原始数据(2秒间隔)→ 小时聚合 → 日聚合,自动降采样</li>
+  <li><strong>自动清理</strong>:原始数据保留 <code>history_retention_days</code> 天(默认 7 天),聚合数据保留 <code>aggregation_retention_days</code> 天(默认 365 天)</li>
+  <li><strong>历史查询 API</strong>:<code>/api/monitor/history?range=1h|6h|24h|7d|30d</code>,自动选择合适的数据粒度</li>
+  <li><strong>统计接口</strong>:<code>/api/monitor/stats</code> 返回数据库记录数和运行状态</li>
+</ul>
+
+<h3>SD 卡写入保护策略(v38)</h3>
+<ul>
+  <li><strong>系统监控</strong>:内存缓冲,每日 23:20 一次写入,SD 卡写入从 2880 次/天降至 1 次</li>
+  <li><strong>运行日志</strong>:内存环形缓冲区,Web 日志页面零磁盘 I/O,每日 23:20 写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>旧版日志清理</strong>:内存模式启用时自动跳过旧版 copytruncate,避免无意义读写</li>
+  <li><strong>退出安全刷盘</strong>:SIGTERM/SIGINT 时立即执行最后一次刷盘</li>
+</ul>
+
+<h3>日志管理(v36 内存缓冲)</h3>
+<ul>
+  <li><strong>内存缓冲模式</strong>(<code>enabled=1</code>,默认):所有日志写入内存环形缓冲区,Web 日志页面直接从内存读取,零磁盘 I/O</li>
+  <li><strong>直写文件模式</strong>(<code>enabled=0</code>):传统模式,日志直接写入文件</li>
+  <li><strong>定时刷盘</strong>:每日 <code>flush_time</code>(默认 23:20)自动将内存日志批量写入 <code>PlateRecApp_YYYYMMDD.log</code></li>
+  <li><strong>退出刷盘</strong>:<code>auto_flush_on_exit=1</code> 时,收到 SIGTERM/SIGINT 信号立即刷盘,防止日志丢失</li>
+  <li><strong>stdout 捕获</strong>:<code>redirect_stdout=1</code> 时,stdout/stderr 通过 pipe 重定向到内存缓冲区统一收集</li>
+  <li>Web 界面实时查看日志(支持 Tail 模式),显示内存使用率和缓冲状态</li>
+  <li>超期日志文件自动清理(由 <code>retention_days</code> 控制),支持手动触发刷盘</li>
+</ul>
+<div class="note">两个子系统的 <code>flush_time</code> 均配置为 23:20,分别在 <code>[log]</code> 和 <code>[system]</code> 段中设置,可独立调整。</div>
+</div>
+</div>
+
+<!-- 15. Web管理 -->
+<div class="card" id="sec15">
+<div class="card-title" onclick="toggleCard(this)">15. Web 管理功能 <span class="arrow">▼</span></div>
+<div class="card-body">
+<table>
+  <tr><th>页面</th><th>路径</th><th>权限</th><th>说明</th></tr>
+  <tr><td>监控首页</td><td>/</td><td>所有用户</td><td>实时监控、识别记录、连接状态、ROI设置</td></tr>
+  <tr><td>视频预览</td><td>/video</td><td>所有用户</td><td>实时视频流</td></tr>
+  <tr><td>称重记录</td><td>/weight</td><td>所有用户</td><td>称重数据</td></tr>
+  <tr><td>锁定管理</td><td>/locks</td><td>所有用户</td><td>交替锁定记录</td></tr>
+  <tr><td>帮助手册</td><td>/help</td><td>超级管理员</td><td>本文档</td></tr>
+  <tr><td>系统配置</td><td>/config</td><td>管理员+</td><td>config.ini 在线编辑</td></tr>
+  <tr><td>系统监控</td><td>/system</td><td>超级管理员</td><td>CPU/内存/温度/磁盘</td></tr>
+  <tr><td>frpc 管理</td><td>/frpc</td><td>超级管理员</td><td>内网穿透状态和配置</td></tr>
+  <tr><td>SSH 密钥</td><td>/ssh-keys</td><td>超级管理员</td><td>SSH 公钥管理</td></tr>
+  <tr><td>日志管理</td><td>/logs</td><td>超级管理员</td><td>系统日志查看</td></tr>
+  <tr><td>登录</td><td>/login</td><td>公开</td><td>用户登录页面</td></tr>
+</table>
+
+<h3>Tab 分离:实时数据 + 历史查询(v41 新增)</h3>
+<p>首页和称重页均改为 Tab 分离结构,自动刷新与手动查询完全独立:</p>
+<table>
+  <tr><th>Tab</th><th>数据来源</th><th>刷新方式</th></tr>
+  <tr><td>📡 实时数据 / 实时称重</td><td>固定最新 20 条,无条件加载</td><td>每 30 秒自动刷新</td></tr>
+  <tr><td>🔍 历史查询</td><td>按筛选条件查询,支持完整分页</td><td>手动触发,不自动刷新</td></tr>
+</table>
+<p>两个 Tab 使用独立的状态和函数,互不干扰。切换 Tab 时自动加载对应数据;重试操作后根据当前 Tab 智能刷新对应数据源。</p>
+
+<h3>分类查询筛选条件(v41)</h3>
+<p>历史查询 Tab 支持多维度组合筛选(所有条件 AND 组合):</p>
+<table>
+  <tr><th>筛选条件</th><th>首页</th><th>称重页</th><th>说明</th></tr>
+  <tr><td>车牌号</td><td>✅</td><td>✅</td><td>模糊匹配,如"沪A"</td></tr>
+  <tr><td>联单编号</td><td>✅</td><td>✅</td><td>模糊匹配</td></tr>
+  <tr><td>方向</td><td>✅</td><td>✅</td><td>全部 / 进站 / 出站</td></tr>
+  <tr><td>上传状态</td><td>✅</td><td>✅</td><td>首页:成功/失败;称重页多一个"待上传"(status=0)</td></tr>
+  <tr><td>飞书状态</td><td>✅</td><td>—</td><td>全部 / 已发送 / 失败 / 未发送</td></tr>
+  <tr><td>日期范围</td><td>✅</td><td>✅</td><td>按抓拍时间过滤(YYYY-MM-DD)</td></tr>
+</table>
+
+<h3>分页控件(v41)</h3>
+<ul>
+  <li><strong>每页条数</strong>:可选 20 / 50 / 100 条</li>
+  <li><strong>页码导航</strong>:首页 / 上一页 / 页码(当前页前后各 2 页,含首末页和省略号)/ 下一页 / 末页</li>
+  <li><strong>跳转</strong>:输入页码 + GO 按钮快速跳转</li>
+  <li><strong>信息栏</strong>:显示"第 X/Y 页 · 共 Z 条"</li>
+</ul>
+</div>
+</div>
+
+<!-- 16. 数据库 -->
+<div class="card" id="sec16">
+<div class="card-title" onclick="toggleCard(this)">16. 数据库与备份 <span class="arrow">▼</span></div>
+<div class="card-body">
+<h3>数据库文件</h3>
+<table>
+  <tr><th>文件</th><th>路径</th><th>说明</th></tr>
+  <tr><td>认证数据库</td><td>data/auth.db</td><td>用户账号、密码、Session</td></tr>
+  <tr><td>识别记录</td><td>data/upload_records.db</td><td>车牌识别上传记录</td></tr>
+  <tr><td>系统监控</td><td>data/system_metrics.db</td><td>性能监控时序数据(原始+小时聚合+日聚合)</td></tr>
+</table>
+<div class="tip">v29 版本起,所有数据库统一存放在 <code>data/</code> 目录下。系统首次启动时自动将旧路径数据库迁移到新位置(旧文件保留为备份)。</div>
+
+<h3>upload_records 表结构</h3>
+<table>
+  <tr><th>字段</th><th>说明</th></tr>
+  <tr><td>id</td><td>自增主键</td></tr>
+  <tr><td>create_time</td><td>记录创建时间</td></tr>
+  <tr><td>capture_time</td><td>抓拍时间(用于时间对齐)</td></tr>
+  <tr><td>plate_number</td><td>车牌号</td></tr>
+  <tr><td>tb_num</td><td>联单编号</td></tr>
+  <tr><td>station_type</td><td>方向:1=进站,2=出站</td></tr>
+  <tr><td>lo_photo_path / hi_photo_path</td><td>照片文件名(相对 PlateJPG/),清理后为 NULL</td></tr>
+  <tr><td>lo_upload_status / hi_upload_status</td><td>上传状态:0=待传,1=成功,2=失败</td></tr>
+  <tr><td>feishu_status</td><td>飞书通知:1=已发送,2=失败,3=未发送</td></tr>
+  <tr><td>retry_count</td><td>重试次数(上限 5 次)</td></tr>
+</table>
+
+<h3>备份建议</h3>
+<ul>
+  <li>定期备份 <code>data/</code> 目录和 <code>config.ini</code></li>
+  <li>nginx SSL 证书和配置文件备份:<code>/etc/nginx/ssl/</code>、<code>/etc/nginx/conf.d/</code></li>
+  <li>frpc 配置修改时系统自动备份</li>
+  <li>照片数据在 <code>PhotoMaxCapacityMB</code> 限制内自动管理,重要照片建议另行备份</li>
+</ul>
+</div>
+</div>
+
+<!-- 17. FAQ -->
+<div class="card" id="sec17">
+<div class="card-title" onclick="toggleCard(this)">17. 常见问题 FAQ <span class="arrow">▼</span></div>
+<div class="card-body">
+
+<h3>Q: HTTP 访问没有跳转到 HTTPS?</h3>
+<p>A: 检查以下几点:</p>
+<ul>
+  <li>确认 nginx stream 模块已安装:<code>nginx -V 2>&amp;1 | grep stream</code></li>
+  <li>确认 nginx 配置正确:<code>sudo nginx -t</code></li>
+  <li>确认 8081 端口正在监听:<code>ss -tlnp | grep 8081</code></li>
+  <li>确认 frpc 隧道 <code>plate_web</code> 正常运行(localPort 为 40963)</li>
+  <li>浏览器可能缓存了旧的重定向,尝试清除缓存或无痕模式</li>
+  <li>测试本机重定向:<code>curl -I http://localhost:40963/</code>,应返回 301</li>
+</ul>
+
+<h3>Q: HTTPS 访问返回错误?</h3>
+<p>A: v2 方案下 TLS 由 C++ 应用处理,检查:</p>
+<ul>
+  <li>PlateRecApp 是否正在运行:<code>systemctl status PlateRecApp</code></li>
+  <li>PlateRecApp 是否启用了 HTTPS:<code>config.ini [ssl] enabled=1</code></li>
+  <li>SSL 证书文件是否存在:<code>ls -l ssl/plate_fullchain.pem ssl/plate_privkey.pem</code></li>
+  <li>nginx stream 是否正常监听:<code>ss -tlnp | grep 40963</code></li>
+  <li>nginx 错误日志:<code>sudo tail -f /var/log/nginx/error.log</code></li>
+</ul>
+
+<h3>Q: <code>unknown directive "stream"</code>?</h3>
+<p>A: stream 模块未加载。执行:<code>sudo apt install -y libnginx-mod-stream</code>,确认 nginx.conf 顶部有 <code>load_module modules/ngx_stream_module.so;</code></p>
+
+<h3>Q: frpc 重启后隧道不生效?</h3>
+<p>A: 检查 frpc.toml 配置语法:</p>
+<pre>frpc verify -c /path/to/frpc.toml</pre>
+<p>常见错误:TOML 格式问题(缺少引号、缩进错误)。通过 Web 管理界面编辑配置时系统会自动校验。</p>
+
+<h3>Q: 自签名证书浏览器警告怎么消除?</h3>
+<p>A: 自签名证书无法消除浏览器警告(这是正常安全行为)。解决方案:</p>
+<ul>
+  <li>方式1:使用 Let's Encrypt 免费证书(需域名解析到公网 IP)</li>
+  <li>方式2:在客户端将自签名 CA 证书添加到受信任的根证书列表</li>
+  <li>方式3:忽略警告,继续使用(加密通信正常,仅证书不受信任)</li>
+</ul>
+
+<h3>Q: 摄像头连接失败怎么办?</h3>
+<p>A: 确认 RTSP 地址格式正确,在 VLC 中测试播放。尝试 <code>rtsp_transport=tcp</code>。</p>
+
+<h3>Q: in_out_interval 填 5 是 5 秒还是 5 分钟?</h3>
+<p>A: <strong>5 分钟</strong>。单位是分钟,系统内部自动 ×60 转为秒。</p>
+
+<h3>Q: 照片被自动清理后,Web 页面看到"已清理"怎么办?</h3>
+<p>A: 这是正常现象。当 <code>PlateJPG/</code> 目录总大小超过 <code>PhotoMaxCapacityMB</code>(默认 1024MB)时,系统每日凌晨 2:00 自动删除最旧的照片释放空间。被删除照片对应的数据库记录仍保留(业务数据不丢失),Web 页面显示灰色「已清理」占位。如需保留更多照片,调大该配置值。</p>
+
+<h3>Q: 日志中出现 <code>[路径校验] 转换绝对路径失败</code> 错误?</h3>
+<p>A: 此错误在旧版本(v41 修复前)出现,原因是照片被物理删除后 DB 路径字段未同步清空,前端仍请求已删除文件。v41 已修复:删除照片时通过 basename 匹配同步清空 DB 路径字段,启动时自动扫描孤儿路径,且 <code>/photo</code> 接口对缺失文件静默返回 404 不再打 ERROR 日志。升级后此错误不再出现。</p>
+
+<h3>Q: 如何调整照片存储上限?</h3>
+<p>A: 修改 <code>config.ini</code> 的 <code>[config]</code> 段中 <code>PhotoMaxCapacityMB</code> 值并重启服务。例如:</p>
+<pre>[config]
+PhotoMaxCapacityMB=2048   # 调整为 2GB</pre>
+
+<h3>Q: 称重页的"待上传"状态是什么意思?</h3>
+<p>A: 称重记录上传状态筛选比首页多一个「待上传」选项,对应数据库中 <code>upload_status = 0</code> 的记录,即称重数据尚未成功上传到云端 API,可通过此筛选快速定位需重试的记录。</p>
+
+<h3>Q: 如何重置管理员密码?</h3>
+<pre>cd /opt/openAI/project/003.PlateRecAPP
+python3 reset_passwords.py</pre>
+</div>
+</div>
+
+</div>
+
+<script>
+function toggleCard(el) {
+  el.classList.toggle('collapsed');
+  var body = el.nextElementSibling;
+  body.classList.toggle('hidden');
+}
+</script>
+</body>
+</html>

+ 284 - 0
assets/web/index.bak.html

@@ -0,0 +1,284 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v26</p>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 3 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 15) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                if (data.success) {
+                    alert('重试请求已提交');
+                    loadMessages(currentPage, currentKeyword);
+                } else {
+                    alert('重试失败');
+                }
+            })
+            .catch(e => alert('重试请求失败'));
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 310 - 0
assets/web/index.bak2.html

@@ -0,0 +1,310 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix9</p>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 22) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 317 - 0
assets/web/index.bak3.html

@@ -0,0 +1,317 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix19</p>
+        <style>.video-link:hover{background:rgba(255,255,255,0.35)!important;}</style>
+        <div class="video-links" style="margin-top:12px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;">
+            <a href="/video?stream=in-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位进站</a>
+            <a href="/video?stream=in-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位进站</a>
+            <a href="/video?stream=out-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位出站</a>
+            <a href="/video?stream=out-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位出站</a>
+        </div>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 22) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 317 - 0
assets/web/index.bak4..html

@@ -0,0 +1,317 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix19</p>
+        <style>.video-link:hover{background:rgba(255,255,255,0.35)!important;}</style>
+        <div class="video-links" style="margin-top:12px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;">
+            <a href="/video?stream=in-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位进站</a>
+            <a href="/video?stream=in-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位进站</a>
+            <a href="/video?stream=out-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位出站</a>
+            <a href="/video?stream=out-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位出站</a>
+        </div>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;font-size:12px;">' + rec.tb_num + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 317 - 0
assets/web/index.bak4.html

@@ -0,0 +1,317 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix19</p>
+        <style>.video-link:hover{background:rgba(255,255,255,0.35)!important;}</style>
+        <div class="video-links" style="margin-top:12px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;">
+            <a href="/video?stream=in-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位进站</a>
+            <a href="/video?stream=in-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位进站</a>
+            <a href="/video?stream=out-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位出站</a>
+            <a href="/video?stream=out-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位出站</a>
+        </div>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;font-size:12px;">' + rec.tb_num + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 475 - 0
assets/web/index.bak5.html

@@ -0,0 +1,475 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        /* 用户信息栏 */
+        .user-bar { background: rgba(255,255,255,0.15); padding: 8px 20px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
+        .user-bar a { color: rgba(255,255,255,0.85); text-decoration: none; margin-left: 12px; padding: 3px 8px; border-radius: 4px; transition: background 0.2s; }
+        .user-bar a:hover { background: rgba(255,255,255,0.2); color: white; }
+        .user-bar .user-info { color: white; font-weight: 500; }
+        .user-bar .role-badge { background: rgba(255,215,0,0.3); color: #ffd700; padding: 2px 8px; border-radius: 10px; font-size: 11px; margin-left: 6px; }
+        .user-bar .logout-btn { color: rgba(255,255,255,0.7); cursor: pointer; border: 1px solid rgba(255,255,255,0.3); padding: 2px 10px; border-radius: 4px; background: transparent; font-size: 12px; }
+        .user-bar .logout-btn:hover { background: rgba(255,255,255,0.15); color: white; }
+        /* 连接状态面板 */
+        .conn-panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); margin: 0 20px 0 20px; max-width: 1360px; }
+        .conn-panel h2 { font-size: 16px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 8px; }
+        .conn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
+        .conn-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef; }
+        .conn-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+        .conn-dot.online { background: #52c41a; box-shadow: 0 0 6px rgba(82,196,26,0.4); }
+        .conn-dot.offline { background: #f5222d; box-shadow: 0 0 6px rgba(245,34,46,0.4); }
+        .conn-dot.warn { background: #faad14; box-shadow: 0 0 6px rgba(250,173,20,0.4); }
+        .conn-dot.disabled { background: #d9d9d9; }
+        .conn-label { font-size: 13px; color: #333; font-weight: 500; }
+        .conn-detail { font-size: 11px; color: #999; margin-top: 2px; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix24</p>
+        <div class="user-bar" id="main-nav">
+            <div id="nav-links">
+                <a href="/">🏠 首页</a>
+                <a href="/video">🎥 视频</a>
+                <a href="/weight">⚖️ 称重</a>
+                <a href="/config" id="nav-config" style="display:none;">⚙️ 配置</a>
+                <a href="/locks" id="nav-locks" style="display:none;">🔒 锁定</a>
+                <a href="/system" id="nav-system" style="display:none;">📊 监控</a>
+                <a href="/frpc" id="nav-frpc" style="display:none;">🔗 隧道</a>
+                <a href="/ssh-keys" id="nav-ssh" style="display:none;">🔑 SSH</a>
+                <a href="/logs" id="nav-logs" style="display:none;">📋 日志</a>
+            </div>
+            <div id="user-info-area">
+                <span class="user-info" id="user-display">未登录</span>
+            </div>
+        </div>
+    </div>
+    
+    <!-- 连接状态面板 -->
+    <div class="conn-panel" style="margin:20px auto;max-width:1360px;padding:0 20px;">
+        <div style="background:white;border-radius:10px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">
+            <h2 style="font-size:16px;color:#333;margin-bottom:15px;border-bottom:2px solid #667eea;padding-bottom:8px;">🔌 连接状态</h2>
+            <div class="conn-grid" id="connections-grid">
+                <div class="conn-item"><div class="conn-dot disabled"></div><div><div class="conn-label">加载中...</div></div></div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        function getCookie(n){let m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;font-size:12px;">' + rec.tb_num + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': getCookie('csrf_token') }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // ==================== 用户信息显示 + 导航栏权限控制 ====================
+        function loadUserInfo() {
+            fetch('/api/auth/status')
+                .then(r => r.json())
+                .then(data => {
+                    const area = document.getElementById('user-info-area');
+                    if (data.authenticated) {
+                        const roles = {0: '普通用户', 1: '管理员', 2: '超级管理员'};
+                        const roleName = roles[data.role] || '未知角色';
+                        const roleClass = data.role === 2 ? 'style="background:rgba(255,215,0,0.3);color:#ffd700;"' : 
+                                          data.role === 1 ? 'style="background:rgba(64,169,255,0.3);color:#40a9ff;"' : '';
+                        area.innerHTML = '<span class="user-info">👤 ' + data.username + '</span>' +
+                            '<span class="role-badge" ' + roleClass + '>' + roleName + '</span>' +
+                            ' <button class="logout-btn" onclick="logout()">退出</button>';
+
+                        // 根据角色显示导航栏项目
+                        // role=0: 首页、视频、称重(已默认显示)
+                        // role=1: + 配置、锁定
+                        // role=2: + 监控、隧道、SSH、日志
+                        if (data.role >= 1) {
+                            show('nav-config');
+                            show('nav-locks');
+                        }
+                        if (data.role >= 2) {
+                            show('nav-system');
+                            show('nav-frpc');
+                            show('nav-ssh');
+                            show('nav-logs');
+                        }
+                    } else {
+                        area.innerHTML = '<a href="/login" style="color:white;text-decoration:underline;">登录</a>';
+                    }
+                })
+                .catch(e => console.error('用户信息加载失败:', e));
+        }
+
+        function show(id) {
+            var el = document.getElementById(id);
+            if (el) el.style.display = '';
+        }
+
+        function logout() {
+            fetch('/api/auth/logout', {method: 'POST', headers: {'X-CSRF-Token': getCookie('csrf_token')}})
+                .then(() => { window.location.href = '/login'; })
+                .catch(() => { window.location.href = '/login'; });
+        }
+
+        // ==================== 连接状态显示 ====================
+        function loadConnections() {
+            fetch('/api/connections')
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+
+                    // RTSP 摄像头
+                    if (data.rtsp) {
+                        data.rtsp.forEach(cam => {
+                            let dot = 'disabled';
+                            let detail = '未配置';
+                            if (cam.configured && cam.running) { dot = 'online'; detail = '采集中'; }
+                            else if (cam.configured && !cam.running) { dot = 'offline'; detail = '已断开'; }
+                            else { dot = 'disabled'; detail = '未配置'; }
+                            html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                                '<div><div class="conn-label">📷 ' + cam.name + '</div>' +
+                                '<div class="conn-detail">' + detail + '</div></div></div>';
+                        });
+                    }
+
+                    // MQTT
+                    if (data.mqtt) {
+                        let dot = 'disabled', detail = '未启用';
+                        if (data.mqtt.enabled) {
+                            if (data.mqtt.status === 1) { dot = 'online'; detail = data.mqtt.state + ' (' + data.mqtt.host + ')'; }
+                            else if (data.mqtt.status === 2) { dot = 'warn'; detail = data.mqtt.state + '...'; }
+                            else { dot = 'offline'; detail = data.mqtt.state; }
+                        }
+                        html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                            '<div><div class="conn-label">📨 MQTT</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    // 数据库
+                    if (data.database) {
+                        let dot = data.database.connected ? 'online' : 'offline';
+                        let detail = data.database.connected ? 'SQLite 已连接' : '未连接';
+                        html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                            '<div><div class="conn-label">🗄️ 数据库</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    // frpc
+                    if (data.frpc) {
+                        let dot = data.frpc.running ? 'online' : 'offline';
+                        let detail = data.frpc.running ? (data.frpc.tunnels + ' 条隧道活跃') : '未运行';
+                        html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                            '<div><div class="conn-label">🔗 frpc 隧道</div>' +
+                            '<div class="conn-detail">' + detail + (data.frpc.version ? ' v' + data.frpc.version : '') + '</div></div></div>';
+                    }
+
+                    // Web 服务器
+                    if (data.web) {
+                        let detail = '端口 ' + data.web.port + (data.web.https ? ' (HTTPS)' : '');
+                        html += '<div class="conn-item"><div class="conn-dot online"></div>' +
+                            '<div><div class="conn-label">🌐 Web 服务</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    document.getElementById('connections-grid').innerHTML = html;
+                })
+                .catch(e => {
+                    console.error('连接状态加载失败:', e);
+                    document.getElementById('connections-grid').innerHTML = 
+                        '<div class="conn-item"><div class="conn-dot offline"></div><div><div class="conn-label">加载失败</div></div></div>';
+                });
+        }
+
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        loadUserInfo();
+        loadConnections();
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 713 - 0
assets/web/index.html

@@ -0,0 +1,713 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        /* fix24 v41: Tab 切换样式 */
+        .tab-bar { display: flex; gap: 0; margin-bottom: 15px; border-bottom: 2px solid #e9ecef; }
+        .tab-btn { padding: 10px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 14px; color: #666; transition: all 0.2s; }
+        .tab-btn:hover { color: #667eea; background: #f8f9fa; }
+        .tab-btn.active { color: #667eea; border-bottom-color: #667eea; font-weight: 600; }
+        .tab-content { display: none; }
+        .tab-content.active { display: block; }
+        /* fix24 v41: 分类查询样式 */
+        .filter-bar { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; margin-bottom: 15px; }
+        .filter-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
+        .filter-row + .filter-row { margin-top: 8px; }
+        .filter-item { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 130px; }
+        .filter-item label { font-size: 12px; color: #666; font-weight: 500; }
+        .filter-item input, .filter-item select { padding: 7px 9px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; background: white; }
+        .filter-item input:focus, .filter-item select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,0.15); }
+        .filter-buttons { flex: 0 0 auto; flex-direction: row !important; gap: 8px !important; }
+        .btn-search, .btn-reset { padding: 7px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; white-space: nowrap; }
+        .btn-search { background: #667eea; color: white; }
+        .btn-search:hover { background: #764ba2; }
+        .btn-reset { background: white; color: #666; border: 1px solid #d9d9d9; }
+        .btn-reset:hover { border-color: #667eea; color: #667eea; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-cleaned { color: #999; font-size: 12px; font-style: italic; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 15px; }
+        .pagination button { padding: 6px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; font-size: 13px; }
+        .pagination button:hover:not(:disabled) { background: #f0f5ff; border-color: #667eea; color: #667eea; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination button.active { background: #667eea; color: white; border-color: #667eea; }
+        .pagination .info { color: #666; font-size: 13px; padding: 0 4px; }
+        .pagination-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px; }
+        .pagination-left, .pagination-right { display: flex; align-items: center; gap: 5px; font-size: 13px; color: #666; }
+        .pagination-left select, .pagination-right input { padding: 5px 8px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; width: 65px; }
+        .pagination-right input { width: 55px; }
+        .pagination-right button { padding: 5px 12px; background: #667eea; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
+        .pagination-right button:hover { background: #764ba2; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        .user-bar { background: rgba(255,255,255,0.15); padding: 8px 20px; display: flex; justify-content: center; align-items: center; font-size: 13px; position: relative; }
+        .user-bar a { color: rgba(255,255,255,0.85); text-decoration: none; padding: 5px 12px; border-radius: 4px; transition: background 0.2s; }
+        .user-bar a:hover { background: rgba(255,255,255,0.2); color: white; }
+        .user-bar .user-info { color: white; font-weight: 500; }
+        .user-bar .role-badge { background: rgba(255,215,0,0.3); color: #ffd700; padding: 2px 8px; border-radius: 10px; font-size: 11px; margin-left: 6px; }
+        .user-bar .logout-btn { color: rgba(255,255,255,0.7); cursor: pointer; border: 1px solid rgba(255,255,255,0.3); padding: 2px 10px; border-radius: 4px; background: transparent; font-size: 12px; }
+        .user-bar .logout-btn:hover { background: rgba(255,255,255,0.15); color: white; }
+        #nav-links { display: flex; gap: 6px; justify-content: center; flex-wrap: wrap; }
+        #user-info-area { position: absolute; right: 20px; top: 50%; transform: translateY(-50%); }
+        .conn-panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); margin: 0 20px 0 20px; max-width: 1360px; }
+        .conn-panel h2 { font-size: 16px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 8px; }
+        .conn-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
+        .conn-item { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: #f8f9fa; border-radius: 8px; border: 1px solid #e9ecef; }
+        .conn-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
+        .conn-dot.online { background: #52c41a; box-shadow: 0 0 6px rgba(82,196,26,0.4); }
+        .conn-dot.offline { background: #f5222d; box-shadow: 0 0 6px rgba(245,34,46,0.4); }
+        .conn-dot.warn { background: #faad14; box-shadow: 0 0 6px rgba(250,173,20,0.4); }
+        .conn-dot.disabled { background: #d9d9d9; }
+        .conn-label { font-size: 13px; color: #333; font-weight: 500; }
+        .conn-detail { font-size: 11px; color: #999; margin-top: 2px; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+        @media (max-width: 768px) {
+            .header { padding: 12px; }
+            .header h1 { font-size: 18px; }
+            .header p { font-size: 11px; }
+            .user-bar { flex-direction: column; gap: 8px; padding: 8px 12px; }
+            #user-info-area { position: static; transform: none; text-align: center; }
+            #nav-links { gap: 4px; }
+            .user-bar a { padding: 4px 8px; font-size: 12px; }
+            .conn-panel { margin: 10px; padding: 0; }
+            .conn-grid { grid-template-columns: 1fr 2fr; gap: 8px; }
+            .container { flex-direction: column; padding: 10px; gap: 10px; }
+            .report-panel { width: 100%; }
+            .panel { padding: 12px; }
+            .panel h2 { font-size: 15px; }
+            table { font-size: 11px; }
+            th, td { padding: 6px 4px; }
+            .photo-thumb { width: 40px; height: 28px; }
+            .tab-btn { padding: 8px 12px; font-size: 12px; }
+        }
+        @media (max-width: 480px) {
+            .conn-grid { grid-template-columns: 1fr; }
+            .header h1 { font-size: 16px; }
+            #nav-links a { font-size: 11px; padding: 3px 6px; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix24</p>
+        <div class="user-bar" id="main-nav">
+            <div id="nav-links">
+                <a href="/">🏠 首页</a>
+                <a href="/video">🎥 视频</a>
+                <a href="/weight">⚖️ 称重</a>
+                <a href="/config" id="nav-config" style="display:none;">⚙️ 配置</a>
+                <a href="/locks" id="nav-locks">🔒 锁定</a>
+                <a href="/system" id="nav-system" style="display:none;">📊 监控</a>
+                <a href="/frpc" id="nav-frpc" style="display:none;">🔗 隧道</a>
+                <a href="/ssh-keys" id="nav-ssh" style="display:none;">🔑 SSH</a>
+                <a href="/logs" id="nav-logs" style="display:none;">📋 日志</a>
+                <a href="/help" id="nav-help" style="display:none;">📖 帮助</a>
+            </div>
+            <div id="user-info-area">
+                <span class="user-info" id="user-display">未登录</span>
+            </div>
+        </div>
+    </div>
+    
+    <div class="conn-panel" style="margin:20px auto;max-width:1360px;padding:0 20px;">
+        <div style="background:white;border-radius:10px;padding:20px;box-shadow:0 2px 10px rgba(0,0,0,0.1);">
+            <h2 style="font-size:16px;color:#333;margin-bottom:15px;border-bottom:2px solid #667eea;padding-bottom:8px;">🔌 连接状态</h2>
+            <div class="conn-grid" id="connections-grid">
+                <div class="conn-item"><div class="conn-dot disabled"></div><div><div class="conn-label">加载中...</div></div></div>
+            </div>
+        </div>
+    </div>
+
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>识别记录</h2>
+            <!-- fix24 v41: Tab 切换 -->
+            <div class="tab-bar">
+                <button class="tab-btn active" onclick="switchTab('realtime')">📡 实时数据</button>
+                <button class="tab-btn" onclick="switchTab('history')">🔍 历史查询</button>
+            </div>
+            
+            <!-- 实时数据 Tab -->
+            <div id="tab-realtime" class="tab-content active">
+                <div style="background:#e6f7ff;border:1px solid #91d5ff;border-radius:6px;padding:10px;margin-bottom:15px;font-size:13px;color:#0050b3;">
+                    💡 实时数据:显示最新 20 条记录,每 30 秒自动刷新
+                </div>
+                <div class="table-container">
+                    <table>
+                        <thead>
+                            <tr>
+                                <th>编号</th>
+                                <th>日期时间</th>
+                                <th>车牌号</th>
+                                <th>联单编号</th>
+                                <th>类型</th>
+                                <th>低位照片</th>
+                                <th>高位照片</th>
+                                <th>上传状态</th>
+                                <th>飞书状态</th>
+                                <th>操作</th>
+                            </tr>
+                        </thead>
+                        <tbody id="realtime-body">
+                            <tr><td colspan="10" class="loading">加载中...</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <div class="refresh-info" id="realtime-refresh-info">数据自动刷新间隔: 30秒</div>
+            </div>
+            
+            <!-- 历史查询 Tab -->
+            <div id="tab-history" class="tab-content">
+                <div class="filter-bar">
+                    <div class="filter-row">
+                        <div class="filter-item">
+                            <label>车牌号</label>
+                            <input type="text" id="filter-plate" placeholder="如 沪EQ3803">
+                        </div>
+                        <div class="filter-item">
+                            <label>联单编号</label>
+                            <input type="text" id="filter-tbnum" placeholder="联单编号">
+                        </div>
+                        <div class="filter-item">
+                            <label>类型</label>
+                            <select id="filter-station">
+                                <option value="0">全部</option>
+                                <option value="1">进站</option>
+                                <option value="2">出站</option>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="filter-row">
+                        <div class="filter-item">
+                            <label>开始日期</label>
+                            <input type="date" id="filter-date-from">
+                        </div>
+                        <div class="filter-item">
+                            <label>结束日期</label>
+                            <input type="date" id="filter-date-to">
+                        </div>
+                        <div class="filter-item">
+                            <label>上传状态</label>
+                            <select id="filter-upload">
+                                <option value="0">全部</option>
+                                <option value="1">成功</option>
+                                <option value="2">失败</option>
+                            </select>
+                        </div>
+                        <div class="filter-item">
+                            <label>飞书状态</label>
+                            <select id="filter-feishu">
+                                <option value="0">全部</option>
+                                <option value="1">已发送</option>
+                                <option value="2">失败</option>
+                                <option value="3">未发送</option>
+                            </select>
+                        </div>
+                        <div class="filter-item filter-buttons">
+                            <button class="btn-search" onclick="searchHistory(1)">🔍 查询</button>
+                            <button class="btn-reset" onclick="resetFilters()">重置</button>
+                        </div>
+                    </div>
+                </div>
+                <div class="table-container">
+                    <table>
+                        <thead>
+                            <tr>
+                                <th>编号</th>
+                                <th>日期时间</th>
+                                <th>车牌号</th>
+                                <th>联单编号</th>
+                                <th>类型</th>
+                                <th>低位照片</th>
+                                <th>高位照片</th>
+                                <th>上传状态</th>
+                                <th>飞书状态</th>
+                                <th>操作</th>
+                            </tr>
+                        </thead>
+                        <tbody id="history-body">
+                            <tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">请设置查询条件后点击"查询"按钮</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <div class="pagination-bar">
+                    <div class="pagination-left">
+                        <span>每页</span>
+                        <select id="page-size" onchange="changePageSize()">
+                            <option value="20">20</option>
+                            <option value="50">50</option>
+                            <option value="100">100</option>
+                        </select>
+                        <span>条</span>
+                    </div>
+                    <div class="pagination" id="pagination"></div>
+                    <div class="pagination-right">
+                        <span>跳至</span>
+                        <input type="number" id="jump-page" min="1" value="1">
+                        <span>页</span>
+                        <button onclick="jumpToPage()">GO</button>
+                    </div>
+                </div>
+                <div class="refresh-info" id="history-refresh-info">历史查询:手动触发,不自动刷新</div>
+            </div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentPageSize = 20;
+        let currentTotal = 0;
+        let currentTotalPages = 0;
+        let currentFilters = {};
+        let currentTab = 'realtime';
+        function getCookie(n){let m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}
+        
+        // fix24 v41: Tab 切换
+        function switchTab(tab) {
+            currentTab = tab;
+            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
+            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
+            
+            if (tab === 'realtime') {
+                document.querySelector('.tab-btn:nth-child(1)').classList.add('active');
+                document.getElementById('tab-realtime').classList.add('active');
+                loadRealtimeData();
+            } else {
+                document.querySelector('.tab-btn:nth-child(2)').classList.add('active');
+                document.getElementById('tab-history').classList.add('active');
+            }
+        }
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric"><span class="metric-label">createBill成功率</span><span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span></div>
+                        <div class="metric"><span class="metric-label">createBill总调用</span><span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span></div>
+                        <div class="metric"><span class="metric-label">照片平均耗时</span><span class="metric-value">${data.avg_upload_time}ms</span></div>
+                        <div class="metric"><span class="metric-label">TIME_WINDOW命中</span><span class="metric-value">${data.time_window_hits}</span></div>
+                        <div class="metric"><span class="metric-label">进站缓存命中率</span><span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span></div>
+                        <div class="metric"><span class="metric-label">出站缓存命中率</span><span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span></div>
+                        <div class="metric"><span class="metric-label">进站统计</span><span class="metric-value">${data.in_station_count}</span></div>
+                        <div class="metric"><span class="metric-label">出站统计</span><span class="metric-value">${data.out_station_count}</span></div>
+                        <div class="metric"><span class="metric-label">总记录数</span><span class="metric-value">${data.total_records}</span></div>
+                        <div class="metric"><span class="metric-label">失败记录</span><span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span></div>
+                        <div class="metric"><span class="metric-label">飞书成功数</span><span class="metric-value">${data.feishu_success_count}</span></div>
+                        <div class="metric"><span class="metric-label">系统运行时间</span><span class="metric-value">${data.uptime}</span></div>
+                        <div class="metric"><span class="metric-label">网络超时</span><span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span></div>
+                        <div class="metric"><span class="metric-label">服务器错误</span><span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span></div>
+                        <div class="metric"><span class="metric-label">总上传错误</span><span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span></div>
+                        <div class="metric"><span class="metric-label">交替锁定</span><span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span></div>
+                        <div class="metric"><span class="metric-label">紧急告警抑制</span><span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span></div>
+                        <div class="metric"><span class="metric-label">交替锁定间隔</span><span class="metric-value">${data.in_out_interval_sec}秒</span></div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        // fix24 v41: 实时数据加载(固定page_size=20)
+        function loadRealtimeData() {
+            fetch('/api/messages?page=1&page_size=20')
+                .then(r => r.json())
+                .then(data => {
+                    renderMessages(data, 'realtime-body');
+                    const refreshEl = document.getElementById('realtime-refresh-info');
+                    refreshEl.textContent = '数据自动刷新间隔: 30秒(共 ' + (data.total || 0) + ' 条,显示最新 20 条)';
+                })
+                .catch(e => {
+                    console.error('实时数据加载失败:', e);
+                    document.getElementById('realtime-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        // fix24 v41: 历史查询
+        function searchHistory(page) {
+            const filters = collectFilters();
+            if (filters.date_from && filters.date_to && filters.date_from > filters.date_to) {
+                alert('开始日期不能晚于结束日期');
+                return;
+            }
+            currentFilters = filters;
+            loadHistoryData(page, filters);
+        }
+        
+        function loadHistoryData(page, filters) {
+            currentPage = page;
+            currentFilters = filters || {};
+            let params = new URLSearchParams();
+            params.set('page', page);
+            params.set('page_size', currentPageSize);
+            if (currentFilters.plate) params.set('plate', currentFilters.plate);
+            if (currentFilters.tb_num) params.set('tb_num', currentFilters.tb_num);
+            if (currentFilters.date_from) params.set('date_from', currentFilters.date_from);
+            if (currentFilters.date_to) params.set('date_to', currentFilters.date_to);
+            if (currentFilters.station_type) params.set('station_type', currentFilters.station_type);
+            if (currentFilters.upload_status) params.set('upload_status', currentFilters.upload_status);
+            if (currentFilters.feishu_status) params.set('feishu_status', currentFilters.feishu_status);
+
+            fetch('/api/messages?' + params.toString())
+                .then(r => r.json())
+                .then(data => {
+                    currentTotal = data.total || 0;
+                    currentTotalPages = data.total_pages || 0;
+                    renderMessages(data, 'history-body');
+                    renderPagination(data.page, currentTotalPages, currentTotal);
+                    document.getElementById('jump-page').value = data.page;
+                    document.getElementById('jump-page').max = currentTotalPages || 1;
+                    const refreshEl = document.getElementById('history-refresh-info');
+                    refreshEl.textContent = '历史查询结果:共 ' + currentTotal + ' 条(手动查询,不自动刷新)';
+                })
+                .catch(e => {
+                    console.error('历史查询失败:', e);
+                    document.getElementById('history-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">查询失败,请重试</td></tr>';
+                });
+        }
+        
+        function renderMessages(data, targetId) {
+            let html = '';
+            if (!data.records || data.records.length === 0) {
+                html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+            } else {
+                data.records.forEach(rec => {
+                    const stationType = rec.station_type === 1 ? '进站' : '出站';
+                    const uploadOk = rec.lo_upload_status === 1 && rec.hi_upload_status === 1;
+                    const uploadStatus = uploadOk ? '成功' : '失败';
+                    const uploadClass = uploadOk ? 'status-success' : 'status-failed';
+                    let feishuClass, feishuText;
+                    if (rec.feishu_status === 1) { feishuClass = 'status-success'; feishuText = '已发送'; }
+                    else if (rec.feishu_status === 2) { feishuClass = 'status-failed'; feishuText = '失败'; }
+                    else { feishuClass = 'status-pending'; feishuText = '未发送'; }
+                    const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+
+                    html += '<tr>';
+                    html += '<td>' + rec.id + '</td>';
+                    html += '<td>' + rec.create_time + '</td>';
+                    html += '<td><b>' + rec.plate_number + '</b></td>';
+                    html += '<td style="font-family:monospace;font-size:12px;">' + (rec.tb_num || '-') + '</td>';
+                    html += '<td>' + stationType + '</td>';
+                    html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)" onerror="this.outerHTML=\'<span class=photo-cleaned title=照片已被自动清理>已清理</span>\'">' : '<span class="photo-cleaned" title="照片已被自动清理">已清理</span>') + '</td>';
+                    html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)" onerror="this.outerHTML=\'<span class=photo-cleaned title=照片已被自动清理>已清理</span>\'">' : '<span class="photo-cleaned" title="照片已被自动清理">已清理</span>') + '</td>';
+                    html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                    html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                    html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ',event)" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                    html += '</tr>';
+                });
+            }
+            document.getElementById(targetId).innerHTML = html;
+        }
+
+        function collectFilters() {
+            return {
+                plate: document.getElementById('filter-plate').value.trim(),
+                tb_num: document.getElementById('filter-tbnum').value.trim(),
+                date_from: document.getElementById('filter-date-from').value,
+                date_to: document.getElementById('filter-date-to').value,
+                station_type: parseInt(document.getElementById('filter-station').value) || 0,
+                upload_status: parseInt(document.getElementById('filter-upload').value) || 0,
+                feishu_status: parseInt(document.getElementById('filter-feishu').value) || 0
+            };
+        }
+
+        function resetFilters() {
+            document.getElementById('filter-plate').value = '';
+            document.getElementById('filter-tbnum').value = '';
+            document.getElementById('filter-date-from').value = '';
+            document.getElementById('filter-date-to').value = '';
+            document.getElementById('filter-station').value = '0';
+            document.getElementById('filter-upload').value = '0';
+            document.getElementById('filter-feishu').value = '0';
+            currentFilters = {};
+            document.getElementById('history-body').innerHTML = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">请设置查询条件后点击"查询"按钮</td></tr>';
+            document.getElementById('pagination').innerHTML = '';
+            document.getElementById('history-refresh-info').textContent = '历史查询:手动触发,不自动刷新';
+        }
+
+        function renderPagination(page, totalPages, total) {
+            if (!totalPages || totalPages < 1) totalPages = 1;
+            let html = '';
+            html += '<button onclick="searchHistory(1)" ' + (page <= 1 ? 'disabled' : '') + '>首页</button>';
+            html += '<button onclick="searchHistory(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>上一页</button>';
+
+            let start = Math.max(1, page - 2);
+            let end = Math.min(totalPages, page + 2);
+            if (start > 1) {
+                html += '<button onclick="searchHistory(1)">1</button>';
+                if (start > 2) html += '<span class="info">…</span>';
+            }
+            for (let i = start; i <= end; i++) {
+                html += '<button onclick="searchHistory(' + i + ')" class="' + (i === page ? 'active' : '') + '">' + i + '</button>';
+            }
+            if (end < totalPages) {
+                if (end < totalPages - 1) html += '<span class="info">…</span>';
+                html += '<button onclick="searchHistory(' + totalPages + ')">' + totalPages + '</button>';
+            }
+
+            html += '<button onclick="searchHistory(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>下一页</button>';
+            html += '<button onclick="searchHistory(' + totalPages + ')" ' + (page >= totalPages ? 'disabled' : '') + '>末页</button>';
+            html += '<span class="info">第 ' + page + '/' + totalPages + ' 页 · 共 ' + total + ' 条</span>';
+            document.getElementById('pagination').innerHTML = html;
+        }
+
+        function changePageSize() {
+            currentPageSize = parseInt(document.getElementById('page-size').value) || 20;
+            if (currentTab === 'history' && hasActiveFilters(currentFilters)) {
+                searchHistory(1);
+            }
+        }
+
+        function jumpToPage() {
+            let p = parseInt(document.getElementById('jump-page').value) || 1;
+            if (p < 1) p = 1;
+            if (currentTotalPages && p > currentTotalPages) p = currentTotalPages;
+            searchHistory(p);
+        }
+        
+        function retryRecord(id, evt) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = evt ? evt.target : event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': getCookie('csrf_token') }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                if (currentTab === 'realtime') loadRealtimeData();
+                else if (hasActiveFilters(currentFilters)) loadHistoryData(currentPage, currentFilters);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        function hasActiveFilters(f) {
+            return f.plate || f.tb_num || f.date_from || f.date_to ||
+                   f.station_type !== 0 || f.upload_status !== 0 || f.feishu_status !== 0;
+        }
+        
+        // ==================== 用户信息显示 + 导航栏权限控制 ====================
+        var _currentUserRole = 0;
+        var _lastConnectionsData = null;
+        function loadUserInfo() {
+            return fetch('/api/auth/status')
+                .then(r => r.json())
+                .then(data => {
+                    const area = document.getElementById('user-info-area');
+                    if (data.authenticated) {
+                        _currentUserRole = data.role;
+                        const roles = {0: '普通用户', 1: '管理员', 2: '超级管理员'};
+                        const roleName = roles[data.role] || '未知角色';
+                        const roleClass = data.role === 2 ? 'style="background:rgba(255,215,0,0.3);color:#ffd700;"' : 
+                                          data.role === 1 ? 'style="background:rgba(64,169,255,0.3);color:#40a9ff;"' : '';
+                        area.innerHTML = '<span class="user-info">👤 ' + data.username + '</span>' +
+                            '<span class="role-badge" ' + roleClass + '>' + roleName + '</span>' +
+                            ' <button class="logout-btn" onclick="logout()">退出</button>';
+
+                        if (data.role >= 1) {
+                            show('nav-config');
+                        }
+                        if (data.role >= 2) {
+                            show('nav-system');
+                            show('nav-frpc');
+                            show('nav-ssh');
+                            show('nav-logs');
+                            show('nav-help');
+                        }
+                        if (data.role >= 2 && _lastConnectionsData) {
+                            renderConnectionsFrpc(_lastConnectionsData);
+                        }
+                    } else {
+                        area.innerHTML = '<a href="/login" style="color:white;text-decoration:underline;">登录</a>';
+                    }
+                })
+                .catch(e => console.error('用户信息加载失败:', e));
+        }
+
+        function show(id) {
+            var el = document.getElementById(id);
+            if (el) el.style.display = '';
+        }
+
+        function logout() {
+            fetch('/api/auth/logout', {method: 'POST', headers: {'X-CSRF-Token': getCookie('csrf_token')}})
+                .then(() => { window.location.href = '/login'; })
+                .catch(() => { window.location.href = '/login'; });
+        }
+
+        // ==================== 连接状态显示 ====================
+        function renderConnectionsFrpc(data) {
+            if (!data.frpc || _currentUserRole < 2) return;
+            var existing = document.getElementById('conn-frpc-item');
+            var dot = data.frpc.running ? 'online' : 'offline';
+            var detail = data.frpc.running ? (data.frpc.tunnels + ' 条隧道活跃') : '未运行';
+            var frpcHtml = '<div class="conn-item" id="conn-frpc-item"><div class="conn-dot ' + dot + '"></div>' +
+                '<div><div class="conn-label">🔗 frpc 隧道</div>' +
+                '<div class="conn-detail">' + detail + (data.frpc.version ? ' v' + data.frpc.version : '') + '</div></div></div>';
+            if (existing) {
+                existing.outerHTML = frpcHtml;
+            } else {
+                document.getElementById('connections-grid').insertAdjacentHTML('beforeend', frpcHtml);
+            }
+        }
+
+        function loadConnections() {
+            fetch('/api/connections')
+                .then(r => r.json())
+                .then(data => {
+                    _lastConnectionsData = data;
+                    let html = '';
+
+                    if (data.rtsp) {
+                        data.rtsp.forEach(cam => {
+                            let dot = 'disabled';
+                            let detail = '未配置';
+                            if (cam.configured && cam.running) { dot = 'online'; detail = '采集中'; }
+                            else if (cam.configured && !cam.running) { dot = 'offline'; detail = '已断开'; }
+                            else { dot = 'disabled'; detail = '未配置'; }
+                            html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                                '<div><div class="conn-label">📷 ' + cam.name + '</div>' +
+                                '<div class="conn-detail">' + detail + '</div></div></div>';
+                        });
+                    }
+
+                    if (data.mqtt) {
+                        let dot = 'disabled', detail = '未启用';
+                        if (data.mqtt.enabled) {
+                            if (data.mqtt.status === 1) { dot = 'online'; detail = data.mqtt.state + ' (' + data.mqtt.host + ')'; }
+                            else if (data.mqtt.status === 2) { dot = 'warn'; detail = data.mqtt.state + '...'; }
+                            else { dot = 'offline'; detail = data.mqtt.state; }
+                        }
+                        html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                            '<div><div class="conn-label">📨 MQTT</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    if (data.database) {
+                        let dot = data.database.connected ? 'online' : 'offline';
+                        let detail = data.database.connected ? 'SQLite 已连接' : '未连接';
+                        html += '<div class="conn-item"><div class="conn-dot ' + dot + '"></div>' +
+                            '<div><div class="conn-label">🗄️ 数据库</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    if (data.frpc && _currentUserRole >= 2) {
+                        html += '<div class="conn-item" id="conn-frpc-item"><div class="conn-dot ' + (data.frpc.running ? 'online' : 'offline') + '"></div>' +
+                            '<div><div class="conn-label">🔗 frpc 隧道</div>' +
+                            '<div class="conn-detail">' + (data.frpc.running ? data.frpc.tunnels + ' 条隧道活跃' : '未运行') + (data.frpc.version ? ' v' + data.frpc.version : '') + '</div></div></div>';
+                    }
+
+                    if (data.web) {
+                        let detail = '端口 ' + data.web.port + (data.web.https ? ' (HTTPS)' : '');
+                        html += '<div class="conn-item"><div class="conn-dot online"></div>' +
+                            '<div><div class="conn-label">🌐 Web 服务</div>' +
+                            '<div class="conn-detail">' + detail + '</div></div></div>';
+                    }
+
+                    document.getElementById('connections-grid').innerHTML = html;
+                })
+                .catch(e => {
+                    console.error('连接状态加载失败:', e);
+                    document.getElementById('connections-grid').innerHTML = 
+                        '<div class="conn-item"><div class="conn-dot offline"></div><div><div class="conn-label">加载失败</div></div></div>';
+                });
+        }
+
+        loadUserInfo();
+        loadConnections();
+        loadMonitorReport();
+        loadRealtimeData();
+        
+        // 自动刷新:只刷新实时数据 Tab
+        setInterval(() => {
+            if (currentTab === 'realtime') {
+                loadMonitorReport();
+                loadRealtimeData();
+            }
+        }, 30000);
+        
+        // 筛选框回车触发查询
+        ['filter-plate','filter-tbnum','filter-date-from','filter-date-to'].forEach(id => {
+            const el = document.getElementById(id);
+            if (el) el.addEventListener('keypress', e => { if (e.key === 'Enter') searchHistory(1); });
+        });
+    </script>
+</body>
+</html>

+ 284 - 0
assets/web/indexbak1.html

@@ -0,0 +1,284 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v26</p>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 3 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 15) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                if (data.success) {
+                    alert('重试请求已提交');
+                    loadMessages(currentPage, currentKeyword);
+                } else {
+                    alert('重试失败');
+                }
+            })
+            .catch(e => alert('重试请求失败'));
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 310 - 0
assets/web/indexbak2.html

@@ -0,0 +1,310 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix9</p>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 22) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 317 - 0
assets/web/indexbak3.html

@@ -0,0 +1,317 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>车牌识别监控系统</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .container { display: flex; gap: 20px; padding: 20px; max-width: 1400px; margin: 0 auto; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
+        .report-panel { width: 320px; flex-shrink: 0; }
+        .list-panel { flex: 1; min-width: 0; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #eee; }
+        .metric:last-child { border-bottom: none; }
+        .metric-label { color: #666; font-size: 14px; }
+        .metric-value { font-weight: bold; color: #333; font-size: 14px; }
+        .metric-value.success { color: #52c41a; }
+        .metric-value.warning { color: #faad14; }
+        .metric-value.error { color: #f5222d; }
+        .search-box { display: flex; gap: 10px; margin-bottom: 15px; }
+        .search-box input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 5px; font-size: 14px; }
+        .search-box button { padding: 10px 20px; background: #667eea; color: white; border: none; border-radius: 5px; cursor: pointer; font-size: 14px; }
+        .search-box button:hover { background: #764ba2; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-retry { background: #fff7e6; color: #fa8c16; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .photo-thumb { width: 60px; height: 40px; object-fit: cover; border-radius: 3px; cursor: pointer; border: 1px solid #ddd; }
+        .photo-modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8); z-index: 1000; justify-content: center; align-items: center; }
+        .photo-modal.active { display: flex; }
+        .photo-modal img { max-width: 90%; max-height: 90%; border-radius: 5px; }
+        .photo-modal-close { position: absolute; top: 20px; right: 20px; color: white; font-size: 30px; cursor: pointer; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 10px; margin-top: 15px; }
+        .pagination button { padding: 8px 15px; border: 1px solid #ddd; background: white; border-radius: 5px; cursor: pointer; }
+        .pagination button:hover { background: #f0f5ff; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination .info { color: #666; font-size: 14px; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        @media (max-width: 900px) {
+            .container { flex-direction: column; }
+            .report-panel { width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>车牌识别监控系统</h1>
+        <p>Plate Recognition Monitoring System v43.2-fix19</p>
+        <style>.video-link:hover{background:rgba(255,255,255,0.35)!important;}</style>
+        <div class="video-links" style="margin-top:12px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;">
+            <a href="/video?stream=in-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位进站</a>
+            <a href="/video?stream=in-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位进站</a>
+            <a href="/video?stream=out-low" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 低位出站</a>
+            <a href="/video?stream=out-high" class="video-link" style="background:rgba(255,255,255,0.2);color:white;padding:8px 16px;border-radius:20px;text-decoration:none;font-size:14px;transition:background 0.2s;">🎥 高位出站</a>
+        </div>
+    </div>
+    
+    <div class="container">
+        <div class="panel report-panel">
+            <h2>监控报告</h2>
+            <div id="monitor-report">
+                <div class="loading">加载中...</div>
+            </div>
+        </div>
+        
+        <div class="panel list-panel">
+            <h2>实时消息列表</h2>
+            <div class="search-box">
+                <input type="text" id="search-input" placeholder="搜索车牌号/联单编号/日期时间">
+                <button onclick="searchMessages(1)">搜索</button>
+            </div>
+            <div class="table-container">
+                <table>
+                    <thead>
+                        <tr>
+                            <th>编号</th>
+                            <th>日期时间</th>
+                            <th>车牌号</th>
+                            <th>联单编号</th>
+                            <th>类型</th>
+                            <th>低位照片</th>
+                            <th>高位照片</th>
+                            <th>上传状态</th>
+                            <th>飞书状态</th>
+                            <th>操作</th>
+                        </tr>
+                    </thead>
+                    <tbody id="messages-body">
+                        <tr><td colspan="10" class="loading">加载中...</td></tr>
+                    </tbody>
+                </table>
+            </div>
+            <div class="pagination" id="pagination"></div>
+            <div class="refresh-info">数据自动刷新间隔: 30秒</div>
+        </div>
+    </div>
+    
+    <div class="photo-modal" id="photo-modal" onclick="closePhotoModal()">
+        <span class="photo-modal-close">&times;</span>
+        <img id="photo-modal-img" src="" alt="照片">
+    </div>
+    
+    <script>
+        let currentPage = 1;
+        let currentKeyword = '';
+        
+        function loadMonitorReport() {
+            fetch('/api/monitor')
+                .then(r => r.json())
+                .then(data => {
+                    document.getElementById('monitor-report').innerHTML = `
+                        <div class="metric">
+                            <span class="metric-label">createBill成功率</span>
+                            <span class="metric-value ${data.createbill_success_rate >= 90 ? 'success' : data.createbill_success_rate >= 70 ? 'warning' : 'error'}">${data.createbill_success_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">createBill总调用</span>
+                            <span class="metric-value">${data.createbill_total} / 成功 ${data.createbill_success}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">照片平均耗时</span>
+                            <span class="metric-value">${data.avg_upload_time}ms</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">TIME_WINDOW命中</span>
+                            <span class="metric-value">${data.time_window_hits}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站缓存命中率</span>
+                            <span class="metric-value">${data.in_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站缓存命中率</span>
+                            <span class="metric-value">${data.out_cache_hit_rate.toFixed(1)}%</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">进站统计</span>
+                            <span class="metric-value">${data.in_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">出站统计</span>
+                            <span class="metric-value">${data.out_station_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总记录数</span>
+                            <span class="metric-value">${data.total_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">失败记录</span>
+                            <span class="metric-value ${data.failed_records > 0 ? 'error' : ''}">${data.failed_records}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">飞书成功数</span>
+                            <span class="metric-value">${data.feishu_success_count}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">系统运行时间</span>
+                            <span class="metric-value">${data.uptime}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">网络超时</span>
+                            <span class="metric-value ${data.network_timeouts > 10 ? 'warning' : ''}">${data.network_timeouts}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">服务器错误</span>
+                            <span class="metric-value ${data.server_errors > 5 ? 'warning' : ''}">${data.server_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">总上传错误</span>
+                            <span class="metric-value ${data.upload_errors > 0 ? 'error' : ''}">${data.upload_errors}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定</span>
+                            <span class="metric-value ${data.alternating_merge_enabled ? 'success' : ''}">${data.alternating_merge_enabled ? '启用' : '禁用'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">紧急告警抑制</span>
+                            <span class="metric-value ${data.emergency_alert_suppressed ? 'warning' : 'success'}">${data.emergency_alert_suppressed ? '已抑制' : '正常'}</span>
+                        </div>
+                        <div class="metric">
+                            <span class="metric-label">交替锁定间隔</span>
+                            <span class="metric-value">${data.in_out_interval_sec}秒</span>
+                        </div>
+                    `;
+                })
+                .catch(e => console.error('监控报告加载失败:', e));
+        }
+        
+        function loadMessages(page, keyword) {
+            let url = '/api/messages?page=' + page;
+            if (keyword) url += '&keyword=' + encodeURIComponent(keyword);
+            
+            fetch(url)
+                .then(r => r.json())
+                .then(data => {
+                    let html = '';
+                    if (data.records.length === 0) {
+                        html = '<tr><td colspan="10" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+                    } else {
+                        data.records.forEach(rec => {
+                            const stationType = rec.station_type === 1 ? '进站' : '出站';
+                            const uploadStatus = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? '成功' : '失败';
+                            const uploadClass = rec.lo_upload_status === 1 && rec.hi_upload_status === 1 ? 'status-success' : 'status-failed';
+                            const feishuClass = rec.feishu_status === 1 ? 'status-success' : rec.feishu_status === 2 ? 'status-failed' : 'status-pending';
+                            const feishuText = rec.feishu_status === 1 ? '已发送' : rec.feishu_status === 2 ? '失败' : '未发送';
+                            const retryDisabled = rec.retry_count >= 5 ? 'disabled' : '';
+                            
+                            html += '<tr>';
+                            html += '<td>' + rec.id + '</td>';
+                            html += '<td>' + rec.create_time + '</td>';
+                            html += '<td><b>' + rec.plate_number + '</b></td>';
+                            html += '<td style="font-family:monospace;">' + rec.tb_num.substring(0, 15) + '</td>';
+                            html += '<td>' + stationType + '</td>';
+                            html += '<td>' + (rec.lo_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.lo_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td>' + (rec.hi_photo_path ? '<img class="photo-thumb" src="/photo?path=' + encodeURIComponent(rec.hi_photo_path) + '" onclick="showPhoto(this.src)">' : '-') + '</td>';
+                            html += '<td><span class="status ' + uploadClass + '">' + uploadStatus + '</span></td>';
+                            html += '<td><span class="status ' + feishuClass + '">' + feishuText + '</span></td>';
+                            html += '<td><button class="btn-retry" onclick="retryRecord(' + rec.id + ')" ' + retryDisabled + '>重试(' + rec.retry_count + '/5)</button></td>';
+                            html += '</tr>';
+                        });
+                    }
+                    document.getElementById('messages-body').innerHTML = html;
+                    
+                    // 分页
+                    let pageHtml = '<button onclick="searchMessages(' + (data.page - 1) + ')" ' + (data.page <= 1 ? 'disabled' : '') + '>上一页</button>';
+                    pageHtml += '<span class="info">第 ' + data.page + ' 页</span>';
+                    pageHtml += '<button onclick="searchMessages(' + (data.page + 1) + ')">下一页</button>';
+                    document.getElementById('pagination').innerHTML = pageHtml;
+                })
+                .catch(e => {
+                    console.error('消息列表加载失败:', e);
+                    document.getElementById('messages-body').innerHTML = '<tr><td colspan="10" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+        
+        function searchMessages(page) {
+            currentPage = page;
+            currentKeyword = document.getElementById('search-input').value;
+            loadMessages(currentPage, currentKeyword);
+        }
+        
+        function retryRecord(id) {
+            if (!confirm('确定要重试此记录吗?')) return;
+            
+            var btn = event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+            
+            fetch('/api/retry', {
+                method: 'POST',
+                body: 'id=' + id,
+                headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
+            })
+            .then(r => r.json())
+            .then(data => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    var msg = '✅ 重试成功';
+                    if (data.tb_num) msg += '\n联单编号: ' + data.tb_num;
+                    if (data.uploaded_photos > 0) msg += '\n上传照片: ' + data.uploaded_photos + '张';
+                    alert(msg);
+                } else {
+                    alert('❌ 重试失败: ' + (data.message || '未知错误'));
+                }
+                loadMessages(currentPage, currentKeyword);
+                loadMonitorReport();
+            })
+            .catch(e => {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                alert('重试请求失败: ' + e);
+            });
+        }
+        
+        function showPhoto(src) {
+            document.getElementById('photo-modal-img').src = src;
+            document.getElementById('photo-modal').classList.add('active');
+        }
+        
+        function closePhotoModal() {
+            document.getElementById('photo-modal').classList.remove('active');
+        }
+        
+        // 初始化
+        loadMonitorReport();
+        loadMessages(1, '');
+        
+        // 自动刷新
+        setInterval(() => {
+            loadMonitorReport();
+            if (currentPage === 1 && !currentKeyword) {
+                loadMessages(1, '');
+            }
+        }, 30000);
+        
+        // 回车搜索
+        document.getElementById('search-input').addEventListener('keypress', e => {
+            if (e.key === 'Enter') searchMessages(1);
+        });
+    </script>
+</body>
+</html>

+ 264 - 0
assets/web/login.html

@@ -0,0 +1,264 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>PlateRecApp - 登录</title>
+    <style>
+        * { margin: 0; padding: 0; box-sizing: border-box; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #0f1419;
+            color: #e1e8ed;
+            min-height: 100vh;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+        }
+        .login-container {
+            width: 100%;
+            max-width: 400px;
+            padding: 20px;
+        }
+        .login-card {
+            background: #1a2332;
+            border-radius: 12px;
+            padding: 40px 32px;
+            box-shadow: 0 8px 32px rgba(0,0,0,0.4);
+            border: 1px solid #2a3a4a;
+        }
+        .logo {
+            text-align: center;
+            margin-bottom: 32px;
+        }
+        .logo h1 {
+            font-size: 24px;
+            font-weight: 600;
+            color: #4fc3f7;
+            margin-bottom: 4px;
+        }
+        .logo p {
+            font-size: 13px;
+            color: #6b7b8d;
+        }
+        .form-group {
+            margin-bottom: 20px;
+        }
+        .form-group label {
+            display: block;
+            font-size: 13px;
+            color: #8899aa;
+            margin-bottom: 6px;
+            font-weight: 500;
+        }
+        .form-group input {
+            width: 100%;
+            padding: 12px 16px;
+            background: #0f1419;
+            border: 1px solid #2a3a4a;
+            border-radius: 8px;
+            color: #e1e8ed;
+            font-size: 15px;
+            transition: border-color 0.2s, box-shadow 0.2s;
+            outline: none;
+        }
+        .form-group input:focus {
+            border-color: #4fc3f7;
+            box-shadow: 0 0 0 3px rgba(79,195,247,0.15);
+        }
+        .form-group input::placeholder {
+            color: #4a5568;
+        }
+        .form-options {
+            display: flex;
+            justify-content: space-between;
+            align-items: center;
+            margin-bottom: 24px;
+            font-size: 13px;
+        }
+        .remember-me {
+            display: flex;
+            align-items: center;
+            gap: 6px;
+            cursor: pointer;
+            color: #8899aa;
+        }
+        .remember-me input[type="checkbox"] {
+            width: 16px;
+            height: 16px;
+            accent-color: #4fc3f7;
+            cursor: pointer;
+        }
+        .btn-login {
+            width: 100%;
+            padding: 12px;
+            background: linear-gradient(135deg, #4fc3f7, #0288d1);
+            border: none;
+            border-radius: 8px;
+            color: white;
+            font-size: 15px;
+            font-weight: 600;
+            cursor: pointer;
+            transition: opacity 0.2s, transform 0.1s;
+        }
+        .btn-login:hover { opacity: 0.9; }
+        .btn-login:active { transform: scale(0.98); }
+        .btn-login:disabled {
+            opacity: 0.6;
+            cursor: not-allowed;
+        }
+        .error-msg {
+            background: rgba(239,68,68,0.1);
+            border: 1px solid rgba(239,68,68,0.3);
+            color: #f87171;
+            padding: 10px 14px;
+            border-radius: 8px;
+            font-size: 13px;
+            margin-bottom: 16px;
+            display: none;
+        }
+        .error-msg.show { display: block; }
+        .rate-limit-msg {
+            background: rgba(245,158,11,0.1);
+            border: 1px solid rgba(245,158,11,0.3);
+            color: #fbbf24;
+            padding: 10px 14px;
+            border-radius: 8px;
+            font-size: 13px;
+            margin-bottom: 16px;
+            display: none;
+        }
+        .footer {
+            text-align: center;
+            margin-top: 24px;
+            font-size: 12px;
+            color: #4a5568;
+        }
+        .loading-spinner {
+            display: inline-block;
+            width: 16px;
+            height: 16px;
+            border: 2px solid rgba(255,255,255,0.3);
+            border-top-color: white;
+            border-radius: 50%;
+            animation: spin 0.6s linear infinite;
+            margin-right: 8px;
+            vertical-align: middle;
+        }
+        @keyframes spin { to { transform: rotate(360deg); } }
+    
+        @media (max-width: 480px) {
+            .login-container { padding: 12px; }
+            .login-card { padding: 24px 20px; }
+            .logo h1 { font-size: 20px; }
+            .form-group input { padding: 10px 12px; font-size: 14px; }
+            .btn-login { padding: 10px; font-size: 14px; }
+        }
+    </style>
+</head>
+<body>
+    <div class="login-container">
+        <div class="login-card">
+            <div class="logo">
+                <h1>🔒 PlateRecApp</h1>
+                <p>车牌识别管理系统</p>
+            </div>
+
+            <div id="error-msg" class="error-msg"></div>
+            <div id="rate-limit-msg" class="rate-limit-msg"></div>
+
+            <form id="login-form" autocomplete="off">
+                <div class="form-group">
+                    <label for="username">用户名</label>
+                    <input type="text" id="username" name="username"
+                           placeholder="请输入用户名" required autofocus
+                           autocomplete="username">
+                </div>
+                <div class="form-group">
+                    <label for="password">密码</label>
+                    <input type="password" id="password" name="password"
+                           placeholder="请输入密码" required
+                           autocomplete="current-password">
+                </div>
+                <div class="form-options">
+                    <label class="remember-me">
+                        <input type="checkbox" id="remember" name="remember">
+                        记住登录 (7天)
+                    </label>
+                </div>
+                <button type="submit" class="btn-login" id="btn-login">登 录</button>
+            </form>
+
+            <div class="footer">
+                PlateRecApp v5.0 | Secure Login
+            </div>
+        </div>
+    </div>
+
+    <script>
+        const form = document.getElementById('login-form');
+        const btnLogin = document.getElementById('btn-login');
+        const errorDiv = document.getElementById('error-msg');
+        const rateLimitDiv = document.getElementById('rate-limit-msg');
+
+        // Check if already logged in
+        fetch('/api/auth/status')
+            .then(r => r.json())
+            .then(data => {
+                if (data.authenticated) {
+                    const redirect = new URLSearchParams(window.location.search).get('redirect') || '/';
+                    window.location.href = redirect;
+                }
+            })
+            .catch(() => {});
+
+        form.addEventListener('submit', async (e) => {
+            e.preventDefault();
+            errorDiv.classList.remove('show');
+            rateLimitDiv.classList.remove('show');
+            btnLogin.disabled = true;
+            btnLogin.innerHTML = '<span class="loading-spinner"></span>登录中...';
+
+            const username = document.getElementById('username').value.trim();
+            const password = document.getElementById('password').value;
+            const remember = document.getElementById('remember').checked;
+
+            try {
+                const resp = await fetch('/api/auth/login', {
+                    method: 'POST',
+                    headers: { 'Content-Type': 'application/json' },
+                    body: JSON.stringify({ username, password, remember })
+                });
+
+                const data = await resp.json();
+
+                if (resp.status === 429) {
+                    rateLimitDiv.textContent = data.message || '请求过于频繁,请稍后再试';
+                    rateLimitDiv.classList.add('show');
+                } else if (resp.status === 401) {
+                    errorDiv.textContent = data.message || '用户名或密码错误';
+                    errorDiv.classList.add('show');
+                    document.getElementById('password').value = '';
+                    document.getElementById('password').focus();
+                } else if (resp.status === 423) {
+                    errorDiv.textContent = data.message || '账户已锁定,请稍后再试';
+                    errorDiv.classList.add('show');
+                } else if (data.success) {
+                    const redirect = new URLSearchParams(window.location.search).get('redirect') || '/';
+                    window.location.href = redirect;
+                    return;
+                } else {
+                    errorDiv.textContent = data.message || '登录失败';
+                    errorDiv.classList.add('show');
+                }
+            } catch (err) {
+                errorDiv.textContent = '网络错误,请重试';
+                errorDiv.classList.add('show');
+            }
+
+            btnLogin.disabled = false;
+            btnLogin.textContent = '登 录';
+        });
+    </script>
+</body>
+</html>

+ 167 - 0
assets/web/logs.html

@@ -0,0 +1,167 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>日志管理 - PlateRecApp</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{background:#0f172a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
+.header{background:#1e293b;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155}
+.header h1{font-size:20px;font-weight:600;color:#f8fafc}
+.nav{display:flex;gap:16px;align-items:center}
+.nav a{color:#60a5fa;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px}
+.nav a:hover,.nav a.active{background:#334155}
+.container{max-width:1200px;margin:0 auto;padding:20px}
+.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:20px}
+.card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155}
+.card-title{font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px}
+.card-value{font-size:28px;font-weight:700}
+.card-sub{font-size:13px;color:#64748b;margin-top:4px}
+.log-card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155;margin-bottom:16px}
+.log-card-title{font-size:15px;font-weight:600;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center}
+pre{background:#0f172a;border:1px solid #334155;border-radius:8px;padding:12px;font-family:'Fira Code',monospace;font-size:12px;line-height:1.6;max-height:500px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;color:#cbd5e1}
+.btn{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:14px;font-weight:500;transition:all .2s}
+.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
+.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
+.btn-warning{background:#d97706;color:#fff}.btn-warning:hover{background:#b45309}
+.btn-group{display:flex;gap:8px;flex-wrap:wrap}
+.progress-bar{height:8px;background:#334155;border-radius:4px;margin-top:8px;overflow:hidden}
+.progress-fill{height:100%;border-radius:4px;transition:width .5s}
+.msg{padding:10px 14px;border-radius:8px;margin-top:10px;font-size:14px;display:none}
+.msg.success{display:block;background:#14532d;color:#86efac;border:1px solid #16a34a}
+.msg.error{display:block;background:#7f1d1d;color:#fca5a5;border:1px solid #dc2626}
+
+    @media (max-width: 768px) {
+        .header { flex-direction: column; gap: 8px; padding: 12px 16px; }
+        .header h1 { font-size: 16px; }
+        .nav { flex-wrap: wrap; gap: 8px; justify-content: center; }
+        .nav a { font-size: 12px; padding: 4px 8px; }
+        .container { padding: 12px; }
+        .grid { grid-template-columns: 1fr 1fr; gap: 10px; }
+        .card { padding: 14px; }
+        .card-value { font-size: 22px; }
+        .log-card { padding: 14px; }
+        .log-card-title { font-size: 13px; flex-direction: column; gap: 8px; align-items: flex-start; }
+        pre { font-size: 11px; max-height: 300px; padding: 10px; }
+        .btn-group { flex-wrap: wrap; }
+        .btn { flex: 1; min-width: 80px; text-align: center; font-size: 12px; }
+    }
+    @media (max-width: 480px) {
+        .grid { grid-template-columns: 1fr; }
+        .card-value { font-size: 20px; }
+    }
+    </style>
+</head>
+<body>
+<div class="header">
+    <h1>📋 日志管理</h1>
+    <div class="nav">
+        <a href="/">监控台</a>
+        <a href="/video">视频</a>
+        <a href="/config">配置</a>
+        <a href="/system">系统</a>
+        <a href="/frpc">frpc</a>
+        <a href="/ssh-keys">SSH</a>
+        <a href="/logs" class="active">日志</a>
+    </div>
+</div>
+<div class="container">
+    <div class="grid">
+        <div class="card">
+            <div class="card-title">文件大小</div>
+            <div class="card-value" id="file-size">-- MB</div>
+            <div class="card-sub">缓冲区上限: <span id="buffer-max">-</span> MB</div>
+            <div class="progress-bar"><div class="progress-fill" id="buffer-bar" style="width:0%"></div></div>
+        </div>
+        <div class="card">
+            <div class="card-title">内存缓冲</div>
+            <div class="card-value" id="memory-usage">-- MB</div>
+            <div class="card-sub">模式: <span id="log-mode">-</span> | 条目: <span id="line-count">-</span></div>
+            <div class="progress-bar"><div class="progress-fill" id="memory-bar" style="width:0%"></div></div>
+        </div>
+        <div class="card">
+            <div class="card-title">定时刷盘</div>
+            <div class="card-value" id="flush-time">--</div>
+            <div class="card-sub">上次刷盘: <span id="last-flush">-</span> | 保留: <span id="retention">-</span> 天</div>
+        </div>
+        <div class="card">
+            <div class="card-title">刷盘统计</div>
+            <div class="card-value" id="flush-count">--</div>
+            <div class="card-sub">磁盘文件: <span id="last-modified">-</span></div>
+        </div>
+    </div>
+    <div class="btn-group" style="margin-bottom:16px">
+        <button class="btn btn-primary" onclick="refreshStats()">🔄 刷新状态</button>
+        <button class="btn btn-warning" onclick="flushLog()">⚡ 手动刷新日志</button>
+        <button class="btn btn-danger" onclick="cleanupLogs()">🗑️ 清理过期日志</button>
+        <button class="btn btn-primary" onclick="refreshTail()">📄 刷新日志尾部</button>
+    </div>
+    <div id="msg" class="msg"></div>
+    <div class="log-card">
+        <div class="log-card-title">
+            <span>日志尾部 (最后 200 行)</span>
+            <span style="font-size:13px;color:#64748b" id="log-path">-</span>
+        </div>
+        <pre id="log-content">加载中...</pre>
+    </div>
+</div>
+<script>
+function getCookie(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}function authFetch(u,o){o=o||{};var h=o.headers||{};if(!(h instanceof Headers)){var nh=new Headers();for(var k in h)nh.set(k,h[k]);h=nh;}if(!h.has("X-CSRF-Token")){var t=getCookie("csrf_token");if(t)h.set("X-CSRF-Token",t);}o.headers=h;return fetch(u,o).then(function(r){if(r.status===401){window.location.href="/login?redirect="+encodeURIComponent(window.location.pathname);return new Promise(function(){});}return r;});}
+function showMsg(text,ok){
+    let el=document.getElementById('msg');
+    el.className='msg '+(ok?'success':'error');
+    el.textContent=text;
+    setTimeout(()=>el.className='msg',5000);
+}
+function refreshStats(){
+    authFetch('/api/logs/stats').then(r=>r.json()).then(d=>{
+        document.getElementById('file-size').textContent=d.size_mb+' MB';
+        document.getElementById('buffer-max').textContent=d.buffer_mb;
+        document.getElementById('line-count').textContent=d.line_count.toLocaleString();
+        document.getElementById('last-modified').textContent=d.last_modified||'-';
+        document.getElementById('flush-time').textContent=d.flush_time;
+        document.getElementById('retention').textContent=d.retention_days;
+        document.getElementById('log-path').textContent=d.file;
+        // ✅ fix24-v36: 内存缓冲新增字段
+        document.getElementById('log-mode').textContent=d.memory_enabled?'内存模式':'直写模式';
+        document.getElementById('memory-usage').textContent=(d.memory_usage_mb||0).toFixed(2)+' MB';
+        document.getElementById('last-flush').textContent=d.last_flush_time||'未刷盘';
+        document.getElementById('flush-count').textContent=(d.total_flush_count||0)+' 次';
+        // 磁盘文件进度条
+        let bar=document.getElementById('buffer-bar');
+        bar.style.width=Math.min(d.buffer_percent,100)+'%';
+        if(d.buffer_percent<70) bar.style.background='#4ade80';
+        else if(d.buffer_percent<90) bar.style.background='#facc15';
+        else bar.style.background='#f87171';
+        // 内存缓冲进度条
+        let mbar=document.getElementById('memory-bar');
+        let memPercent=d.buffer_percent||0;
+        mbar.style.width=Math.min(memPercent,100)+'%';
+        if(memPercent<70) mbar.style.background='#4ade80';
+        else if(memPercent<90) mbar.style.background='#facc15';
+        else mbar.style.background='#f87171';
+    });
+}
+function refreshTail(){
+    document.getElementById('log-content').textContent='加载中...';
+    authFetch('/api/logs/tail?lines=200').then(r=>r.text()).then(t=>{
+        document.getElementById('log-content').textContent=t||'(空日志)';
+    });
+}
+function flushLog(){
+    authFetch('/api/logs/flush',{method:'POST'}).then(r=>r.json()).then(d=>{
+        showMsg(d.message||d.error,d.success);
+    });
+}
+function cleanupLogs(){
+    if(!confirm('确定要清理过期日志文件吗?')) return;
+    authFetch('/api/logs/cleanup',{method:'POST'}).then(r=>r.json()).then(d=>{
+        showMsg(d.message||d.error,d.success);
+    });
+}
+refreshStats();
+refreshTail();
+</script>
+</body>
+</html>

+ 129 - 0
assets/web/ssh_keys.html

@@ -0,0 +1,129 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>SSH 密钥管理 - PlateRecApp</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{background:#0f172a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
+.header{background:#1e293b;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155}
+.header h1{font-size:20px;font-weight:600;color:#f8fafc}
+.nav{display:flex;gap:16px;align-items:center}
+.nav a{color:#60a5fa;text-decoration:none;font-size:14px;padding:6px 12px;border-radius:6px}
+.nav a:hover,.nav a.active{background:#334155}
+.container{max-width:900px;margin:0 auto;padding:20px}
+.card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155;margin-bottom:16px}
+.card-title{font-size:15px;font-weight:600;margin-bottom:12px;color:#f8fafc}
+table{width:100%;border-collapse:collapse;font-size:14px}
+th{text-align:left;padding:8px 12px;color:#94a3b8;border-bottom:1px solid #334155;font-weight:500}
+td{padding:8px 12px;border-bottom:1px solid #1e293b}
+.key-type{display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-weight:500}
+.key-type.rsa{background:#854d0e;color:#fde047}
+.key-type.ed25519{background:#1e3a5f;color:#7dd3fc}
+.key-type.other{background:#374151;color:#9ca3af}
+.btn{padding:8px 16px;border-radius:8px;border:none;cursor:pointer;font-size:14px;font-weight:500;transition:all .2s}
+.btn-primary{background:#3b82f6;color:#fff}.btn-primary:hover{background:#2563eb}
+.btn-danger{background:#dc2626;color:#fff}.btn-danger:hover{background:#b91c1c}
+.btn-sm{padding:4px 10px;font-size:12px}
+textarea{width:100%;background:#0f172a;color:#e2e8f0;border:1px solid #334155;border-radius:8px;padding:12px;font-family:'Fira Code',monospace;font-size:13px;resize:vertical;min-height:80px}
+textarea:focus{outline:none;border-color:#3b82f6}
+.msg{padding:10px 14px;border-radius:8px;margin-top:10px;font-size:14px;display:none}
+.msg.success{display:block;background:#14532d;color:#86efac;border:1px solid #16a34a}
+.msg.error{display:block;background:#7f1d1d;color:#fca5a5;border:1px solid #dc2626}
+.empty{text-align:center;color:#64748b;padding:20px}
+
+    @media (max-width: 768px) {
+        .header { flex-direction: column; gap: 8px; padding: 12px 16px; }
+        .header h1 { font-size: 16px; }
+        .nav { flex-wrap: wrap; gap: 8px; justify-content: center; }
+        .nav a { font-size: 12px; padding: 4px 8px; }
+        .container { padding: 12px; }
+        .card { padding: 14px; }
+        table { font-size: 12px; display: block; overflow-x: auto; white-space: nowrap; }
+        th, td { padding: 6px 8px; }
+        .btn-group { flex-wrap: wrap; }
+        .btn { flex: 1; min-width: 80px; text-align: center; font-size: 12px; }
+        textarea { min-height: 60px; font-size: 12px; }
+    }
+    @media (max-width: 480px) {
+        .card-title { font-size: 13px; }
+    }
+    </style>
+</head>
+<body>
+<div class="header">
+    <h1>🔑 SSH 密钥管理</h1>
+    <div class="nav">
+        <a href="/">监控台</a>
+        <a href="/video">视频</a>
+        <a href="/config">配置</a>
+        <a href="/system">系统</a>
+        <a href="/frpc">frpc</a>
+        <a href="/ssh-keys" class="active">SSH</a>
+        <a href="/logs">日志</a>
+        <a href="/help">帮助</a>
+    </div>
+</div>
+<div class="container">
+    <div class="card">
+        <div class="card-title">已授权公钥</div>
+        <table>
+            <thead><tr><th>#</th><th>类型</th><th>注释</th><th>密钥预览</th><th>操作</th></tr></thead>
+            <tbody id="key-list"><tr><td colspan="5" class="empty">加载中...</td></tr></tbody>
+        </table>
+    </div>
+    <div class="card">
+        <div class="card-title">添加公钥</div>
+        <textarea id="new-key" placeholder="粘贴 SSH 公钥,例如:&#10;ssh-rsa AAAAB3NzaC1yc2EAAA... user@host&#10;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host"></textarea>
+        <div style="margin-top:10px">
+            <button class="btn btn-primary" onclick="addKey()">➕ 添加公钥</button>
+            <button class="btn btn-primary" onclick="loadKeys()" style="margin-left:8px">🔄 刷新列表</button>
+        </div>
+        <div id="msg" class="msg"></div>
+    </div>
+</div>
+<script>
+function getCookie(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}function authFetch(u,o){o=o||{};var h=o.headers||{};if(!(h instanceof Headers)){var nh=new Headers();for(var k in h)nh.set(k,h[k]);h=nh;}if(!h.has("X-CSRF-Token")){var t=getCookie("csrf_token");if(t)h.set("X-CSRF-Token",t);}o.headers=h;return fetch(u,o).then(function(r){if(r.status===401){window.location.href="/login?redirect="+encodeURIComponent(window.location.pathname);return new Promise(function(){});}return r;});}
+function showMsg(text,ok){
+    let el=document.getElementById('msg');
+    el.className='msg '+(ok?'success':'error');
+    el.textContent=text;
+    setTimeout(()=>el.className='msg',5000);
+}
+function getKeyTypeClass(type){
+    if(type.includes('ed25519'))return'ed25519';
+    if(type.includes('rsa'))return'rsa';
+    return'other';
+}
+function loadKeys(){
+    authFetch('/api/ssh/keys').then(r=>r.json()).then(d=>{
+        let tbody=document.getElementById('key-list');
+        if(!d.keys||d.keys.length===0){
+            tbody.innerHTML='<tr><td colspan="5" class="empty">暂无授权公钥</td></tr>';
+            return;
+        }
+        tbody.innerHTML=d.keys.map((k,i)=>'<tr><td>'+(i+1)+'</td><td><span class="key-type '+getKeyTypeClass(k.type)+'">'+k.type+'</span></td><td>'+k.comment+'</td><td style="font-family:monospace;font-size:12px;color:#64748b">'+k.key_preview+'</td><td><button class="btn btn-danger btn-sm" onclick="deleteKey('+k.index+')">删除</button></td></tr>').join('');
+    });
+}
+function addKey(){
+    let key=document.getElementById('new-key').value.trim();
+    if(!key){showMsg('请输入公钥',false);return}
+    authFetch('/api/ssh/keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key:key})})
+    .then(r=>r.json()).then(d=>{
+        showMsg(d.message||d.error,d.success);
+        if(d.success){document.getElementById('new-key').value='';loadKeys()}
+    });
+}
+function deleteKey(index){
+    if(!confirm('确定要删除第 '+(index+1)+' 条公钥吗?此操作不可撤销!')) return;
+    authFetch('/api/ssh/keys',{method:'DELETE',headers:{'Content-Type':'application/json'},body:JSON.stringify({index:index})})
+    .then(r=>r.json()).then(d=>{
+        showMsg(d.message||d.error,d.success);
+        if(d.success) loadKeys();
+    });
+}
+loadKeys();
+</script>
+</body>
+</html>

+ 174 - 0
assets/web/system.html

@@ -0,0 +1,174 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>系统监控 - PlateRecApp</title>
+<style>
+*{margin:0;padding:0;box-sizing:border-box}
+body{background:#0f172a;color:#e2e8f0;font-family:'Segoe UI',system-ui,sans-serif;min-height:100vh}
+.header{background:#1e293b;padding:16px 24px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #334155}
+.header h1{font-size:20px;font-weight:600;color:#f8fafc}
+.header a{color:#60a5fa;text-decoration:none;font-size:14px}
+.nav{display:flex;gap:16px;align-items:center}
+.nav a{padding:6px 12px;border-radius:6px;transition:background .2s}
+.nav a:hover{background:#334155}
+.container{max-width:1200px;margin:0 auto;padding:20px}
+.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:20px}
+.card{background:#1e293b;border-radius:12px;padding:20px;border:1px solid #334155}
+.card-title{font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:12px}
+.card-value{font-size:32px;font-weight:700;margin-bottom:4px}
+.card-sub{font-size:13px;color:#64748b}
+.progress-bar{height:8px;background:#334155;border-radius:4px;margin-top:8px;overflow:hidden}
+.progress-fill{height:100%;border-radius:4px;transition:width .5s}
+.green{color:#4ade80}.yellow{color:#facc15}.red{color:#f87171}
+.bg-green{background:#4ade80}.bg-yellow{background:#facc15}.bg-red{background:#f87171}
+.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:14px}
+.info-grid .label{color:#94a3b8}.info-grid .val{color:#e2e8f0;text-align:right}
+.alert-banner{background:#7f1d1d;border:1px solid #dc2626;border-radius:8px;padding:12px 16px;margin-bottom:16px;display:none;color:#fca5a5;font-size:14px}
+.refresh-info{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;font-size:13px;color:#64748b}
+
+    @media (max-width: 768px) {
+        .header { flex-direction: column; gap: 8px; padding: 12px 16px; }
+        .header h1 { font-size: 16px; }
+        .nav { flex-wrap: wrap; gap: 8px; justify-content: center; }
+        .nav a { font-size: 12px; padding: 4px 8px; }
+        .container { padding: 12px; }
+        .grid { grid-template-columns: 1fr; gap: 12px; }
+        .card { padding: 14px; }
+        .card-value { font-size: 24px; }
+        .info-grid { grid-template-columns: 1fr; gap: 4px; }
+        .info-grid .val { text-align: left; }
+        .refresh-info { flex-direction: column; gap: 8px; text-align: center; }
+    }
+    @media (max-width: 480px) {
+        .card-value { font-size: 20px; }
+        .card-title { font-size: 11px; }
+    }
+    </style>
+</head>
+<body>
+<div class="header">
+    <h1>🖥️ 系统监控</h1>
+    <div class="nav">
+        <a href="/">监控台</a>
+        <a href="/video">视频</a>
+        <a href="/config">配置</a>
+        <a href="/system" style="background:#334155">系统</a>
+        <a href="/frpc">frpc</a>
+        <a href="/ssh-keys">SSH</a>
+        <a href="/logs">日志</a>
+        <a href="/help">帮助</a>
+    </div>
+</div>
+<div class="container">
+    <div id="alert-banner" class="alert-banner"></div>
+    <div class="refresh-info">
+        <span>自动刷新: 每 <span id="interval">2</span> 秒</span>
+        <span>最后更新: <span id="last-update">--</span></span>
+    </div>
+    <div class="grid">
+        <div class="card">
+            <div class="card-title">CPU 使用率</div>
+            <div class="card-value" id="cpu-usage">--%</div>
+            <div class="card-sub"><span id="cpu-cores">-</span> 核 | 负载: <span id="cpu-load">-</span></div>
+            <div class="progress-bar"><div class="progress-fill bg-green" id="cpu-bar" style="width:0%"></div></div>
+        </div>
+        <div class="card">
+            <div class="card-title">内存使用</div>
+            <div class="card-value" id="mem-usage">--%</div>
+            <div class="card-sub"><span id="mem-detail">-</span></div>
+            <div class="progress-bar"><div class="progress-fill bg-green" id="mem-bar" style="width:0%"></div></div>
+        </div>
+        <div class="card">
+            <div class="card-title">CPU 温度</div>
+            <div class="card-value" id="cpu-temp">--°C</div>
+            <div class="card-sub">告警阈值: <span id="temp-threshold">-</span>°C</div>
+            <div class="progress-bar"><div class="progress-fill bg-green" id="temp-bar" style="width:0%"></div></div>
+        </div>
+        <div class="card">
+            <div class="card-title">磁盘使用</div>
+            <div class="card-value" id="disk-usage">--%</div>
+            <div class="card-sub"><span id="disk-detail">-</span></div>
+            <div class="progress-bar"><div class="progress-fill bg-green" id="disk-bar" style="width:0%"></div></div>
+        </div>
+    </div>
+    <div class="grid">
+        <div class="card">
+            <div class="card-title">系统信息</div>
+            <div class="info-grid">
+                <span class="label">主机名</span><span class="val" id="hostname">-</span>
+                <span class="label">内核版本</span><span class="val" id="kernel">-</span>
+                <span class="label">运行时间</span><span class="val" id="uptime">-</span>
+                <span class="label">进程PID</span><span class="val" id="pid">-</span>
+                <span class="label">进程内存</span><span class="val" id="proc-rss">-</span>
+            </div>
+        </div>
+        <div class="card">
+            <div class="card-title">告警阈值</div>
+            <div class="info-grid">
+                <span class="label">CPU 告警</span><span class="val" id="cpu-threshold">-</span>
+                <span class="label">温度告警</span><span class="val" id="temp-threshold2">-</span>
+            </div>
+        </div>
+    </div>
+</div>
+<script>
+function getCookie(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}function authFetch(u,o){o=o||{};var h=o.headers||{};if(!(h instanceof Headers)){var nh=new Headers();for(var k in h)nh.set(k,h[k]);h=nh;}if(!h.has("X-CSRF-Token")){var t=getCookie("csrf_token");if(t)h.set("X-CSRF-Token",t);}o.headers=h;return fetch(u,o).then(function(r){if(r.status===401){window.location.href="/login?redirect="+encodeURIComponent(window.location.pathname);return new Promise(function(){});}return r;});}
+function getColor(pct){return pct<60?'green':pct<85?'yellow':'red'}
+function updateBar(id,pct,max){
+    let bar=document.getElementById(id);
+    bar.style.width=Math.min(pct/max*100,100)+'%';
+    bar.className='progress-fill bg-'+getColor(pct);
+}
+function update(){
+    authFetch('/api/system/status').then(r=>r.json()).then(d=>{
+        // CPU
+        document.getElementById('cpu-usage').textContent=d.cpu.usage.toFixed(1)+'%';
+        document.getElementById('cpu-usage').className='card-value '+getColor(d.cpu.usage);
+        document.getElementById('cpu-cores').textContent=d.cpu.cores;
+        document.getElementById('cpu-load').textContent=d.cpu.load1.toFixed(2)+' / '+d.cpu.load5.toFixed(2)+' / '+d.cpu.load15.toFixed(2);
+        updateBar('cpu-bar',d.cpu.usage,100);
+        // Memory
+        document.getElementById('mem-usage').textContent=d.memory.percent.toFixed(1)+'%';
+        document.getElementById('mem-usage').className='card-value '+getColor(d.memory.percent);
+        document.getElementById('mem-detail').textContent=d.memory.used+' MB / '+d.memory.total+' MB (可用 '+d.memory.available+' MB)';
+        updateBar('mem-bar',d.memory.percent,100);
+        // Temperature
+        if(d.temperature.available){
+            document.getElementById('cpu-temp').textContent=d.temperature.value.toFixed(1)+'°C';
+            let tc=d.temperature.value>d.temperature.alert_threshold?'red':d.temperature.value>60?'yellow':'green';
+            document.getElementById('cpu-temp').className='card-value '+tc;
+            updateBar('temp-bar',d.temperature.value,d.temperature.alert_threshold*1.3);
+        }else{
+            document.getElementById('cpu-temp').textContent='不可用';
+            document.getElementById('cpu-temp').className='card-value';
+        }
+        document.getElementById('temp-threshold').textContent=d.temperature.alert_threshold;
+        // Disk
+        document.getElementById('disk-usage').textContent=d.disk.percent.toFixed(1)+'%';
+        document.getElementById('disk-usage').className='card-value '+getColor(d.disk.percent);
+        document.getElementById('disk-detail').textContent=d.disk.used+' GB / '+d.disk.total+' GB (可用 '+d.disk.available+' GB)';
+        updateBar('disk-bar',d.disk.percent,100);
+        // System
+        document.getElementById('hostname').textContent=d.system.hostname;
+        document.getElementById('kernel').textContent=d.system.kernel;
+        document.getElementById('uptime').textContent=d.system.uptime;
+        document.getElementById('pid').textContent=d.process.pid;
+        document.getElementById('proc-rss').textContent=d.process.rss_mb+' MB';
+        document.getElementById('cpu-threshold').textContent=d.thresholds.cpu_alert+'%';
+        document.getElementById('temp-threshold2').textContent=d.thresholds.temp_alert+'°C';
+        // Alerts
+        let alerts=[];
+        if(d.cpu.usage>d.thresholds.cpu_alert) alerts.push('⚠️ CPU使用率 '+d.cpu.usage.toFixed(1)+'% 超过阈值 '+d.thresholds.cpu_alert+'%');
+        if(d.temperature.available&&d.temperature.value>d.temperature.alert_threshold) alerts.push('⚠️ CPU温度 '+d.temperature.value.toFixed(1)+'°C 超过阈值 '+d.temperature.alert_threshold+'°C');
+        let ab=document.getElementById('alert-banner');
+        if(alerts.length){ab.style.display='block';ab.innerHTML=alerts.join('<br>')}else{ab.style.display='none'}
+        document.getElementById('last-update').textContent=new Date().toLocaleTimeString();
+    }).catch(e=>console.error(e));
+}
+update();
+setInterval(update,2000);
+</script>
+</body>
+</html>

+ 381 - 0
assets/web/video.ba3.html

@@ -0,0 +1,381 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频监控</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column;
+        }
+        .top-bar {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
+        }
+        .top-bar h1 { font-size: 18px; color: white; }
+        .nav-links { display: flex; gap: 8px; }
+        .nav-link {
+            background: rgba(255,255,255,0.2); color: white; padding: 6px 14px;
+            border-radius: 16px; text-decoration: none; font-size: 13px;
+            transition: background 0.2s; white-space: nowrap;
+        }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+
+        .video-area {
+            flex: 1; display: flex; flex-direction: column; align-items: center;
+            padding: 16px; overflow: auto;
+        }
+        .video-wrapper {
+            position: relative; background: #000; border-radius: 8px;
+            overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+            max-width: 960px; width: 100%;
+        }
+        .video-wrapper img { width: 100%; display: block; }
+        #roi-canvas {
+            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+            cursor: crosshair; display: none; z-index: 10;
+        }
+        .stream-label {
+            position: absolute; top: 8px; left: 12px; background: rgba(0,0,0,0.6);
+            color: white; padding: 4px 10px; border-radius: 4px; font-size: 13px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status {
+            position: absolute; top: 8px; right: 12px; background: rgba(0,0,0,0.6);
+            color: #52c41a; padding: 4px 10px; border-radius: 4px; font-size: 12px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status.offline { color: #f5222d; }
+
+        .controls {
+            margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap;
+            justify-content: center; max-width: 960px; width: 100%;
+        }
+        .btn {
+            padding: 10px 20px; border: 1px solid #444; background: #2a2a4a;
+            color: #eee; border-radius: 8px; cursor: pointer; font-size: 14px;
+            transition: all 0.2s; display: flex; align-items: center; gap: 6px;
+        }
+        .btn:hover { background: #3a3a5a; border-color: #667eea; }
+        .btn.primary { background: #667eea; border-color: #667eea; }
+        .btn.primary:hover { background: #5a6ed6; }
+        .btn.danger { background: #ff4d4f; border-color: #ff4d4f; color: white; }
+        .btn.danger:hover { background: #e04445; }
+        .btn.drawing { background: #ff4d4f; color: white; border-color: #ff4d4f; animation: pulse 1.5s infinite; }
+        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .roi-info {
+            margin-top: 12px; padding: 12px 16px; background: #2a2a4a;
+            border-radius: 8px; font-size: 13px; line-height: 1.8;
+            max-width: 960px; width: 100%;
+        }
+        .roi-info .label { color: #999; }
+        .roi-info .value { color: #667eea; font-weight: bold; }
+        .help-text { margin-top: 8px; font-size: 12px; color: #666; text-align: center; }
+
+        @media (max-width: 600px) {
+            .top-bar { flex-direction: column; gap: 8px; }
+            .nav-links { flex-wrap: wrap; justify-content: center; }
+        }
+    </style>
+</head>
+<body>
+    <div class="top-bar">
+        <h1 id="page-title">视频监控</h1>
+        <div class="nav-links">
+            <a href="/" class="nav-link">📊 监控台</a>
+            <a href="/video?stream=in-low" class="nav-link" id="nav-in-low">低位进站</a>
+            <a href="/video?stream=in-high" class="nav-link" id="nav-in-high">高位进站</a>
+            <a href="/video?stream=out-low" class="nav-link" id="nav-out-low">低位出站</a>
+            <a href="/video?stream=out-high" class="nav-link" id="nav-out-high">高位出站</a>
+        </div>
+    </div>
+
+    <div class="video-area">
+        <div class="video-wrapper" id="video-wrapper">
+            <div class="stream-label" id="stream-label">加载中...</div>
+            <div class="stream-status" id="stream-status">⏳ 连接中</div>
+            <img id="video-stream" src="" alt="视频流">
+            <canvas id="roi-canvas"></canvas>
+        </div>
+
+        <div class="controls">
+            <button class="btn primary" onclick="takeSnapshot()">📷 截图</button>
+            <button class="btn" id="btn-draw-roi" onclick="startDrawROI()">✏️ 绘制ROI</button>
+            <button class="btn" id="btn-clear-roi" onclick="clearROI()">🗑️ 清除ROI</button>
+            <button class="btn" onclick="refreshStream()">🔄 刷新视频</button>
+        </div>
+
+        <div class="roi-info" id="roi-info">
+            <span class="label">加载配置中...</span>
+        </div>
+        <div class="help-text" id="help-text"></div>
+    </div>
+
+    <script>
+    var STREAM_NAMES = {
+        'in-low': '低位进站', 'in-high': '高位进站',
+        'out-low': '低位出站', 'out-high': '高位出站'
+    };
+    var ROI_STREAMS = ['in-low', 'out-low'];
+    var currentStream = 'in-low';
+    var hasROI = false;
+    var isDrawingROI = false;
+    var drawStart = null;
+    var currentROI = null;
+    var videoResolution = { width: 1920, height: 1080 };
+
+    (function init() {
+        var params = new URLSearchParams(window.location.search);
+        currentStream = params.get('stream') || 'in-low';
+        hasROI = ROI_STREAMS.indexOf(currentStream) >= 0;
+        var streamName = STREAM_NAMES[currentStream] || '未知';
+        document.title = streamName + ' - 视频监控';
+        document.getElementById('page-title').textContent = streamName;
+        document.getElementById('stream-label').textContent = streamName;
+        var navEl = document.getElementById('nav-' + currentStream);
+        if (navEl) navEl.classList.add('active');
+        if (!hasROI) {
+            document.getElementById('btn-draw-roi').style.display = 'none';
+            document.getElementById('btn-clear-roi').style.display = 'none';
+            document.getElementById('help-text').textContent = '高位摄像头无ROI功能';
+        } else {
+            document.getElementById('help-text').innerHTML =
+                'ROI仅影响推理裁剪区域,<b>上传照片仍为原始全帧</b> | ESC退出绘制模式';
+        }
+        startStream();
+        loadRoiConfig();
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape' && isDrawingROI) cancelDrawROI();
+        });
+        var img = document.getElementById('video-stream');
+        img.onload = function() {
+            document.getElementById('stream-status').textContent = '🟢 在线';
+            document.getElementById('stream-status').className = 'stream-status';
+        };
+        img.onerror = function() {
+            document.getElementById('stream-status').textContent = '🔴 离线';
+            document.getElementById('stream-status').className = 'stream-status offline';
+            setTimeout(function() {
+                img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+            }, 3000);
+        };
+    })();
+
+    function startStream() {
+        document.getElementById('video-stream').src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+    }
+    function refreshStream() {
+        var img = document.getElementById('video-stream');
+        img.src = '';
+        setTimeout(function() { img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now(); }, 100);
+    }
+    function takeSnapshot() {
+        window.open('/api/stream?stream=' + currentStream + '&t=' + Date.now(), '_blank');
+    }
+
+    function loadRoiConfig() {
+        fetch('/api/roi/config')
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                var roi = null;
+                if (currentStream === 'in-low') roi = data.roi_in;
+                else if (currentStream === 'out-low') roi = data.roi_out;
+                if (data.resolution && data.resolution[currentStream]) {
+                    videoResolution = data.resolution[currentStream];
+                }
+                var infoEl = document.getElementById('roi-info');
+                if (!hasROI) {
+                    infoEl.innerHTML = '<span class="label">高位摄像头 — 无ROI配置</span>';
+                    return;
+                }
+                if (roi && roi.enabled) {
+                    currentROI = { x: roi.x, y: roi.y, w: roi.w, h: roi.h };
+                    infoEl.innerHTML =
+                        '<span class="label">ROI区域:</span> <span class="value">(' +
+                        roi.x + ', ' + roi.y + ') ' + roi.w + '×' + roi.h +
+                        '</span> <span class="label">| 分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height +
+                        '</span><br><span class="label">状态:</span> <span class="value" style="color:#52c41a;">✅ 已启用</span>' +
+                        ' <span class="label">| 推理裁剪,上传全帧</span>';
+                } else {
+                    currentROI = null;
+                    infoEl.innerHTML =
+                        '<span class="label">ROI状态:</span> <span class="value" style="color:#faad14;">⚪ 未启用(全帧推理)</span>' +
+                        '<br><span class="label">分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height + '</span>';
+                }
+            })
+            .catch(function(e) {
+                document.getElementById('roi-info').innerHTML =
+                    '<span class="label" style="color:#f5222d;">⚠️ 配置加载失败</span>';
+            });
+    }
+
+    function startDrawROI() {
+        if (!hasROI) return;
+        isDrawingROI = true;
+        drawStart = null;
+        var img = document.getElementById('video-stream');
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        img.src = '/api/stream?stream=' + currentStream + '&t=' + Date.now();
+        resizeCanvas();
+        canvas.style.display = 'block';
+        btn.classList.add('drawing');
+        btn.innerHTML = '🔲 绘制中...';
+        canvas.onmousedown = onDrawStart;
+        canvas.onmousemove = onDrawMove;
+        canvas.onmouseup = onDrawEnd;
+        canvas.ontouchstart = onTouchStart;
+        canvas.ontouchmove = onTouchMove;
+        canvas.ontouchend = onTouchEnd;
+    }
+
+    function cancelDrawROI() {
+        isDrawingROI = false;
+        drawStart = null;
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        canvas.style.display = 'none';
+        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+        btn.classList.remove('drawing');
+        btn.innerHTML = '✏️ 绘制ROI';
+        startStream();
+    }
+
+    function finishDrawROI(rect) {
+        var canvas = document.getElementById('roi-canvas');
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        var roiX = Math.round(rect.x * scaleX);
+        var roiY = Math.round(rect.y * scaleY);
+        var roiW = Math.round(rect.w * scaleX);
+        var roiH = Math.round(rect.h * scaleY);
+        roiX = Math.max(0, Math.min(roiX, videoResolution.width - 1));
+        roiY = Math.max(0, Math.min(roiY, videoResolution.height - 1));
+        roiW = Math.min(roiW, videoResolution.width - roiX);
+        roiH = Math.min(roiH, videoResolution.height - roiY);
+        if (roiW < 50 || roiH < 50) {
+            alert('ROI区域太小(最小50×50像素),请重新绘制');
+            cancelDrawROI();
+            return;
+        }
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (confirm('确认应用' + sideName + 'ROI区域?\n坐标: (' + roiX + ', ' + roiY + ')\n尺寸: ' + roiW + '×' + roiH)) {
+            fetch('/api/roi/update', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ side: side, x: roiX, y: roiY, w: roiW, h: roiH, enabled: true })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                if (data.success) {
+                    alert('✅ ' + sideName + 'ROI已更新并保存到config.ini');
+                    loadRoiConfig();
+                } else {
+                    alert('❌ 更新失败: ' + (data.message || '未知错误'));
+                }
+            })
+            .catch(function(e) { alert('请求失败: ' + e); });
+        }
+        cancelDrawROI();
+    }
+
+    function clearROI() {
+        if (!hasROI) return;
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (!confirm('确定清除' + sideName + 'ROI区域?\n清除后将使用全帧推理')) return;
+        fetch('/api/roi/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ side: side, x: 0, y: 0, w: 0, h: 0, enabled: false })
+        })
+        .then(function(r) { return r.json(); })
+        .then(function(data) {
+            if (data.success) {
+                alert('✅ ROI已清除,恢复全帧推理');
+                loadRoiConfig();
+            } else {
+                alert('❌ 清除失败: ' + (data.message || '未知错误'));
+            }
+        })
+        .catch(function(e) { alert('请求失败: ' + e); });
+    }
+
+    function resizeCanvas() {
+        var wrapper = document.getElementById('video-wrapper');
+        var canvas = document.getElementById('roi-canvas');
+        canvas.width = wrapper.clientWidth;
+        canvas.height = wrapper.clientHeight;
+    }
+
+    function getCanvasPos(e) {
+        var rect = document.getElementById('roi-canvas').getBoundingClientRect();
+        return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+    }
+
+    function onDrawStart(e) { drawStart = getCanvasPos(e); }
+    function onDrawMove(e) {
+        if (!drawStart) return;
+        var canvas = document.getElementById('roi-canvas');
+        var ctx = canvas.getContext('2d');
+        var pos = getCanvasPos(e);
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        var rx = Math.min(drawStart.x, pos.x);
+        var ry = Math.min(drawStart.y, pos.y);
+        var rw = Math.abs(pos.x - drawStart.x);
+        var rh = Math.abs(pos.y - drawStart.y);
+        ctx.fillStyle = 'rgba(255, 0, 0, 0.15)';
+        ctx.fillRect(rx, ry, rw, rh);
+        ctx.strokeStyle = '#ff0000';
+        ctx.lineWidth = 2;
+        ctx.setLineDash([6, 3]);
+        ctx.strokeRect(rx, ry, rw, rh);
+        ctx.setLineDash([]);
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        ctx.fillStyle = '#ff0000';
+        ctx.font = '14px sans-serif';
+        ctx.fillText(Math.round(rw * scaleX) + '×' + Math.round(rh * scaleY), rx + 4, ry + 18);
+    }
+    function onDrawEnd(e) {
+        if (!drawStart) return;
+        var pos = getCanvasPos(e);
+        var x = Math.min(drawStart.x, pos.x);
+        var y = Math.min(drawStart.y, pos.y);
+        var w = Math.abs(pos.x - drawStart.x);
+        var h = Math.abs(pos.y - drawStart.y);
+        if (w < 5 || h < 5) { cancelDrawROI(); return; }
+        finishDrawROI({ x: x, y: y, w: w, h: h });
+    }
+
+    function onTouchStart(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawStart({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchMove(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawMove({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchEnd(e) {
+        e.preventDefault();
+        var t = e.changedTouches[0];
+        onDrawEnd({ clientX: t.clientX, clientY: t.clientY });
+    }
+    </script>
+</body>
+</html>

+ 381 - 0
assets/web/video.bak4.html

@@ -0,0 +1,381 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频监控</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column;
+        }
+        .top-bar {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
+        }
+        .top-bar h1 { font-size: 18px; color: white; }
+        .nav-links { display: flex; gap: 8px; }
+        .nav-link {
+            background: rgba(255,255,255,0.2); color: white; padding: 6px 14px;
+            border-radius: 16px; text-decoration: none; font-size: 13px;
+            transition: background 0.2s; white-space: nowrap;
+        }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+
+        .video-area {
+            flex: 1; display: flex; flex-direction: column; align-items: center;
+            padding: 16px; overflow: auto;
+        }
+        .video-wrapper {
+            position: relative; background: #000; border-radius: 8px;
+            overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+            max-width: 960px; width: 100%;
+        }
+        .video-wrapper img { width: 100%; display: block; }
+        #roi-canvas {
+            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+            cursor: crosshair; display: none; z-index: 10;
+        }
+        .stream-label {
+            position: absolute; top: 18px; left: 12px; background: rgba(0,0,0,0.6);
+            color: white; padding: 4px 10px; border-radius: 4px; font-size: 13px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status {
+            position: absolute; top: 18px; right: 12px; background: rgba(0,0,0,0.6);
+            color: #52c41a; padding: 4px 10px; border-radius: 4px; font-size: 12px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status.offline { color: #f5222d; }
+
+        .controls {
+            margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap;
+            justify-content: center; max-width: 960px; width: 100%;
+        }
+        .btn {
+            padding: 10px 20px; border: 1px solid #444; background: #2a2a4a;
+            color: #eee; border-radius: 8px; cursor: pointer; font-size: 14px;
+            transition: all 0.2s; display: flex; align-items: center; gap: 6px;
+        }
+        .btn:hover { background: #3a3a5a; border-color: #667eea; }
+        .btn.primary { background: #667eea; border-color: #667eea; }
+        .btn.primary:hover { background: #5a6ed6; }
+        .btn.danger { background: #ff4d4f; border-color: #ff4d4f; color: white; }
+        .btn.danger:hover { background: #e04445; }
+        .btn.drawing { background: #ff4d4f; color: white; border-color: #ff4d4f; animation: pulse 1.5s infinite; }
+        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .roi-info {
+            margin-top: 12px; padding: 12px 16px; background: #2a2a4a;
+            border-radius: 8px; font-size: 13px; line-height: 1.8;
+            max-width: 960px; width: 100%;
+        }
+        .roi-info .label { color: #999; }
+        .roi-info .value { color: #667eea; font-weight: bold; }
+        .help-text { margin-top: 8px; font-size: 12px; color: #666; text-align: center; }
+
+        @media (max-width: 600px) {
+            .top-bar { flex-direction: column; gap: 8px; }
+            .nav-links { flex-wrap: wrap; justify-content: center; }
+        }
+    </style>
+</head>
+<body>
+    <div class="top-bar">
+        <h1 id="page-title">视频监控</h1>
+        <div class="nav-links">
+            <a href="/" class="nav-link">📊 监控台</a>
+            <a href="/video?stream=in-low" class="nav-link" id="nav-in-low">低位进站</a>
+            <a href="/video?stream=in-high" class="nav-link" id="nav-in-high">高位进站</a>
+            <a href="/video?stream=out-low" class="nav-link" id="nav-out-low">低位出站</a>
+            <a href="/video?stream=out-high" class="nav-link" id="nav-out-high">高位出站</a>
+        </div>
+    </div>
+
+    <div class="video-area">
+        <div class="video-wrapper" id="video-wrapper">
+            <div class="stream-label" id="stream-label">加载中...</div>
+            <div class="stream-status" id="stream-status">⏳ 连接中</div>
+            <img id="video-stream" src="" alt="视频流">
+            <canvas id="roi-canvas"></canvas>
+        </div>
+
+        <div class="controls">
+            <button class="btn primary" onclick="takeSnapshot()">📷 截图</button>
+            <button class="btn" id="btn-draw-roi" onclick="startDrawROI()">✏️ 绘制ROI</button>
+            <button class="btn" id="btn-clear-roi" onclick="clearROI()">🗑️ 清除ROI</button>
+            <button class="btn" onclick="refreshStream()">🔄 刷新视频</button>
+        </div>
+
+        <div class="roi-info" id="roi-info">
+            <span class="label">加载配置中...</span>
+        </div>
+        <div class="help-text" id="help-text"></div>
+    </div>
+
+    <script>
+    var STREAM_NAMES = {
+        'in-low': '低位进站', 'in-high': '高位进站',
+        'out-low': '低位出站', 'out-high': '高位出站'
+    };
+    var ROI_STREAMS = ['in-low', 'out-low'];
+    var currentStream = 'in-low';
+    var hasROI = false;
+    var isDrawingROI = false;
+    var drawStart = null;
+    var currentROI = null;
+    var videoResolution = { width: 1920, height: 1080 };
+
+    (function init() {
+        var params = new URLSearchParams(window.location.search);
+        currentStream = params.get('stream') || 'in-low';
+        hasROI = ROI_STREAMS.indexOf(currentStream) >= 0;
+        var streamName = STREAM_NAMES[currentStream] || '未知';
+        document.title = streamName + ' - 视频监控';
+        document.getElementById('page-title').textContent = streamName;
+        document.getElementById('stream-label').textContent = streamName;
+        var navEl = document.getElementById('nav-' + currentStream);
+        if (navEl) navEl.classList.add('active');
+        if (!hasROI) {
+            document.getElementById('btn-draw-roi').style.display = 'none';
+            document.getElementById('btn-clear-roi').style.display = 'none';
+            document.getElementById('help-text').textContent = '高位摄像头无ROI功能';
+        } else {
+            document.getElementById('help-text').innerHTML =
+                'ROI仅影响推理裁剪区域,<b>上传照片仍为原始全帧</b> | ESC退出绘制模式';
+        }
+        startStream();
+        loadRoiConfig();
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape' && isDrawingROI) cancelDrawROI();
+        });
+        var img = document.getElementById('video-stream');
+        img.onload = function() {
+            document.getElementById('stream-status').textContent = '🟢 在线';
+            document.getElementById('stream-status').className = 'stream-status';
+        };
+        img.onerror = function() {
+            document.getElementById('stream-status').textContent = '🔴 离线';
+            document.getElementById('stream-status').className = 'stream-status offline';
+            setTimeout(function() {
+                img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+            }, 3000);
+        };
+    })();
+
+    function startStream() {
+        document.getElementById('video-stream').src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+    }
+    function refreshStream() {
+        var img = document.getElementById('video-stream');
+        img.src = '';
+        setTimeout(function() { img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now(); }, 100);
+    }
+    function takeSnapshot() {
+        window.open('/api/stream?stream=' + currentStream + '&t=' + Date.now(), '_blank');
+    }
+
+    function loadRoiConfig() {
+        fetch('/api/roi/config')
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                var roi = null;
+                if (currentStream === 'in-low') roi = data.roi_in;
+                else if (currentStream === 'out-low') roi = data.roi_out;
+                if (data.resolution && data.resolution[currentStream]) {
+                    videoResolution = data.resolution[currentStream];
+                }
+                var infoEl = document.getElementById('roi-info');
+                if (!hasROI) {
+                    infoEl.innerHTML = '<span class="label">高位摄像头 — 无ROI配置</span>';
+                    return;
+                }
+                if (roi && roi.enabled) {
+                    currentROI = { x: roi.x, y: roi.y, w: roi.w, h: roi.h };
+                    infoEl.innerHTML =
+                        '<span class="label">ROI区域:</span> <span class="value">(' +
+                        roi.x + ', ' + roi.y + ') ' + roi.w + '×' + roi.h +
+                        '</span> <span class="label">| 分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height +
+                        '</span><br><span class="label">状态:</span> <span class="value" style="color:#52c41a;">✅ 已启用</span>' +
+                        ' <span class="label">| 推理裁剪,上传全帧</span>';
+                } else {
+                    currentROI = null;
+                    infoEl.innerHTML =
+                        '<span class="label">ROI状态:</span> <span class="value" style="color:#faad14;">⚪ 未启用(全帧推理)</span>' +
+                        '<br><span class="label">分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height + '</span>';
+                }
+            })
+            .catch(function(e) {
+                document.getElementById('roi-info').innerHTML =
+                    '<span class="label" style="color:#f5222d;">⚠️ 配置加载失败</span>';
+            });
+    }
+
+    function startDrawROI() {
+        if (!hasROI) return;
+        isDrawingROI = true;
+        drawStart = null;
+        var img = document.getElementById('video-stream');
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        img.src = '/api/stream?stream=' + currentStream + '&t=' + Date.now();
+        resizeCanvas();
+        canvas.style.display = 'block';
+        btn.classList.add('drawing');
+        btn.innerHTML = '🔲 绘制中...';
+        canvas.onmousedown = onDrawStart;
+        canvas.onmousemove = onDrawMove;
+        canvas.onmouseup = onDrawEnd;
+        canvas.ontouchstart = onTouchStart;
+        canvas.ontouchmove = onTouchMove;
+        canvas.ontouchend = onTouchEnd;
+    }
+
+    function cancelDrawROI() {
+        isDrawingROI = false;
+        drawStart = null;
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        canvas.style.display = 'none';
+        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+        btn.classList.remove('drawing');
+        btn.innerHTML = '✏️ 绘制ROI';
+        startStream();
+    }
+
+    function finishDrawROI(rect) {
+        var canvas = document.getElementById('roi-canvas');
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        var roiX = Math.round(rect.x * scaleX);
+        var roiY = Math.round(rect.y * scaleY);
+        var roiW = Math.round(rect.w * scaleX);
+        var roiH = Math.round(rect.h * scaleY);
+        roiX = Math.max(0, Math.min(roiX, videoResolution.width - 1));
+        roiY = Math.max(0, Math.min(roiY, videoResolution.height - 1));
+        roiW = Math.min(roiW, videoResolution.width - roiX);
+        roiH = Math.min(roiH, videoResolution.height - roiY);
+        if (roiW < 50 || roiH < 50) {
+            alert('ROI区域太小(最小50×50像素),请重新绘制');
+            cancelDrawROI();
+            return;
+        }
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (confirm('确认应用' + sideName + 'ROI区域?\n坐标: (' + roiX + ', ' + roiY + ')\n尺寸: ' + roiW + '×' + roiH)) {
+            fetch('/api/roi/update', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ side: side, x: roiX, y: roiY, w: roiW, h: roiH, enabled: true })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                if (data.success) {
+                    alert('✅ ' + sideName + 'ROI已更新并保存到config.ini');
+                    loadRoiConfig();
+                } else {
+                    alert('❌ 更新失败: ' + (data.message || '未知错误'));
+                }
+            })
+            .catch(function(e) { alert('请求失败: ' + e); });
+        }
+        cancelDrawROI();
+    }
+
+    function clearROI() {
+        if (!hasROI) return;
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (!confirm('确定清除' + sideName + 'ROI区域?\n清除后将使用全帧推理')) return;
+        fetch('/api/roi/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ side: side, x: 0, y: 0, w: 0, h: 0, enabled: false })
+        })
+        .then(function(r) { return r.json(); })
+        .then(function(data) {
+            if (data.success) {
+                alert('✅ ROI已清除,恢复全帧推理');
+                loadRoiConfig();
+            } else {
+                alert('❌ 清除失败: ' + (data.message || '未知错误'));
+            }
+        })
+        .catch(function(e) { alert('请求失败: ' + e); });
+    }
+
+    function resizeCanvas() {
+        var wrapper = document.getElementById('video-wrapper');
+        var canvas = document.getElementById('roi-canvas');
+        canvas.width = wrapper.clientWidth;
+        canvas.height = wrapper.clientHeight;
+    }
+
+    function getCanvasPos(e) {
+        var rect = document.getElementById('roi-canvas').getBoundingClientRect();
+        return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+    }
+
+    function onDrawStart(e) { drawStart = getCanvasPos(e); }
+    function onDrawMove(e) {
+        if (!drawStart) return;
+        var canvas = document.getElementById('roi-canvas');
+        var ctx = canvas.getContext('2d');
+        var pos = getCanvasPos(e);
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        var rx = Math.min(drawStart.x, pos.x);
+        var ry = Math.min(drawStart.y, pos.y);
+        var rw = Math.abs(pos.x - drawStart.x);
+        var rh = Math.abs(pos.y - drawStart.y);
+        ctx.fillStyle = 'rgba(255, 0, 0, 0.15)';
+        ctx.fillRect(rx, ry, rw, rh);
+        ctx.strokeStyle = '#ff0000';
+        ctx.lineWidth = 2;
+        ctx.setLineDash([6, 3]);
+        ctx.strokeRect(rx, ry, rw, rh);
+        ctx.setLineDash([]);
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        ctx.fillStyle = '#ff0000';
+        ctx.font = '14px sans-serif';
+        ctx.fillText(Math.round(rw * scaleX) + '×' + Math.round(rh * scaleY), rx + 4, ry + 18);
+    }
+    function onDrawEnd(e) {
+        if (!drawStart) return;
+        var pos = getCanvasPos(e);
+        var x = Math.min(drawStart.x, pos.x);
+        var y = Math.min(drawStart.y, pos.y);
+        var w = Math.abs(pos.x - drawStart.x);
+        var h = Math.abs(pos.y - drawStart.y);
+        if (w < 5 || h < 5) { cancelDrawROI(); return; }
+        finishDrawROI({ x: x, y: y, w: w, h: h });
+    }
+
+    function onTouchStart(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawStart({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchMove(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawMove({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchEnd(e) {
+        e.preventDefault();
+        var t = e.changedTouches[0];
+        onDrawEnd({ clientX: t.clientX, clientY: t.clientY });
+    }
+    </script>
+</body>
+</html>

+ 407 - 0
assets/web/video.html

@@ -0,0 +1,407 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频监控</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column;
+        }
+        .top-bar {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
+        }
+        .top-bar h1 { font-size: 18px; color: white; }
+        .nav-links { display: flex; gap: 8px; }
+        .nav-link {
+            background: rgba(255,255,255,0.2); color: white; padding: 6px 14px;
+            border-radius: 16px; text-decoration: none; font-size: 13px;
+            transition: background 0.2s; white-space: nowrap;
+        }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+
+        .video-area {
+            flex: 1; display: flex; flex-direction: column; align-items: center;
+            padding: 16px; overflow: auto;
+        }
+        .video-wrapper {
+            position: relative; background: #000; border-radius: 8px;
+            overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+            max-width: 960px; width: 100%;
+        }
+        .video-wrapper img { width: 100%; display: block; }
+        #roi-canvas {
+            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+            cursor: crosshair; display: none; z-index: 10;
+        }
+        .stream-label {
+            position: absolute; top: 18px; left: 12px; background: rgba(0,0,0,0.6);
+            color: white; padding: 4px 10px; border-radius: 4px; font-size: 13px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status {
+            position: absolute; top: 18px; right: 12px; background: rgba(0,0,0,0.6);
+            color: #52c41a; padding: 4px 10px; border-radius: 4px; font-size: 12px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status.offline { color: #f5222d; }
+
+        .controls {
+            margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap;
+            justify-content: center; max-width: 960px; width: 100%;
+        }
+        .btn {
+            padding: 10px 20px; border: 1px solid #444; background: #2a2a4a;
+            color: #eee; border-radius: 8px; cursor: pointer; font-size: 14px;
+            transition: all 0.2s; display: flex; align-items: center; gap: 6px;
+        }
+        .btn:hover { background: #3a3a5a; border-color: #667eea; }
+        .btn.primary { background: #667eea; border-color: #667eea; }
+        .btn.primary:hover { background: #5a6ed6; }
+        .btn.danger { background: #ff4d4f; border-color: #ff4d4f; color: white; }
+        .btn.danger:hover { background: #e04445; }
+        .btn.drawing { background: #ff4d4f; color: white; border-color: #ff4d4f; animation: pulse 1.5s infinite; }
+        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .roi-info {
+            margin-top: 12px; padding: 12px 16px; background: #2a2a4a;
+            border-radius: 8px; font-size: 13px; line-height: 1.8;
+            max-width: 960px; width: 100%;
+        }
+        .roi-info .label { color: #999; }
+        .roi-info .value { color: #667eea; font-weight: bold; }
+        .help-text { margin-top: 8px; font-size: 12px; color: #666; text-align: center; }
+
+        @media (max-width: 600px) {
+            .top-bar { flex-direction: column; gap: 8px; }
+            .nav-links { flex-wrap: wrap; justify-content: center; }
+        }
+    
+        @media (max-width: 600px) {
+            .top-bar { padding: 10px 12px; }
+            .top-bar h1 { font-size: 15px; }
+            .nav-link { padding: 4px 10px; font-size: 12px; }
+            .video-area { padding: 8px; }
+            .controls { gap: 6px; }
+            .btn { padding: 8px 14px; font-size: 13px; }
+            .roi-info { padding: 10px 12px; font-size: 12px; }
+        }
+    </style>
+</head>
+<body>
+    <div class="top-bar">
+        <h1 id="page-title">视频监控</h1>
+        <div class="nav-links">
+            <a href="/" class="nav-link">📊 监控台</a>
+            <a href="/video?stream=in-low" class="nav-link" id="nav-in-low">低位进站</a>
+            <a href="/video?stream=in-high" class="nav-link" id="nav-in-high">高位进站</a>
+            <a href="/video?stream=out-low" class="nav-link" id="nav-out-low">低位出站</a>
+            <a href="/video?stream=out-high" class="nav-link" id="nav-out-high">高位出站</a>
+            <a href="/help" class="nav-link" id="nav-help" style="display:none;">📖 帮助</a>
+        </div>
+    </div>
+
+    <div class="video-area">
+        <div class="video-wrapper" id="video-wrapper">
+            <div class="stream-label" id="stream-label">加载中...</div>
+            <div class="stream-status" id="stream-status">⏳ 连接中</div>
+            <div class="stream-status" id="shared-badge" style="display:none; top:18px; right:100px; background:rgba(102,126,234,0.7); color:white;">🔗 共享模式</div>
+            <img id="video-stream" src="" alt="视频流">
+            <canvas id="roi-canvas"></canvas>
+        </div>
+
+        <div class="controls">
+            <button class="btn primary" onclick="takeSnapshot()">📷 截图</button>
+            <button class="btn" id="btn-draw-roi" onclick="startDrawROI()">✏️ 绘制ROI</button>
+            <button class="btn" id="btn-clear-roi" onclick="clearROI()">🗑️ 清除ROI</button>
+            <button class="btn" onclick="refreshStream()">🔄 刷新视频</button>
+        </div>
+
+        <div class="roi-info" id="roi-info">
+            <span class="label">加载配置中...</span>
+        </div>
+        <div class="help-text" id="help-text"></div>
+    </div>
+
+    <script>
+    function getCookie(n){let m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}
+    var STREAM_NAMES = {
+        'in-low': '低位进站', 'in-high': '高位进站',
+        'out-low': '低位出站', 'out-high': '高位出站'
+    };
+    var ROI_STREAMS = ['in-low', 'out-low'];
+    var currentStream = 'in-low';
+    var hasROI = false;
+    var isDrawingROI = false;
+    var drawStart = null;
+    var currentROI = null;
+    var videoResolution = { width: 1920, height: 1080 };
+
+    (function init() {
+        var params = new URLSearchParams(window.location.search);
+        currentStream = params.get('stream') || 'in-low';
+        hasROI = ROI_STREAMS.indexOf(currentStream) >= 0;
+        var streamName = STREAM_NAMES[currentStream] || '未知';
+        document.title = streamName + ' - 视频监控';
+        document.getElementById('page-title').textContent = streamName;
+        document.getElementById('stream-label').textContent = streamName;
+        var navEl = document.getElementById('nav-' + currentStream);
+        if (navEl) navEl.classList.add('active');
+        if (!hasROI) {
+            document.getElementById('btn-draw-roi').style.display = 'none';
+            document.getElementById('btn-clear-roi').style.display = 'none';
+            document.getElementById('help-text').textContent = '高位摄像头无ROI功能';
+        } else {
+            document.getElementById('help-text').innerHTML =
+                'ROI仅影响推理裁剪区域,<b>上传照片仍为原始全帧</b> | ESC退出绘制模式';
+        }
+        startStream();
+        loadRoiConfig();
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape' && isDrawingROI) cancelDrawROI();
+        });
+        var img = document.getElementById('video-stream');
+        img.onload = function() {
+            document.getElementById('stream-status').textContent = '🟢 在线';
+            document.getElementById('stream-status').className = 'stream-status';
+        };
+        img.onerror = function() {
+            document.getElementById('stream-status').textContent = '🔴 离线';
+            document.getElementById('stream-status').className = 'stream-status offline';
+            setTimeout(function() {
+                img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+            }, 3000);
+        };
+    })();
+
+    function startStream() {
+        document.getElementById('video-stream').src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+    }
+    function refreshStream() {
+        var img = document.getElementById('video-stream');
+        img.src = '';
+        setTimeout(function() { img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now(); }, 100);
+    }
+    function takeSnapshot() {
+        window.open('/api/stream?stream=' + currentStream + '&t=' + Date.now(), '_blank');
+    }
+
+    function loadRoiConfig() {
+        fetch('/api/roi/config')
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                var roi = null;
+                if (currentStream === 'in-low') roi = data.roi_in;
+                else if (currentStream === 'out-low') roi = data.roi_out;
+                if (data.resolution && data.resolution[currentStream]) {
+                    videoResolution = data.resolution[currentStream];
+                }
+                // ✅ fix24-v9: 共享模式下出站页面显示提示
+                if (data.shared_mode && currentStream === 'out-low') {
+                    var badge = document.getElementById('shared-badge');
+                    if (badge) badge.style.display = 'block';
+                }
+                var infoEl = document.getElementById('roi-info');
+                if (!hasROI) {
+                    infoEl.innerHTML = '<span class="label">高位摄像头 — 无ROI配置</span>';
+                    return;
+                }
+                if (roi && roi.enabled) {
+                    currentROI = { x: roi.x, y: roi.y, w: roi.w, h: roi.h };
+                    infoEl.innerHTML =
+                        '<span class="label">ROI区域:</span> <span class="value">(' +
+                        roi.x + ', ' + roi.y + ') ' + roi.w + '×' + roi.h +
+                        '</span> <span class="label">| 分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height +
+                        '</span><br><span class="label">状态:</span> <span class="value" style="color:#52c41a;">✅ 已启用</span>' +
+                        ' <span class="label">| 推理裁剪,上传全帧</span>';
+                } else {
+                    currentROI = null;
+                    infoEl.innerHTML =
+                        '<span class="label">ROI状态:</span> <span class="value" style="color:#faad14;">⚪ 未启用(全帧推理)</span>' +
+                        '<br><span class="label">分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height + '</span>';
+                }
+            })
+            .catch(function(e) {
+                document.getElementById('roi-info').innerHTML =
+                    '<span class="label" style="color:#f5222d;">⚠️ 配置加载失败</span>';
+            });
+    }
+
+    function startDrawROI() {
+        if (!hasROI) return;
+        isDrawingROI = true;
+        drawStart = null;
+        var img = document.getElementById('video-stream');
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        img.src = '/api/stream?stream=' + currentStream + '&t=' + Date.now();
+        resizeCanvas();
+        canvas.style.display = 'block';
+        btn.classList.add('drawing');
+        btn.innerHTML = '🔲 绘制中...';
+        canvas.onmousedown = onDrawStart;
+        canvas.onmousemove = onDrawMove;
+        canvas.onmouseup = onDrawEnd;
+        canvas.ontouchstart = onTouchStart;
+        canvas.ontouchmove = onTouchMove;
+        canvas.ontouchend = onTouchEnd;
+    }
+
+    function cancelDrawROI() {
+        isDrawingROI = false;
+        drawStart = null;
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        canvas.style.display = 'none';
+        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+        btn.classList.remove('drawing');
+        btn.innerHTML = '✏️ 绘制ROI';
+        startStream();
+    }
+
+    function finishDrawROI(rect) {
+        var canvas = document.getElementById('roi-canvas');
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        var roiX = Math.round(rect.x * scaleX);
+        var roiY = Math.round(rect.y * scaleY);
+        var roiW = Math.round(rect.w * scaleX);
+        var roiH = Math.round(rect.h * scaleY);
+        roiX = Math.max(0, Math.min(roiX, videoResolution.width - 1));
+        roiY = Math.max(0, Math.min(roiY, videoResolution.height - 1));
+        roiW = Math.min(roiW, videoResolution.width - roiX);
+        roiH = Math.min(roiH, videoResolution.height - roiY);
+        if (roiW < 50 || roiH < 50) {
+            alert('ROI区域太小(最小50×50像素),请重新绘制');
+            cancelDrawROI();
+            return;
+        }
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (confirm('确认应用' + sideName + 'ROI区域?\n坐标: (' + roiX + ', ' + roiY + ')\n尺寸: ' + roiW + '×' + roiH)) {
+            fetch('/api/roi/update', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie('csrf_token') },
+                body: JSON.stringify({ side: side, x: roiX, y: roiY, w: roiW, h: roiH, enabled: true })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                if (data.success) {
+                    alert('✅ ' + sideName + 'ROI已更新并保存到config.ini');
+                    loadRoiConfig();
+                } else {
+                    alert('❌ 更新失败: ' + (data.message || '未知错误'));
+                }
+            })
+            .catch(function(e) { alert('请求失败: ' + e); });
+        }
+        cancelDrawROI();
+    }
+
+    function clearROI() {
+        if (!hasROI) return;
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (!confirm('确定清除' + sideName + 'ROI区域?\n清除后将使用全帧推理')) return;
+        fetch('/api/roi/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie('csrf_token') },
+            body: JSON.stringify({ side: side, x: 0, y: 0, w: 0, h: 0, enabled: false })
+        })
+        .then(function(r) { return r.json(); })
+        .then(function(data) {
+            if (data.success) {
+                alert('✅ ROI已清除,恢复全帧推理');
+                loadRoiConfig();
+            } else {
+                alert('❌ 清除失败: ' + (data.message || '未知错误'));
+            }
+        })
+        .catch(function(e) { alert('请求失败: ' + e); });
+    }
+
+    function resizeCanvas() {
+        var wrapper = document.getElementById('video-wrapper');
+        var canvas = document.getElementById('roi-canvas');
+        canvas.width = wrapper.clientWidth;
+        canvas.height = wrapper.clientHeight;
+    }
+
+    function getCanvasPos(e) {
+        var rect = document.getElementById('roi-canvas').getBoundingClientRect();
+        return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+    }
+
+    function onDrawStart(e) { drawStart = getCanvasPos(e); }
+    function onDrawMove(e) {
+        if (!drawStart) return;
+        var canvas = document.getElementById('roi-canvas');
+        var ctx = canvas.getContext('2d');
+        var pos = getCanvasPos(e);
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        var rx = Math.min(drawStart.x, pos.x);
+        var ry = Math.min(drawStart.y, pos.y);
+        var rw = Math.abs(pos.x - drawStart.x);
+        var rh = Math.abs(pos.y - drawStart.y);
+        ctx.fillStyle = 'rgba(255, 0, 0, 0.15)';
+        ctx.fillRect(rx, ry, rw, rh);
+        ctx.strokeStyle = '#ff0000';
+        ctx.lineWidth = 2;
+        ctx.setLineDash([6, 3]);
+        ctx.strokeRect(rx, ry, rw, rh);
+        ctx.setLineDash([]);
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        ctx.fillStyle = '#ff0000';
+        ctx.font = '14px sans-serif';
+        ctx.fillText(Math.round(rw * scaleX) + '×' + Math.round(rh * scaleY), rx + 4, ry + 18);
+    }
+    function onDrawEnd(e) {
+        if (!drawStart) return;
+        var pos = getCanvasPos(e);
+        var x = Math.min(drawStart.x, pos.x);
+        var y = Math.min(drawStart.y, pos.y);
+        var w = Math.abs(pos.x - drawStart.x);
+        var h = Math.abs(pos.y - drawStart.y);
+        if (w < 5 || h < 5) { cancelDrawROI(); return; }
+        finishDrawROI({ x: x, y: y, w: w, h: h });
+    }
+
+    function onTouchStart(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawStart({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchMove(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawMove({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchEnd(e) {
+        e.preventDefault();
+        var t = e.changedTouches[0];
+        onDrawEnd({ clientX: t.clientX, clientY: t.clientY });
+    }
+
+    // 角色检查:仅超级管理员可看到帮助链接
+    fetch('/api/auth/status', {credentials:'same-origin'}).then(r=>r.json()).then(data=>{
+        if(data.authenticated && data.role >= 2){
+            var el = document.getElementById('nav-help');
+            if(el) el.style.display = '';
+        }
+    }).catch(()=>{});
+    </script>
+</body>
+</html>

+ 381 - 0
assets/web/video3del.html

@@ -0,0 +1,381 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频监控</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column;
+        }
+        .top-bar {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
+        }
+        .top-bar h1 { font-size: 18px; color: white; }
+        .nav-links { display: flex; gap: 8px; }
+        .nav-link {
+            background: rgba(255,255,255,0.2); color: white; padding: 6px 14px;
+            border-radius: 16px; text-decoration: none; font-size: 13px;
+            transition: background 0.2s; white-space: nowrap;
+        }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+
+        .video-area {
+            flex: 1; display: flex; flex-direction: column; align-items: center;
+            padding: 16px; overflow: auto;
+        }
+        .video-wrapper {
+            position: relative; background: #000; border-radius: 8px;
+            overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+            max-width: 960px; width: 100%;
+        }
+        .video-wrapper img { width: 100%; display: block; }
+        #roi-canvas {
+            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+            cursor: crosshair; display: none; z-index: 10;
+        }
+        .stream-label {
+            position: absolute; top: 18px; left: 12px; background: rgba(0,0,0,0.6);
+            color: white; padding: 4px 10px; border-radius: 4px; font-size: 13px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status {
+            position: absolute; top: 18px; right: 12px; background: rgba(0,0,0,0.6);
+            color: #52c41a; padding: 4px 10px; border-radius: 4px; font-size: 12px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status.offline { color: #f5222d; }
+
+        .controls {
+            margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap;
+            justify-content: center; max-width: 960px; width: 100%;
+        }
+        .btn {
+            padding: 10px 20px; border: 1px solid #444; background: #2a2a4a;
+            color: #eee; border-radius: 8px; cursor: pointer; font-size: 14px;
+            transition: all 0.2s; display: flex; align-items: center; gap: 6px;
+        }
+        .btn:hover { background: #3a3a5a; border-color: #667eea; }
+        .btn.primary { background: #667eea; border-color: #667eea; }
+        .btn.primary:hover { background: #5a6ed6; }
+        .btn.danger { background: #ff4d4f; border-color: #ff4d4f; color: white; }
+        .btn.danger:hover { background: #e04445; }
+        .btn.drawing { background: #ff4d4f; color: white; border-color: #ff4d4f; animation: pulse 1.5s infinite; }
+        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .roi-info {
+            margin-top: 12px; padding: 12px 16px; background: #2a2a4a;
+            border-radius: 8px; font-size: 13px; line-height: 1.8;
+            max-width: 960px; width: 100%;
+        }
+        .roi-info .label { color: #999; }
+        .roi-info .value { color: #667eea; font-weight: bold; }
+        .help-text { margin-top: 8px; font-size: 12px; color: #666; text-align: center; }
+
+        @media (max-width: 600px) {
+            .top-bar { flex-direction: column; gap: 8px; }
+            .nav-links { flex-wrap: wrap; justify-content: center; }
+        }
+    </style>
+</head>
+<body>
+    <div class="top-bar">
+        <h1 id="page-title">视频监控</h1>
+        <div class="nav-links">
+            <a href="/" class="nav-link">📊 监控台</a>
+            <a href="/video?stream=in-low" class="nav-link" id="nav-in-low">低位进站</a>
+            <a href="/video?stream=in-high" class="nav-link" id="nav-in-high">高位进站</a>
+            <a href="/video?stream=out-low" class="nav-link" id="nav-out-low">低位出站</a>
+            <a href="/video?stream=out-high" class="nav-link" id="nav-out-high">高位出站</a>
+        </div>
+    </div>
+
+    <div class="video-area">
+        <div class="video-wrapper" id="video-wrapper">
+            <div class="stream-label" id="stream-label">加载中...</div>
+            <div class="stream-status" id="stream-status">⏳ 连接中</div>
+            <img id="video-stream" src="" alt="视频流">
+            <canvas id="roi-canvas"></canvas>
+        </div>
+
+        <div class="controls">
+            <button class="btn primary" onclick="takeSnapshot()">📷 截图</button>
+            <button class="btn" id="btn-draw-roi" onclick="startDrawROI()">✏️ 绘制ROI</button>
+            <button class="btn" id="btn-clear-roi" onclick="clearROI()">🗑️ 清除ROI</button>
+            <button class="btn" onclick="refreshStream()">🔄 刷新视频</button>
+        </div>
+
+        <div class="roi-info" id="roi-info">
+            <span class="label">加载配置中...</span>
+        </div>
+        <div class="help-text" id="help-text"></div>
+    </div>
+
+    <script>
+    var STREAM_NAMES = {
+        'in-low': '低位进站', 'in-high': '高位进站',
+        'out-low': '低位出站', 'out-high': '高位出站'
+    };
+    var ROI_STREAMS = ['in-low', 'out-low'];
+    var currentStream = 'in-low';
+    var hasROI = false;
+    var isDrawingROI = false;
+    var drawStart = null;
+    var currentROI = null;
+    var videoResolution = { width: 1920, height: 1080 };
+
+    (function init() {
+        var params = new URLSearchParams(window.location.search);
+        currentStream = params.get('stream') || 'in-low';
+        hasROI = ROI_STREAMS.indexOf(currentStream) >= 0;
+        var streamName = STREAM_NAMES[currentStream] || '未知';
+        document.title = streamName + ' - 视频监控';
+        document.getElementById('page-title').textContent = streamName;
+        document.getElementById('stream-label').textContent = streamName;
+        var navEl = document.getElementById('nav-' + currentStream);
+        if (navEl) navEl.classList.add('active');
+        if (!hasROI) {
+            document.getElementById('btn-draw-roi').style.display = 'none';
+            document.getElementById('btn-clear-roi').style.display = 'none';
+            document.getElementById('help-text').textContent = '高位摄像头无ROI功能';
+        } else {
+            document.getElementById('help-text').innerHTML =
+                'ROI仅影响推理裁剪区域,<b>上传照片仍为原始全帧</b> | ESC退出绘制模式';
+        }
+        startStream();
+        loadRoiConfig();
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape' && isDrawingROI) cancelDrawROI();
+        });
+        var img = document.getElementById('video-stream');
+        img.onload = function() {
+            document.getElementById('stream-status').textContent = '🟢 在线';
+            document.getElementById('stream-status').className = 'stream-status';
+        };
+        img.onerror = function() {
+            document.getElementById('stream-status').textContent = '🔴 离线';
+            document.getElementById('stream-status').className = 'stream-status offline';
+            setTimeout(function() {
+                img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+            }, 3000);
+        };
+    })();
+
+    function startStream() {
+        document.getElementById('video-stream').src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+    }
+    function refreshStream() {
+        var img = document.getElementById('video-stream');
+        img.src = '';
+        setTimeout(function() { img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now(); }, 100);
+    }
+    function takeSnapshot() {
+        window.open('/api/stream?stream=' + currentStream + '&t=' + Date.now(), '_blank');
+    }
+
+    function loadRoiConfig() {
+        fetch('/api/roi/config')
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                var roi = null;
+                if (currentStream === 'in-low') roi = data.roi_in;
+                else if (currentStream === 'out-low') roi = data.roi_out;
+                if (data.resolution && data.resolution[currentStream]) {
+                    videoResolution = data.resolution[currentStream];
+                }
+                var infoEl = document.getElementById('roi-info');
+                if (!hasROI) {
+                    infoEl.innerHTML = '<span class="label">高位摄像头 — 无ROI配置</span>';
+                    return;
+                }
+                if (roi && roi.enabled) {
+                    currentROI = { x: roi.x, y: roi.y, w: roi.w, h: roi.h };
+                    infoEl.innerHTML =
+                        '<span class="label">ROI区域:</span> <span class="value">(' +
+                        roi.x + ', ' + roi.y + ') ' + roi.w + '×' + roi.h +
+                        '</span> <span class="label">| 分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height +
+                        '</span><br><span class="label">状态:</span> <span class="value" style="color:#52c41a;">✅ 已启用</span>' +
+                        ' <span class="label">| 推理裁剪,上传全帧</span>';
+                } else {
+                    currentROI = null;
+                    infoEl.innerHTML =
+                        '<span class="label">ROI状态:</span> <span class="value" style="color:#faad14;">⚪ 未启用(全帧推理)</span>' +
+                        '<br><span class="label">分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height + '</span>';
+                }
+            })
+            .catch(function(e) {
+                document.getElementById('roi-info').innerHTML =
+                    '<span class="label" style="color:#f5222d;">⚠️ 配置加载失败</span>';
+            });
+    }
+
+    function startDrawROI() {
+        if (!hasROI) return;
+        isDrawingROI = true;
+        drawStart = null;
+        var img = document.getElementById('video-stream');
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        img.src = '/api/stream?stream=' + currentStream + '&t=' + Date.now();
+        resizeCanvas();
+        canvas.style.display = 'block';
+        btn.classList.add('drawing');
+        btn.innerHTML = '🔲 绘制中...';
+        canvas.onmousedown = onDrawStart;
+        canvas.onmousemove = onDrawMove;
+        canvas.onmouseup = onDrawEnd;
+        canvas.ontouchstart = onTouchStart;
+        canvas.ontouchmove = onTouchMove;
+        canvas.ontouchend = onTouchEnd;
+    }
+
+    function cancelDrawROI() {
+        isDrawingROI = false;
+        drawStart = null;
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        canvas.style.display = 'none';
+        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+        btn.classList.remove('drawing');
+        btn.innerHTML = '✏️ 绘制ROI';
+        startStream();
+    }
+
+    function finishDrawROI(rect) {
+        var canvas = document.getElementById('roi-canvas');
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        var roiX = Math.round(rect.x * scaleX);
+        var roiY = Math.round(rect.y * scaleY);
+        var roiW = Math.round(rect.w * scaleX);
+        var roiH = Math.round(rect.h * scaleY);
+        roiX = Math.max(0, Math.min(roiX, videoResolution.width - 1));
+        roiY = Math.max(0, Math.min(roiY, videoResolution.height - 1));
+        roiW = Math.min(roiW, videoResolution.width - roiX);
+        roiH = Math.min(roiH, videoResolution.height - roiY);
+        if (roiW < 50 || roiH < 50) {
+            alert('ROI区域太小(最小50×50像素),请重新绘制');
+            cancelDrawROI();
+            return;
+        }
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (confirm('确认应用' + sideName + 'ROI区域?\n坐标: (' + roiX + ', ' + roiY + ')\n尺寸: ' + roiW + '×' + roiH)) {
+            fetch('/api/roi/update', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ side: side, x: roiX, y: roiY, w: roiW, h: roiH, enabled: true })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                if (data.success) {
+                    alert('✅ ' + sideName + 'ROI已更新并保存到config.ini');
+                    loadRoiConfig();
+                } else {
+                    alert('❌ 更新失败: ' + (data.message || '未知错误'));
+                }
+            })
+            .catch(function(e) { alert('请求失败: ' + e); });
+        }
+        cancelDrawROI();
+    }
+
+    function clearROI() {
+        if (!hasROI) return;
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (!confirm('确定清除' + sideName + 'ROI区域?\n清除后将使用全帧推理')) return;
+        fetch('/api/roi/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ side: side, x: 0, y: 0, w: 0, h: 0, enabled: false })
+        })
+        .then(function(r) { return r.json(); })
+        .then(function(data) {
+            if (data.success) {
+                alert('✅ ROI已清除,恢复全帧推理');
+                loadRoiConfig();
+            } else {
+                alert('❌ 清除失败: ' + (data.message || '未知错误'));
+            }
+        })
+        .catch(function(e) { alert('请求失败: ' + e); });
+    }
+
+    function resizeCanvas() {
+        var wrapper = document.getElementById('video-wrapper');
+        var canvas = document.getElementById('roi-canvas');
+        canvas.width = wrapper.clientWidth;
+        canvas.height = wrapper.clientHeight;
+    }
+
+    function getCanvasPos(e) {
+        var rect = document.getElementById('roi-canvas').getBoundingClientRect();
+        return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+    }
+
+    function onDrawStart(e) { drawStart = getCanvasPos(e); }
+    function onDrawMove(e) {
+        if (!drawStart) return;
+        var canvas = document.getElementById('roi-canvas');
+        var ctx = canvas.getContext('2d');
+        var pos = getCanvasPos(e);
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        var rx = Math.min(drawStart.x, pos.x);
+        var ry = Math.min(drawStart.y, pos.y);
+        var rw = Math.abs(pos.x - drawStart.x);
+        var rh = Math.abs(pos.y - drawStart.y);
+        ctx.fillStyle = 'rgba(255, 0, 0, 0.15)';
+        ctx.fillRect(rx, ry, rw, rh);
+        ctx.strokeStyle = '#ff0000';
+        ctx.lineWidth = 2;
+        ctx.setLineDash([6, 3]);
+        ctx.strokeRect(rx, ry, rw, rh);
+        ctx.setLineDash([]);
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        ctx.fillStyle = '#ff0000';
+        ctx.font = '14px sans-serif';
+        ctx.fillText(Math.round(rw * scaleX) + '×' + Math.round(rh * scaleY), rx + 4, ry + 18);
+    }
+    function onDrawEnd(e) {
+        if (!drawStart) return;
+        var pos = getCanvasPos(e);
+        var x = Math.min(drawStart.x, pos.x);
+        var y = Math.min(drawStart.y, pos.y);
+        var w = Math.abs(pos.x - drawStart.x);
+        var h = Math.abs(pos.y - drawStart.y);
+        if (w < 5 || h < 5) { cancelDrawROI(); return; }
+        finishDrawROI({ x: x, y: y, w: w, h: h });
+    }
+
+    function onTouchStart(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawStart({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchMove(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawMove({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchEnd(e) {
+        e.preventDefault();
+        var t = e.changedTouches[0];
+        onDrawEnd({ clientX: t.clientX, clientY: t.clientY });
+    }
+    </script>
+</body>
+</html>

+ 381 - 0
assets/web/videobak3.html

@@ -0,0 +1,381 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>视频监控</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body {
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            background: #1a1a2e; color: #eee; min-height: 100vh; display: flex; flex-direction: column;
+        }
+        .top-bar {
+            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+            padding: 12px 20px; display: flex; align-items: center; justify-content: space-between;
+            flex-shrink: 0;
+        }
+        .top-bar h1 { font-size: 18px; color: white; }
+        .nav-links { display: flex; gap: 8px; }
+        .nav-link {
+            background: rgba(255,255,255,0.2); color: white; padding: 6px 14px;
+            border-radius: 16px; text-decoration: none; font-size: 13px;
+            transition: background 0.2s; white-space: nowrap;
+        }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+
+        .video-area {
+            flex: 1; display: flex; flex-direction: column; align-items: center;
+            padding: 16px; overflow: auto;
+        }
+        .video-wrapper {
+            position: relative; background: #000; border-radius: 8px;
+            overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.5);
+            max-width: 960px; width: 100%;
+        }
+        .video-wrapper img { width: 100%; display: block; }
+        #roi-canvas {
+            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
+            cursor: crosshair; display: none; z-index: 10;
+        }
+        .stream-label {
+            position: absolute; top: 8px; left: 12px; background: rgba(0,0,0,0.6);
+            color: white; padding: 4px 10px; border-radius: 4px; font-size: 13px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status {
+            position: absolute; top: 8px; right: 12px; background: rgba(0,0,0,0.6);
+            color: #52c41a; padding: 4px 10px; border-radius: 4px; font-size: 12px;
+            pointer-events: none; z-index: 5;
+        }
+        .stream-status.offline { color: #f5222d; }
+
+        .controls {
+            margin-top: 14px; display: flex; gap: 10px; flex-wrap: wrap;
+            justify-content: center; max-width: 960px; width: 100%;
+        }
+        .btn {
+            padding: 10px 20px; border: 1px solid #444; background: #2a2a4a;
+            color: #eee; border-radius: 8px; cursor: pointer; font-size: 14px;
+            transition: all 0.2s; display: flex; align-items: center; gap: 6px;
+        }
+        .btn:hover { background: #3a3a5a; border-color: #667eea; }
+        .btn.primary { background: #667eea; border-color: #667eea; }
+        .btn.primary:hover { background: #5a6ed6; }
+        .btn.danger { background: #ff4d4f; border-color: #ff4d4f; color: white; }
+        .btn.danger:hover { background: #e04445; }
+        .btn.drawing { background: #ff4d4f; color: white; border-color: #ff4d4f; animation: pulse 1.5s infinite; }
+        .btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+        @keyframes pulse {
+            0%, 100% { opacity: 1; }
+            50% { opacity: 0.7; }
+        }
+
+        .roi-info {
+            margin-top: 12px; padding: 12px 16px; background: #2a2a4a;
+            border-radius: 8px; font-size: 13px; line-height: 1.8;
+            max-width: 960px; width: 100%;
+        }
+        .roi-info .label { color: #999; }
+        .roi-info .value { color: #667eea; font-weight: bold; }
+        .help-text { margin-top: 8px; font-size: 12px; color: #666; text-align: center; }
+
+        @media (max-width: 600px) {
+            .top-bar { flex-direction: column; gap: 8px; }
+            .nav-links { flex-wrap: wrap; justify-content: center; }
+        }
+    </style>
+</head>
+<body>
+    <div class="top-bar">
+        <h1 id="page-title">视频监控</h1>
+        <div class="nav-links">
+            <a href="/" class="nav-link">📊 监控台</a>
+            <a href="/video?stream=in-low" class="nav-link" id="nav-in-low">低位进站</a>
+            <a href="/video?stream=in-high" class="nav-link" id="nav-in-high">高位进站</a>
+            <a href="/video?stream=out-low" class="nav-link" id="nav-out-low">低位出站</a>
+            <a href="/video?stream=out-high" class="nav-link" id="nav-out-high">高位出站</a>
+        </div>
+    </div>
+
+    <div class="video-area">
+        <div class="video-wrapper" id="video-wrapper">
+            <div class="stream-label" id="stream-label">加载中...</div>
+            <div class="stream-status" id="stream-status">⏳ 连接中</div>
+            <img id="video-stream" src="" alt="视频流">
+            <canvas id="roi-canvas"></canvas>
+        </div>
+
+        <div class="controls">
+            <button class="btn primary" onclick="takeSnapshot()">📷 截图</button>
+            <button class="btn" id="btn-draw-roi" onclick="startDrawROI()">✏️ 绘制ROI</button>
+            <button class="btn" id="btn-clear-roi" onclick="clearROI()">🗑️ 清除ROI</button>
+            <button class="btn" onclick="refreshStream()">🔄 刷新视频</button>
+        </div>
+
+        <div class="roi-info" id="roi-info">
+            <span class="label">加载配置中...</span>
+        </div>
+        <div class="help-text" id="help-text"></div>
+    </div>
+
+    <script>
+    var STREAM_NAMES = {
+        'in-low': '低位进站', 'in-high': '高位进站',
+        'out-low': '低位出站', 'out-high': '高位出站'
+    };
+    var ROI_STREAMS = ['in-low', 'out-low'];
+    var currentStream = 'in-low';
+    var hasROI = false;
+    var isDrawingROI = false;
+    var drawStart = null;
+    var currentROI = null;
+    var videoResolution = { width: 1920, height: 1080 };
+
+    (function init() {
+        var params = new URLSearchParams(window.location.search);
+        currentStream = params.get('stream') || 'in-low';
+        hasROI = ROI_STREAMS.indexOf(currentStream) >= 0;
+        var streamName = STREAM_NAMES[currentStream] || '未知';
+        document.title = streamName + ' - 视频监控';
+        document.getElementById('page-title').textContent = streamName;
+        document.getElementById('stream-label').textContent = streamName;
+        var navEl = document.getElementById('nav-' + currentStream);
+        if (navEl) navEl.classList.add('active');
+        if (!hasROI) {
+            document.getElementById('btn-draw-roi').style.display = 'none';
+            document.getElementById('btn-clear-roi').style.display = 'none';
+            document.getElementById('help-text').textContent = '高位摄像头无ROI功能';
+        } else {
+            document.getElementById('help-text').innerHTML =
+                'ROI仅影响推理裁剪区域,<b>上传照片仍为原始全帧</b> | ESC退出绘制模式';
+        }
+        startStream();
+        loadRoiConfig();
+        document.addEventListener('keydown', function(e) {
+            if (e.key === 'Escape' && isDrawingROI) cancelDrawROI();
+        });
+        var img = document.getElementById('video-stream');
+        img.onload = function() {
+            document.getElementById('stream-status').textContent = '🟢 在线';
+            document.getElementById('stream-status').className = 'stream-status';
+        };
+        img.onerror = function() {
+            document.getElementById('stream-status').textContent = '🔴 离线';
+            document.getElementById('stream-status').className = 'stream-status offline';
+            setTimeout(function() {
+                img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+            }, 3000);
+        };
+    })();
+
+    function startStream() {
+        document.getElementById('video-stream').src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now();
+    }
+    function refreshStream() {
+        var img = document.getElementById('video-stream');
+        img.src = '';
+        setTimeout(function() { img.src = '/api/mjpeg?stream=' + currentStream + '&t=' + Date.now(); }, 100);
+    }
+    function takeSnapshot() {
+        window.open('/api/stream?stream=' + currentStream + '&t=' + Date.now(), '_blank');
+    }
+
+    function loadRoiConfig() {
+        fetch('/api/roi/config')
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                var roi = null;
+                if (currentStream === 'in-low') roi = data.roi_in;
+                else if (currentStream === 'out-low') roi = data.roi_out;
+                if (data.resolution && data.resolution[currentStream]) {
+                    videoResolution = data.resolution[currentStream];
+                }
+                var infoEl = document.getElementById('roi-info');
+                if (!hasROI) {
+                    infoEl.innerHTML = '<span class="label">高位摄像头 — 无ROI配置</span>';
+                    return;
+                }
+                if (roi && roi.enabled) {
+                    currentROI = { x: roi.x, y: roi.y, w: roi.w, h: roi.h };
+                    infoEl.innerHTML =
+                        '<span class="label">ROI区域:</span> <span class="value">(' +
+                        roi.x + ', ' + roi.y + ') ' + roi.w + '×' + roi.h +
+                        '</span> <span class="label">| 分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height +
+                        '</span><br><span class="label">状态:</span> <span class="value" style="color:#52c41a;">✅ 已启用</span>' +
+                        ' <span class="label">| 推理裁剪,上传全帧</span>';
+                } else {
+                    currentROI = null;
+                    infoEl.innerHTML =
+                        '<span class="label">ROI状态:</span> <span class="value" style="color:#faad14;">⚪ 未启用(全帧推理)</span>' +
+                        '<br><span class="label">分辨率:</span> <span class="value">' +
+                        videoResolution.width + '×' + videoResolution.height + '</span>';
+                }
+            })
+            .catch(function(e) {
+                document.getElementById('roi-info').innerHTML =
+                    '<span class="label" style="color:#f5222d;">⚠️ 配置加载失败</span>';
+            });
+    }
+
+    function startDrawROI() {
+        if (!hasROI) return;
+        isDrawingROI = true;
+        drawStart = null;
+        var img = document.getElementById('video-stream');
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        img.src = '/api/stream?stream=' + currentStream + '&t=' + Date.now();
+        resizeCanvas();
+        canvas.style.display = 'block';
+        btn.classList.add('drawing');
+        btn.innerHTML = '🔲 绘制中...';
+        canvas.onmousedown = onDrawStart;
+        canvas.onmousemove = onDrawMove;
+        canvas.onmouseup = onDrawEnd;
+        canvas.ontouchstart = onTouchStart;
+        canvas.ontouchmove = onTouchMove;
+        canvas.ontouchend = onTouchEnd;
+    }
+
+    function cancelDrawROI() {
+        isDrawingROI = false;
+        drawStart = null;
+        var canvas = document.getElementById('roi-canvas');
+        var btn = document.getElementById('btn-draw-roi');
+        canvas.style.display = 'none';
+        canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
+        btn.classList.remove('drawing');
+        btn.innerHTML = '✏️ 绘制ROI';
+        startStream();
+    }
+
+    function finishDrawROI(rect) {
+        var canvas = document.getElementById('roi-canvas');
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        var roiX = Math.round(rect.x * scaleX);
+        var roiY = Math.round(rect.y * scaleY);
+        var roiW = Math.round(rect.w * scaleX);
+        var roiH = Math.round(rect.h * scaleY);
+        roiX = Math.max(0, Math.min(roiX, videoResolution.width - 1));
+        roiY = Math.max(0, Math.min(roiY, videoResolution.height - 1));
+        roiW = Math.min(roiW, videoResolution.width - roiX);
+        roiH = Math.min(roiH, videoResolution.height - roiY);
+        if (roiW < 50 || roiH < 50) {
+            alert('ROI区域太小(最小50×50像素),请重新绘制');
+            cancelDrawROI();
+            return;
+        }
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (confirm('确认应用' + sideName + 'ROI区域?\n坐标: (' + roiX + ', ' + roiY + ')\n尺寸: ' + roiW + '×' + roiH)) {
+            fetch('/api/roi/update', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ side: side, x: roiX, y: roiY, w: roiW, h: roiH, enabled: true })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                if (data.success) {
+                    alert('✅ ' + sideName + 'ROI已更新并保存到config.ini');
+                    loadRoiConfig();
+                } else {
+                    alert('❌ 更新失败: ' + (data.message || '未知错误'));
+                }
+            })
+            .catch(function(e) { alert('请求失败: ' + e); });
+        }
+        cancelDrawROI();
+    }
+
+    function clearROI() {
+        if (!hasROI) return;
+        var side = (currentStream === 'in-low') ? 'in' : 'out';
+        var sideName = (side === 'in') ? '进站' : '出站';
+        if (!confirm('确定清除' + sideName + 'ROI区域?\n清除后将使用全帧推理')) return;
+        fetch('/api/roi/update', {
+            method: 'POST',
+            headers: { 'Content-Type': 'application/json' },
+            body: JSON.stringify({ side: side, x: 0, y: 0, w: 0, h: 0, enabled: false })
+        })
+        .then(function(r) { return r.json(); })
+        .then(function(data) {
+            if (data.success) {
+                alert('✅ ROI已清除,恢复全帧推理');
+                loadRoiConfig();
+            } else {
+                alert('❌ 清除失败: ' + (data.message || '未知错误'));
+            }
+        })
+        .catch(function(e) { alert('请求失败: ' + e); });
+    }
+
+    function resizeCanvas() {
+        var wrapper = document.getElementById('video-wrapper');
+        var canvas = document.getElementById('roi-canvas');
+        canvas.width = wrapper.clientWidth;
+        canvas.height = wrapper.clientHeight;
+    }
+
+    function getCanvasPos(e) {
+        var rect = document.getElementById('roi-canvas').getBoundingClientRect();
+        return { x: e.clientX - rect.left, y: e.clientY - rect.top };
+    }
+
+    function onDrawStart(e) { drawStart = getCanvasPos(e); }
+    function onDrawMove(e) {
+        if (!drawStart) return;
+        var canvas = document.getElementById('roi-canvas');
+        var ctx = canvas.getContext('2d');
+        var pos = getCanvasPos(e);
+        ctx.clearRect(0, 0, canvas.width, canvas.height);
+        var rx = Math.min(drawStart.x, pos.x);
+        var ry = Math.min(drawStart.y, pos.y);
+        var rw = Math.abs(pos.x - drawStart.x);
+        var rh = Math.abs(pos.y - drawStart.y);
+        ctx.fillStyle = 'rgba(255, 0, 0, 0.15)';
+        ctx.fillRect(rx, ry, rw, rh);
+        ctx.strokeStyle = '#ff0000';
+        ctx.lineWidth = 2;
+        ctx.setLineDash([6, 3]);
+        ctx.strokeRect(rx, ry, rw, rh);
+        ctx.setLineDash([]);
+        var scaleX = videoResolution.width / canvas.clientWidth;
+        var scaleY = videoResolution.height / canvas.clientHeight;
+        ctx.fillStyle = '#ff0000';
+        ctx.font = '14px sans-serif';
+        ctx.fillText(Math.round(rw * scaleX) + '×' + Math.round(rh * scaleY), rx + 4, ry + 18);
+    }
+    function onDrawEnd(e) {
+        if (!drawStart) return;
+        var pos = getCanvasPos(e);
+        var x = Math.min(drawStart.x, pos.x);
+        var y = Math.min(drawStart.y, pos.y);
+        var w = Math.abs(pos.x - drawStart.x);
+        var h = Math.abs(pos.y - drawStart.y);
+        if (w < 5 || h < 5) { cancelDrawROI(); return; }
+        finishDrawROI({ x: x, y: y, w: w, h: h });
+    }
+
+    function onTouchStart(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawStart({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchMove(e) {
+        e.preventDefault();
+        var t = e.touches[0];
+        onDrawMove({ clientX: t.clientX, clientY: t.clientY });
+    }
+    function onTouchEnd(e) {
+        e.preventDefault();
+        var t = e.changedTouches[0];
+        onDrawEnd({ clientX: t.clientX, clientY: t.clientY });
+    }
+    </script>
+</body>
+</html>

+ 751 - 0
assets/web/weight.html

@@ -0,0 +1,751 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>称重管理</title>
+    <style>
+        * { box-sizing: border-box; margin: 0; padding: 0; }
+        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
+        .header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; text-align: center; }
+        .header h1 { font-size: 24px; margin-bottom: 5px; }
+        .nav-bar { margin-top: 12px; display: flex; justify-content: center; gap: 10px; flex-wrap: wrap; }
+        .nav-link { background: rgba(255,255,255,0.2); color: white; padding: 8px 16px; border-radius: 20px; text-decoration: none; font-size: 14px; transition: background 0.2s; }
+        .nav-link:hover { background: rgba(255,255,255,0.35); }
+        .nav-link.active { background: rgba(255,255,255,0.45); font-weight: bold; }
+        .container { max-width: 1200px; margin: 20px auto; padding: 0 20px; }
+        .panel { background: white; border-radius: 10px; padding: 20px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); margin-bottom: 20px; }
+        .panel h2 { font-size: 18px; color: #333; margin-bottom: 15px; border-bottom: 2px solid #667eea; padding-bottom: 10px; }
+        .stats { display: flex; gap: 20px; margin-bottom: 20px; flex-wrap: wrap; }
+        .stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px 20px; border-radius: 8px; min-width: 150px; }
+        .stat-card.warning { background: linear-gradient(135deg, #f5222d 0%, #cf1322 100%); }
+        .stat-card .label { font-size: 12px; opacity: 0.8; }
+        .stat-card .value { font-size: 24px; font-weight: bold; margin-top: 5px; }
+        /* fix24 v41: Tab 切换样式 */
+        .tab-bar { display: flex; gap: 0; margin-bottom: 15px; border-bottom: 2px solid #e9ecef; }
+        .tab-btn { padding: 10px 20px; background: transparent; border: none; border-bottom: 3px solid transparent; cursor: pointer; font-size: 14px; color: #666; transition: all 0.2s; }
+        .tab-btn:hover { color: #667eea; background: #f8f9fa; }
+        .tab-btn.active { color: #667eea; border-bottom-color: #667eea; font-weight: 600; }
+        .tab-content { display: none; }
+        .tab-content.active { display: block; }
+        /* fix24 v41: 分类查询样式 */
+        .filter-bar { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; margin-bottom: 15px; }
+        .filter-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end; }
+        .filter-row + .filter-row { margin-top: 8px; }
+        .filter-item { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 130px; }
+        .filter-item label { font-size: 12px; color: #666; font-weight: 500; }
+        .filter-item input, .filter-item select { padding: 7px 9px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; background: white; }
+        .filter-item input:focus, .filter-item select:focus { outline: none; border-color: #667eea; box-shadow: 0 0 0 2px rgba(102,126,234,0.15); }
+        .filter-buttons { flex: 0 0 auto; flex-direction: row !important; gap: 8px !important; }
+        .btn-search, .btn-reset { padding: 7px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 13px; white-space: nowrap; }
+        .btn-search { background: #667eea; color: white; }
+        .btn-search:hover { background: #764ba2; }
+        .btn-reset { background: white; color: #666; border: 1px solid #d9d9d9; }
+        .btn-reset:hover { border-color: #667eea; color: #667eea; }
+        .table-container { overflow-x: auto; }
+        table { width: 100%; border-collapse: collapse; font-size: 13px; }
+        th, td { padding: 10px 8px; text-align: left; border-bottom: 1px solid #eee; }
+        th { background: #f8f9fa; font-weight: 600; color: #333; white-space: nowrap; }
+        tr:hover { background: #f8f9fa; }
+        .status { padding: 3px 8px; border-radius: 3px; font-size: 12px; display: inline-block; }
+        .status-success { background: #d9f7be; color: #52c41a; }
+        .status-failed { background: #fff1f0; color: #f5222d; }
+        .status-pending { background: #f0f5ff; color: #1890ff; }
+        .btn-retry { padding: 3px 8px; background: #1890ff; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 12px; }
+        .btn-retry:hover { background: #40a9ff; }
+        .btn-retry:disabled { background: #d9d9d9; cursor: not-allowed; }
+        .pagination { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 0; flex-wrap: wrap; }
+        .pagination button { padding: 6px 12px; border: 1px solid #ddd; background: white; border-radius: 4px; cursor: pointer; font-size: 13px; }
+        .pagination button:hover:not(:disabled) { background: #f0f5ff; border-color: #667eea; color: #667eea; }
+        .pagination button:disabled { background: #f5f5f5; color: #d9d9d9; cursor: not-allowed; }
+        .pagination button.active { background: #667eea; color: white; border-color: #667eea; }
+        .pagination .info { color: #666; font-size: 13px; padding: 0 4px; }
+        .pagination-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 15px; flex-wrap: wrap; gap: 10px; }
+        .pagination-left, .pagination-right { display: flex; align-items: center; gap: 5px; font-size: 13px; color: #666; }
+        .pagination-left select, .pagination-right input { padding: 5px 8px; border: 1px solid #d9d9d9; border-radius: 4px; font-size: 13px; width: 65px; }
+        .pagination-right input { width: 55px; }
+        .pagination-right button { padding: 5px 12px; background: #667eea; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; }
+        .pagination-right button:hover { background: #764ba2; }
+        .refresh-info { text-align: center; margin-top: 10px; color: #999; font-size: 12px; }
+        .loading { text-align: center; padding: 20px; color: #666; }
+        .toast { position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; color: #fff; font-size: 14px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 9999; display: none; }
+        .toast.success { background: #52c41a; }
+        .toast.error { background: #f5222d; }
+        .toast.show { display: block; }
+        @media (max-width: 768px) {
+            .header { padding: 14px; }
+            .header h1 { font-size: 18px; }
+            .nav-bar { gap: 6px; }
+            .nav-link { padding: 6px 12px; font-size: 12px; }
+            .container { padding: 0 10px; margin: 10px auto; }
+            .panel { padding: 12px; }
+            .panel h2 { font-size: 15px; }
+            .stats { flex-direction: column; gap: 10px; }
+            .stat-card { min-width: auto; }
+            .stat-card .value { font-size: 20px; }
+            .filter-item { min-width: 100px; }
+            .pagination-bar { flex-direction: column; align-items: stretch; }
+            .pagination-left, .pagination-right { justify-content: center; }
+            table { font-size: 11px; }
+            th, td { padding: 6px 4px; }
+            .tab-btn { padding: 8px 12px; font-size: 12px; }
+        }
+        @media (max-width: 480px) {
+            .header h1 { font-size: 16px; }
+            .nav-link { font-size: 11px; padding: 5px 10px; }
+            .filter-item { min-width: 100%; }
+        }
+    </style>
+</head>
+<body>
+    <div class="header">
+        <h1>⚖️ 称重管理系统</h1>
+        <p>Weight Management System v43.2-fix24-v43</p>
+        <div class="nav-bar">
+            <a href="/" class="nav-link">🏠 首页</a>
+            <a href="/video" class="nav-link">🎥 视频</a>
+            <a href="/weight" class="nav-link active">⚖️ 称重</a>
+            <a href="/config" class="nav-link" id="nav-config" style="display:none;">⚙️ 配置</a>
+            <a href="/locks" class="nav-link">🔒 锁定</a>
+            <a href="/help" class="nav-link" id="nav-help" style="display:none;">📖 帮助</a>
+        </div>
+    </div>
+
+    <div class="container">
+        <div class="stats">
+            <div class="stat-card">
+                <div class="label">总记录数</div>
+                <div class="value" id="stat-total">-</div>
+            </div>
+            <div class="stat-card warning">
+                <div class="label">失败记录</div>
+                <div class="value" id="stat-failed">-</div>
+            </div>
+        </div>
+
+        <div class="panel">
+            <h2>称重记录</h2>
+            
+            <!-- fix24 v41: Tab 切换 -->
+            <div class="tab-bar">
+                <button class="tab-btn active" onclick="switchTab('realtime')">📡 实时称重</button>
+                <button class="tab-btn" onclick="switchTab('history')">🔍 历史查询</button>
+                <button class="tab-btn" onclick="switchTab('sync')">🔗 同步视图</button>
+            </div>
+            
+            <!-- 实时称重 Tab -->
+            <div id="tab-realtime" class="tab-content active">
+                <div style="background:#e6f7ff;border:1px solid #91d5ff;border-radius:6px;padding:10px;margin-bottom:15px;font-size:13px;color:#0050b3;">
+                    💡 实时称重:显示最新 20 条记录,每 30 秒自动刷新
+                </div>
+                <div class="table-container">
+                    <table>
+                        <thead>
+                            <tr>
+                                <th>编号</th>
+                                <th>日期时间</th>
+                                <th>车牌号</th>
+                                <th>联单编号</th>
+                                <th>方向</th>
+                                <th>重量(kg)</th>
+                                <th>重量(吨)</th>
+                                <th>上传状态</th>
+                                <th>重试次数</th>
+                                <th>响应消息</th>
+                                <th>操作</th>
+                            </tr>
+                        </thead>
+                        <tbody id="realtime-body">
+                            <tr><td colspan="11" class="loading">加载中...</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <div class="refresh-info" id="realtime-refresh-info">数据自动刷新间隔: 30秒</div>
+            </div>
+            
+            <!-- 历史查询 Tab -->
+            <div id="tab-history" class="tab-content">
+                <div class="filter-bar">
+                    <div class="filter-row">
+                        <div class="filter-item">
+                            <label>车牌号</label>
+                            <input type="text" id="filter-plate" placeholder="如 沪EQ3803">
+                        </div>
+                        <div class="filter-item">
+                            <label>联单编号</label>
+                            <input type="text" id="filter-tbnum" placeholder="联单编号">
+                        </div>
+                        <div class="filter-item">
+                            <label>方向</label>
+                            <select id="filter-station">
+                                <option value="0">全部</option>
+                                <option value="1">进站</option>
+                                <option value="2">出站</option>
+                            </select>
+                        </div>
+                        <div class="filter-item">
+                            <label>上传状态</label>
+                            <select id="filter-upload">
+                                <option value="0">全部</option>
+                                <option value="1">成功</option>
+                                <option value="2">失败</option>
+                            </select>
+                        </div>
+                    </div>
+                    <div class="filter-row">
+                        <div class="filter-item">
+                            <label>开始日期</label>
+                            <input type="date" id="filter-date-from">
+                        </div>
+                        <div class="filter-item">
+                            <label>结束日期</label>
+                            <input type="date" id="filter-date-to">
+                        </div>
+                        <div class="filter-item filter-buttons">
+                            <button class="btn-search" onclick="searchHistory(1)">🔍 查询</button>
+                            <button class="btn-reset" onclick="resetFilters()">重置</button>
+                        </div>
+                    </div>
+                </div>
+                <div class="table-container">
+                    <table>
+                        <thead>
+                            <tr>
+                                <th>编号</th>
+                                <th>日期时间</th>
+                                <th>车牌号</th>
+                                <th>联单编号</th>
+                                <th>方向</th>
+                                <th>重量(kg)</th>
+                                <th>重量(吨)</th>
+                                <th>上传状态</th>
+                                <th>重试次数</th>
+                                <th>响应消息</th>
+                                <th>操作</th>
+                            </tr>
+                        </thead>
+                        <tbody id="history-body">
+                            <tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">请设置查询条件后点击"查询"按钮</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <div class="pagination-bar">
+                    <div class="pagination-left">
+                        <span>每页</span>
+                        <select id="page-size" onchange="changePageSize()">
+                            <option value="20">20</option>
+                            <option value="50">50</option>
+                            <option value="100">100</option>
+                        </select>
+                        <span>条</span>
+                    </div>
+                    <div class="pagination" id="pagination"></div>
+                    <div class="pagination-right">
+                        <span>跳至</span>
+                        <input type="number" id="jump-page" min="1" value="1">
+                        <span>页</span>
+                        <button onclick="jumpToPage()">GO</button>
+                    </div>
+                </div>
+                <div class="refresh-info" id="history-refresh-info">历史查询:手动触发,不自动刷新</div>
+            </div>
+            <!-- 同步视图 Tab (fix24 v43 P2) -->
+            <div id="tab-sync" class="tab-content">
+                <div style="background:#f0f5ff;border:1px solid #adc6ff;border-radius:6px;padding:10px;margin-bottom:15px;font-size:13px;color:#1d39c4;">
+                    🔗 同步视图:基于 tb_num + 车牌号 联合匹配,展示抓拍事件与称重记录的同步状态
+                </div>
+                <!-- 同步筛选栏 -->
+                <div class="filter-bar" style="margin-bottom:15px;">
+                    <div class="filter-row">
+                    <div class="filter-item">
+                        <label>车牌号</label>
+                        <input type="text" id="sync-filter-plate" placeholder="模糊匹配">
+                    </div>
+                    <div class="filter-item">
+                        <label>联单号</label>
+                        <input type="text" id="sync-filter-tbnum" placeholder="模糊匹配">
+                    </div>
+                    <div class="filter-item">
+                        <label>起始日期</label>
+                        <input type="date" id="sync-filter-date-from">
+                    </div>
+                    <div class="filter-item">
+                        <label>截止日期</label>
+                        <input type="date" id="sync-filter-date-to">
+                    </div>
+                    <div class="filter-item">
+                        <label>站点</label>
+                        <select id="sync-filter-station">
+                            <option value="0">全部</option>
+                            <option value="1">进站</option>
+                            <option value="2">出站</option>
+                        </select>
+                    </div>
+                    <div class="filter-item">
+                        <label>同步状态</label>
+                        <select id="sync-filter-status">
+                            <option value="0">全部</option>
+                            <option value="1">已匹配</option>
+                            <option value="2">未匹配(无重量)</option>
+                            <option value="3">重量上传失败</option>
+                        </select>
+                    </div>
+                    <div class="filter-item" style="align-self:flex-end;">
+                        <button class="btn-search" onclick="searchSync(1)">🔍 查询</button>
+                        <button class="btn-reset" onclick="resetSyncFilters()" style="margin-left:5px;">重置</button>
+                    </div>
+                    </div>
+                </div>
+                <!-- 同步统计卡片 -->
+                <div class="stats" id="sync-stats-bar" style="margin-bottom:15px;">
+                    <div class="stat-card"><div class="label">同步模式</div><div class="value" id="sync-mode-display">-</div></div>
+                    <div class="stat-card"><div class="label">今日匹配率</div><div class="value" id="sync-today-rate" style="color:#52c41a;">-</div></div>
+                    <div class="stat-card"><div class="label">总匹配率</div><div class="value" id="sync-total-rate">-</div></div>
+                    <div class="stat-card"><div class="label">重量待上传</div><div class="value" id="sync-pending" style="color:#faad14;">-</div></div>
+                </div>
+                <!-- 同步结果表格 -->
+                <div class="table-container">
+                    <table >
+                        <thead>
+                            <tr>
+                                <th>抓拍时间</th>
+                                <th>车牌号</th>
+                                <th>联单号</th>
+                                <th>站点</th>
+                                <th>照片上传</th>
+                                <th>重量匹配</th>
+                                <th>重量(kg)</th>
+                                <th>重量上传状态</th>
+                            </tr>
+                        </thead>
+                        <tbody id="sync-table-body">
+                            <tr><td colspan="8" style="text-align:center;padding:30px;color:#999;">点击"查询"加载同步数据</td></tr>
+                        </tbody>
+                    </table>
+                </div>
+                <div class="pagination" id="sync-pagination"></div>
+                <div class="refresh-info" id="sync-refresh-info">同步视图:手动查询,统计每30秒自动刷新</div>
+            </div>
+        </div>
+    </div>
+
+    <div class="toast" id="toast"></div>
+
+    <script>
+    function getCookie(n){let m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:"";}
+
+        let currentPage = 1;
+        let currentPageSize = 20;
+        let currentTotal = 0;
+        let currentTotalPages = 0;
+        let currentFilters = {};
+        let currentTab = 'realtime';
+
+        function showToast(msg, type) {
+            var t = document.getElementById('toast');
+            t.textContent = msg;
+            t.className = 'toast ' + type + ' show';
+            setTimeout(function() { t.className = 'toast'; }, 3000);
+        }
+
+        // fix24 v41: Tab 切换
+        function switchTab(tab) {
+            currentTab = tab;
+            document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.remove('active'); });
+            document.querySelectorAll('.tab-content').forEach(function(content) { content.classList.remove('active'); });
+            
+            if (tab === 'realtime') {
+                document.querySelector('.tab-btn:nth-child(1)').classList.add('active');
+                document.getElementById('tab-realtime').classList.add('active');
+                loadRealtimeData();
+            } else if (tab === 'history') {
+                document.querySelector('.tab-btn:nth-child(2)').classList.add('active');
+                document.getElementById('tab-history').classList.add('active');
+            } else if (tab === 'sync') {
+                document.querySelector('.tab-btn:nth-child(3)').classList.add('active');
+                document.getElementById('tab-sync').classList.add('active');
+                loadSyncStats();
+                loadSyncData(1);
+            }
+        }
+
+        function collectFilters() {
+            return {
+                plate: document.getElementById('filter-plate').value.trim(),
+                tb_num: document.getElementById('filter-tbnum').value.trim(),
+                date_from: document.getElementById('filter-date-from').value,
+                date_to: document.getElementById('filter-date-to').value,
+                station_type: parseInt(document.getElementById('filter-station').value) || 0,
+                upload_status: parseInt(document.getElementById('filter-upload').value) || 0
+            };
+        }
+
+        function resetFilters() {
+            document.getElementById('filter-plate').value = '';
+            document.getElementById('filter-tbnum').value = '';
+            document.getElementById('filter-date-from').value = '';
+            document.getElementById('filter-date-to').value = '';
+            document.getElementById('filter-station').value = '0';
+            document.getElementById('filter-upload').value = '0';
+            currentFilters = {};
+            document.getElementById('history-body').innerHTML = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">请设置查询条件后点击"查询"按钮</td></tr>';
+            document.getElementById('pagination').innerHTML = '';
+            document.getElementById('history-refresh-info').textContent = '历史查询:手动触发,不自动刷新';
+        }
+
+        function hasActiveFilters(f) {
+            return f.plate || f.tb_num || f.date_from || f.date_to ||
+                   f.station_type !== 0 || f.upload_status !== 0;
+        }
+
+        // fix24 v41: 实时称重数据加载
+        function loadRealtimeData() {
+            fetch('/api/weight/records?page=1&page_size=20')
+                .then(function(r) { return r.json(); })
+                .then(function(data) {
+                    currentTotal = data.total || 0;
+                    document.getElementById('stat-total').textContent = currentTotal;
+                    document.getElementById('stat-failed').textContent = data.failed;
+                    renderRecords(data, 'realtime-body');
+                    var refreshEl = document.getElementById('realtime-refresh-info');
+                    refreshEl.textContent = '数据自动刷新间隔: 30秒(共 ' + currentTotal + ' 条,显示最新 20 条)';
+                })
+                .catch(function(e) {
+                    console.error('实时称重数据加载失败:', e);
+                    document.getElementById('realtime-body').innerHTML = '<tr><td colspan="11" style="text-align:center;color:#f5222d;">加载失败,请刷新页面</td></tr>';
+                });
+        }
+
+        // fix24 v41: 历史查询
+        function searchHistory(page) {
+            var filters = collectFilters();
+            if (filters.date_from && filters.date_to && filters.date_from > filters.date_to) {
+                alert('开始日期不能晚于结束日期');
+                return;
+            }
+            currentFilters = filters;
+            loadHistoryData(page, filters);
+        }
+
+        function loadHistoryData(page, filters) {
+            currentPage = page;
+            currentFilters = filters || {};
+            var params = new URLSearchParams();
+            params.set('page', page);
+            params.set('page_size', currentPageSize);
+            if (currentFilters.plate) params.set('plate', currentFilters.plate);
+            if (currentFilters.tb_num) params.set('tb_num', currentFilters.tb_num);
+            if (currentFilters.date_from) params.set('date_from', currentFilters.date_from);
+            if (currentFilters.date_to) params.set('date_to', currentFilters.date_to);
+            if (currentFilters.station_type) params.set('station_type', currentFilters.station_type);
+            if (currentFilters.upload_status) params.set('upload_status', currentFilters.upload_status);
+
+            fetch('/api/weight/records?' + params.toString())
+                .then(function(r) { return r.json(); })
+                .then(function(data) {
+                    currentTotal = data.total || 0;
+                    currentTotalPages = data.total_pages || 0;
+                    document.getElementById('stat-total').textContent = currentTotal;
+                    document.getElementById('stat-failed').textContent = data.failed;
+                    renderRecords(data, 'history-body');
+                    renderPagination(data.page, currentTotalPages, currentTotal);
+                    document.getElementById('jump-page').value = data.page;
+                    document.getElementById('jump-page').max = currentTotalPages || 1;
+                    var refreshEl = document.getElementById('history-refresh-info');
+                    refreshEl.textContent = '历史查询结果:共 ' + currentTotal + ' 条(手动查询,不自动刷新)';
+                })
+                .catch(function(e) {
+                    console.error('历史查询失败:', e);
+                    document.getElementById('history-body').innerHTML = '<tr><td colspan="11" style="text-align:center;color:#f5222d;">查询失败,请重试</td></tr>';
+                });
+        }
+
+        function renderRecords(data, targetId) {
+            var html = '';
+            if (!data.records || data.records.length === 0) {
+                html = '<tr><td colspan="11" style="text-align:center;padding:30px;color:#999;">暂无数据</td></tr>';
+            } else {
+                data.records.forEach(function(rec) {
+                    var direction = rec.station_type === 1 ? '进站' : '出站';
+                    var weightTon = rec.weight_kg > 0 ? (rec.weight_kg / 1000).toFixed(3) : '-';
+                    var weightKg = rec.weight_kg > 0 ? rec.weight_kg.toFixed(0) : '-';
+                    var statusClass, statusText;
+                    if (rec.upload_status === 1) { statusClass = 'status-success'; statusText = '成功'; }
+                    else if (rec.upload_status === 2) { statusClass = 'status-failed'; statusText = '失败'; }
+                    else { statusClass = 'status-pending'; statusText = '待上传'; }
+                    var retryBtn = rec.upload_status === 2 ? '<button class="btn-retry" onclick="retryWeight(' + rec.id + ',event)">重试</button>' : '-';
+
+                    html += '<tr>';
+                    html += '<td>' + rec.id + '</td>';
+                    html += '<td style="font-size:12px;">' + rec.create_time + '</td>';
+                    html += '<td><b>' + rec.plate_number + '</b></td>';
+                    html += '<td style="font-family:monospace;font-size:12px;">' + (rec.tb_num || '-') + '</td>';
+                    html += '<td>' + direction + '</td>';
+                    html += '<td>' + weightKg + '</td>';
+                    html += '<td>' + weightTon + '</td>';
+                    html += '<td><span class="status ' + statusClass + '">' + statusText + '</span></td>';
+                    html += '<td>' + rec.retry_count + '</td>';
+                    html += '<td style="font-size:11px;max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="' + (rec.response_msg || '') + '">' + (rec.response_msg || '-') + '</td>';
+                    html += '<td>' + retryBtn + '</td>';
+                    html += '</tr>';
+                });
+            }
+            document.getElementById(targetId).innerHTML = html;
+        }
+
+        function renderPagination(page, totalPages, total) {
+            if (!totalPages || totalPages < 1) totalPages = 1;
+            var html = '';
+            html += '<button onclick="searchHistory(1)" ' + (page <= 1 ? 'disabled' : '') + '>首页</button>';
+            html += '<button onclick="searchHistory(' + (page - 1) + ')" ' + (page <= 1 ? 'disabled' : '') + '>上一页</button>';
+
+            var start = Math.max(1, page - 2);
+            var end = Math.min(totalPages, page + 2);
+            if (start > 1) {
+                html += '<button onclick="searchHistory(1)">1</button>';
+                if (start > 2) html += '<span class="info">…</span>';
+            }
+            for (var i = start; i <= end; i++) {
+                html += '<button onclick="searchHistory(' + i + ')" class="' + (i === page ? 'active' : '') + '">' + i + '</button>';
+            }
+            if (end < totalPages) {
+                if (end < totalPages - 1) html += '<span class="info">…</span>';
+                html += '<button onclick="searchHistory(' + totalPages + ')">' + totalPages + '</button>';
+            }
+
+            html += '<button onclick="searchHistory(' + (page + 1) + ')" ' + (page >= totalPages ? 'disabled' : '') + '>下一页</button>';
+            html += '<button onclick="searchHistory(' + totalPages + ')" ' + (page >= totalPages ? 'disabled' : '') + '>末页</button>';
+            html += '<span class="info">第 ' + page + '/' + totalPages + ' 页 · 共 ' + total + ' 条</span>';
+            document.getElementById('pagination').innerHTML = html;
+        }
+
+        function changePageSize() {
+            currentPageSize = parseInt(document.getElementById('page-size').value) || 20;
+            if (currentTab === 'history' && hasActiveFilters(currentFilters)) {
+                searchHistory(1);
+            }
+        }
+
+        function jumpToPage() {
+            var p = parseInt(document.getElementById('jump-page').value) || 1;
+            if (p < 1) p = 1;
+            if (currentTotalPages && p > currentTotalPages) p = currentTotalPages;
+            searchHistory(p);
+        }
+
+        function retryWeight(id, evt) {
+            if (!confirm('确定要重新上传此称重记录吗?')) return;
+
+            var btn = evt ? evt.target : event.target;
+            btn.disabled = true;
+            btn.textContent = '重试中...';
+
+            fetch('/api/weight/retry', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCookie('csrf_token') },
+                body: JSON.stringify({ id: id })
+            })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                if (data.success) {
+                    showToast('✅ ' + data.message, 'success');
+                    if (currentTab === 'realtime') loadRealtimeData();
+                    else if (hasActiveFilters(currentFilters)) loadHistoryData(currentPage, currentFilters);
+                } else {
+                    showToast('❌ ' + data.message, 'error');
+                }
+            })
+            .catch(function(e) {
+                btn.disabled = false;
+                btn.textContent = '重试';
+                showToast('请求失败: ' + e, 'error');
+            });
+        }
+
+        // 初始化
+        loadRealtimeData();
+        loadSyncStats();
+
+        // 自动刷新:实时称重 Tab + 同步统计
+        setInterval(function() {
+            if (currentTab === 'realtime') {
+                loadRealtimeData();
+            }
+            // 每30秒刷新同步统计(如果同步视图tab激活)
+            if (currentTab === 'sync') {
+                loadSyncStats();
+            }
+        }, 30000);
+
+        // 回车搜索
+        document.getElementById('filter-plate').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') searchHistory(1);
+        });
+        document.getElementById('filter-tbnum').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') searchHistory(1);
+        });
+        // fix24 v43 P2: 同步视图回车搜索
+        document.getElementById('sync-filter-plate').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') searchSync(1);
+        });
+        document.getElementById('sync-filter-tbnum').addEventListener('keypress', function(e) {
+            if (e.key === 'Enter') searchSync(1);
+        });
+
+        // 角色检查
+        fetch('/api/auth/status', {credentials:'same-origin'}).then(function(r){return r.json();}).then(function(data){
+            if(data.authenticated){
+                if(data.role >= 1){ var c=document.getElementById('nav-config'); if(c) c.style.display=''; }
+                if(data.role >= 2){ var h=document.getElementById('nav-help'); if(h) h.style.display=''; }
+            }
+        }).catch(function(){});
+        // ==================== fix24 v43 P2: 同步视图 JS ====================
+        let syncPage = 1;
+        let syncPageSize = 20;
+        let syncTotalPages = 1;
+
+        function loadSyncStats() {
+            fetch('/api/sync/stats', { credentials: 'same-origin' })
+            .then(function(r) { return r.json(); })
+            .then(function(d) {
+                document.getElementById('sync-mode-display').textContent = d.parallel_mode ? '✅ 并行模式' : '⏳ 串行模式';
+                document.getElementById('sync-mode-display').style.color = d.parallel_mode ? '#52c41a' : '#faad14';
+                document.getElementById('sync-today-rate').textContent = d.today_match_rate.toFixed(1) + '%';
+                document.getElementById('sync-total-rate').textContent = d.match_rate.toFixed(1) + '%';
+                document.getElementById('sync-pending').textContent = d.weight_pending;
+                // 同时更新首页统计卡片(如果有)
+                var el = document.getElementById('stat-sync-mode');
+                if (el) el.textContent = d.parallel_mode ? '并行' : '串行';
+                var el2 = document.getElementById('stat-match-rate');
+                if (el2) el2.textContent = d.today_match_rate.toFixed(1) + '%';
+            })
+            .catch(function() {});
+        }
+
+        function searchSync(page) {
+            syncPage = page || 1;
+            loadSyncData(syncPage);
+        }
+
+        function loadSyncData(page) {
+            syncPage = page || 1;
+            var params = new URLSearchParams({
+                page: syncPage,
+                page_size: syncPageSize,
+                plate: document.getElementById('sync-filter-plate').value.trim(),
+                tb_num: document.getElementById('sync-filter-tbnum').value.trim(),
+                date_from: document.getElementById('sync-filter-date-from').value,
+                date_to: document.getElementById('sync-filter-date-to').value,
+                station_type: document.getElementById('sync-filter-station').value,
+                sync_status: document.getElementById('sync-filter-status').value
+            });
+            fetch('/api/sync/events?' + params.toString(), { credentials: 'same-origin' })
+            .then(function(r) { return r.json(); })
+            .then(function(data) {
+                syncTotalPages = data.total_pages || 1;
+                renderSyncRecords(data.records || []);
+                renderSyncPagination(data.total, syncTotalPages);
+            })
+            .catch(function(e) {
+                document.getElementById('sync-table-body').innerHTML =
+                    '<tr><td colspan="8" style="color:#f5222d;text-align:center;padding:20px;">加载失败: ' + e + '</td></tr>';
+            });
+        }
+
+        function renderSyncRecords(records) {
+            var tbody = document.getElementById('sync-table-body');
+            if (!records.length) {
+                tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:30px;color:#999;">无匹配记录</td></tr>';
+                return;
+            }
+            var html = '';
+            for (var i = 0; i < records.length; i++) {
+                var rec = records[i];
+                var stationBadge = rec.station_type === 1
+                    ? '<span style="background:#e6f7ff;color:#0050b3;padding:2px 8px;border-radius:10px;font-size:12px;">进站</span>'
+                    : '<span style="background:#fff7e6;color:#d46b08;padding:2px 8px;border-radius:10px;font-size:12px;">出站</span>';
+                // 照片上传状态
+                var photoStatus = '';
+                if (rec.lo_upload_status === 1 && rec.hi_upload_status === 1) {
+                    photoStatus = '<span style="color:#52c41a;">✅ 双侧成功</span>';
+                } else if (rec.lo_upload_status === 2 || rec.hi_upload_status === 2) {
+                    photoStatus = '<span style="color:#f5222d;">❌ 有失败</span>';
+                } else {
+                    photoStatus = '<span style="color:#faad14;">⏳ 待上传</span>';
+                }
+                // 重量匹配列
+                var weightMatch = '';
+                if (rec.synced) {
+                    weightMatch = '<span style="color:#52c41a;font-weight:bold;">✅ 已匹配</span>';
+                } else {
+                    weightMatch = '<span style="color:#8c8c8c;">— 未匹配</span>';
+                }
+                // 重量值
+                var weightVal = rec.synced ? rec.weight_kg.toFixed(1) : '<span style="color:#bbb;">—</span>';
+                // 重量上传状态
+                var wus = '';
+                if (!rec.synced) {
+                    wus = '<span style="color:#bbb;">无记录</span>';
+                } else if (rec.weight_upload_status === 0) {
+                    wus = '<span style="color:#faad14;">⏳ 待上传</span>';
+                } else if (rec.weight_upload_status === 1) {
+                    wus = '<span style="color:#52c41a;">✅ 成功</span>';
+                } else if (rec.weight_upload_status === 2) {
+                    wus = '<span style="color:#f5222d;" title="' + (rec.weight_response_msg || '') + '">❌ 失败</span>';
+                }
+                html += '<tr>' +
+                    '<td>' + (rec.capture_time || rec.create_time) + '</td>' +
+                    '<td><strong>' + rec.plate_number + '</strong></td>' +
+                    '<td><code style="background:#f5f5f5;padding:2px 6px;border-radius:3px;">' + rec.tb_num + '</code></td>' +
+                    '<td>' + stationBadge + '</td>' +
+                    '<td>' + photoStatus + '</td>' +
+                    '<td>' + weightMatch + '</td>' +
+                    '<td>' + weightVal + '</td>' +
+                    '<td>' + wus + '</td>' +
+                    '</tr>';
+            }
+            tbody.innerHTML = html;
+        }
+
+        function renderSyncPagination(total, totalPages) {
+            var div = document.getElementById('sync-pagination');
+            if (totalPages <= 1) {
+                div.innerHTML = '<span style="color:#999;font-size:13px;">共 ' + total + ' 条记录</span>';
+                return;
+            }
+            var html = '<span style="color:#666;font-size:13px;margin-right:10px;">共 ' + total + ' 条,' + totalPages + ' 页</span>';
+            if (syncPage > 1) {
+                html += '<button onclick="searchSync(1)">首页</button>';
+                html += '<button onclick="searchSync(' + (syncPage - 1) + ')">上一页</button>';
+            }
+            var start = Math.max(1, syncPage - 2);
+            var end = Math.min(totalPages, syncPage + 2);
+            for (var p = start; p <= end; p++) {
+                if (p === syncPage) {
+                    html += '<button class="active" disabled>' + p + '</button>';
+                } else {
+                    html += '<button onclick="searchSync(' + p + ')">' + p + '</button>';
+                }
+            }
+            if (syncPage < totalPages) {
+                html += '<button onclick="searchSync(' + (syncPage + 1) + ')">下一页</button>';
+                html += '<button onclick="searchSync(' + totalPages + ')">末页</button>';
+            }
+            div.innerHTML = html;
+        }
+
+        function resetSyncFilters() {
+            document.getElementById('sync-filter-plate').value = '';
+            document.getElementById('sync-filter-tbnum').value = '';
+            document.getElementById('sync-filter-date-from').value = '';
+            document.getElementById('sync-filter-date-to').value = '';
+            document.getElementById('sync-filter-station').value = '0';
+            document.getElementById('sync-filter-status').value = '0';
+            searchSync(1);
+        }
+
+    </script>
+</body>
+</html>

+ 162 - 0
deploy.sh

@@ -0,0 +1,162 @@
+#!/bin/bash
+set -e
+
+# ====================== 配置 ======================
+PROJECT_DIR="/opt/openAI/project/003.PlateRecAPP"
+SERVICE_NAME="plate-log2mqtt"
+BINARY_NAME="plate_log2mqtt"
+CONFIG_FILE="config.ini"
+LOG_FILE="PlateRecApp.log"
+
+# ====================== 颜色输出 ======================
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+print_info() {
+    echo -e "${GREEN}[INFO]${NC} $1"
+}
+
+print_warn() {
+    echo -e "${YELLOW}[WARN]${NC} $1"
+}
+
+print_error() {
+    echo -e "${RED}[ERROR]${NC} $1"
+}
+
+# ====================== 检查root权限 ======================
+if [ "$EUID" -ne 0 ]; then 
+    print_error "请使用root权限运行: sudo $0"
+    exit 1
+fi
+
+# ====================== 检查项目目录 ======================
+print_info "检查项目目录: $PROJECT_DIR"
+if [ ! -d "$PROJECT_DIR" ]; then
+    print_error "项目目录不存在: $PROJECT_DIR"
+    exit 1
+fi
+
+cd "$PROJECT_DIR"
+
+# ====================== 检查二进制文件 ======================
+print_info "检查二进制文件..."
+if [ ! -f "$BINARY_NAME" ]; then
+    print_error "二进制文件不存在: $PROJECT_DIR/$BINARY_NAME"
+    exit 1
+fi
+
+if [ ! -x "$BINARY_NAME" ]; then
+    print_info "设置执行权限..."
+    chmod +x "$BINARY_NAME"
+fi
+
+# ====================== 检查配置文件 ======================
+if [ ! -f "$CONFIG_FILE" ]; then
+    print_error "配置文件不存在: $CONFIG_FILE"
+    exit 1
+fi
+
+# ====================== 设置权限 ======================
+print_info "设置文件权限..."
+chown -R root:root "$PROJECT_DIR"
+
+# ====================== 创建日志文件 ======================
+if [ ! -f "$LOG_FILE" ]; then
+    print_info "创建日志文件: $LOG_FILE"
+    touch "$LOG_FILE"
+    chown root:root "$LOG_FILE"
+fi
+
+# ====================== 创建systemd服务 ======================
+print_info "创建systemd服务: $SERVICE_NAME"
+cat > "/etc/systemd/system/$SERVICE_NAME.service" << EOF
+[Unit]
+Description=Plate Recognition Log to MQTT Service
+After=network.target mosquitto.service
+Wants=network.target
+
+[Service]
+Type=simple
+User=root
+WorkingDirectory=$PROJECT_DIR
+ExecStart=$PROJECT_DIR/$BINARY_NAME
+Restart=always
+RestartSec=5
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=$SERVICE_NAME
+
+# 资源限制
+LimitNOFILE=65536
+LimitNPROC=4096
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+# ====================== 创建日志轮转配置 ======================
+print_info "创建日志轮转配置..."
+cat > "/etc/logrotate.d/$SERVICE_NAME" << EOF
+$PROJECT_DIR/$LOG_FILE {
+    daily
+    rotate 30
+    compress
+    delaycompress
+    missingok
+    notifempty
+    create 0644 root root
+    sharedscripts
+    postrotate
+        systemctl reload $SERVICE_NAME > /dev/null 2>&1 || true
+    endscript
+}
+EOF
+
+# ====================== 重载systemd ======================
+print_info "重载systemd配置..."
+systemctl daemon-reload
+
+# ====================== 停止旧服务(如果存在) ======================
+if systemctl is-active --quiet "$SERVICE_NAME"; then
+    print_info "停止旧服务..."
+    systemctl stop "$SERVICE_NAME"
+fi
+
+# ====================== 启用并启动服务 ======================
+print_info "启用服务开机自启..."
+systemctl enable "$SERVICE_NAME"
+
+print_info "启动服务..."
+systemctl start "$SERVICE_NAME"
+
+# ====================== 等待服务启动 ======================
+print_info "等待服务启动..."
+sleep 3
+
+# ====================== 检查服务状态 ======================
+if systemctl is-active --quiet "$SERVICE_NAME"; then
+    print_info "服务启动成功!"
+    echo ""
+    print_info "=========================================="
+    print_info "  部署成功!"
+    print_info "=========================================="
+    print_info "  服务名称: $SERVICE_NAME"
+    print_info "  程序路径: $PROJECT_DIR/$BINARY_NAME"
+    print_info "  配置文件: $PROJECT_DIR/$CONFIG_FILE"
+    print_info "  日志文件: $PROJECT_DIR/$LOG_FILE"
+    print_info ""
+    print_info "  常用命令:"
+    print_info "  查看状态: systemctl status $SERVICE_NAME"
+    print_info "  查看日志: journalctl -u $SERVICE_NAME -f"
+    print_info "  停止服务: systemctl stop $SERVICE_NAME"
+    print_info "  启动服务: systemctl start $SERVICE_NAME"
+    print_info "  重启服务: systemctl restart $SERVICE_NAME"
+    print_info "=========================================="
+else
+    print_error "服务启动失败"
+    print_error "查看日志: journalctl -u $SERVICE_NAME -n 50"
+    exit 1
+fi

+ 214 - 0
scripts/reset_passwords.py

@@ -0,0 +1,214 @@
+#!/usr/bin/env python3
+"""
+车牌识别系统 - 用户密码重置/创建脚本
+fix24 v29 (2026-07-05)
+
+用法:
+  cd /opt/openAI/project/003.PlateRecAPP
+  python3 reset_passwords.py
+
+功能:
+  - 使用 data/auth.db 认证数据库
+  - 创建或更新两个用户账号
+  - 普通用户 zhonjin (role=0)
+  - 超级管理员 admin (role=2)
+  - 同时修复 config.ini 中的 auth db_path 指向正确路径
+"""
+
+import sqlite3
+import sys
+import os
+import configparser
+
+try:
+    import bcrypt
+except ImportError:
+    print("正在安装 bcrypt 库...")
+    os.system(f"{sys.executable} -m pip install bcrypt -q")
+    import bcrypt
+
+# ==================== 配置 ====================
+# 候选数据库路径(按优先级排列)
+DB_CANDIDATES = [
+    "data/auth.db",             # fix24-v29: 唯一正确的认证数据库路径
+]
+
+CONFIG_FILE = "config.ini"
+
+USERS = [
+    {
+        "username": "zhonjin",
+        "password": "zhonjin",
+        "role": 0,        # 0=普通用户
+        "display": "普通用户"
+    },
+    {
+        "username": "admin",
+        "password": "zhonjin188A",
+        "role": 2,        # 2=超级管理员
+        "display": "超级管理员"
+    },
+]
+
+# Bcrypt cost factor (与 config.ini 中 bcrypt_cost 一致)
+BCRYPT_COST = 12
+
+# ==================== 工具函数 ====================
+def hash_password(password: str) -> str:
+    """使用 bcrypt 生成密码哈希"""
+    salt = bcrypt.gensalt(rounds=BCRYPT_COST)
+    return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
+
+def find_auth_db() -> str:
+    """自动检测正确的认证数据库"""
+    for db_path in DB_CANDIDATES:
+        if os.path.exists(db_path):
+            try:
+                conn = sqlite3.connect(db_path)
+                cursor = conn.cursor()
+                cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
+                if cursor.fetchone():
+                    conn.close()
+                    return db_path
+                conn.close()
+            except Exception:
+                continue
+    # 都不存在,返回第一个候选(让程序创建)
+    return DB_CANDIDATES[0]
+
+def fix_config_ini(correct_db_path: str):
+    """修复 config.ini 中的 auth db_path"""
+    if not os.path.exists(CONFIG_FILE):
+        print(f"⚠️  配置文件不存在: {CONFIG_FILE},跳过配置修复")
+        return
+
+    with open(CONFIG_FILE, 'r') as f:
+        content = f.read()
+
+    # 检查 [auth] 段中的 db_path
+    lines = content.split('\n')
+    new_lines = []
+    in_auth_section = False
+    db_path_found = False
+
+    for line in lines:
+        stripped = line.strip()
+        if stripped.lower() == '[auth]':
+            in_auth_section = True
+            new_lines.append(line)
+            continue
+
+        if in_auth_section and stripped.startswith('['):
+            in_auth_section = False
+
+        if in_auth_section and stripped.lower().startswith('db_path'):
+            db_path_found = True
+            new_lines.append(f'db_path={correct_db_path}')
+            print(f"🔧 config.ini: db_path 已更新为 {correct_db_path}")
+        else:
+            new_lines.append(line)
+
+    if not db_path_found:
+        # 如果没找到 db_path,在 [auth] 段的 enabled 后面添加
+        final_lines = []
+        for line in new_lines:
+            final_lines.append(line)
+            if line.strip().lower().startswith('enabled') and in_auth_section:
+                # 这里有个问题:in_auth_section 可能已经被重置了
+                pass
+        # 简单处理:直接在文件末尾的 [auth] 段添加
+        print(f"⚠️  config.ini [auth] 段未找到 db_path,请手动添加: db_path={correct_db_path}")
+    else:
+        with open(CONFIG_FILE, 'w') as f:
+            f.write('\n'.join(new_lines))
+
+# ==================== 主逻辑 ====================
+def main():
+    # 自动检测正确的数据库
+    db_path = find_auth_db()
+    print(f"📂 认证数据库: {os.path.abspath(db_path)}")
+    print(f"🔐 Bcrypt cost: {BCRYPT_COST}")
+    print()
+
+    # 确保 data/ 目录存在(如果需要创建新数据库)
+    db_dir = os.path.dirname(db_path)
+    if db_dir and not os.path.exists(db_dir):
+        os.makedirs(db_dir, exist_ok=True)
+
+    conn = sqlite3.connect(db_path)
+    cursor = conn.cursor()
+
+    # 确保 users 表存在
+    cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
+    if not cursor.fetchone():
+        print("📋 创建 users 表...")
+        cursor.execute("""
+            CREATE TABLE users (
+                id INTEGER PRIMARY KEY AUTOINCREMENT,
+                username TEXT UNIQUE NOT NULL,
+                password_hash TEXT NOT NULL,
+                role INTEGER DEFAULT 0,
+                enabled INTEGER DEFAULT 1,
+                failed_attempts INTEGER DEFAULT 0,
+                locked_until INTEGER DEFAULT 0,
+                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
+                updated_at TEXT DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        conn.commit()
+
+    for user in USERS:
+        username = user["username"]
+        password = user["password"]
+        role = user["role"]
+        display = user["display"]
+
+        # 生成密码哈希
+        pw_hash = hash_password(password)
+
+        # 检查用户是否已存在
+        cursor.execute("SELECT id, role FROM users WHERE username = ?", (username,))
+        existing = cursor.fetchone()
+
+        if existing:
+            # 更新密码和角色
+            cursor.execute(
+                "UPDATE users SET password_hash = ?, role = ?, enabled = 1, "
+                "failed_attempts = 0, locked_until = 0, updated_at = CURRENT_TIMESTAMP "
+                "WHERE username = ?",
+                (pw_hash, role, username)
+            )
+            print(f"✅ [{display}] {username} — 密码已更新 (role={role})")
+        else:
+            # 创建新用户
+            cursor.execute(
+                "INSERT INTO users (username, password_hash, role, enabled, "
+                "failed_attempts, locked_until, created_at, updated_at) "
+                "VALUES (?, ?, ?, 1, 0, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)",
+                (username, pw_hash, role)
+            )
+            print(f"✅ [{display}] {username} — 用户已创建 (role={role})")
+
+    conn.commit()
+    conn.close()
+
+    # 修复 config.ini
+    print()
+    fix_config_ini(db_path)
+
+    print()
+    print("=" * 50)
+    print("🎉 密码重置完成!")
+    print()
+    print("账号信息:")
+    print(f"  普通用户:   zhonjin / zhonjin")
+    print(f"  超级管理员: admin / zhonjin188A")
+    print()
+    print("📌 认证数据库: " + os.path.abspath(db_path))
+    print()
+    print("⚠️  请确认 config.ini [auth] 段:")
+    print(f"     db_path={db_path}")
+    print("=" * 50)
+
+if __name__ == "__main__":
+    main()

+ 30 - 0
scripts/setup_frpc_sudoers.sh

@@ -0,0 +1,30 @@
+#!/bin/bash
+# 配置 sudoers 允许 PlateRecApp 无密码重启 frpc 服务
+# 用法: sudo bash setup_frpc_sudoers.sh [运行用户名]
+# 如果不指定用户名,自动检测当前用户
+
+RUN_USER="${1:-$(whoami)}"
+SUDOERS_FILE="/etc/sudoers.d/platerec-frpc"
+
+echo "配置 frpc 服务管理权限 for user: $RUN_USER"
+
+cat > "$SUDOERS_FILE" << EOF
+# PlateRecApp Web 管理 frpc 服务所需权限
+# 允许无密码执行 systemctl 操作 frpc 服务
+$RUN_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart frpc
+$RUN_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active frpc
+$RUN_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl status frpc
+$RUN_USER ALL=(ALL) NOPASSWD: /usr/bin/systemctl reload frpc
+EOF
+
+chmod 440 "$SUDOERS_FILE"
+
+# 验证 sudoers 语法
+if visudo -cf "$SUDOERS_FILE"; then
+    echo "✅ sudoers 配置成功: $SUDOERS_FILE"
+    echo "用户 $RUN_USER 现在可以无密码重启 frpc 服务"
+else
+    echo "❌ sudoers 语法错误,已删除"
+    rm -f "$SUDOERS_FILE"
+    exit 1
+fi