feat: 新增设备画面截图、列表复选框/排序/批量停止,修复端口绑定失败

This commit is contained in:
2026-08-07 15:08:36 +08:00
parent bce48fc137
commit 0fe2a2fc5f
7 changed files with 355 additions and 55 deletions
+3 -1
View File
@@ -27,7 +27,9 @@ else:
# ================== Web 后台 ==================
WEB_HOST = "0.0.0.0"
WEB_PORT = 5050
# 端口必须避开 Windows 动态端口范围(本机被改成 1024-15000,默认 49152-65535)
# 用 18050 处于动态范围之外,不会被出站连接随机占用,避免 WinError 10013
WEB_PORT = 18050
# ================== 数据目录 ==================
# 持久化数据(分组、任务计划)存放目录
+29
View File
@@ -124,6 +124,35 @@ def adb_disconnect(remote_adb_url):
pass
def screenshot(serial, timeout=5):
"""获取设备当前画面截图(PNG bytes)。
用 `adb exec-out -s <serial> shell screencap -p`,只读操作,
不抢占 u2 的 atx-agent 通道,任务运行中调用安全。
返回 (ok, png_bytes_or_errmsg)。
"""
if not serial:
return False, "缺少 serial"
# 先尝试连一下(任务运行中设备已 connect,这里是幂等的)
if not adb_connect_light(serial):
return False, "adb connect 失败"
try:
r = subprocess.run(
[ADB_PATH, "-s", serial, "exec-out", "screencap", "-p"],
capture_output=True, timeout=timeout,
)
# PNG 文件头:\x89PNG
if r.stdout and r.stdout[:4] == b"\x89PNG":
return True, r.stdout
# 失败时 stdout 可能为空,看 stderr
err = (r.stderr or b"").decode("utf-8", errors="replace").strip()
return False, err or "截图失败(设备可能离线)"
except subprocess.TimeoutExpired:
return False, "截图超时"
except Exception as e:
return False, str(e)
def identify_device(serial, duration=3):
"""让设备响铃以供识别(直连 serial,不经过 STF)。
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -52,6 +52,6 @@ echo ============================================
echo Fix complete! Now:
echo 1. Close ALL browser windows and reopen
echo 2. Run: python web_server.py
echo 3. Open: http://127.0.0.1:5050/
echo 3. Open: http://127.0.0.1:18050/
echo ============================================
pause
+2 -2
View File
@@ -23,8 +23,8 @@ if %errorlevel% neq 0 (
echo ============================================
echo 管理后台已启动(局域网可访问)
echo 本机访问: http://127.0.0.1:5050/
echo 局域网访问: http://本机IP:5050/
echo 本机访问: http://127.0.0.1:18050/
echo 局域网访问: http://本机IP:18050/
echo 默认账号: admin / admin123
echo 按 Ctrl+C 停止
echo ============================================
+269 -31
View File
@@ -143,6 +143,27 @@ select.form-control{cursor:pointer}
.toast.show{display:block}
.toast-success{background:#dcfce7;color:#166534;border:1px solid #86efac}
.toast-error{background:#fee2e2;color:#991b1b;border:1px solid #fca5a5}
/* 截图弹窗 */
.shot-modal{max-width:none;width:min(440px,95vw)}
.shot-modal .modal-body{text-align:center;padding:14px}
.shot-img{max-width:100%;max-height:calc(100vh - 240px);width:auto;height:auto;border-radius:8px;box-shadow:0 2px 8px rgba(0,0,0,.15);background:#1e293b;min-height:200px;display:block;margin:0 auto}
.shot-loading{color:var(--text-light);padding:60px 0;font-size:13px}
.shot-err{color:var(--danger);padding:40px 0;font-size:13px;white-space:pre-wrap;word-break:break-all}
.shot-info{font-size:12px;color:var(--text-muted);margin-top:10px}
.shot-toolbar{display:flex;gap:8px;justify-content:center;align-items:center;margin-top:12px;flex-wrap:wrap}
.shot-toolbar .auto-label{font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:4px}
.shot-toolbar .auto-label input{width:14px;height:14px}
/* 表头排序 */
.table thead th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
.table thead th.sortable:hover{background:#e2e8f0}
.table thead th.sortable::after{content:" ⇅";color:#cbd5e1;font-weight:400;font-size:11px}
.table thead th.sortable.sort-asc::after{content:" ↑";color:var(--primary);font-weight:700}
.table thead th.sortable.sort-desc::after{content:" ↓";color:var(--primary);font-weight:700}
.table tbody tr.row-checked td{background:#eff6ff}
.table .col-cb{width:36px;text-align:center}
.table .col-cb input{width:14px;height:14px;cursor:pointer;vertical-align:middle}
</style>
</head>
<body>
@@ -176,15 +197,25 @@ select.form-control{cursor:pointer}
<div class="section-title">设备列表</div>
<div class="toolbar">
<button class="btn btn-danger" onclick="apiAction('/api/stop_all')">停止全部</button>
<button class="btn btn-danger" id="btn-stop-selected" onclick="stopSelectedDevices()" title="停止所有勾选的运行中设备">停止选中</button>
<button class="btn" onclick="apiAction('/api/release')">强制释放占用</button>
<button class="btn btn-primary" id="btn-scan-fg" onclick="scanForeground()">扫描前台App</button>
<button class="btn" onclick="loadMonitor()">刷新</button>
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span></span>
<span class="hint"><span id="fg-status"></span> · 每 5 秒自动刷新 · <span id="srvTime"></span> · <span id="sel-count">未选中</span></span>
</div>
<table class="table">
<thead><tr>
<th>设备</th><th>型号</th><th>STF</th><th>任务状态</th><th>前台App</th><th>进度</th>
<th>当前任务</th><th>当前动作</th><th>最近错误</th><th>操作</th>
<th class="col-cb"><input type="checkbox" id="cb-all" onclick="toggleAllDevices(this.checked)" title="全选/取消全选"></th>
<th class="sortable" data-key="serial" onclick="sortBy('serial')">设备</th>
<th class="sortable" data-key="model" onclick="sortBy('model')">型号</th>
<th class="sortable" data-key="stf_occupied" onclick="sortBy('stf_occupied')">STF</th>
<th class="sortable" data-key="worker_status" onclick="sortBy('worker_status')">任务状态</th>
<th class="sortable" data-key="foreground_app" onclick="sortBy('foreground_app')">前台App</th>
<th class="sortable" data-key="progress" onclick="sortBy('progress')">进度</th>
<th class="sortable" data-key="task_job" onclick="sortBy('task_job')">当前任务</th>
<th class="sortable" data-key="current_action" onclick="sortBy('current_action')">当前动作</th>
<th class="sortable" data-key="last_error" onclick="sortBy('last_error')">最近错误</th>
<th>操作</th>
</tr></thead>
<tbody id="tb-devices"></tbody>
</table>
@@ -289,7 +320,7 @@ select.form-control{cursor:pointer}
<!-- ========== 模态框 ========== -->
<div id="modal-overlay" class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal" id="modal-box">
<div class="modal-header">
<h3 id="modal-title">标题</h3>
<button class="close" onclick="closeModal()">&times;</button>
@@ -326,12 +357,181 @@ async function apiPut(url,body){
if(r.status===401){window.location='/login';return null;}
return r.json();
}
// 识别设备:让设备响铃 3 秒
async function identifyDevice(serial){
showToast('正在连接设备响铃...','');
const r=await apiPost('/api/device/identify',{serial:serial});
if(r&&r.ok){showToast(r.msg||'已响铃','success');}
else{showToast(r?r.error:'响铃失败','error');}
// ================== 设备列表排序与多选 ==================
let _sortKey=''; // 当前排序字段
let _sortDir=''; // '' | 'asc' | 'desc'
let _selectedSerials=new Set(); // 已勾选的 serial 集合
// 点击表头排序
function sortBy(key){
if(_sortKey===key){
// 同一列:切换方向;已是降序则取消
if(_sortDir==='asc'){_sortDir='desc';}
else if(_sortDir==='desc'){_sortKey='';_sortDir='';}
else{_sortDir='asc';}
}else{
_sortKey=key;_sortDir='asc';
}
// 更新表头样式
document.querySelectorAll('.table thead th.sortable').forEach(th=>{
th.classList.remove('sort-asc','sort-desc');
if(th.dataset.key===_sortKey){
th.classList.add(_sortDir==='asc'?'sort-asc':(_sortDir==='desc'?'sort-desc':''));
}
});
renderDevices();
}
// 排序比较:从设备对象取排序值
function _sortValue(dev,key){
let v;
if(key==='progress'){
const p=dev.progress||{};
v=(p.done||0)+(((p.total||0)>0?(p.done||0)/(p.total):0)*10000);
}else if(key==='stf_occupied'){
v=dev.stf_occupied?1:0;
}else if(key==='worker_status'){
// 按状态优先级排序:running>connecting>idle>done>error>released
const order={running:0,connecting:1,idle:2,done:3,released:4,error:5,failed:6};
v=order[dev.worker_status]||99;
}else{
v=dev[key]||'';
}
return v;
}
// 全选/取消全选
function toggleAllDevices(checked){
if(checked){
_statusData.devices.forEach(d=>_selectedSerials.add(d.serial));
}else{
_selectedSerials.clear();
}
renderDevices();
updateSelCount();
}
// 单个勾选
function toggleDevice(serial,checked){
if(checked){_selectedSerials.add(serial);}else{_selectedSerials.delete(serial);}
updateSelCount();
// 同步全选框状态
const all=document.getElementById('cb-all');
if(all){
const total=_statusData?(_statusData.devices||[]).length:0;
all.checked=total>0&&_selectedSerials.size===total;
}
}
function updateSelCount(){
const n=_selectedSerials.size;
const el=document.getElementById('sel-count');
if(el)el.textContent=n>0?('已选 '+n+' 台'):'未选中';
const btn=document.getElementById('btn-stop-selected');
if(btn)btn.disabled=n===0;
}
// 批量停止选中的运行中设备
async function stopSelectedDevices(){
const serials=Array.from(_selectedSerials);
if(serials.length===0){showToast('请先勾选设备','error');return;}
// 只停止运行中/连接中的
const toStop=serials.filter(s=>{
const d=(_statusData.devices||[]).find(x=>x.serial===s);
return d&&(d.worker_status==='running'||d.worker_status==='connecting');
});
if(toStop.length===0){showToast('选中的设备没有运行中的任务','error');return;}
if(!confirm('确定停止选中的 '+toStop.length+' 台设备的任务吗?'))return;
let ok=0,fail=0;
for(const s of toStop){
const r=await apiPost('/api/stop_device',{serial:s});
if(r&&r.ok){ok++;}else{fail++;}
}
showToast('已停止 '+ok+' 台'+(fail>0?(',失败 '+fail+' 台'):''),ok>0?'success':'error');
setTimeout(loadMonitor,500);
}
// 打开截图弹窗
function openScreenshot(serial){
_shotSerial=serial;
_shotLoading=false;
// 渲染弹窗
document.getElementById('modal-title').textContent='设备画面 - '+serial;
document.getElementById('modal-body').innerHTML=
'<div id="shot-content" class="shot-loading">正在获取画面...</div>'+
'<div class="shot-toolbar">'+
'<button class="btn btn-primary btn-sm" onclick="refreshShot()">刷新</button>'+
'<label class="auto-label"><input type="checkbox" id="shot-auto" checked onchange="toggleShotAuto()"> 自动刷新(3秒)</label>'+
'<span class="shot-info" id="shot-time"></span>'+
'</div>';
document.getElementById('modal-footer').innerHTML='<button class="btn" onclick="closeShotModal()">关闭</button>';
// 切换为截图弹窗样式(自适应大小)
document.getElementById('modal-box').classList.add('shot-modal');
showModal();
// 立即取一次
refreshShot();
// 开启自动刷新
startShotTimer();
}
// 关闭截图弹窗(停掉定时器 + 关闭 modal + 还原样式)
function closeShotModal(){
stopShotTimer();
_shotSerial='';
document.getElementById('modal-box').classList.remove('shot-modal');
closeModal();
}
function toggleShotAuto(){
const cb=document.getElementById('shot-auto');
if(cb&&cb.checked){startShotTimer();}else{stopShotTimer();}
}
function startShotTimer(){
stopShotTimer();
_shotTimer=setInterval(refreshShot,3000);
}
function stopShotTimer(){
if(_shotTimer){clearInterval(_shotTimer);_shotTimer=null;}
}
// 取一次截图
async function refreshShot(){
if(!_shotSerial)return;
if(_shotLoading)return; // 上一次还没回来,跳过这次
_shotLoading=true;
const el=document.getElementById('shot-content');
if(!el){_shotLoading=false;return;}
// 首次或出错后显示 loading
if(!el.querySelector('img')){
el.className='shot-loading';
el.textContent='正在获取画面...';
}
const url='/api/device/screenshot?serial='+encodeURIComponent(_shotSerial)+'&t='+Date.now();
try{
const r=await fetch(url);
if(r.status===401){window.location='/login';return;}
if(!r.ok){
const j=await r.json().catch(()=>({}));
throw new Error(j.error||('HTTP '+r.status));
}
const blob=await r.blob();
const objUrl=URL.createObjectURL(blob);
el.className='';
el.innerHTML='<img class="shot-img" src="'+objUrl+'" alt="设备画面">';
// 更新时间
const tEl=document.getElementById('shot-time');
if(tEl)tEl.textContent='更新于 '+new Date().toLocaleTimeString();
// 释放上一个 objUrl(避免内存泄漏)
if(el._lastObjUrl)URL.revokeObjectURL(el._lastObjUrl);
el._lastObjUrl=objUrl;
}catch(e){
el.className='shot-err';
el.textContent='截图失败:'+e.message;
}finally{
_shotLoading=false;
}
}
async function apiDelete(url){
const r=await fetch(url,{method:'DELETE'});
@@ -415,32 +615,64 @@ async function loadMonitor(){
document.getElementById('stats').innerHTML=cards.map(c=>
'<div class="stat-card" style="--accent:'+c.c+'"><div class="stat-num" style="color:'+c.c+'">'+c.n+'</div><div class="stat-lbl">'+c.l+'</div></div>'
).join('');
const rows=devices.map(dev=>{
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
const fgApp=dev.foreground_app;
const fg=fgApp?(
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
):'<span class="text-muted">-</span>';
// 通用进度展示:解析 dev.progress = {done, total, unit, action_counts}
const prog=fmtProgress(dev.progress, dev.worker_status);
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
const btn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
return '<tr><td><button class="btn btn-xs" onclick="identifyDevice(\''+esc(dev.serial)+'\')" title="响铃识别">识别</button> '+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btn+'</td></tr>';
});
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="10" class="empty">暂无设备</td></tr>';
// 渲染设备表格(含排序+多选)
renderDevices();
// 同步全选框状态
const all=document.getElementById('cb-all');
if(all){
all.checked=devices.length>0&&_selectedSerials.size===devices.length;
}
updateSelCount();
// 加载任务卡片
loadJobsForMonitor();
}catch(e){console.error(e);}
}
// 渲染设备表格(带排序,复选状态保持)
function renderDevices(){
if(!_statusData||!_statusData.devices){return;}
let devices=_statusData.devices.slice();
// 排序
if(_sortKey&&_sortDir){
const dir=_sortDir==='asc'?1:-1;
devices.sort((a,b)=>{
let va=_sortValue(a,_sortKey),vb=_sortValue(b,_sortKey);
if(typeof va==='string'&&typeof vb==='string'){
return va.localeCompare(vb)*dir;
}
va=Number(va)||0;vb=Number(vb)||0;
return (va-vb)*dir;
});
}
const rows=devices.map(dev=>{
const stf=dev.stf_occupied?'<span class="label label-success">占用</span>':'<span class="label label-default">空闲</span>';
const offline=dev.present?'':' <span class="label label-danger">离线</span>';
const st=STATUS_MAP[dev.worker_status]||{t:dev.worker_status,c:'default'};
const wb='<span class="label label-'+st.c+'">'+st.t+'</span>';
const fgApp=dev.foreground_app;
const fg=fgApp?(
fgApp==='(他人占用)'?'<span class="label label-warning">他人占用</span>':
fgApp==='(不可达)'?'<span class="label label-default">不可达</span>':
'<span class="label label-primary">'+esc(APP_NAMES[fgApp]||fgApp)+'</span>'
):'<span class="text-muted">-</span>';
const prog=fmtProgress(dev.progress, dev.worker_status);
const tname=dev.task_job?'<span class="label label-info">'+esc(dev.task_job)+'</span>':'<span class="text-muted">-</span>';
const retry=dev.attempt>0?' <span class="text-muted">'+dev.attempt+'次</span>':'';
const stopBtn=(dev.worker_status==='running'||dev.worker_status==='connecting')?'<button class="btn btn-danger btn-xs" onclick="apiAction(\'/api/stop_device\',{serial:\''+esc(dev.serial)+'\'})">停止</button>':'';
// 截图按钮(任何状态都可看,离线时会报错)
const shotBtn='<button class="btn btn-xs" onclick="openScreenshot(\''+esc(dev.serial)+'\')" title="查看设备画面(不影响任务)">截图</button>';
const btns='<div style="display:flex;gap:4px;flex-wrap:wrap">'+shotBtn+stopBtn+'</div>';
const devName=dev.device_name||dev.model||'';
const devLabel=devName?'<strong>'+esc(dev.serial)+'</strong><br><span style="font-size:11px;color:#94a3b8">'+esc(devName)+'</span>':'<strong>'+esc(dev.serial)+'</strong>';
// 复选框列
const checked=_selectedSerials.has(dev.serial)?'checked':'';
const rowCls=_selectedSerials.has(dev.serial)?'row-checked':'';
const cb='<input type="checkbox" '+checked+' onclick="toggleDevice(\''+esc(dev.serial)+'\',this.checked)">';
return '<tr class="'+rowCls+'"><td class="col-cb">'+cb+'</td><td>'+devLabel+'</td><td>'+esc(dev.model)+'</td><td>'+stf+offline+'</td><td>'+wb+'</td><td>'+fg+'</td><td>'+prog+'</td><td>'+tname+retry+'</td><td class="text-muted">'+esc(dev.current_action||'-')+'</td><td class="err-text" title="'+esc(dev.last_error||'')+'">'+esc(dev.last_error||'-')+'</td><td>'+btns+'</td></tr>';
});
document.getElementById('tb-devices').innerHTML=rows.length?rows.join(''):'<tr><td colspan="11" class="empty">暂无设备</td></tr>';
}
async function loadJobsForMonitor(){
try{
const r=await apiGet('/api/jobs');if(!r||!r.ok)return;
@@ -1079,6 +1311,12 @@ function showModal(){
document.getElementById('modal-overlay').classList.add('show');
}
function closeModal(){
// 清理截图弹窗的自动刷新定时器(避免关闭后后台继续请求)
stopShotTimer();
_shotSerial='';
// 还原弹窗样式(截图弹窗会加 shot-modal,关闭时移除)
const box=document.getElementById('modal-box');
if(box)box.classList.remove('shot-modal');
document.getElementById('modal-overlay').classList.remove('show');
}
+51 -20
View File
@@ -15,7 +15,7 @@
import os
import time
from flask import Flask, jsonify, request, redirect, url_for, render_template
from flask import Flask, jsonify, request, redirect, url_for, render_template, Response
from flask_login import (LoginManager, login_user, logout_user,
login_required, current_user)
@@ -24,7 +24,7 @@ from core.task_manager import TaskManager
from core.logger import get_logger, _LOG_DIR, _MODULE_FILES
from core.models import db, init_db, User, DeviceGroup, TaskJob
from core.apk_manager import ApkManager
from core.adb_helper import identify_device
from core.adb_helper import screenshot
from tasks import list_task_types, get_task_class
_log = get_logger("web")
@@ -341,17 +341,24 @@ def api_release():
return jsonify({"ok": True, "released": released})
@app.route("/api/device/identify", methods=["POST"])
@app.route("/api/device/screenshot")
@login_required
def api_device_identify():
"""让设备响铃以供识别(直连 serial,不经过 STF)。"""
serial = (request.json or {}).get("serial", "")
def api_device_screenshot():
"""获取设备当前画面截图(PNG)。
用 adb exec-out screencap -p,只读操作,不抢占 u2 的 atx-agent 通道,
任务运行中调用安全。直接返回 image/png,前端用 <img> 加载。
?serial=xxx 设备 serial
?t=123 时间戳,避免浏览器缓存(前端自动加)
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
ok, msg = identify_device(serial)
ok, data = screenshot(serial)
if ok:
return jsonify({"ok": True, "msg": msg})
return jsonify({"ok": False, "error": msg}), 500
return Response(data, mimetype="image/png",
headers={"Cache-Control": "no-store"})
return jsonify({"ok": False, "error": data}), 500
# ================== API:应用管理(APK 上传/安装)==================
@@ -407,20 +414,44 @@ def api_apks_install_status():
return jsonify({"ok": True, "status": status})
def _run_server(host, port):
"""启动 Flask,自动处理端口冲突和权限问题。
常见失败原因:
WinError 10013 — 端口在 Windows 动态端口范围内被出站连接占用,
或非管理员绑定 0.0.0.0。前者换端口,后者降级 127.0.0.1。
WinError 10048 — 端口已被其他进程监听。换端口重试。
策略:原端口失败 → 试 127.0.0.1:原端口 → 试 127.0.0.1:原端口+1..+5
"""
import socket
# 先探测原端口是否可用(避免 flask 报错打日志难看)
candidates = [(host, port)]
if host == "0.0.0.0":
candidates.append(("127.0.0.1", port))
for i in range(1, 6):
candidates.append(("127.0.0.1", port + i))
for h, p in candidates:
try:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((h, p))
# 探测成功,启动 flask
if (h, p) != (host, port):
_log.warning(f"原端口 {host}:{port} 不可用,改用 {h}:{p}")
_log.info(f"启动服务: http://localhost:{p}/")
app.run(host=h, port=p, debug=False, threaded=True)
return
except OSError as e:
_log.warning(f"绑定 {h}:{p} 失败: {e}")
# 全部失败
raise OSError(f"无法绑定任何候选端口({host}:{port} 及 127.0.0.1:{port}~{port+5})")
if __name__ == "__main__":
from config import WEB_HOST, WEB_PORT
_log.info(f"管理后台: http://localhost:{WEB_PORT}/ (admin/admin123)")
try:
try:
app.run(host=WEB_HOST, port=WEB_PORT, debug=False, threaded=True)
except OSError as e:
# 0.0.0.0 在 Windows 非管理员下会触发 WinError 10013
# 降级到 127.0.0.1 保证本机至少能访问,局域网访问需用管理员启动
if WEB_HOST == "0.0.0.0":
_log.warning(f"绑定 {WEB_HOST} 失败({e}),降级到 127.0.0.1(仅本机可访问)")
_log.warning("如需局域网访问,请用管理员身份运行 start_web.bat")
app.run(host="127.0.0.1", port=WEB_PORT, debug=False, threaded=True)
else:
raise
_run_server(WEB_HOST, WEB_PORT)
finally:
mgr.shutdown()