[verified] refactor: harden operations and redesign device console
This commit is contained in:
+91
-558
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user