[verified] refactor: harden operations and redesign device console

This commit is contained in:
chick
2026-07-15 16:33:16 +08:00
parent e04a16e817
commit 4977afdc20
24 changed files with 769 additions and 779 deletions
+91 -558
View File
@@ -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
$('frame').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()
+48 -153
View File
@@ -1,168 +1,63 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Multi SimAdmin Island</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>设备首页 · Multi SimAdmin Island</title><link rel="stylesheet" href="/styles.css">
</head>
<body>
<div class="island-bg" aria-hidden="true">
<span class="cloud cloud-a"></span>
<span class="cloud cloud-b"></span>
<span class="leaf leaf-a"></span>
<span class="leaf leaf-b"></span>
</div>
<div id="appShell" class="sim-shell">
<header class="sim-topbar island-card pattern-app-teal">
<div>
<p class="eyebrow">MULTI SIMADMIN</p>
<h1>SimAdmin 设备卡片墙</h1>
<p class="topbar-copy">首页直接展示每个 SimAdmin 设备卡片;点击卡片进入设备详情页,再进行状态查看和功能交互。</p>
</div>
<div class="hero-actions">
<button id="homeBtn" class="animal-btn default-btn" type="button">设备首页</button>
<button id="refreshBtn" class="animal-btn primary-btn" type="button">刷新状态</button>
<button id="addInstanceBtn" class="animal-btn primary-btn" type="button"> 添加设备</button>
<button id="densityBtn" class="animal-btn default-btn" type="button">切换密度</button>
</div>
<div class="island-bg" aria-hidden="true"></div>
<div id="appShell" class="app-shell">
<aside class="desktop-sidebar" aria-label="主导航">
<a class="brand" href="#" data-view="home"><span>🏝️</span><b>SIM 岛</b></a>
<nav class="side-nav">
<button type="button" data-view="home" aria-current="page"><span>设备首页</span></button>
<button type="button" data-view="detail"><span>设备详情</span></button>
<button type="button" data-view="rawpage"><span>原始页面</span></button>
<button type="button" data-view="api"><span>API 工作台</span></button>
</nav>
<div class="sidebar-foot"><span>本地配置</span><code id="configPath">config.json</code></div>
</aside>
<div class="app-content">
<header class="sim-topbar">
<div><p class="eyebrow">MULTI SIMADMIN</p><h1 id="pageHeading">设备首页</h1><p id="pageSubtitle">集中巡检你的 SimAdmin 设备</p></div>
<div class="hero-actions"><button id="refreshBtn" class="animal-btn default-btn" type="button">刷新状态</button><button id="addInstanceBtn" class="animal-btn primary-btn" type="button"> 添加设备</button></div>
</header>
<div id="persistentError" class="persistent-message error" role="alert" hidden><span id="persistentErrorText"></span><button id="dismissError" type="button">关闭</button></div>
<div id="staleBanner" class="persistent-message warn" role="status" hidden></div>
<main class="sim-main">
<section id="homeView" class="view active home-view" aria-label="SimAdmin 卡片首页">
<section class="home-toolbar island-card pattern-default">
<div>
<div class="section-ribbon color-app-green">设备卡片墙</div>
<p class="board-caption">每张卡片对应一个 SimAdmin;卡片上直接显示在线状态、SIM、信号、温度、流量和短信摘要。</p>
</div>
<div class="home-tools">
<section class="wallet-row" id="fleetStats"></section>
<label class="search-pill" aria-label="搜索设备">
<span></span>
<input id="instanceSearch" type="search" placeholder="搜索设备、标签、地址、ICCID…" autocomplete="off" />
</label>
<nav class="island-tabs" id="statusFilters" aria-label="状态筛选">
<button class="tab-leaf active" data-filter="all">全部</button>
<button class="tab-leaf" data-filter="online">在线</button>
<button class="tab-leaf" data-filter="auth">需登录</button>
<button class="tab-leaf" data-filter="offline">离线</button>
<section id="homeView" class="view active home-view" aria-labelledby="pageHeading">
<div id="initialLoading" class="state-card" role="status">正在读取本地配置与设备状态…</div>
<div id="fatalState" class="state-card error" role="alert" hidden><h2>应用无法初始化</h2><p id="fatalText"></p><button id="retryInit" class="animal-btn primary-btn" type="button">重试</button></div>
<section id="homeContent" hidden>
<div class="fleet-overview island-card"><div><h2>设备卡片墙</h2><p>状态、资产与连接信息一览</p></div><div id="fleetStats" class="wallet-row"></div></div>
<div class="home-toolbar island-card">
<label for="instanceSearch">搜索设备</label><input id="instanceSearch" type="search" placeholder="名称、ID、标签、地址、ICCID…" autocomplete="off">
<label for="sortSelect">排序</label><select id="sortSelect"><option value="name">名称</option><option value="status">状态</option><option value="latency">延迟</option></select>
<button id="densityBtn" class="animal-btn default-btn" type="button" aria-pressed="false">紧凑密度</button>
<nav class="status-filters" id="statusFilters" aria-label="设备状态筛选">
<button type="button" data-filter="all" aria-pressed="true">全部</button><button type="button" data-filter="online" aria-pressed="false">在线</button><button type="button" data-filter="auth" aria-pressed="false">需登录</button><button type="button" data-filter="offline" aria-pressed="false">离线</button><button type="button" data-filter="unknown" aria-pressed="false">未知</button>
</nav>
</div>
</section>
<section id="homeDeviceGrid" class="home-device-grid" aria-label="设备卡片列表"></section>
</section>
<section id="detailView" class="view detail-view" aria-label="设备详情页">
<header class="workspace-hero island-card pattern-app-teal detail-hero">
<div>
<p id="activeKicker" class="eyebrow">DEVICE DETAIL</p>
<h1 id="activeName">选择一个 SimAdmin 设备</h1>
<p id="activeMeta">从首页设备卡片进入详情页后,可查看完整状态并使用功能入口。</p>
</div>
<div class="hero-actions">
<button id="backToFleetBtn" type="button" class="animal-btn default-btn">← 返回卡片墙</button>
<button id="loginBtn" type="button" class="animal-btn default-btn" disabled>登录/刷新会话</button>
<a id="openExternal" href="#" target="_blank" rel="noreferrer" class="animal-btn default-btn disabled">原站打开</a>
</div>
</header>
<section class="detail-layout">
<section class="signal-board island-card pattern-app-yellow monitor-summary-card">
<div class="section-ribbon color-app-yellow">实时状态</div>
<div id="signalPanel" class="signal-grid"></div>
</section>
<section id="overview" class="nook-metrics monitor-kpis" aria-label="关键状态指标"></section>
<section class="module-board island-card pattern-default device-detail-board">
<div class="board-head">
<div class="section-ribbon color-app-green">设备详情</div>
<p class="board-caption">设备、SIM、蜂窝网络、系统资源、温度、流量、短信与版本。</p>
</div>
<div id="detailDeviceGrid" class="device-detail-grid"></div>
</section>
<section class="island-card pattern-purple detail-actions-card">
<div class="section-ribbon color-purple">功能交互</div>
<div id="detailActions" class="detail-actions"></div>
</section>
<section id="homeDeviceGrid" class="home-device-grid" aria-label="设备卡片列表"></section>
</section>
</section>
<section id="rawpageView" class="view rawpage-view">
<div class="frame-toolbar island-card pattern-app-blue">
<button type="button" class="animal-btn default-btn" data-view="detail">← 返回详情</button>
<span id="frameHint">原始 SimAdmin 页面会在下方显示;若目标站禁止 iframe,请使用“原站打开”。</span>
</div>
<section id="emptyState" class="empty-state island-card pattern-default">
<div class="section-ribbon color-app-pink">等待选择</div>
<h3>还没有选择设备</h3>
<p>先在首页点击一个设备卡片,再进入原始页面。</p>
</section>
<iframe id="frame" class="frame" title="SimAdmin instance" hidden></iframe>
<section id="detailView" class="view detail-view" aria-label="设备详情">
<header class="workspace-hero island-card"><div><p id="activeKicker" class="eyebrow">DEVICE DETAIL</p><h2 id="activeName">设备详情</h2><p id="activeMeta"></p></div><div class="hero-actions"><button id="backToFleetBtn" class="animal-btn default-btn" type="button">返回首页</button><button id="editActiveBtn" class="animal-btn default-btn" type="button">编辑设备</button><button id="loginBtn" class="animal-btn default-btn" type="button">登录/刷新会话</button><a id="openExternal" class="animal-btn default-btn" href="#" target="_blank" rel="noopener noreferrer">原站打开</a></div></header>
<section class="detail-layout"><section class="signal-board island-card"><h3>实时状态</h3><div id="signalPanel" class="signal-grid"></div></section><section id="overview" class="nook-metrics" aria-label="关键指标"></section><section class="island-card detail-board"><h3>设备详情</h3><div id="detailDeviceGrid" class="device-detail-grid"></div></section><section class="island-card detail-actions-card"><h3>功能入口</h3><div id="detailActions" class="detail-actions"></div></section></section>
</section>
<section id="apiView" class="view api-layout">
<aside class="api-catalog island-card pattern-purple">
<div class="section-ribbon color-purple">调试接口</div>
<div id="endpointList" class="endpoint-list"></div>
</aside>
<section class="api-console island-card pattern-default">
<div class="console-toolbar">
<select id="methodSelect" aria-label="请求方法">
<option>GET</option>
<option>POST</option>
<option>DELETE</option>
</select>
<input id="endpointInput" value="/api/device" spellcheck="false" aria-label="接口路径" />
<button id="runEndpoint" class="animal-btn primary-btn" type="button">发送</button>
</div>
<textarea id="requestBody" class="request-body" placeholder='POST/DELETE 请求体 JSON,例如 {"enabled":true}'></textarea>
<pre id="rawOutput" class="raw-output">从设备详情页进入后,可对当前设备调用代理接口。</pre>
</section>
<section id="rawpageView" class="view rawpage-view" aria-label="原始管理页"><div class="frame-toolbar island-card"><button type="button" class="animal-btn default-btn" data-view="detail">返回详情</button><span id="frameHint">若目标站禁止 iframe,请使用“原站打开”。</span></div><iframe id="frame" class="frame" title="SimAdmin 原始管理页面" hidden></iframe></section>
<section id="apiView" class="view api-layout" aria-label="API 工作台">
<aside class="api-catalog island-card"><h2>接口目录</h2><label for="endpointSearch">搜索接口</label><input id="endpointSearch" type="search" placeholder="搜索 endpoint"><div id="endpointList" class="endpoint-list"></div></aside>
<section class="api-console island-card"><div class="console-toolbar"><label for="methodSelect">方法</label><select id="methodSelect"><option>GET</option><option>POST</option><option>PUT</option><option>PATCH</option><option>DELETE</option></select><label for="endpointInput">路径</label><input id="endpointInput" value="/api/device" spellcheck="false"><button id="runEndpoint" class="animal-btn primary-btn" type="button">发送请求</button></div><div id="writeWarning" class="danger-warning" role="alert" hidden>写操作可能改变设备状态。发送前将创建一次性确认令牌。</div><label for="requestBody">JSON 请求体(GET 不发送请求体)</label><textarea id="requestBody" class="request-body" placeholder='{"enabled":true}'></textarea><div class="output-tools"><button id="outputPretty" type="button" aria-pressed="true">Pretty</button><button id="outputRaw" type="button" aria-pressed="false">Raw</button><button id="copyOutput" type="button">复制结果</button></div><pre id="rawOutput" class="raw-output">选择接口不会自动发送。确认设备和参数后点击“发送请求”。</pre></section>
</section>
</main>
<footer class="config-note sim-config-note">
<span>本地配置</span>
<code id="configPath">config.json</code>
</footer>
</div>
<dialog id="loginDialog">
<form method="dialog" id="loginForm" class="login-card island-card pattern-default">
<div class="section-ribbon color-app-teal">会话通行证</div>
<h3>登录 SimAdmin</h3>
<p>输入该实例管理员密码;只发送到本地聚合服务,不写入浏览器存储。</p>
<input id="passwordInput" type="password" placeholder="管理员密码" autocomplete="current-password" />
<menu>
<button value="cancel" class="animal-btn default-btn">取消</button>
<button id="submitLogin" value="default" class="animal-btn primary-btn">登录</button>
</menu>
</form>
</dialog>
<dialog id="instanceDialog">
<form method="dialog" id="instanceForm" class="instance-form island-card pattern-default">
<div id="instanceFormRibbon" class="section-ribbon color-app-green">添加设备地址</div>
<p class="form-help">配置会立即写入本地 config.json,并更新左侧设备列表。</p>
<input type="hidden" id="instanceOriginalId" />
<label>设备 ID <input id="instanceIdInput" required pattern="[a-zA-Z0-9_.-]+" placeholder="home-cpe" /></label>
<label>显示名称 <input id="instanceNameInput" required placeholder="客厅 CPE" /></label>
<label>设备地址 <input id="instanceUrlInput" required type="url" placeholder="http://192.168.1.1" /></label>
<label>描述 <textarea id="instanceDescInput" placeholder="位置、用途或备注"></textarea></label>
<label>标签 <input id="instanceTagsInput" placeholder="home, 5g, backup" /></label>
<label>管理员密码 <input id="instancePasswordInput" type="password" placeholder="留空表示无密码或清除已保存密码" autocomplete="new-password" /></label>
<menu class="form-menu">
<button value="cancel" class="animal-btn default-btn">取消</button>
<button id="deleteInstanceBtn" type="button" class="animal-btn danger-btn" hidden>删除</button>
<button id="saveInstanceBtn" value="default" class="animal-btn primary-btn">保存</button>
</menu>
</form>
</dialog>
<div id="toast" class="toast" hidden></div>
<script src="/app.js" type="module"></script>
</body>
</html>
<nav class="mobile-nav" aria-label="移动端主导航"><button type="button" data-view="home" aria-current="page"><span>首页</span></button><button type="button" data-view="detail"><span>详情</span></button><button type="button" data-view="rawpage"><span>原页</span></button><button type="button" data-view="api"><span>API</span></button></nav>
</div>
<dialog id="loginDialog" aria-labelledby="loginTitle"><form method="dialog" id="loginForm" class="dialog-card"><h2 id="loginTitle">登录 SimAdmin</h2><p>密码仅发送至本地聚合服务。</p><label for="passwordInput">管理员密码</label><input id="passwordInput" type="password" autocomplete="current-password"><p id="loginError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="loginDialog" class="animal-btn default-btn">取消</button><button id="submitLogin" value="default" class="animal-btn primary-btn">登录</button></menu></form></dialog>
<dialog id="instanceDialog" aria-labelledby="instanceDialogTitle"><form method="dialog" id="instanceForm" class="dialog-card"><h2 id="instanceDialogTitle">添加设备</h2><input type="hidden" id="instanceOriginalId"><label for="instanceIdInput">设备 ID</label><input id="instanceIdInput" required pattern="[a-zA-Z0-9_.-]+"><label for="instanceNameInput">显示名称</label><input id="instanceNameInput" required><label for="instanceUrlInput">设备地址</label><input id="instanceUrlInput" required type="url"><label for="instanceDescInput">描述</label><textarea id="instanceDescInput"></textarea><label for="instanceTagsInput">标签(逗号分隔)</label><input id="instanceTagsInput"><fieldset id="passwordActions"><legend>保存密码</legend><label><input id="passwordPreserve" type="radio" name="passwordAction" value="preserve" checked> 保留现有密码</label><label><input id="passwordSet" type="radio" name="passwordAction" value="set"> 设置新密码</label><label><input id="passwordClear" type="radio" name="passwordAction" value="clear"> 清除密码</label></fieldset><label id="newPasswordLabel" for="instancePasswordInput" hidden>新密码<input id="instancePasswordInput" type="password" autocomplete="new-password"></label><label id="clearPasswordLabel" class="confirm-check" hidden><input id="clearPasswordConfirm" type="checkbox"> 我确认清除已保存密码</label><p id="instanceFormError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="instanceDialog" class="animal-btn default-btn">取消</button><button id="deleteInstanceBtn" type="button" class="animal-btn danger-btn" hidden>删除设备</button><button id="saveInstanceBtn" value="default" class="animal-btn primary-btn">保存</button></menu></form></dialog>
<dialog id="deleteDialog" aria-labelledby="deleteDialogTitle"><form method="dialog" class="dialog-card"><h2 id="deleteDialogTitle">确认删除设备</h2><div id="deleteDetails" class="delete-details"></div><p>此操作将立即写入本地配置,且不可撤销。</p><p id="deleteError" class="form-error" role="alert" hidden></p><menu><button type="button" data-dialog-cancel="deleteDialog" class="animal-btn default-btn">取消</button><button id="confirmDeleteBtn" value="default" class="animal-btn danger-primary">确认删除</button></menu></form></dialog>
<dialog id="apiDeleteDialog" aria-labelledby="apiDeleteTitle"><form method="dialog" class="dialog-card"><h2 id="apiDeleteTitle">确认发送写操作</h2><p id="apiDeleteDetails"></p><pre id="apiDeleteBody" class="raw-output"></pre><p>该请求将改变设备状态,请核对设备、方法、路径和请求体。</p><menu><button type="button" data-dialog-cancel="apiDeleteDialog" class="animal-btn default-btn">取消</button><button id="confirmApiDelete" value="default" class="animal-btn danger-primary">确认并发送</button></menu></form></dialog>
<div id="toast" class="toast" aria-live="polite" role="status" hidden></div>
<script src="/app.js" type="module"></script>
</body></html>
+1
View File
@@ -18,6 +18,7 @@ export function createApiClient({ fetchImpl = globalThis.fetch } = {}) {
catalog: () => request('/api/catalog'),
status: () => request('/api/status'),
proxy: (id, endpoint, options) => fetchImpl(`/api/proxy/${encodeURIComponent(id)}${endpoint}`, options),
prepareConfirmation: payload => request('/api/proxy-confirmations/prepare', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
createInstance: payload => request('/api/instances', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
updateInstance: (id, payload) => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }),
deleteInstance: id => request(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' }),
+6 -1
View File
@@ -1,10 +1,15 @@
export const VIEW_IDS = Object.freeze(['home', 'detail', 'rawpage', 'api'])
export function resolveView(view, hasActiveInstance = false) {
if (!hasActiveInstance && view !== 'home') return 'home'
return VIEW_IDS.includes(view) ? view : hasActiveInstance ? 'detail' : 'home'
}
export function createViewRouter({ panels, controls, storage = globalThis.localStorage } = {}) {
return view => {
for (const control of controls()) control.classList.toggle('active', control.dataset.view === view)
for (const control of controls()) {
const active = control.dataset.view === view
control.classList.toggle('active', active)
if (active) control.setAttribute?.('aria-current', 'page'); else control.removeAttribute?.('aria-current')
}
for (const panel of panels()) panel.classList.toggle('active', panel.id === `${view}View`)
storage?.setItem('multi-simadmin-view', view)
}
+30
View File
@@ -0,0 +1,30 @@
export function fleetStatusKind(status) {
if (!status) return 'unknown'
if (!status.reachable) return 'offline'
if (status.authenticated === false) return 'auth'
return 'online'
}
export function filterAndSortFleet(instances, statuses, { filter = 'all', query = '', sort = 'name' } = {}) {
const needle = String(query).trim().toLocaleLowerCase()
const rank = { online: 0, auth: 1, offline: 2, unknown: 3 }
return instances.filter(item => {
const status = statuses.get(item.id)
if (filter !== 'all' && fleetStatusKind(status) !== filter) return false
if (!needle) return true
return [item.id, item.name, item.url, item.description, ...(item.tags || []), JSON.stringify(status?.summary || {})]
.join(' ').toLocaleLowerCase().includes(needle)
}).slice().sort((a, b) => {
const sa = statuses.get(a.id); const sb = statuses.get(b.id)
if (sort === 'latency') return (sa?.latencyMs ?? Infinity) - (sb?.latencyMs ?? Infinity) || String(a.name || a.id).localeCompare(String(b.name || b.id))
if (sort === 'status') return rank[fleetStatusKind(sa)] - rank[fleetStatusKind(sb)] || String(a.name || a.id).localeCompare(String(b.name || b.id))
return String(a.name || a.id).localeCompare(String(b.name || b.id))
})
}
export function emptyFleetReason({ total = 0, query = '', filter = 'all', visible = 0 } = {}) {
if (!total) return 'config'
if (!visible && String(query).trim()) return 'search'
if (!visible && filter !== 'all') return 'filter'
return null
}
+21
View File
@@ -0,0 +1,21 @@
export function instanceDraftPayload(values, editing = false) {
const action = editing ? (values.passwordAction || 'preserve') : (values.passwordAction || (values.password ? 'set' : 'clear'))
if (!['preserve', 'set', 'clear'].includes(action)) throw new Error('无效密码操作')
if (action === 'set' && !String(values.password || '')) throw new Error('设置密码时必须输入密码')
if (action === 'clear' && editing && !values.clearConfirmed) throw new Error('清除密码需要显式确认')
const payload = {
id: String(values.id || '').trim(), name: String(values.name || '').trim(), url: String(values.url || '').trim(),
description: String(values.description || '').trim(), tags: Array.isArray(values.tags) ? values.tags : [], passwordAction: action,
}
if (action === 'set') payload.password = String(values.password)
return payload
}
export function requestDraft(method, path, body) {
const normalizedMethod = String(method || 'GET').toUpperCase()
const result = { method: normalizedMethod, path: String(path || '') }
if (normalizedMethod !== 'GET' && body !== undefined) result.body = body
return result
}
export const isWriteMethod = method => ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method).toUpperCase())
+9 -4
View File
@@ -1,14 +1,19 @@
export class RequestCoordinator {
#versions = new Map()
async run(channel, ownerId, operation) {
next(channel) {
const version = (this.#versions.get(channel) || 0) + 1
this.#versions.set(channel, version)
return version
}
isCurrent(channel, version) { return this.#versions.get(channel) === version }
async run(channel, ownerId, operation) {
const version = this.next(channel)
try {
const value = await operation()
return { accepted: this.#versions.get(channel) === version, ownerId, value }
return { accepted: this.isCurrent(channel, version), ownerId, value }
} catch (error) {
return { accepted: this.#versions.get(channel) === version, ownerId, error }
return { accepted: this.isCurrent(channel, version), ownerId, error }
}
}
invalidate(channel) { this.#versions.set(channel, (this.#versions.get(channel) || 0) + 1) }
invalidate(channel) { return this.next(channel) }
}
+1 -2
View File
@@ -1,2 +1 @@
*{box-sizing:border-box}html,body{min-height:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow:hidden}button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,.sim-device-card:focus-visible{outline:3px solid #ffcc00;outline-offset:3px}
.island-bg{position:fixed;inset:0;z-index:-1;overflow:hidden;background:radial-gradient(circle at 12% 10%,rgba(130,213,187,.28),transparent 24%),radial-gradient(circle at 88% 6%,rgba(247,205,103,.28),transparent 22%),linear-gradient(180deg,#f8f8f0 0%,#f4efd9 54%,#e8dfbd 100%)}.island-bg:before{content:"";position:absolute;inset:0;background-image:radial-gradient(circle,rgba(196,184,158,.15) 1.5px,transparent 1.5px),radial-gradient(circle,rgba(196,184,158,.1) 1px,transparent 1px);background-size:28px 28px,14px 14px;background-position:0 0,7px 7px}.cloud{position:absolute;border-radius:999px;background:rgba(255,255,255,.72)}.cloud:before,.cloud:after{content:"";position:absolute;border-radius:50%;background:inherit}.cloud-a{width:170px;height:48px;left:8%;top:7%}.cloud-a:before{width:72px;height:72px;left:28px;top:-28px}.cloud-a:after{width:56px;height:56px;right:26px;top:-18px}.cloud-b{width:210px;height:58px;right:8%;top:12%;opacity:.7}.cloud-b:before{width:86px;height:86px;left:34px;top:-36px}.cloud-b:after{width:72px;height:72px;right:28px;top:-26px}.leaf{position:absolute;color:#8ac68a;font-size:18px;opacity:.42;animation:leafFloat 7s ease-in-out infinite}.leaf-a{left:46%;top:8%}.leaf-b{right:26%;bottom:9%;animation-delay:1.7s}@keyframes leafFloat{0%,100%{transform:translateY(0) rotate(0)}50%{transform:translateY(-12px) rotate(12deg)}}
*{box-sizing:border-box;min-width:0}html,body{min-height:100%;max-width:100%}body{margin:0;min-height:100vh;font-family:var(--animal-font-family);font-weight:500;letter-spacing:.01em;color:var(--animal-text-color);background:var(--animal-bg-color);overflow-x:hidden}button,input,select,textarea{font:inherit;color:inherit}button,a,input,select,textarea{min-height:44px}button{cursor:pointer}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:3px solid #ffcc00;outline-offset:3px}h1,h2,h3,h4,p{margin-top:0}p,span,b,dd,dt,label,h1,h2,h3,h4,a,button{overflow-wrap:break-word;word-break:break-word}.island-bg{position:fixed;inset:0;z-index:-1;background:radial-gradient(circle at 12% 8%,rgba(130,213,187,.3),transparent 25%),radial-gradient(circle at 88% 5%,rgba(247,205,103,.3),transparent 24%),linear-gradient(180deg,#f8f8f0,#f4efd9)}code,pre{overflow-wrap:normal;word-break:normal}
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -1 +1,2 @@
@media(max-width:1260px){.home-toolbar{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.device-detail-board{grid-column:auto;grid-row:auto}.detail-actions-card{grid-column:auto}.device-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.nook-metrics{grid-template-columns:repeat(4,1fr)}}@media(max-width:900px){body{overflow:auto}.sim-shell{height:auto;padding:14px}.sim-topbar,.workspace-hero{flex-direction:column;align-items:flex-start}.sim-main{overflow:visible}.view{overflow:visible}.home-tools{grid-template-columns:1fr}.home-device-grid{overflow:visible;grid-template-columns:1fr}.api-layout.active{grid-template-columns:1fr}.nook-metrics,.device-detail-grid{grid-template-columns:1fr 1fr}.hero-actions{justify-content:flex-start}}@media(max-width:560px){.wallet-row,.signal-grid,.nook-metrics,.device-detail-grid,.sim-card-kpis{grid-template-columns:1fr}.hero-actions{width:100%}.hero-actions .animal-btn{width:100%}.console-toolbar{grid-template-columns:1fr}.device-info-card dl div{grid-template-columns:76px minmax(0,1fr)}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important}}
@media(max-width:1023px){.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.console-toolbar{grid-template-columns:auto 100px;align-items:center}.console-toolbar label:nth-of-type(2),.console-toolbar input,.console-toolbar .animal-btn{grid-column:1/-1}.console-toolbar label{margin-bottom:-6px}.dialog-card{max-height:calc(100dvh - 20px);overflow-y:auto;margin-block:10px}}
@media(max-width:767px){button,a,input,select,textarea{min-height:44px}.desktop-sidebar{display:none}.app-content{margin-left:0;padding:12px 12px 82px}.sim-topbar,.fleet-overview,.workspace-hero{align-items:flex-start;flex-direction:column}.sim-topbar .hero-actions,.workspace-hero .hero-actions{width:100%}.home-toolbar,.console-toolbar{grid-template-columns:1fr}.home-toolbar label,.console-toolbar label{margin-bottom:-6px}.status-filters{grid-column:auto}.home-device-grid,.device-detail-grid,.signal-grid,.nook-metrics{grid-template-columns:1fr}.detail-layout{grid-template-columns:1fr}.detail-board,.detail-actions-card{grid-column:auto;grid-row:auto}.api-layout.active{grid-template-columns:1fr}.endpoint-list{max-height:300px}.mobile-nav{position:fixed;z-index:10;inset:auto 0 0;display:grid;grid-template-columns:repeat(4,1fr);background:#f0e8d8;border-top:2px solid #d4c9b4;padding:6px max(6px,env(safe-area-inset-right)) max(6px,env(safe-area-inset-bottom)) max(6px,env(safe-area-inset-left))}.mobile-nav button{min-height:52px;border:0;border-radius:14px;background:transparent;color:#725d42}.mobile-nav button.active{background:#82d5bb}.mobile-nav span{display:block;font-size:11px}.sim-card-kpis{grid-template-columns:1fr}.frame{height:70vh}}@media(min-width:768px){.app-content{padding-inline:18px}.home-device-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(min-width:1024px){.app-content{padding-inline:24px}.home-device-grid{grid-template-columns:repeat(auto-fill,minmax(280px,1fr))}}@media(min-width:1440px){:root{--sidebar-width:240px}.app-content{padding-inline:32px}.home-device-grid{grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}}@media(max-width:390px){.app-content{padding-inline:10px}.hero-actions .animal-btn,.card-actions .animal-btn{flex:1}.dialog-card{padding:16px}}@media(max-width:320px){.app-content{padding-inline:8px}.sim-topbar h1{font-size:26px}.animal-btn{padding-inline:12px}}@media(prefers-reduced-motion:reduce){*,*:before,*:after{animation:none!important;transition:none!important;scroll-behavior:auto!important}}
+1 -10
View File
@@ -1,10 +1 @@
:root{
--animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#50b9ab;--animal-primary-color-bg:#e6f9f6;
--animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a;
--animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-text-color-disabled:#c4b89e;
--animal-border-color:#aaa69d;--animal-border-color-hover:#827157;--animal-border-color-light:#e8e2d6;
--animal-bg-color:#f8f8f0;--animal-bg-color-content:rgb(247,243,223);--animal-bg-color-secondary:#f0e8d8;--animal-bg-color-disabled:#f0ece2;
--animal-font-family:Nunito,'Noto Sans SC',-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;
--animal-shadow-sm:0 2px 4px 0 rgba(61,52,40,.06);--animal-shadow-base:0 3px 10px 0 rgba(61,52,40,.1);--animal-shadow-lg:0 8px 24px 0 rgba(61,52,40,.14);
--animal-motion-duration-base:.25s;--animal-motion-ease:cubic-bezier(.4,0,.2,1);
}
:root{--animal-primary-color:#19c8b9;--animal-primary-color-hover:#3dd4c6;--animal-primary-color-active:#11a89b;--animal-success-color:#6fba2c;--animal-warning-color:#f5c31c;--animal-error-color:#e05a5a;--animal-text-color:#794f27;--animal-text-color-body:#725d42;--animal-text-color-secondary:#9f927d;--animal-text-color-muted:#8a7b66;--animal-border-color:#9f927d;--animal-border-light:#d4c9b4;--animal-bg-color:#f8f8f0;--animal-bg-content:rgb(247,243,223);--animal-bg-secondary:#f0e8d8;--animal-font-family:Nunito,'Noto Sans SC','PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif;--animal-motion:.25s cubic-bezier(.4,0,.2,1);--sidebar-width:224px}