@@ -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 ) 1 px , transparent 1 px ) ,
linear-gradient ( 90 deg , rgba ( 45 , 212 , 191 , .035 ) 1 px , transparent 1 px ) ;
background-size : 42 px 42 px }
/* 顶部栏 */
header { display : flex ; align-items : center ; gap : 28 px ; padding : 16 px 26 px 12 px ; border-bottom : 1 px solid var ( - - ink - line ) ; background : rgba ( 13 , 15 , 19 , .85 ) ; backdrop-filter : blur ( 6 px ) ; position : relative ; z-index : 5 }
header . title { font-family : var ( - - display ) ; font-size : 22 px ; font-weight : 800 ; letter-spacing : 1 px ; color : #fff ; display : flex ; align-items : center ; gap : 12 px }
header . title :: before { content : "" ; width : 10 px ; height : 10 px ; border-radius : 50 % ; background : var ( - - teal ) ; box-shadow : 0 0 0 0 rgba ( 45 , 212 , 191 , .55 ) ; animation : pulse 2.2 s infinite }
@ keyframes pulse { 0 % { box-shadow : 0 0 0 0 rgba ( 45 , 212 , 191 , .5 ) } 70 % { box-shadow : 0 0 0 9 px rgba ( 45 , 212 , 191 , 0 ) } 100 % { box-shadow : 0 0 0 0 rgba ( 45 , 212 , 191 , 0 ) } }
header . stats { display : flex ; gap : 10 px ; flex : 1 }
header . stat { background : var ( - - card ) ; border : 1 px solid var ( - - card - line ) ; border-radius : 10 px ; padding : 7 px 18 px ; text-align : center ; min-width : 86 px }
header . stat . n { font-family : var ( - - mono ) ; font-size : 21 px ; font-weight : 600 ; line-height : 1.15 }
header . stat . l { font-size : 11 px ; color : var ( - - faint ) ; margin-top : 2 px ; letter-spacing : .4 px }
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 : 19 px ; color : var ( - - muted ) ; text-align : right ; line-height : 1.2 }
header . clock . date { font-size : 11 px ; color : var ( - - faint ) }
/* 设备网格 */
main { position : relative ; z-index : 2 ; height : calc ( 100 vh - 86 px ) ; overflow-y : auto ; padding : 18 px 24 px 30 px ; display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 210 px , 1 fr ) ) ; gap : 16 px ; align-content : start }
/* 设备卡片 */
. card { background : var ( - - card ) ; border : 1 px solid var ( - - card - line ) ; border-radius : 14 px ; overflow : hidden ; position : relative ; animation : cardIn .4 s ease-out backwards ; transition : border-color .2 s , box-shadow .2 s }
@ keyframes cardIn { from { opacity : 0 ; transform : translateY ( 10 px ) } to { opacity : 1 ; transform : none } }
. card . online { border-color : #263142 }
. card . running { border-color : rgba ( 45 , 212 , 191 , .55 ) ; box-shadow : 0 0 18 px rgba ( 45 , 212 , 191 , .12 ) , inset 0 0 0 1 px 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 .2 s }
. 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 : 8 px ; color : var ( - - faint ) ; font-size : 12 px ; background : repeating-linear-gradient ( 45 deg , #0c0e12 , #0c0e12 12 px , #0f1218 12 px , #0f1218 24 px ) }
. card . thumb . ph . ico { font-size : 26 px ; opacity : .6 }
/* 状态角标 */
. card . badge { position : absolute ; top : 8 px ; left : 8 px ; display : flex ; align-items : center ; gap : 6 px ; background : rgba ( 13 , 15 , 19 , .78 ) ; backdrop-filter : blur ( 4 px ) ; border-radius : 99 px ; padding : 3 px 10 px ; font-size : 11 px ; font-weight : 600 ; color : var ( - - muted ) ; border : 1 px 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 : 7 px ; height : 7 px ; border-radius : 50 % ; background : var ( - - faint ) }
. card . online . badge . dot { background : var ( - - green ) ; box-shadow : 0 0 6 px rgba ( 52 , 211 , 153 , .7 ) }
. card . running . badge . dot { background : var ( - - teal ) ; box-shadow : 0 0 7 px rgba ( 45 , 212 , 191 , .8 ) ; animation : pulse 1.6 s infinite }
. card . error . badge . dot { background : var ( - - red ) ; box-shadow : 0 0 6 px rgba ( 248 , 113 , 113 , .7 ) }
. card . offline . badge . dot { background : var ( - - faint ) }
/* 信息区 */
. card . info { padding : 10 px 12 px 12 px }
. card . info . serial { font-family : var ( - - mono ) ; font-size : 13 px ; font-weight : 600 ; color : #eef2f7 ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis }
. card . info . model { font-size : 11 px ; color : var ( - - faint ) ; margin-top : 2 px }
. card . info . action { font-size : 11.5 px ; color : var ( - - muted ) ; margin-top : 6 px ; white-space : nowrap ; overflow : hidden ; text-overflow : ellipsis ; min-height : 16 px }
. card . info . action b { color : var ( - - amber ) ; font-weight : 600 }
. card . info . fg { margin-top : 4 px }
. card . info . fg . tag { font-size : 10.5 px ; background : #1c2430 ; color : #9fb3c8 ; border : 1 px solid var ( - - ink - line ) ; border-radius : 5 px ; padding : 1 px 7 px ; display : inline-block }
/* 进度条 */
. card . info . prog { margin-top : 7 px ; height : 4 px ; background : #1d242e ; border-radius : 99 px ; overflow : hidden }
. card . info . prog i { display : block ; height : 100 % ; background : linear-gradient ( 90 deg , var ( - - teal ) , #5eead4 ) ; border-radius : 99 px ; transition : width .5 s }
. card . info . prog-txt { font-size : 10 px ; color : var ( - - faint ) ; margin-top : 3 px ; font-family : var ( - - mono ) }
/* 空状态 */
. empty-wall { grid-column : 1 /- 1 ; text-align : center ; color : var ( - - faint ) ; padding : 80 px 0 ; font-size : 14 px }
/* 滚动条 */
:: -webkit-scrollbar { width : 8 px }
:: -webkit-scrollbar-thumb { background : #232936 ; border-radius : 4 px }
:: -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 => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ 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 >