feat: 监控大屏(/wall 全屏深色控制室风格:设备卡片网格+缩略图轮询+统计+时钟,缩略图 360px/q55 每 2.5s 一帧 20 台仅 ~0.2 核 CPU)+ /api/screen/thumb 接口 + 导航栏大屏入口

This commit is contained in:
2026-08-19 14:13:15 +08:00
parent 5543bad0e6
commit c78f17d72f
5 changed files with 277 additions and 1 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ self.set_progress(done=5, total=80, unit="视频",
| Tab | 功能 | 可见性 |
|-----|------|--------|
| 监控 | 设备状态大屏:在线/离线、型号、运行任务、当前动作、进度条、前台 App、截图、勾选批量操作(亮屏/息屏/停止) | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 监控 | 设备状态大屏:在线/离线、型号、运行任务、当前动作、进度条、前台 App、截图、勾选批量操作(亮屏/息屏/停止);导航栏「📺 大屏」打开全屏监控墙(/wall,缩略图+统计+时钟,挂墙/电视用) | 所有登录用户(设备操作按钮需"设备控制"权限) |
| 任务 | 子分栏:任务计划(CRUD/启用停用/立即执行/下次运行时间/**离线设备自动跳过**)、自定义动作(打包复用) | 所有登录用户可看,写操作需"任务管理"权限 |
| 分组 | 设备分组管理:创建/编辑/删除分组 | 所有登录用户可看,写操作需"任务管理"权限 |
| 日志 | 实时日志查看:按模块切换(core/task/web/action) | 需"日志查看"权限 |
+13
View File
@@ -642,6 +642,19 @@ IP:5555 设备添加后立即尝试 adb connect,并后台采集型号(getpro
`?serial=xxx` 指定设备;浏览器 `<img src>` 直接渲染,客户端断开自动停止。数据源 u2(atx-agent minicap,约 3-6 帧/秒)。
### GET /api/screen/thumb
大屏缩略图:单张 JPEG(360px 宽,质量 55)。**权限**:`devices`。
`?serial=xxx` 指定设备。监控大屏按设备每 ~2.5s 轮询一帧(页面不可见时暂停);
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
### GET /wall
监控大屏页面(全屏深色控制室风格,登录后可访问):设备卡片网格(缩略图/型号/
状态/当前动作/进度)、顶部统计与时钟;状态每 5s 刷新、缩略图每 2.5s 轮询。
20 台设备整体开销约 0.2 核 CPU + 100KB/s 带宽,普通电脑无压力。
### POST /api/screen/tap
点击设备屏幕。**权限**:`devices`。
+1
View File
@@ -320,6 +320,7 @@ body{background:var(--bg);font-family:var(--body);color:var(--text);font-size:14
</div>
<div class="user-info">
<span id="me-username">...</span>
<a href="/wall" target="_blank" title="监控大屏(全屏,挂墙/电视用)">📺 大屏</a>
<a href="/logout">退出</a>
</div>
</nav>
+219
View File
@@ -0,0 +1,219 @@
<!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>
/* ========== 监控大屏:深色控制室风格 ========== */
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:700;font-display:swap;src:url('/static/fonts/bricolage-700.woff2') format('woff2')}
@font-face{font-family:'Bricolage Grotesque';font-style:normal;font-weight:800;font-display:swap;src:url('/static/fonts/bricolage-800.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url('/static/fonts/plexmono-400.woff2') format('woff2')}
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:600;font-display:swap;src:url('/static/fonts/plexmono-600.woff2') format('woff2')}
:root{
--bg:#0d0f13; --card:#14181f; --card-line:#232936; --ink-line:#2a303c;
--text:#e8ecf2; --muted:#8b93a3; --faint:#5c6472;
--teal:#2dd4bf; --green:#34d399; --red:#f87171; --amber:#fbbf24; --indigo:#818cf8;
--display:'Bricolage Grotesque','PingFang SC','Microsoft YaHei',sans-serif;
--mono:'IBM Plex Mono',Menlo,Consolas,monospace;
--body:-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);font-family:var(--body);color:var(--text);overflow:hidden}
/* 背景网格纹理 */
body::before{content:"";position:fixed;inset:0;pointer-events:none;
background-image:linear-gradient(rgba(45,212,191,.035) 1px,transparent 1px),
linear-gradient(90deg,rgba(45,212,191,.035) 1px,transparent 1px);
background-size:42px 42px}
/* 顶部栏 */
header{display:flex;align-items:center;gap:28px;padding:16px 26px 12px;border-bottom:1px solid var(--ink-line);background:rgba(13,15,19,.85);backdrop-filter:blur(6px);position:relative;z-index:5}
header .title{font-family:var(--display);font-size:22px;font-weight:800;letter-spacing:1px;color:#fff;display:flex;align-items:center;gap:12px}
header .title::before{content:"";width:10px;height:10px;border-radius:50%;background:var(--teal);box-shadow:0 0 0 0 rgba(45,212,191,.55);animation:pulse 2.2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(45,212,191,.5)}70%{box-shadow:0 0 0 9px rgba(45,212,191,0)}100%{box-shadow:0 0 0 0 rgba(45,212,191,0)}}
header .stats{display:flex;gap:10px;flex:1}
header .stat{background:var(--card);border:1px solid var(--card-line);border-radius:10px;padding:7px 18px;text-align:center;min-width:86px}
header .stat .n{font-family:var(--mono);font-size:21px;font-weight:600;line-height:1.15}
header .stat .l{font-size:11px;color:var(--faint);margin-top:2px;letter-spacing:.4px}
header .stat.online .n{color:var(--green)}
header .stat.running .n{color:var(--teal)}
header .stat.error .n{color:var(--red)}
header .clock{font-family:var(--mono);font-size:19px;color:var(--muted);text-align:right;line-height:1.2}
header .clock .date{font-size:11px;color:var(--faint)}
/* 设备网格 */
main{position:relative;z-index:2;height:calc(100vh - 86px);overflow-y:auto;padding:18px 24px 30px;display:grid;grid-template-columns:repeat(auto-fill,minmax(210px,1fr));gap:16px;align-content:start}
/* 设备卡片 */
.card{background:var(--card);border:1px solid var(--card-line);border-radius:14px;overflow:hidden;position:relative;animation:cardIn .4s ease-out backwards;transition:border-color .2s,box-shadow .2s}
@keyframes cardIn{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.card.online{border-color:#263142}
.card.running{border-color:rgba(45,212,191,.55);box-shadow:0 0 18px rgba(45,212,191,.12),inset 0 0 0 1px rgba(45,212,191,.2)}
.card.error{border-color:rgba(248,113,113,.5)}
.card.offline{opacity:.62}
.card .thumb{aspect-ratio:9/16;background:#0a0c10;position:relative;overflow:hidden}
.card .thumb img{width:100%;height:100%;object-fit:cover;display:block;transition:opacity .2s}
.card.offline .thumb img{display:none}
.card .thumb .ph{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;color:var(--faint);font-size:12px;background:repeating-linear-gradient(45deg,#0c0e12,#0c0e12 12px,#0f1218 12px,#0f1218 24px)}
.card .thumb .ph .ico{font-size:26px;opacity:.6}
/* 状态角标 */
.card .badge{position:absolute;top:8px;left:8px;display:flex;align-items:center;gap:6px;background:rgba(13,15,19,.78);backdrop-filter:blur(4px);border-radius:99px;padding:3px 10px;font-size:11px;font-weight:600;color:var(--muted);border:1px solid var(--ink-line)}
.card.running .badge{color:var(--teal);border-color:rgba(45,212,191,.4)}
.card.error .badge{color:var(--red)}
.card .badge .dot{width:7px;height:7px;border-radius:50%;background:var(--faint)}
.card.online .badge .dot{background:var(--green);box-shadow:0 0 6px rgba(52,211,153,.7)}
.card.running .badge .dot{background:var(--teal);box-shadow:0 0 7px rgba(45,212,191,.8);animation:pulse 1.6s infinite}
.card.error .badge .dot{background:var(--red);box-shadow:0 0 6px rgba(248,113,113,.7)}
.card.offline .badge .dot{background:var(--faint)}
/* 信息区 */
.card .info{padding:10px 12px 12px}
.card .info .serial{font-family:var(--mono);font-size:13px;font-weight:600;color:#eef2f7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .info .model{font-size:11px;color:var(--faint);margin-top:2px}
.card .info .action{font-size:11.5px;color:var(--muted);margin-top:6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-height:16px}
.card .info .action b{color:var(--amber);font-weight:600}
.card .info .fg{margin-top:4px}
.card .info .fg .tag{font-size:10.5px;background:#1c2430;color:#9fb3c8;border:1px solid var(--ink-line);border-radius:5px;padding:1px 7px;display:inline-block}
/* 进度条 */
.card .info .prog{margin-top:7px;height:4px;background:#1d242e;border-radius:99px;overflow:hidden}
.card .info .prog i{display:block;height:100%;background:linear-gradient(90deg,var(--teal),#5eead4);border-radius:99px;transition:width .5s}
.card .info .prog-txt{font-size:10px;color:var(--faint);margin-top:3px;font-family:var(--mono)}
/* 空状态 */
.empty-wall{grid-column:1/-1;text-align:center;color:var(--faint);padding:80px 0;font-size:14px}
/* 滚动条 */
::-webkit-scrollbar{width:8px}
::-webkit-scrollbar-thumb{background:#232936;border-radius:4px}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<header>
<div class="title">设备监控大屏</div>
<div class="stats" id="wall-stats"></div>
<div class="clock"><span id="wall-clock">--:--:--</span><div class="date" id="wall-date"></div></div>
</header>
<main id="wall-grid"></main>
<script>
// ================== 监控大屏(深色控制室) ==================
// 状态每 5s 刷新;缩略图每 2.5s 轮询(页面不可见时暂停,省资源)
const STATUS_MAP={idle:'空闲',connecting:'连接中',running:'运行中',done:'完成',error:'异常',released:'已释放',failed:'失败'};
const APP_NAMES={'com.ss.android.ugc.aweme':'抖音','com.ss.android.ugc.aweme.lite':'抖音极速版','com.smile.gifmaker':'快手','com.kuaishou.nebula':'快手极速版','com.tencent.mm':'微信','com.tencent.mobileqq':'QQ','com.miui.home':'桌面','com.android.launcher':'桌面','com.android.settings':'设置','com.android.systemui':'系统'};
let _devices=[];
function esc(s){return String(s||'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
async function apiGet(url){
try{
const r=await fetch(url);
if(r.status===401){window.location='/login';return null;}
return await r.json();
}catch(e){return null;}
}
// 时钟
function tickClock(){
const now=new Date();
document.getElementById('wall-clock').textContent=
String(now.getHours()).padStart(2,'0')+':'+String(now.getMinutes()).padStart(2,'0')+':'+String(now.getSeconds()).padStart(2,'0');
document.getElementById('wall-date').textContent=
now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-'+String(now.getDate()).padStart(2,'0')+' '+['周日','周一','周二','周三','周四','周五','周六'][now.getDay()];
}
// 状态刷新(5s)
async function refreshStatus(){
const r=await apiGet('/api/status');
if(!r||!r.ok)return;
const devs=r.devices||[];
// 保持卡片顺序稳定:按 serial 排序
devs.sort((a,b)=>a.serial.localeCompare(b.serial));
const changed=JSON.stringify(devs.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.foreground_app]))!==JSON.stringify(_devices.map(d=>[d.serial,d.present,d.worker_status,d.task_job,d.current_action,d.progress,d.foreground_app]));
_devices=devs;
renderStats(devs);
if(changed)renderGrid(devs);
}
function renderStats(devs){
const online=devs.filter(d=>d.present).length;
const running=devs.filter(d=>['running','connecting'].includes(d.worker_status)).length;
const error=devs.filter(d=>['error','failed'].includes(d.worker_status)).length;
document.getElementById('wall-stats').innerHTML=
'<div class="stat"><div class="n">'+devs.length+'</div><div class="l">设备总数</div></div>'+
'<div class="stat online"><div class="n">'+online+'</div><div class="l">在线</div></div>'+
'<div class="stat running"><div class="n">'+running+'</div><div class="l">运行中</div></div>'+
'<div class="stat error"><div class="n">'+error+'</div><div class="l">异常</div></div>';
}
function cardCls(d){
if(['error','failed'].includes(d.worker_status))return 'error';
if(['running','connecting'].includes(d.worker_status))return 'running';
return d.present?'online':'offline';
}
function badgeText(d){
if(['running','connecting'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
if(['error','failed'].includes(d.worker_status))return STATUS_MAP[d.worker_status]||d.worker_status;
return d.present?'在线':'离线';
}
function fmtProgress(p){
if(!p||!p.total)return '';
const pct=Math.min(100,Math.round((p.done||0)/p.total*100));
return '<div class="prog"><i style="width:'+pct+'%"></i></div>'+
'<div class="prog-txt">'+(p.done||0)+'/'+p.total+(p.unit?' '+esc(p.unit):'')+' · '+pct+'%</div>';
}
function renderGrid(devs){
const grid=document.getElementById('wall-grid');
if(!devs.length){
grid.innerHTML='<div class="empty-wall">设备池为空,请到「工具 → 设备池管理」添加设备</div>';
return;
}
grid.innerHTML=devs.map((d,i)=>{
const cls=cardCls(d);
const serial=esc(d.serial);
const fg=d.foreground_app&&d.foreground_app!=='空闲'&&!String(d.foreground_app).startsWith('(')
?'<div class="fg"><span class="tag">'+(APP_NAMES[d.foreground_app]?esc(APP_NAMES[d.foreground_app]):esc(d.foreground_app))+'</span></div>':'';
const action=d.current_action
?'<div class="action">'+(d.task_job?'<b>'+esc(d.task_job)+'</b> · ':'')+esc(d.current_action)+'</div>'
:(d.task_job?'<div class="action"><b>'+esc(d.task_job)+'</b></div>':'<div class="action"></div>');
return '<div class="card '+cls+'" data-serial="'+serial+'" style="animation-delay:'+(i*35)+'ms">'+
'<div class="thumb">'+
'<img data-thumb="'+serial+'" alt="">'+
'<div class="ph" style="display:none"><span class="ico">📱</span><span>'+serial+'</span></div>'+
'<div class="badge"><span class="dot"></span>'+badgeText(d)+'</div>'+
'</div>'+
'<div class="info">'+
'<div class="serial">'+serial+'</div>'+
'<div class="model">'+esc(d.model||'')+'</div>'+
action+fg+fmtProgress(d.progress)+
'</div>'+
'</div>';
}).join('');
// 离线卡片显示占位
grid.querySelectorAll('.card').forEach(c=>{
const d=devs.find(x=>x.serial===c.dataset.serial);
const img=c.querySelector('img[data-thumb]');
const ph=c.querySelector('.ph');
if(d&&d.present&&img){img.style.display='block';ph.style.display='none';}
else if(ph){ph.style.display='flex';}
});
refreshThumbs();
}
// 缩略图轮询(2.5s,页面不可见时暂停)
let _thumbTimer=null;
function refreshThumbs(){
if(document.visibilityState!=='visible')return;
const now=Date.now();
document.querySelectorAll('img[data-thumb]').forEach(img=>{
img.src='/api/screen/thumb?serial='+encodeURIComponent(img.dataset.thumb)+'&t='+now;
});
}
setInterval(()=>{if(document.visibilityState==='visible')refreshThumbs();},2500);
setInterval(()=>{if(document.visibilityState==='visible')refreshStatus();},5000);
// 初始化
tickClock();setInterval(tickClock,1000);
refreshStatus();
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState==='visible'){refreshStatus();refreshThumbs();}
});
</script>
</body>
</html>
+43
View File
@@ -187,6 +187,19 @@ def index():
return resp
@app.route("/wall")
@login_required
def wall():
"""监控大屏(独立全屏页面,供挂墙/电视展示)。
深色控制室风格;设备缩略图按需轮询(每设备 ~2.5s 一帧),
20 台设备整体开销约 0.2 核 CPU + 100KB/s,普通电脑无压力。
"""
resp = make_response(render_template("admin/wall.html"))
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
return resp
@app.route("/login", methods=["GET", "POST"])
def login():
if request.method == "POST":
@@ -1020,6 +1033,36 @@ def _screen_get_device(serial):
return u2.connect(serial)
@app.route("/api/screen/thumb")
@perm_required(PERM_DEVICES)
def api_screen_thumb():
"""大屏缩略图:单张 JPEG(360px 宽,质量 55),供监控大屏轮询。
一次性请求(非流),与任务并发安全(与任务截图同走 u2 minicap)。
大屏按需轮询:每设备约 2.5s 一帧,20 台 ≈ 0.2 核 CPU + 100KB/s。
"""
serial = request.args.get("serial", "")
if not serial:
return jsonify({"ok": False, "error": "缺少 serial"}), 400
import io
import uiautomator2 as u2
try:
d = u2.connect(serial)
img = d.screenshot()
if img is None:
return jsonify({"ok": False, "error": "截图失败"}), 503
if img.width > 360:
ratio = 360 / img.width
img = img.resize((360, int(img.height * ratio)))
buf = io.BytesIO()
img.convert("RGB").save(buf, "JPEG", quality=55)
return Response(buf.getvalue(), mimetype="image/jpeg",
headers={"Cache-Control": "no-store"})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 503
@app.route("/api/screen/tap", methods=["POST"])
@perm_required(PERM_DEVICES)
def api_screen_tap():