@@ -1,577 +1,110 @@
import { escapeHtml , value , formatBytes } from './domain/formatters.js'
import { createApiClient , normalizeTargetUrl } from './infrastructure/api-client.js'
import { RequestCoordinator } from './state/request-coordinator.js'
import { filterAndSortFleet , fleetStatusKind , emptyFleetReason } from './state/fleet-view-model.js'
import { instanceDraftPayload , requestDraft , isWriteMethod } from './state/operation-draft.js'
import { createViewRouter , resolveView } from './router/view-router.js'
let instances = [ ]
let statuses = new Map ( )
let catalog = [ ]
let activeId = null
let activeFilter = 'all'
let configPath = 'config.json'
let autoRefreshTimer = null
const $ = ( id ) => document . getElementById ( id )
const $$ = ( selector , root = document ) => [ ... root . querySelectorAll ( selector ) ]
const modules = [
{ id : 'overview' , title : '设备总览' , desc : '设备、SIM、网络、连接状态与流量统计。适合日常巡检和资产核对。' , tone : 'ok' , endpoints : [ '/api/device' , '/api/sim' , '/api/network' , '/api/stats' , '/api/connectivity' ] } ,
{ id : 'sms' , title : '短信中心' , desc : '短信统计、列表、会话读取与短信发送。发送类接口请先在 API 工作台确认请求体。' , tone : 'warn' , endpoints : [ '/api/sms/stats' , '/api/sms/list' , '/api/sms/conversation' , '/api/sms/send' ] } ,
{ id : 'network' , title : '蜂窝网络' , desc : '数据开关、漫游、飞行模式、无线制式、频段/小区锁定与 APN。' , tone : 'danger' , endpoints : [ '/api/data' , '/api/roaming' , '/api/airplane-mode' , '/api/radio-mode' , '/api/band-lock' , '/api/cell-lock' , '/api/apn' , '/api/cells' ] } ,
{ id : 'device-network' , title : '设备网络' , desc : 'WLAN、DDNS、连接地址与局域网访问能力。' , tone : 'neutral' , endpoints : [ '/api/device-network/wlan/status' , '/api/device-network/wlan/profiles' , '/api/device-network/ddns/status' , '/api/device-network/ddns/config' , '/api/network/connection-addresses' ] } ,
{ id : 'calls' , title : '电话能力' , desc : '通话列表、历史记录、IMS 与语音信箱状态。' , tone : 'neutral' , endpoints : [ '/api/calls' , '/api/call/history' , '/api/ims/status' , '/api/voicemail/status' ] } ,
{ id : 'esim' , title : 'eSIM / 工作模式' , desc : 'eUICC、profile、LPAC 状态、工作模式与 eSIM 配置。' , tone : 'warn' , endpoints : [ '/api/work-mode' , '/api/esim/config' , '/api/esim/lpac/status' , '/api/esim/euicc' , '/api/esim/profiles' ] } ,
{ id : 'notify-auto-ota' , title : '通知 / 自动化 / OTA' , desc : '通知配置与日志、自动化任务日志、在线升级状态。' , tone : 'neutral' , endpoints : [ '/api/notifications/config' , '/api/notifications/logs' , '/api/automation/config' , '/api/automation/logs' , '/api/ota/status' ] } ,
]
const requests = new RequestCoordinator ( )
const api = createApiClient ( )
let instances = [ ] , statuses = new Map ( ) , catalog = [ ] , activeId = null , activeFilter = 'all' , activeSort = 'name'
let lastSuccessfulRefresh = null , currentView = 'home' , outputText = '' , outputPrettyText = '' , pendingDeleteId = null , pendingApiRequest = null
let instanceDialogEpoch = 0 , deleteDialogEpoch = 0 , loginDialogEpoch = 0
const $ = id => document . getElementById ( id ) , $$ = ( selector , root = document ) => [ ... root . querySelectorAll ( selector ) ]
const requests = new RequestCoordinator ( ) , api = createApiClient ( )
const routeView = createViewRouter ( { controls : ( ) => $$ ( '[data-view]' ) , panels : ( ) => $$ ( '.view' ) } )
function boolText ( v ) {
if ( v === true ) return '开启'
if ( v === false ) return '关闭'
return value ( v )
const modules = [
{ name : '设备与网络' , endpoints : [ '/api/device' , '/api/sim' , '/api/network' , '/api/stats' , '/api/connectivity' , '/api/data' , '/api/roaming' , '/api/airplane-mode' , '/api/radio-mode' , '/api/band-lock' , '/api/cell-lock' , '/api/apn' ] } ,
{ name : '短信与通话' , endpoints : [ '/api/sms/stats' , '/api/sms/list' , '/api/sms/conversation' , '/api/sms/send' , '/api/calls' , '/api/call/history' ] } ,
{ name : '系统服务' , endpoints : [ '/api/work-mode' , '/api/esim/config' , '/api/notifications/config' , '/api/automation/config' , '/api/ota/status' ] } ,
]
const firstValue = ( ... items ) => items . find ( x => x !== undefined && x !== null && x !== '' ) ? ? null
const activeInstance = ( ) => instances . find ( x => x . id === activeId )
const activeStatus = ( ) => statuses . get ( activeId )
const tagsFromInput = text => String ( text || '' ) . split ( ',' ) . map ( x => x . trim ( ) ) . filter ( Boolean )
const statusText = s => ( { online : ` 在线 ${ s ? . latencyMs != null ? ` · ${ s . latencyMs } ms ` : '' } ` , auth : '需登录' , offline : '离线' , unknown : '未知' } ) [ fleetStatusKind ( s ) ]
const showToast = message => { $ ( 'toast' ) . textContent = message ; $ ( 'toast' ) . hidden = false ; clearTimeout ( showToast . timer ) ; showToast . timer = setTimeout ( ( ) => { $ ( 'toast' ) . hidden = true } , 3000 ) }
function persistentError ( message = '' ) { $ ( 'persistentErrorText' ) . textContent = message ; $ ( 'persistentError' ) . hidden = ! message }
function setPending ( button , pending , label ) { button . disabled = pending ; if ( label ) button . textContent = pending ? label : button . dataset . idleLabel }
function invalidateDialog ( id ) {
if ( id === 'instanceDialog' ) { instanceDialogEpoch += 1 ; setPending ( $ ( 'saveInstanceBtn' ) , false , '保存中…' ) }
if ( id === 'deleteDialog' ) { deleteDialogEpoch += 1 ; setPending ( $ ( 'confirmDeleteBtn' ) , false , '删除中…' ) }
if ( id === 'loginDialog' ) { loginDialogEpoch += 1 ; requests . invalidate ( 'login' ) ; $ ( 'submitLogin' ) . disabled = false ; $ ( 'passwordInput' ) . value = '' }
if ( id === 'apiDeleteDialog' ) pendingApiRequest = null
}
function activeInstance ( ) { return instances . find ( x => x . id === activeId ) }
function activeStatus ( ) { return statuses . get ( activeId ) }
function tagsFromInput ( text ) { return String ( text || '' ) . split ( ',' ) . map ( x => x . trim ( ) ) . filter ( Boolean ) }
function instancePayloadFromForm ( ) {
return {
id : $ ( 'instanceIdInput' ) . value . trim ( ) ,
name : $ ( 'instanceNameInput' ) . value . trim ( ) ,
url : $ ( 'instanceUrlInput' ) . value . trim ( ) ,
description : $ ( 'instanceDescInput' ) . value . trim ( ) ,
tags : tagsFromInput ( $ ( 'instanceTagsInput' ) . value ) ,
auth : { password : $ ( 'instancePasswordInput' ) . value } ,
}
}
function statusKind ( s ) {
if ( ! s ) return 'unknown'
if ( ! s . reachable ) return 'offline'
if ( s . authenticated === false ) return 'auth'
return 'online'
}
function statusText ( s ) {
const kind = statusKind ( s )
if ( kind === 'online' ) return ` 在线 ${ s . latencyMs != null ? ` ${ s . latencyMs } ms ` : '' } `
if ( kind === 'auth' ) return '需登录'
if ( kind === 'offline' ) return '离线'
return '未知'
}
function pillClass ( s ) {
const kind = statusKind ( s )
if ( kind === 'online' ) return 'ok'
if ( kind === 'auth' ) return 'warn'
if ( kind === 'offline' ) return 'bad'
return ''
}
function showToast ( message , timeout = 2600 ) {
const toast = $ ( 'toast' )
toast . textContent = message
toast . hidden = false
clearTimeout ( showToast . _timer )
showToast . _timer = setTimeout ( ( ) => { toast . hidden = true } , timeout )
}
function flattenEndpoints ( ) {
return [ ... new Set ( [
... catalog . flatMap ( g => g . endpoints || [ ] ) ,
... modules . flatMap ( m => m . endpoints || [ ] ) ,
] ) ] . sort ( )
}
function inferMethod ( endpoint ) {
if ( /send|config|data$|roaming$|airplane-mode$|radio-mode$|band-lock$|cell-lock$|work-mode|login|logout/i . test ( endpoint ) ) return 'POST'
return 'GET'
}
function firstValue ( ... items ) {
for ( const item of items ) if ( item !== undefined && item !== null && item !== '' ) return item
return null
function closeDialog ( id ) { invalidateDialog ( id ) ; const dialog = $ ( id ) ; if ( dialog . open ) dialog . close ( ) }
function frameClear ( ) { $ ( 'frame' ) . hidden = true ; $ ( 'frame' ) . removeAttribute ( 'src' ) }
function switchView ( requested ) {
const view = resolveView ( requested , Boolean ( activeId ) ) ; currentView = view
if ( view !== 'rawpage' ) frameClear ( )
if ( view === 'rawpage' ) { const item = activeInstance ( ) ; $ ( 'frame' ) . src = item . url ; $ ( 'frame' ) . hidden = false }
routeView ( view )
const item = activeInstance ( ) , titles = { home : '设备首页' , detail : item ? ` ${ item . name || item . id } · 设备详情 ` : '设备详情' , rawpage : item ? ` ${ item . name || item . id } · 原始页面 ` : '原始页面' , api : item ? ` ${ item . name || item . id } · API 工作台 ` : 'API 工作台' }
$ ( 'pageHeading' ) . textContent = titles [ view ] ; $ ( 'pageSubtitle' ) . textContent = view === 'home' ? '集中巡检你的 SimAdmin 设备' : item ? . url || ''
document . title = ` ${ titles [ view ] } · Multi SimAdmin Island `
}
function formatSpeed ( speed ) {
if ( ! speed || typeof speed !== 'object' ) return '-'
if ( Array . isArray ( speed . interfaces ) ) {
return speed . interfaces . map ( item => ` ${ item . interface || 'net' } ↓ ${ formatBytes ( firstValue ( item . rx _bytes _per _sec , item . rx , item . download ) ) } /s · ↑ ${ formatBytes ( firstValue ( item . tx _bytes _per _sec , item . tx , item . upload ) ) } /s ` ) . join ( ' | ' )
}
const rx = firstValue ( speed . rx , speed . download , speed . recv , speed . received , speed . rx _bytes _per _sec )
const tx = firstValue ( speed . tx , speed . upload , speed . sent , speed . tx _bytes _per _sec )
return ` ↓ ${ formatBytes ( rx ) } /s · ↑ ${ formatBytes ( tx ) } /s `
if ( Array . isArray ( speed . interfaces ) ) return speed . interfaces . map ( x => ` ${ x . interface || 'net' } ↓ ${ formatBytes ( firstValue ( x . rx _bytes _per _sec , x . rx ) ) } /s · ↑ ${ formatBytes ( firstValue ( x . tx _bytes _per _sec , x . tx ) ) } /s ` ) . join ( ' | ' )
return ` ↓ ${ formatBytes ( firstValue ( speed . rx , speed . download , speed . rx _bytes _per _sec ) ) } /s · ↑ ${ formatBytes ( firstValue ( speed . tx , speed . upload , speed . tx _bytes _per _sec ) ) } /s `
}
function formatTemperature ( temp ) {
if ( temp === undefined || temp === null || temp === '' ) return '-'
if ( typeof temp === 'number' ) return ` ${ temp } ℃ `
if ( typeof temp === 'string' ) return temp . includes ( '°' ) || temp . includes ( '℃' ) ? temp : ` ${ temp } ℃ `
if ( Array . isArray ( temp ) ) {
return temp . map ( item => ` ${ item . type || item . zone || 'temp' } : ${ value ( item . temperature ) } ℃ ` ) . join ( ' | ' ) || '-'
}
if ( typeof temp === 'object' ) {
return Object . entries ( temp ) . map ( ( [ k , v ] ) => ` ${ k } : ${ typeof v === 'number' ? ` ${ v } ℃ ` : value ( v ) } ` ) . join ( ' · ' ) || '-'
}
return value ( temp )
}
function formatPercentPair ( obj ) {
if ( ! obj || typeof obj !== 'object' ) return '-'
if ( Array . isArray ( obj ) ) {
return obj . map ( item => ` ${ item . mount _point || item . name || 'disk' } ${ formatPercentPair ( item ) } ` ) . join ( ' | ' )
}
const used = Number ( firstValue ( obj . used , obj . used _bytes ) )
const total = Number ( firstValue ( obj . total , obj . total _bytes ) )
const percent = Number ( firstValue ( obj . used _percent , obj . percent ) )
if ( Number . isFinite ( used ) && Number . isFinite ( total ) && total > 0 ) return ` ${ formatBytes ( used ) } / ${ formatBytes ( total ) } ( ${ Math . round ( Number . isFinite ( percent ) ? percent : used / total * 100 ) } %) `
const available = firstValue ( obj . available , obj . available _bytes , obj . free , obj . free _bytes )
if ( available != null && total ) return ` 可用 ${ formatBytes ( available ) } / 总 ${ formatBytes ( total ) } `
return Object . entries ( obj ) . map ( ( [ k , v ] ) => ` ${ k } : ${ typeof v === 'number' ? formatBytes ( v ) : value ( v ) } ` ) . join ( ' · ' ) || '-'
}
function formatUptime ( seconds ) {
if ( seconds && typeof seconds === 'object' ) return seconds . uptime _formatted || seconds . formatted || formatUptime ( seconds . uptime _seconds )
const n = Number ( seconds )
if ( ! Number . isFinite ( n ) ) return value ( seconds )
const d = Math . floor ( n / 86400 )
const h = Math . floor ( ( n % 86400 ) / 3600 )
const m = Math . floor ( ( n % 3600 ) / 60 )
return [ d ? ` ${ d } 天 ` : '' , h ? ` ${ h } 小时 ` : '' , ` ${ m } 分 ` ] . filter ( Boolean ) . join ( ' ' )
}
function metricCard ( label , val , tone = '' ) {
return ` <div class="metric monitor-metric ${ tone } "><span> ${ escapeHtml ( label ) } </span><b title=" ${ escapeHtml ( value ( val ) ) } "> ${ escapeHtml ( value ( val ) ) } </b></div> `
}
function detailCard ( title , rows , tone = '' ) {
return ` <article class="device-info-card ${ tone } "><h4> ${ escapeHtml ( title ) } </h4><dl> ${ rows . map ( ( [ k , v ] ) => ` <div><dt> ${ escapeHtml ( k ) } </dt><dd title=" ${ escapeHtml ( value ( v ) ) } "> ${ escapeHtml ( value ( v ) ) } </dd></div> ` ) . join ( '' ) } </dl></article> `
}
function getMonitorData ( ) {
const item = activeInstance ( )
const s = activeStatus ( )
const sum = s ? . summary || { }
return { item , s , sum , device : sum . device || { } , sim : sum . sim || { } , network : sum . network || { } , data : sum . data || { } , sms : sum . sms || { } , ota : sum . ota || { } , system : sum . system || { } }
}
function formatTemperature ( temp ) { if ( temp == null || temp === '' ) return '-' ; if ( typeof temp === 'object' ) return Object . entries ( temp ) . map ( ( [ k , v ] ) => ` ${ k } : ${ v } ${ typeof v === 'number' ? '℃' : '' } ` ) . join ( ' · ' ) ; return ` ${ temp } ${ typeof temp === 'number' ? '℃' : '' } ` }
function renderFleetStats ( ) {
const total = instances . length
const counts = { online : 0 , auth : 0 , offline : 0 , unknown : 0 }
for ( const inst of instances ) counts [ statusKind ( statuses . get ( inst . id ) ) ] += 1
$ ( 'fleetStats' ) . innerHTML = [
[ '全部' , total ] , [ '在线' , counts . online ] , [ '需登录' , counts . auth ] , [ '离线' , counts . offline ] ,
] . map ( ( [ label , num ] ) => ` <div class="fleet-card"><b> ${ num } </b><span> ${ label } </span></div> ` ) . join ( '' )
const counts = { all : instances . length , online : 0 , auth : 0 , offline : 0 , unknown : 0 } ; for ( const item of instances ) counts [ fleetStatusKind ( statuses . get ( item . id ) ) ] ++
$ ( 'fleetStats' ) . innerHTML = Object . entries ( { all : '全部' , online : '在线' , auth : '需登录' , offline : '离线' , unknown : '未知' } ) . map ( ( [ k , label ] ) => ` <button type="button" data-stat-filter=" ${ k } " aria-pressed=" ${ activeFilter === k } "><b> ${ counts [ k ] } </b><span> ${ label } </span></button> ` ) . join ( '' )
$$ ( '[data-stat-filter]' ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => setFilter ( btn . dataset . statFilter ) ) )
}
function filteredInstances ( ) {
const q = $ ( 'instanceSearch' ) ? . value . trim ( ) . toLowerCase ( ) || ''
return instances . filter ( item => {
const s = statuses . get ( item . id )
const kind = statusKind ( s )
if ( activeFilter !== 'all' && kind !== activeFilter ) return false
if ( ! q ) return true
const haystack = [ item . id , item . name , item . url , item . description , JSON . stringify ( s ? . summary || { } ) ] . join ( ' ' ) . toLowerCase ( )
return haystack . includes ( q )
} )
}
function deviceSummary ( item ) {
const s = statuses . get ( item . id )
const sum = s ? . summary || { }
return { item , s , sum , device : sum . device || { } , sim : sum . sim || { } , network : sum . network || { } , data : sum . data || { } , sms : sum . sms || { } , ota : sum . ota || { } , system : sum . system || { } }
}
function setFilter ( filter ) { activeFilter = filter ; $$ ( '[data-filter]' ) . forEach ( b => b . setAttribute ( 'aria-pressed' , String ( b . dataset . filter === filter ) ) ) ; renderHomeCards ( ) }
function deviceSummary ( item ) { const s = statuses . get ( item . id ) , sum = s ? . summary || { } ; return { s , device : sum . device || { } , sim : sum . sim || { } , network : sum . network || { } , data : sum . data || { } , sms : sum . sms || { } , system : sum . system || { } , ota : sum . ota || { } } }
function renderHomeCards ( ) {
const root = $ ( 'homeDeviceGrid' )
renderFleetStats ( )
if ( ! instances . length ) {
root . innerHTML = '<section class="empty-state mini-empty"><h3>未配置设备</h3><p>点击“添加设备”新增 SimAdmin 地址后,这里会出现设备卡片。</p></section>'
return
}
const list = filteredInstances ( )
if ( ! list . length ) {
root . innerHTML = '<section class="empty-state"><h3>没有匹配设备</h3><p>调整搜索关键字或状态筛选。</p></section>'
return
}
root . innerHTML = list . map ( item => {
const { s , device , sim , network , data , sms , system , ota } = deviceSummary ( item )
const dataState = firstValue ( data . active , data . connected , data . enabled )
return ` <article class="sim-device-card ${ pillClass ( s ) } " data-open-detail=" ${ escapeHtml ( item . id ) } " role="button" tabindex="0" aria-label="打开 ${ escapeHtml ( item . name || item . id ) } 详情">
<div class="sim-card-head">
<div>
<p class="eyebrow"> ${ escapeHtml ( item . id ) } </p>
<h2> ${ escapeHtml ( item . name || item . id ) } </h2>
<span class="instance-url"> ${ escapeHtml ( item . url ) } </span>
</div>
<span class="status-pill ${ pillClass ( s ) } "> ${ escapeHtml ( statusText ( s ) ) } </span>
</div>
<div class="sim-card-model"> ${ escapeHtml ( value ( [ device . manufacturer , device . model ] . filter ( Boolean ) . join ( ' ' ) || device . imei || '未知设备' ) ) } </div>
<div class="sim-card-kpis">
<span><em>SIM</em><b> ${ escapeHtml ( sim . present === false ? '未插卡' : value ( sim . iccid ) ) } </b></span>
<span><em>信号</em><b> ${ escapeHtml ( value ( firstValue ( network . signal , sim . signal ) ) ) } </b></span>
<span><em>温度</em><b> ${ escapeHtml ( formatTemperature ( system . temperature ) ) } </b></span>
<span><em>流量</em><b> ${ escapeHtml ( formatSpeed ( system . networkSpeed ) ) } </b></span>
<span><em>数据</em><b> ${ escapeHtml ( dataState === true ? '已连接' : dataState === false ? '未连接' : '-' ) } </b></span>
<span><em>短信</em><b> ${ escapeHtml ( sms . total != null ? ` 总 ${ sms . total } ` : '-' ) } </b></span>
</div>
<div class="sim-card-foot">
<span> ${ escapeHtml ( value ( network . operator || sim . operator || network . registration ) ) } </span>
<span> ${ escapeHtml ( value ( ota . currentVersion || device . firmware || device . revision ) ) } </span>
</div>
</article> `
} ) . join ( '' )
$$ ( '[data-open-detail]' , root ) . forEach ( card => {
const open = ( ) => openDeviceDetail ( card . dataset . openDetail )
card . addEventListener ( 'click' , open )
card . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Enter' || event . key === ' ' ) { event . preventDefault ( ) ; open ( ) }
} )
} )
renderFleetStats ( ) ; const query = $ ( 'instanceSearch' ) . value , list = filterAndSortFleet ( instances , statuses , { filter : activeFilter , query , sort : activeSort } ) , reason = emptyFleetReason ( { total : instances . length , query , filter : activeFilter , visible : list . length } )
if ( reason ) { const copy = { config : [ '未配置设备' , '添加第一个 SimAdmin 地址,开始建设你的设备岛。' ] , search : [ '搜索无结果' , '没有设备匹配当前搜索词,请修改或清空搜索。' ] , filter : [ '筛选无结果' , '当前状态下没有设备,可切换到“全部”。' ] } [ reason ] ; $ ( 'homeDeviceGrid' ) . innerHTML = ` <section class="empty-state"><h2> ${ copy [ 0 ] } </h2><p> ${ copy [ 1 ] } </p></section> ` ; return }
$ ( 'homeDeviceGrid' ) . innerHTML = list . map ( item => { const { s , device , sim , network , data , sms , system , ota } = deviceSummary ( item ) ; return ` <article class="sim-device-card ${ fleetStatusKind ( s ) } "><div class="sim-card-head"><div><p class="eyebrow"> ${ escapeHtml ( item . id ) } </p><h2> ${ escapeHtml ( item . name || item . id ) } </h2></div><span class="status-pill"> ${ escapeHtml ( statusText ( s ) ) } </span></div><p class="instance-url"> ${ escapeHtml ( item . url ) } </p><p> ${ escapeHtml ( item . description || '暂无描述' ) } </p><div class="tag-row"> ${ ( item . tags || [ ] ) . map ( tag => ` <span> ${ escapeHtml ( tag ) } </span> ` ) . join ( '' ) } </div><div class="sim-card-model"> ${ escapeHtml ( [ device . manufacturer , device . model ] . filter ( Boolean ) . join ( ' ' ) || device . imei || '未知设备' ) } </div><dl class="sim-card-kpis"><div><dt>ICCID / SIM</dt><dd> ${ escapeHtml ( sim . present === false ? '未插卡' : value ( sim . iccid ) ) } </dd></div><div><dt>信号</dt><dd> ${ escapeHtml ( value ( firstValue ( network . signal , sim . signal ) ) ) } </dd></div><div><dt>温度</dt><dd> ${ escapeHtml ( formatTemperature ( system . temperature ) ) } </dd></div><div><dt>实时流量</dt><dd> ${ escapeHtml ( formatSpeed ( system . networkSpeed ) ) } </dd></div><div><dt>短信</dt><dd> ${ escapeHtml ( sms . total != null ? ` 总 ${ sms . total } ` : '-' ) } </dd></div><div><dt>版本 / Commit</dt><dd> ${ escapeHtml ( value ( ota . currentVersion || device . firmware || device . revision ) ) } ${ ota . currentCommit ? ` · ${ escapeHtml ( ota . currentCommit ) } ` : '' } </dd></div></dl><div class="card-actions"><button class="animal-btn primary-btn" type="button" data-open-detail=" ${ escapeHtml ( item . id ) } ">查看详情</button><button class="animal-btn default-btn" type="button" data-edit-instance=" ${ escapeHtml ( item . id ) } ">编辑设备</button></div></article> ` } ) . join ( '' )
$$ ( '[data-open-detail]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => openDeviceDetail ( b . dataset . openDetail ) ) ) ; $$ ( '[data-edit-instance]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => openInstanceDialog ( b . dataset . editInstance ) ) )
}
function renderInstances ( ) { renderHomeCards ( ) }
function openDeviceDetail ( id ) {
const item = instances . find ( x => x . id === id )
if ( ! item ) return
if ( activeId !== id ) { requests . invalidate ( 'api-console' ) ; requests . invalidate ( 'login' ) }
activeId = id
localStorage . setItem ( 'multi-simadmin-active' , id )
$ ( 'loginBtn' ) . disabled = false
$ ( 'openExternal' ) . href = item . url
$ ( 'openExternal' ) . classList . remove ( 'disabled' )
$ ( 'emptyState' ) . hidden = true
$ ( 'frame' ) . hidden = false
$ ( 'frame' ) . src = item . url
switchView ( 'detail' )
renderHomeCards ( )
renderOverview ( )
renderDeviceDetails ( )
renderDetailActions ( )
function detailCard ( title , rows ) { return ` <article class="device-info-card"><h4> ${ escapeHtml ( title ) } </h4><dl> ${ rows . map ( ( [ k , v ] ) => ` <div><dt> ${ escapeHtml ( k ) } </dt><dd> ${ escapeHtml ( value ( v ) ) } </dd></div> ` ) . join ( '' ) } </dl></article> ` }
function renderDeviceDetails ( ) { renderDetail ( ) }
function renderDetail ( ) {
const item = activeInstance ( ) , s = activeStatus ( ) ; if ( ! item ) return ; const { device , sim , network , data , sms , system , ota } = deviceSummary ( item )
$ ( 'activeKicker' ) . textContent = ` ${ item . id } · ${ statusText ( s ) } ` ; $ ( 'activeName' ) . textContent = item . name || item . id ; $ ( 'activeMeta' ) . textContent = ` ${ item . url } ${ item . description ? ` · ${ item . description } ` : '' } `
const signal = [ [ '状态' , statusText ( s ) ] , [ '运营商' , network . operator || sim . operator ] , [ '信号' , firstValue ( network . signal , sim . signal ) ] , [ '温度' , formatTemperature ( system . temperature ) ] , [ '流量' , formatSpeed ( system . networkSpeed ) ] , [ '数据连接' , value ( firstValue ( data . active , data . connected , data . enabled ) ) ] ] ; $ ( 'signalPanel' ) . innerHTML = signal . map ( ( [ k , v ] ) => ` <div><span> ${ k } </span><b> ${ escapeHtml ( value ( v ) ) } </b></div> ` ) . join ( '' )
$ ( 'overview' ) . innerHTML = [ [ '延迟' , s ? . latencyMs != null ? ` ${ s . latencyMs } ms ` : '-' ] , [ 'SIM' , sim . present === false ? '未插卡' : sim . iccid ] , [ '短信' , sms . total ] , [ '版本' , ota . currentVersion || device . firmware ] ] . map ( ( [ k , v ] ) => ` <div class="metric"><span> ${ k } </span><b> ${ escapeHtml ( value ( v ) ) } </b></div> ` ) . join ( '' )
$ ( 'detailDeviceGrid' ) . innerHTML = [ detailCard ( '设备信息' , [ [ '名称' , item . name ] , [ 'ID' , item . id ] , [ '地址' , item . url ] , [ '型号' , device . model ] , [ '厂商' , device . manufacturer ] , [ 'IMEI' , device . imei ] , [ '版本' , ota . currentVersion || device . firmware ] , [ 'Commit' , ota . currentCommit ] ] ) , detailCard ( 'SIM 卡' , [ [ 'ICCID' , sim . iccid ] , [ 'IMSI' , sim . imsi ] , [ '号码' , sim . phoneNumber ] , [ '短信中心' , sim . smsCenter ] ] ) , detailCard ( '蜂窝网络' , [ [ '运营商' , network . operator ] , [ '注册状态' , network . registration ] , [ '网络制式' , network . accessTechnology ] , [ '信号' , network . signal ] ] ) , detailCard ( '系统资源' , [ [ '温度' , formatTemperature ( system . temperature ) ] , [ 'CPU' , Array . isArray ( system . cpuLoad ) ? system . cpuLoad . join ( ' / ' ) : system . cpuLoad ] , [ '内存' , JSON . stringify ( system . memory || { } ) ] , [ '磁盘' , JSON . stringify ( system . disk || { } ) ] ] ) ] . join ( '' )
const eps = [ '/api/device' , '/api/sim' , '/api/network' , '/api/stats' , '/api/sms/stats' , '/api/ota/status' ] ; $ ( 'detailActions' ) . innerHTML = ` <button type="button" data-view="rawpage">打开原始管理页</button> ${ eps . map ( ep => ` <button type="button" data-detail-ep=" ${ ep } "><span>在工作台打开</span><code> ${ ep } </code></button> ` ) . join ( '' ) } ` ; $$ ( '[data-detail-ep]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { switchView ( 'api' ) ; $ ( 'endpointInput' ) . value = b . dataset . detailEp ; $ ( 'methodSelect' ) . value = 'GET' ; updateWriteUI ( ) } ) ) ; $$ ( '[data-view]' , $ ( 'detailActions' ) ) . forEach ( b => b . addEventListener ( 'click' , ( ) => switchView ( b . dataset . view ) ) )
}
function setActiveHome ( ) {
requests . invalidate ( 'api-console' ) ; requests . invalidate ( 'login' )
activeId = null
localStorage . removeItem ( 'multi-simadmin-active' )
$ ( 'loginBtn' ) . disabled = true
$ ( 'openExternal' ) . href = '#'
$ ( 'openExternal' ) . classList . add ( 'disabled' )
$ ( 'emptyState' ) . hidden = false
$ ( 'f rame ' ) . hidden = true
$ ( 'frame' ) . removeAttribute ( 'src' )
switchView ( 'home' )
renderHomeCards ( )
}
function renderHero ( ) {
const { item , s , device , sim , network , system , data , ota } = getMonitorData ( )
if ( ! item ) {
$ ( 'activeKicker' ) . textContent = 'DEVICE DETAIL'
$ ( 'activeName' ) . textContent = '选择一个 SimAdmin 设备'
$ ( 'activeMeta' ) . textContent = '从首页卡片进入详情页后,可查看状态并使用功能交互。'
$ ( 'signalPanel' ) . innerHTML = [
[ '状态' , '等待选择' ] , [ '设备' , '-' ] , [ '温度' , '-' ] , [ '流量速率' , '-' ] ,
] . map ( ( [ k , v ] ) => ` <div class="signal-item"><span> ${ k } </span><b> ${ v } </b></div> ` ) . join ( '' )
return
}
$ ( 'activeKicker' ) . textContent = ` ${ item . id } · ${ statusText ( s ) } `
$ ( 'activeName' ) . textContent = ` ${ item . name || item . id } 详情页 `
$ ( 'activeMeta' ) . textContent = ` ${ device . manufacturer || '' } ${ device . model || '' } ` . trim ( ) || item . url
$ ( 'signalPanel' ) . innerHTML = [
[ '在线状态' , statusText ( s ) ] ,
[ 'SIM / 运营商' , ` ${ sim . present === false ? '未插卡' : '已插卡' } · ${ value ( network . operator || sim . operator ) } ` ] ,
[ '信号 / 制式' , ` ${ value ( network . signal || sim . signal ) } · ${ value ( network . accessTechnology || network . registration ) } ` ] ,
[ '温度' , formatTemperature ( system . temperature ) ] ,
[ '流量速率' , formatSpeed ( system . networkSpeed ) ] ,
[ '数据连接' , firstValue ( data . active , data . connected , data . enabled ) === true ? '已连接' : firstValue ( data . active , data . connected , data . enabled ) === false ? '未连接' : '-' ] ,
[ '系统负载' , Array . isArray ( system . cpuLoad ) ? system . cpuLoad . join ( ' / ' ) : value ( system . cpuLoad ) ] ,
[ '版本' , value ( ota . currentVersion || device . firmware || device . revision ) ] ,
] . map ( ( [ k , v ] ) => ` <div class="signal-item"><span> ${ escapeHtml ( k ) } </span><b title=" ${ escapeHtml ( value ( v ) ) } "> ${ escapeHtml ( value ( v ) ) } </b></div> ` ) . join ( '' )
}
function renderOverview ( ) {
const { item , s , sim , network , data , sms , system } = getMonitorData ( )
if ( ! item ) return
const dataState = firstValue ( data . active , data . connected , data . enabled )
$ ( 'overview' ) . innerHTML = [
metricCard ( '状态' , s ? ( s . reachable ? ( s . authenticated === false ? '在线/未登录' : '在线' ) : '离线' ) : '未知' , pillClass ( s ) ) ,
metricCard ( '延迟' , s ? . latencyMs != null ? ` ${ s . latencyMs } ms ` : '-' ) ,
metricCard ( '信号' , firstValue ( network . signal , sim . signal ) , 'signal' ) ,
metricCard ( '温度' , formatTemperature ( system . temperature ) , 'temperature' ) ,
metricCard ( '流量速率' , formatSpeed ( system . networkSpeed ) , 'traffic' ) ,
metricCard ( '数据连接' , dataState === true ? '已连接' : dataState === false ? '未连接' : '-' ) ,
metricCard ( 'SIM' , sim . present === false ? '未插卡' : value ( sim . iccid ) ) ,
metricCard ( '短信' , sms . total != null ? ` 总 ${ sms . total } / 收 ${ value ( sms . incoming ) } / 发 ${ value ( sms . outgoing ) } ` : '-' ) ,
] . join ( '' )
renderHero ( )
}
function renderDeviceDetails ( ) {
const { item , device , sim , network , data , sms , ota , system , s } = getMonitorData ( )
if ( ! item ) return
$ ( 'detailDeviceGrid' ) . innerHTML = [
detailCard ( '设备信息' , [
[ '名称' , item . name || item . id ] , [ '地址' , item . url ] , [ '型号' , device . model ] , [ '厂商' , device . manufacturer ] , [ 'IMEI' , device . imei ] , [ '电源' , boolText ( device . powered ) ] , [ '在线' , boolText ( device . online ) ] , [ '固件/版本' , ota . currentVersion || device . firmware || device . revision ] , [ 'Commit' , ota . currentCommit ] ,
] , 'device' ) ,
detailCard ( 'SIM 卡' , [
[ '状态' , sim . present === false ? '未插卡' : sim . present === true ? '已插卡' : '-' ] , [ 'ICCID' , sim . iccid ] , [ 'IMSI' , sim . imsi ] , [ '号码' , sim . phoneNumber ] , [ '短信中心' , sim . smsCenter ] , [ 'MCC/MNC' , [ network . mcc || sim . mcc , network . mnc || sim . mnc ] . filter ( Boolean ) . join ( '/' ) || '-' ] ,
] , 'sim' ) ,
detailCard ( '蜂窝网络' , [
[ '运营商' , network . operator || sim . operator ] , [ '注册状态' , network . registration ] , [ '网络制式' , network . accessTechnology ] , [ '信号强度' , firstValue ( network . signal , sim . signal ) ] , [ '数据连接' , firstValue ( data . active , data . connected , data . enabled ) === true ? '已连接' : firstValue ( data . active , data . connected , data . enabled ) === false ? '未连接' : '-' ] , [ '漫游' , boolText ( data . roaming ) ] ,
] , 'network' ) ,
detailCard ( '温度 / 系统' , [
[ '温度' , formatTemperature ( system . temperature ) ] , [ 'CPU 负载' , Array . isArray ( system . cpuLoad ) ? system . cpuLoad . join ( ' / ' ) : system . cpuLoad ] , [ '内存' , formatPercentPair ( system . memory ) ] , [ '磁盘' , formatPercentPair ( system . disk ) ] , [ '运行时间' , formatUptime ( system . uptime ) ] , [ '系统' , system . info ? Object . values ( system . info ) . filter ( Boolean ) . join ( ' · ' ) : '-' ] ,
] , 'system' ) ,
detailCard ( '流量 / 短信' , [
[ '实时速率' , formatSpeed ( system . networkSpeed ) ] , [ '短信总数' , sms . total ] , [ '接收短信' , sms . incoming ] , [ '发送短信' , sms . outgoing ] , [ '推送成功' , sms . pushed ] , [ '通话记录' , s ? . summary ? . calls ? . calls ? . length ? ? '-' ] ,
] , 'traffic' ) ,
] . join ( '' )
}
function renderDetailActions ( ) {
const disabled = activeId ? '' : ' disabled'
const primary = [
[ '设备状态' , '/api/device' ] , [ 'SIM 信息' , '/api/sim' ] , [ '蜂窝网络' , '/api/network' ] , [ '系统资源' , '/api/stats' ] ,
[ '短信统计' , '/api/sms/stats' ] , [ '通话记录' , '/api/calls' ] , [ 'OTA 状态' , '/api/ota/status' ] , [ '数据连接' , '/api/data' ] ,
]
$ ( 'detailActions' ) . innerHTML = `
<button type="button" class="detail-action raw-action" data-view="rawpage">打开原始管理页</button>
${ primary . map ( ( [ label , ep ] ) => ` <button type="button" class="detail-action" ${ disabled } data-detail-ep=" ${ escapeHtml ( ep ) } "><span> ${ escapeHtml ( label ) } </span><code> ${ escapeHtml ( ep ) } </code></button> ` ) . join ( '' ) }
`
$$ ( '[data-detail-ep]' , $ ( 'detailActions' ) ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => {
switchView ( 'api' )
$ ( 'endpointInput' ) . value = btn . dataset . detailEp
$ ( 'methodSelect' ) . value = inferMethod ( btn . dataset . detailEp )
runEndpoint ( )
} ) )
$$ ( '[data-view]' , $ ( 'detailActions' ) ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => switchView ( btn . dataset . view ) ) )
}
function renderFeatures ( ) { renderDeviceDetails ( ) ; renderDetailActions ( ) }
function renderEndpointList ( ) {
const groups = catalog . length ? catalog : modules . map ( m => ( { id : m . id , name : m . title , endpoints : m . endpoints } ) )
$ ( 'endpointList' ) . innerHTML = groups . map ( group => ` <section class="endpoint-group">
<h4> ${ escapeHtml ( group . name || group . id ) } </h4>
${ ( group . endpoints || [ ] ) . map ( ep => ` <button class="endpoint-row" data-ep=" ${ escapeHtml ( ep ) } "><span> ${ escapeHtml ( inferMethod ( ep ) ) } </span> ${ escapeHtml ( ep ) } </button> ` ) . join ( '' ) }
</section> ` ) . join ( '' )
$$ ( '.endpoint-row' , $ ( 'endpointList' ) ) . forEach ( row => row . addEventListener ( 'click' , ( ) => {
$ ( 'endpointInput' ) . value = row . dataset . ep
$ ( 'methodSelect' ) . value = inferMethod ( row . dataset . ep )
$$ ( '.endpoint-row' , $ ( 'endpointList' ) ) . forEach ( x => x . classList . toggle ( 'active' , x === row ) )
} ) )
}
function switchView ( view ) {
view = resolveView ( view , Boolean ( activeId ) )
routeView ( view )
}
function selectInstance ( id ) { openDeviceDetail ( id ) }
function openInstanceDialog ( id = '' ) {
const item = id ? instances . find ( x => x . id === id ) : null
$ ( 'instanceOriginalId' ) . value = item ? . id || ''
$ ( 'instanceFormRibbon' ) . textContent = item ? '编辑设备地址' : '添加设备地址'
$ ( 'instanceIdInput' ) . value = item ? . id || ''
$ ( 'instanceNameInput' ) . value = item ? . name || ''
$ ( 'instanceUrlInput' ) . value = item ? . url || ''
$ ( 'instanceDescInput' ) . value = item ? . description || ''
$ ( 'instanceTagsInput' ) . value = ( item ? . tags || [ ] ) . join ( ', ' )
$ ( 'instancePasswordInput' ) . value = ''
$ ( 'instancePasswordInput' ) . placeholder = item ? . auth ? . hasPassword ? '留空将清除已保存密码;输入新密码可替换' : '留空表示无密码'
$ ( 'deleteInstanceBtn' ) . hidden = ! item
$ ( 'instanceDialog' ) . showModal ( )
setTimeout ( ( ) => ( item ? $ ( 'instanceNameInput' ) : $ ( 'instanceIdInput' ) ) . focus ( ) , 50 )
}
async function reloadConfigAndRefresh ( { selectId = activeId , toast = '' } = { } ) {
await loadConfig ( { preserveSelection : selectId } )
await refreshStatus ( { silent : true } )
if ( selectId && instances . some ( x => x . id === selectId ) ) selectInstance ( selectId )
else setActiveHome ( )
if ( toast ) showToast ( toast )
}
async function saveInstanceFromForm ( ) {
const originalId = $ ( 'instanceOriginalId' ) . value
const payload = instancePayloadFromForm ( )
payload . url = normalizeTargetUrl ( payload . url )
const data = originalId ? await api . updateInstance ( originalId , payload ) : await api . createInstance ( payload )
$ ( 'instanceDialog' ) . close ( )
await reloadConfigAndRefresh ( { selectId : data . instance ? . id || payload . id , toast : originalId ? '设备地址已更新' : '设备地址已添加' } )
}
async function deleteInstance ( id ) {
const item = instances . find ( x => x . id === id )
if ( ! item ) return
if ( ! confirm ( ` 确定删除设备“ ${ item . name || item . id } ”? \n 配置会立即写入 config.json。 ` ) ) return
try { await api . deleteInstance ( id ) } catch ( error ) { showToast ( error . message , 4200 ) ; return }
await reloadConfigAndRefresh ( { selectId : null , toast : '设备地址已删除' } )
}
async function deleteCurrentInstanceFromDialog ( ) {
const id = $ ( 'instanceOriginalId' ) . value
if ( ! id ) return
$ ( 'instanceDialog' ) . close ( )
await deleteInstance ( id )
}
async function loadConfig ( { preserveSelection = activeId } = { } ) {
const [ data , catalogData ] = await Promise . all ( [ api . config ( ) , api . catalog ( ) . catch ( ( ) => ( { groups : [ ] } ) ) ] )
instances = data . instances || [ ]
configPath = data . configPath || 'config.json'
$ ( 'configPath' ) . textContent = configPath
catalog = catalogData . groups || [ ]
const saved = preserveSelection || localStorage . getItem ( 'multi-simadmin-active' )
activeId = saved && instances . some ( x => x . id === saved ) ? saved : null
renderEndpointList ( )
renderHomeCards ( )
if ( activeId ) openDeviceDetail ( activeId )
}
async function refreshStatus ( { silent = false } = { } ) {
$ ( 'refreshBtn' ) . disabled = true
if ( ! silent ) showToast ( '正在刷新全部实例…' , 1200 )
function openDeviceDetail ( id ) { if ( ! instances . some ( x => x . id === id ) ) return ; if ( activeId !== id ) { requests . invalidate ( 'api-console' ) ; requests . invalidate ( 'login' ) ; loginDialogEpoch += 1 ; $ ( 'submitLogin' ) . disabled = false ; pendingApiRequest = null ; $ ( 'runEndpoint' ) . disabled = false ; $ ( 'runEndpoint' ) . textContent = '发送请求' ; frameClear ( ) ; clearApiOutput ( ) } activeId = id ; localStorage . setItem ( 'multi-simadmin-active' , id ) ; const item = activeInstance ( ) ; $ ( 'openExternal' ) . href = item . url ; renderDetail ( ) ; switchView ( 'detail' ) }
function setActiveHome ( ) { requests . invalidate ( 'api-console' ) ; requests . invalidate ( 'login' ) ; loginDialogEpoch += 1 ; $ ( 'submitLogin' ) . disabled = false ; pendingApiRequest = null ; $ ( 'runEndpoint' ) . disabled = false ; $ ( 'runEndpoint' ) . textContent = '发送请求' ; activeId = null ; localStorage . removeItem ( 'multi-simadmin-active' ) ; frameClear ( ) ; switchView ( 'home' ) ; renderHomeCards ( ) }
function openInstanceDialog ( id = '' ) { instanceDialogEpoch += 1 ; const item = instances . find ( x => x . id === id ) ; $ ( 'instanceDialogTitle' ) . textContent = item ? '编辑设备' : '添加设备' ; $ ( 'instanceOriginalId' ) . value = item ? . id || '' ; $ ( 'instanceIdInput' ) . value = item ? . id || '' ; $ ( 'instanceNameInput' ) . value = item ? . name || '' ; $ ( 'instanceUrlInput' ) . value = item ? . url || '' ; $ ( 'instanceDescInput' ) . value = item ? . description || '' ; $ ( 'instanceTagsInput' ) . value = ( item ? . tags || [ ] ) . join ( ', ' ) ; $ ( 'instancePasswordInput' ) . value = '' ; $ ( 'clearPasswordConfirm' ) . checked = false ; $ ( 'passwordPreserve' ) . checked = Boolean ( item ) ; $ ( 'passwordClear' ) . checked = ! item ; $ ( 'passwordPreserve' ) . disabled = ! item ; $ ( 'deleteInstanceBtn' ) . hidden = ! item ; $ ( 'instanceFormError' ) . hidden = true ; updatePasswordUI ( ) ; $ ( 'instanceDialog' ) . showModal ( ) }
function updatePasswordUI ( ) { const action = $ ( 'instanceForm' ) . elements . passwordAction . value ; $ ( 'newPasswordLabel' ) . hidden = action !== 'set' ; $ ( 'instancePasswordInput' ) . required = action === 'set' ; $ ( 'clearPasswordLabel' ) . hidden = action !== 'clear' || ! $ ( 'instanceOriginalId' ) . value }
async function saveInstanceFromForm ( ) { const epoch = instanceDialogEpoch , editing = Boolean ( $ ( 'instanceOriginalId' ) . value ) , btn = $ ( 'saveInstanceBtn' ) ; if ( btn . disabled ) return ; btn . dataset . idleLabel = '保存' ; setPending ( btn , true , '保存中…' ) ; $ ( 'instanceFormError' ) . hidden = true ; try { const payload = instanceDraftPayload ( { id : $ ( 'instanceIdInput' ) . value , name : $ ( 'instanceNameInput' ) . value , url : normalizeTargetUrl ( $ ( 'instanceUrlInput' ) . value ) , description : $ ( 'instanceDescInput' ) . value , tags : tagsFromInput ( $ ( 'instanceTagsInput' ) . value ) , passwordAction : $ ( 'instanceForm' ) . elements . passwordAction . value , password : $ ( 'instancePasswordInput' ) . value , clearConfirmed : $ ( 'clearPasswordConfirm' ) . checked } , editing ) ; const data = editing ? await api . updateInstance ( $ ( 'instanceOriginalId' ) . value , payload ) : await api . createInstance ( payload ) ; if ( epoch !== instanceDialogEpoch ) return ; $ ( 'instanceDialog' ) . close ( ) ; await reload ( { selectId : data . instance ? . id || payload . id } ) ; showToast ( editing ? '设备已更新' : '设备已添加' ) } catch ( error ) { if ( epoch !== instanceDialogEpoch ) return ; $ ( 'instanceFormError' ) . textContent = error . message ; $ ( 'instanceFormError' ) . hidden = false } finally { if ( epoch === instanceDialogEpoch ) setPending ( btn , false , '保存中…' ) } }
function openDeleteDialog ( ) { const item = instances . find ( x => x . id === $ ( 'instanceOriginalId' ) . value ) ; deleteDialogEpoch + = 1 ; if ( ! item ) return ; pendingDeleteId = item . id ; $ ( 'deleteDetails' ) . innerHTML = ` <dl><div><dt>名称</dt><dd> ${ escapeHtml ( item . name || item . id ) } </dd></div><div><dt>ID</dt><dd> ${ escapeHtml ( item . id ) } </dd></div><div><dt>URL</dt><dd> ${ escapeHtml ( item . url ) } </dd></div></dl> ` ; $ ( 'deleteError' ) . hidden = true ; $ ( 'deleteDialog' ) . showModal ( ) }
async function confirmDelete ( ) { const epoch = deleteDialogEpoch , btn = $ ( 'confirmDeleteBtn' ) ; if ( btn . disabled ) return ; btn . dataset . idleLabel = '确认删除' ; setPending ( btn , true , '删除中…' ) ; try { await api . deleteInstance ( pendingDeleteId ) ; if ( epoch !== deleteDialogEpoch ) return ; $ ( 'deleteDialog' ) . close ( ) ; $ ( 'instanceDialog' ) . close ( ) ; await reload ( { selectId : null } ) ; setActiveHome ( ) ; showToast ( '设备已删除' ) } catch ( error ) { if ( epoch !== deleteDialogEpoch ) return ; $ ( 'deleteError' ) . textContent = error . message ; $ ( 'deleteError' ) . hidden = false } finally { if ( epoch === deleteDialogEpoch ) setPending ( btn , false , '删除中…' ) } }
function renderEndpointList ( ) { const q = $ ( 'endpointSearch' ) . value . trim ( ) . toLowerCase ( ) , groups = catalog . length ? catalog : modules ; $ ( 'endpointList' ) . innerHTML = groups . map ( g => { const eps = ( g . endpoints || [ ] ) . filter ( ep => ep . toLowerCase ( ) . includes ( q ) ) ; return eps . length ? ` <section><h3> ${ escapeHtml ( g . name || g . id ) } </h3> ${ eps . map ( ep => ` <button type="button" class="endpoint-row" data-ep=" ${ escapeHtml ( ep ) } "> ${ escapeHtml ( ep ) } </button> ` ) . join ( '' ) } </section> ` : '' } ) . join ( '' ) || '<p>没有匹配接口</p>' ; $$ ( '.endpoint-row' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => { $ ( 'endpointInput' ) . value = b . dataset . ep ; $ ( 'methodSelect' ) . value = 'GET' ; updateWriteUI ( ) ; $$ ( '.endpoint-row' ) . forEach ( x => x . setAttribute ( 'aria-pressed' , String ( x === b ) ) ) } ) ) }
function updateWriteUI ( ) { const write = isWriteMethod ( $ ( 'methodSelect' ) . value ) ; $ ( 'writeWarning' ) . hidden = ! write ; $ ( 'requestBody' ) . disabled = $ ( 'methodSelect' ) . value === 'GET' }
function clearApiOutput ( ) { outputText = '' ; outputPrettyText = '' ; $ ( 'rawOutput ' ) . textContent = '设备已切换。选择接口并点击“发送请求”。' }
function parseBody ( ) { const raw = $ ( 'requestBody' ) . value . trim ( ) ; return raw ? JSON . parse ( raw ) : undefined }
async function runEndpoint ( confirmed = false ) {
if ( ! activeId ) return
let draft
try {
const result = await requests . run ( 'fleet-status' , 'fleet' , ( ) => api . status ( ) )
if ( ! result . accepted || result . error ) {
if ( result . accepted && result . error ) showToast ( result . error . message || '刷新失败' , 4200 )
return
if ( confirmed ) { draft = pendingApiRequest ; pendingApiRequest = null ; if ( ! draft || draft . owner !== activeId ) return }
else {
const method = $ ( 'methodSelect' ) . value , endpoint = $ ( 'endpointInput' ) . value . trim ( )
if ( ! endpoint . startsWith ( '/' ) ) { persistentError ( '接口路径必须以 / 开头' ) ; return }
const body = method === 'GET' ? undefined : parseBody ( ) ; draft = { ... requestDraft ( method , endpoint , body ) , owner : activeId , body }
if ( isWriteMethod ( method ) ) {
pendingApiRequest = draft
$ ( 'apiDeleteDetails' ) . textContent = ` ${ activeInstance ( ) . name || activeId } ( ${ activeId } ) · ${ method } ${ endpoint } `
$ ( 'apiDeleteBody' ) . textContent = body === undefined ? '(无请求体)' : JSON . stringify ( body , null , 2 )
$ ( 'apiDeleteDialog' ) . showModal ( ) ; return
}
}
const data = result . value
statuses = new Map ( ( data . instances || [ ] ) . map ( item => [ item . id , item ] ) )
renderHomeCards ( )
if ( activeId ) {
renderOverview ( )
renderDeviceDetails ( )
renderDetailActions ( )
}
if ( ! silent ) showToast ( ` 已刷新 ${ statuses . size } 个实例 ` )
} catch ( error ) {
showToast ( error . message || '刷新失败' , 4200 )
} finally {
$ ( 'refreshBtn' ) . disabled = false
}
}
async function loginActive ( ) {
if ( ! activeId ) return
const item = activeInstance ( )
if ( ! item ) return
// If config already has a saved password, try direct refresh first; otherwise ask for password.
if ( item . auth ? . hasPassword ) {
const loginOwnerId = activeId
showToast ( '正在使用本地配置密码刷新会话…' , 1600 )
const result = await requests . run ( 'login' , loginOwnerId , ( ) => api . login ( loginOwnerId ) )
if ( ! result . accepted || result . ownerId !== activeId ) return
if ( result . error ) { showToast ( result . error . message || '登录失败' , 4200 ) ; return }
await refreshStatus ( { silent : true } )
return
}
$ ( 'loginDialog' ) . showModal ( )
$ ( 'loginDialog' ) . dataset . instanceId = activeId
setTimeout ( ( ) => $ ( 'passwordInput' ) . focus ( ) , 50 )
}
function parseBody ( ) {
const raw = $ ( 'requestBody' ) . value . trim ( )
if ( ! raw ) return undefined
return JSON . parse ( raw )
}
async function runEndpoint ( ) {
if ( ! activeId ) {
showToast ( '请先在首页选择设备卡片' )
return
}
const requestOwnerId = activeId
const endpoint = $ ( 'endpointInput' ) . value . trim ( )
const method = $ ( 'methodSelect' ) . value
if ( ! endpoint . startsWith ( '/' ) ) {
showToast ( '接口路径必须以 / 开头' )
return
}
$ ( 'rawOutput' ) . textContent = '调用中…'
try {
const init = { method }
if ( method !== 'GET' ) {
init . headers = { 'content-type' : 'application/json' }
const body = parseBody ( )
if ( body !== undefined ) init . body = JSON . stringify ( body )
}
const started = performance . now ( )
const result = await requests . run ( 'api-console' , requestOwnerId , async ( ) => {
const normalized = endpoint . startsWith ( '/' ) ? endpoint : ` / ${ endpoint } `
const res = await api . proxy ( requestOwnerId , normalized , init )
return { res , text : await res . text ( ) }
} )
if ( ! result . accepted || result . ownerId !== activeId ) return
if ( result . error ) {
$ ( 'rawOutput' ) . textContent = result . error . message || String ( result . error )
showToast ( '接口调用失败' , 4200 )
return
}
const { res , text } = result . value
const elapsed = Math . round ( performance . now ( ) - started )
let formatted = text
try { formatted = JSON . stringify ( JSON . parse ( text ) , null , 2 ) } catch { }
$ ( 'rawOutput' ) . textContent = ` HTTP ${ res . status } · ${ elapsed } ms · ${ method } ${ endpoint } \n \n ${ formatted } `
} catch ( error ) {
$ ( 'rawOutput' ) . textContent = error . message || String ( error )
showToast ( '接口调用失败' , 4200 )
}
}
function bindEvents ( ) {
$ ( 'refreshBtn' ) . addEventListener ( 'click' , ( ) => refreshStatus ( ) )
$ ( 'homeBtn' ) . addEventListener ( 'click' , setActiveHome )
$ ( 'addInstanceBtn' ) . addEventListener ( 'click' , ( ) => openInstanceDialog ( ) )
$ ( 'loginBtn' ) . addEventListener ( 'click' , loginActive )
$ ( 'runEndpoint' ) . addEventListener ( 'click' , runEndpoint )
$ ( 'instanceSearch' ) . addEventListener ( 'input' , renderHomeCards )
$ ( 'backToFleetBtn' ) . addEventListener ( 'click' , setActiveHome )
$ ( 'densityBtn' ) . addEventListener ( 'click' , ( ) => {
document . body . classList . toggle ( 'compact' )
localStorage . setItem ( 'multi-simadmin-density' , document . body . classList . contains ( 'compact' ) ? 'compact' : 'normal' )
} )
$$ ( '[data-view]' ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => switchView ( btn . dataset . view ) ) )
$$ ( '#statusFilters .tab-leaf' ) . forEach ( btn => btn . addEventListener ( 'click' , ( ) => {
activeFilter = btn . dataset . filter
$$ ( '#statusFilters .tab-leaf' ) . forEach ( x => x . classList . toggle ( 'active' , x === btn ) )
renderHomeCards ( )
} ) )
$ ( 'loginForm' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( )
const loginInstanceId = $ ( 'loginDialog' ) . dataset . instanceId
if ( ! loginInstanceId ) return
const password = $ ( 'passwordInput' ) . value
$ ( 'passwordInput' ) . value = ''
$ ( 'loginDialog' ) . close ( )
const result = await requests . run ( 'login' , loginInstanceId , ( ) => api . login ( loginInstanceId , password ) )
if ( ! result . accepted || result . ownerId !== activeId ) return
if ( result . error ) { showToast ( result . error . message || '登录失败' , 4200 ) ; return }
showToast ( '会话已刷新' )
await refreshStatus ( { silent : true } )
} )
$ ( 'instanceForm' ) . addEventListener ( 'submit' , async ( event ) => {
event . preventDefault ( )
try {
await saveInstanceFromForm ( )
} catch ( error ) {
showToast ( error . message || '保存失败' , 5200 )
}
} )
$ ( 'deleteInstanceBtn' ) . addEventListener ( 'click' , deleteCurrentInstanceFromDialog )
$ ( 'endpointInput' ) . addEventListener ( 'keydown' , ( event ) => {
if ( event . key === 'Enter' ) runEndpoint ( )
} )
}
function restorePrefs ( ) {
if ( localStorage . getItem ( 'multi-simadmin-density' ) === 'compact' ) document . body . classList . add ( 'compact' )
switchView ( localStorage . getItem ( 'multi-simadmin-view' ) || 'home' )
}
try {
restorePrefs ( )
bindEvents ( )
await loadConfig ( )
await refreshStatus ( { silent : true } )
autoRefreshTimer = setInterval ( ( ) => refreshStatus ( { silent : true } ) , 30000 )
window . addEventListener ( 'beforeunload' , ( ) => clearInterval ( autoRefreshTimer ) )
} catch ( error ) {
console . error ( error )
showToast ( error . message || '初始化失败' , 8000 )
$ ( 'rawOutput' ) . textContent = error . stack || String ( error )
} catch ( error ) { persistentError ( ` 请求参数无效: ${ error . message || error } ` ) ; return }
const { owner , method , path : endpoint , body } = draft , btn = $ ( 'runEndpoint' ) , token = requests . next ( 'api-console' ) ; if ( btn . disabled ) return
btn . dataset . idleLabel = '发送请求' ; setPending ( btn , true , '发送中…' ) ; $ ( 'rawOutput' ) . textContent = '请求进行中…' ; persistentError ( '' ) ; const started = performance . now ( )
try { const init = { method } ; if ( isWriteMethod ( method ) ) { const prepared = await api . prepareConfirmation ( { instanceId : owner , method , path : endpoint , body } ) ; if ( ! requests . isCurrent ( 'api-console' , token ) || owner !== activeId ) return ; init . headers = { 'content-type' : 'application/json' , 'x-confirmation-token' : prepared . token } ; if ( body !== undefined ) init . body = JSON . stringify ( body ) } const res = await api . proxy ( owner , endpoint , init ) , text = await res . text ( ) ; if ( ! requests . isCurrent ( 'api-console' , token ) || owner !== activeId ) return ; const elapsed = Math . round ( performance . now ( ) - started ) ; outputText = text ; try { outputPrettyText = JSON . stringify ( JSON . parse ( text ) , null , 2 ) } catch { outputPrettyText = text } $ ( 'rawOutput' ) . textContent = ` HTTP ${ res . status } ${ res . statusText } · ${ elapsed } ms · ${ draft . method } ${ draft . path } \n \n ${ $ ( 'outputPretty' ) . getAttribute ( 'aria-pressed' ) === 'true' ? outputPrettyText : outputText } ` ; if ( ! res . ok ) persistentError ( ` API 请求失败:HTTP ${ res . status } ${ res . statusText } \n ${ text } ` ) } catch ( error ) { if ( requests . isCurrent ( 'api-console' , token ) && owner === activeId ) { $ ( 'rawOutput' ) . textContent = error . message || String ( error ) ; persistentError ( ` API 请求失败: ${ error . message || error } ` ) } } finally { if ( requests . isCurrent ( 'api-console' , token ) ) setPending ( btn , false , '发送中…' ) }
}
async function refreshStatus ( { silent = false } = { } ) { const btn = $ ( 'refreshBtn' ) , token = requests . next ( 'fleet-status' ) ; btn . disabled = true ; try { const data = await api . status ( ) ; if ( ! requests . isCurrent ( 'fleet-status' , token ) ) return ; statuses = new Map ( ( data . instances || [ ] ) . map ( x => [ x . id , x ] ) ) ; lastSuccessfulRefresh = new Date ( ) ; $ ( 'staleBanner' ) . hidden = true ; persistentError ( '' ) ; renderHomeCards ( ) ; if ( activeId ) renderDetail ( ) ; if ( ! silent ) showToast ( ` 已刷新 ${ statuses . size } 个设备 ` ) } catch ( error ) { if ( ! requests . isCurrent ( 'fleet-status' , token ) ) return ; $ ( 'staleBanner' ) . textContent = ` 刷新失败,当前显示上次成功数据。上次成功: ${ lastSuccessfulRefresh ? lastSuccessfulRefresh . toLocaleString ( ) : '尚无' } 。 ${ error . message } ` ; $ ( 'staleBanner' ) . hidden = false ; persistentError ( ` 状态刷新失败: ${ error . message } ` ) } finally { if ( requests . isCurrent ( 'fleet-status' , token ) ) btn . disabled = false } }
async function loadConfig ( ) { const [ data , cat ] = await Promise . all ( [ api . config ( ) , api . catalog ( ) . catch ( ( ) => ( { groups : [ ] } ) ) ] ) ; instances = data . instances || [ ] ; $ ( 'configPath' ) . textContent = data . configPath || 'config.json' ; catalog = cat . groups || [ ] ; renderEndpointList ( ) ; const saved = localStorage . getItem ( 'multi-simadmin-active' ) ; activeId = saved && instances . some ( x => x . id === saved ) ? saved : null ; renderHomeCards ( ) }
async function reload ( { selectId = activeId } = { } ) { await loadConfig ( ) ; await refreshStatus ( { silent : true } ) ; if ( selectId && instances . some ( x => x . id === selectId ) ) openDeviceDetail ( selectId ) ; else setActiveHome ( ) }
async function initialize ( ) { $ ( 'initialLoading' ) . hidden = false ; $ ( 'fatalState' ) . hidden = true ; $ ( 'homeContent' ) . hidden = true ; persistentError ( '' ) ; try { await loadConfig ( ) ; await refreshStatus ( { silent : true } ) ; $ ( 'initialLoading' ) . hidden = true ; $ ( 'homeContent' ) . hidden = false ; if ( activeId ) { renderDetail ( ) ; switchView ( resolveView ( localStorage . getItem ( 'multi-simadmin-view' ) || 'home' , true ) ) } else switchView ( 'home' ) } catch ( error ) { $ ( 'initialLoading' ) . hidden = true ; $ ( 'fatalText' ) . textContent = error . message ; $ ( 'fatalState' ) . hidden = false } }
function bindEvents ( ) { $$ ( '[data-dialog-cancel]' ) . forEach ( button => button . addEventListener ( 'click' , ( ) => closeDialog ( button . dataset . dialogCancel ) ) ) ; for ( const id of [ 'instanceDialog' , 'deleteDialog' , 'loginDialog' , 'apiDeleteDialog' ] ) $ ( id ) . addEventListener ( 'cancel' , ( ) => invalidateDialog ( id ) ) ; $ ( 'refreshBtn' ) . addEventListener ( 'click' , ( ) => refreshStatus ( ) ) ; $ ( 'addInstanceBtn' ) . addEventListener ( 'click' , ( ) => openInstanceDialog ( ) ) ; $ ( 'backToFleetBtn' ) . addEventListener ( 'click' , setActiveHome ) ; $ ( 'editActiveBtn' ) . addEventListener ( 'click' , ( ) => openInstanceDialog ( activeId ) ) ; $ ( 'instanceSearch' ) . addEventListener ( 'input' , renderHomeCards ) ; $ ( 'sortSelect' ) . addEventListener ( 'change' , ( ) => { activeSort = $ ( 'sortSelect' ) . value ; renderHomeCards ( ) } ) ; $ ( 'densityBtn' ) . addEventListener ( 'click' , ( ) => { const compact = document . body . classList . toggle ( 'compact' ) ; $ ( 'densityBtn' ) . setAttribute ( 'aria-pressed' , String ( compact ) ) } ) ; $$ ( '[data-filter]' ) . forEach ( b => b . addEventListener ( 'click' , ( ) => setFilter ( b . dataset . filter ) ) ) ; $$ ( '[data-view]' ) . forEach ( b => b . addEventListener ( 'click' , e => { e . preventDefault ( ) ; switchView ( b . dataset . view ) } ) ) ; $$ ( '[name=passwordAction]' ) . forEach ( r => r . addEventListener ( 'change' , updatePasswordUI ) ) ; $ ( 'instanceForm' ) . addEventListener ( 'submit' , e => { e . preventDefault ( ) ; saveInstanceFromForm ( ) } ) ; $ ( 'deleteInstanceBtn' ) . addEventListener ( 'click' , openDeleteDialog ) ; $ ( 'confirmDeleteBtn' ) . addEventListener ( 'click' , e => { e . preventDefault ( ) ; confirmDelete ( ) } ) ; $ ( 'endpointSearch' ) . addEventListener ( 'input' , renderEndpointList ) ; $ ( 'methodSelect' ) . addEventListener ( 'change' , updateWriteUI ) ; $ ( 'runEndpoint' ) . addEventListener ( 'click' , ( ) => runEndpoint ( ) ) ; $ ( 'confirmApiDelete' ) . addEventListener ( 'click' , e => { e . preventDefault ( ) ; $ ( 'apiDeleteDialog' ) . close ( ) ; runEndpoint ( true ) } ) ; $ ( 'outputPretty' ) . addEventListener ( 'click' , ( ) => { $ ( 'outputPretty' ) . setAttribute ( 'aria-pressed' , 'true' ) ; $ ( 'outputRaw' ) . setAttribute ( 'aria-pressed' , 'false' ) ; if ( outputText ) $ ( 'rawOutput' ) . textContent = $ ( 'rawOutput' ) . textContent . split ( '\n\n' ) [ 0 ] + '\n\n' + outputPrettyText } ) ; $ ( 'outputRaw' ) . addEventListener ( 'click' , ( ) => { $ ( 'outputPretty' ) . setAttribute ( 'aria-pressed' , 'false' ) ; $ ( 'outputRaw' ) . setAttribute ( 'aria-pressed' , 'true' ) ; if ( outputText ) $ ( 'rawOutput' ) . textContent = $ ( 'rawOutput' ) . textContent . split ( '\n\n' ) [ 0 ] + '\n\n' + outputText } ) ; $ ( 'copyOutput' ) . addEventListener ( 'click' , async ( ) => { try { await navigator . clipboard . writeText ( $ ( 'rawOutput' ) . textContent ) ; showToast ( '结果已复制' ) } catch { showToast ( '复制不可用,请手动选择文本' ) } } ) ; $ ( 'dismissError' ) . addEventListener ( 'click' , ( ) => persistentError ( '' ) ) ; $ ( 'retryInit' ) . addEventListener ( 'click' , initialize ) ; $ ( 'loginBtn' ) . addEventListener ( 'click' , ( ) => { loginDialogEpoch += 1 ; $ ( 'submitLogin' ) . disabled = false ; $ ( 'loginDialog' ) . dataset . instanceId = activeId ; $ ( 'passwordInput' ) . value = '' ; $ ( 'loginError' ) . textContent = '' ; $ ( 'loginError' ) . hidden = true ; $ ( 'loginDialog' ) . showModal ( ) } ) ; $ ( 'loginForm' ) . addEventListener ( 'submit' , async e => { e . preventDefault ( ) ; const epoch = loginDialogEpoch , id = $ ( 'loginDialog' ) . dataset . instanceId , btn = $ ( 'submitLogin' ) ; btn . disabled = true ; $ ( 'loginError' ) . hidden = true ; const password = $ ( 'passwordInput' ) . value ; const result = await requests . run ( 'login' , id , ( ) => api . login ( id , password ) ) ; $ ( 'passwordInput' ) . value = '' ; if ( epoch !== loginDialogEpoch ) return ; if ( result . accepted && id === activeId ) { if ( result . error ) { $ ( 'loginError' ) . textContent = result . error . message ; $ ( 'loginError' ) . hidden = false } else if ( ! result . value . authenticated ) { $ ( 'loginError' ) . textContent = '登录失败,请检查密码' ; $ ( 'loginError' ) . hidden = false } else { $ ( 'loginDialog' ) . close ( ) ; await refreshStatus ( { silent : true } ) ; showToast ( '会话已刷新' ) } } if ( epoch === loginDialogEpoch ) btn . disabled = false } ) ; updateWriteUI ( ) }
bindEvents ( ) ; await initialize ( )