Files
multi-simadmin/public/app.js
T
2026-07-07 23:33:27 +08:00

561 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let instances = []
let statuses = new Map()
let catalog = []
let activeId = null
let activeFilter = 'all'
let activeModule = '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'] },
]
function escapeHtml(text) {
return String(text ?? '').replace(/[&<>"']/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]))
}
function value(v) { return v === undefined || v === null || v === '' ? '-' : String(v) }
function boolText(v) {
if (v === true) return '开启'
if (v === false) return '关闭'
return value(v)
}
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 setActiveHome() {
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('dashboard')
renderInstances()
renderOverview()
renderFeatures()
}
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 proxiedUrl(endpoint) {
if (!activeId) return null
const normalized = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
return `/api/proxy/${encodeURIComponent(activeId)}${normalized}`
}
function firstValue(...items) {
for (const item of items) if (item !== undefined && item !== null && item !== '') return item
return null
}
function formatBytes(n) {
if (n === undefined || n === null || n === '') return '-'
const num = Number(n)
if (!Number.isFinite(num)) return value(n)
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = Math.abs(num)
let i = 0
while (size >= 1024 && i < units.length - 1) { size /= 1024; i += 1 }
const signed = num < 0 ? -size : size
return `${signed.toFixed(size >= 10 || i === 0 ? 0 : 1)} ${units[i]}`
}
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`
}
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 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('')
}
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 renderInstances() {
const root = $('instances')
renderFleetStats()
if (!instances.length) {
root.innerHTML = '<section class="empty-state mini-empty"><h3>未配置设备</h3><p>点击上方“添加设备地址”即可新增 CPE / 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 = statuses.get(item.id)
const sum = s?.summary || {}
const network = sum.network || {}
const sim = sum.sim || {}
const system = sum.system || {}
return `<article class="instance-card monitor-device-card ${item.id === activeId ? 'active' : ''}" data-id="${escapeHtml(item.id)}">
<div class="instance-top">
<div>
<div class="instance-name">${escapeHtml(item.name || item.id)}</div>
<div class="instance-url">${escapeHtml(item.url)}</div>
</div>
<span class="status-pill ${pillClass(s)}">${escapeHtml(statusText(s))}</span>
</div>
<div class="mini-grid monitor-mini-grid">
<span>信号<b>${escapeHtml(value(network.signal || sim.signal))}</b></span>
<span>温度<b>${escapeHtml(formatTemperature(system.temperature))}</b></span>
<span>速率<b>${escapeHtml(formatSpeed(system.networkSpeed))}</b></span>
<span>SIM<b>${escapeHtml(sim.present === false ? '未插卡' : value(sim.iccid))}</b></span>
</div>
<div class="instance-card-actions">
<button type="button" class="mini-action" data-edit-id="${escapeHtml(item.id)}">编辑</button>
<button type="button" class="mini-action danger" data-delete-id="${escapeHtml(item.id)}">删除</button>
</div>
</article>`
}).join('')
$$('.instance-card', root).forEach(card => card.addEventListener('click', (event) => {
if (event.target.closest('button')) return
selectInstance(card.dataset.id)
}))
$$('[data-edit-id]', root).forEach(btn => btn.addEventListener('click', () => openInstanceDialog(btn.dataset.editId)))
$$('[data-delete-id]', root).forEach(btn => btn.addEventListener('click', () => deleteInstance(btn.dataset.deleteId)))
}
function renderHero() {
const { item, s, device, sim, network, system, data, ota } = getMonitorData()
if (!item) {
$('activeKicker').textContent = 'DEVICE MONITOR'
$('activeName').textContent = '选择一个设备查看实时状态'
$('activeMeta').textContent = '主页面展示各设备的状态、温度、流量、SIM、信号、系统资源与短信统计。'
$('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, device, sim, network, data, sms, system } = getMonitorData()
if (!item) {
$('overview').innerHTML = [
metricCard('设备', `${instances.length}`),
metricCard('在线', [...statuses.values()].filter(x => statusKind(x) === 'online').length),
metricCard('温度', '选择设备后显示'),
metricCard('流量', '选择设备后显示'),
].join('')
renderHero()
renderDeviceDetails()
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()
renderDeviceDetails()
}
function renderDeviceDetails() {
const { item, device, sim, network, data, sms, ota, system, s } = getMonitorData()
if (!item) {
$('featureGrid').innerHTML = `<section class="empty-state mini-empty"><h3>设备监控大屏</h3><p>选择左侧任一设备后,这里会按分类展示真实设备信息、温度、流量、系统资源、SIM 与短信状态。</p></section>`
return
}
$('featureGrid').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 renderFeatures() { renderDeviceDetails() }
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) {
$$('.phone-app[data-view]').forEach(btn => btn.classList.toggle('active', btn.dataset.view === view))
$$('.view').forEach(panel => panel.classList.toggle('active', panel.id === `${view}View`))
localStorage.setItem('multi-simadmin-view', view)
}
function selectInstance(id) {
const item = instances.find(x => x.id === id)
if (!item) return
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
renderInstances()
renderOverview()
renderFeatures()
}
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()
const method = originalId ? 'PUT' : 'POST'
const url = originalId ? `/api/instances/${encodeURIComponent(originalId)}` : '/api/instances'
const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) })
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(data.error || `保存失败:HTTP ${res.status}`)
$('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
const res = await fetch(`/api/instances/${encodeURIComponent(id)}`, { method: 'DELETE' })
const data = await res.json().catch(() => ({}))
if (!res.ok) {
showToast(data.error || `删除失败:HTTP ${res.status}`, 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 [configRes, catalogRes] = await Promise.all([fetch('/api/config'), fetch('/api/catalog')])
if (!configRes.ok) throw new Error(`配置读取失败:HTTP ${configRes.status}`)
const data = await configRes.json()
instances = data.instances || []
configPath = data.configPath || 'config.json'
$('configPath').textContent = configPath
if (catalogRes.ok) catalog = (await catalogRes.json()).groups || []
const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
activeId = saved && instances.some(x => x.id === saved) ? saved : null
renderEndpointList()
renderInstances()
renderOverview()
renderFeatures()
if (activeId) selectInstance(activeId)
}
async function refreshStatus({ silent = false } = {}) {
$('refreshBtn').disabled = true
if (!silent) showToast('正在刷新全部实例…', 1200)
try {
const res = await fetch('/api/status')
if (!res.ok) throw new Error(`状态读取失败:HTTP ${res.status}`)
const data = await res.json()
statuses = new Map((data.instances || []).map(item => [item.id, item]))
renderInstances()
renderOverview()
renderFeatures()
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) {
showToast('正在使用本地配置密码刷新会话…', 1600)
const res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({}) })
if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200)
await refreshStatus({ silent: true })
return
}
$('loginDialog').showModal()
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 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 res = await fetch(proxiedUrl(endpoint), init)
const elapsed = Math.round(performance.now() - started)
const text = await res.text()
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', renderInstances)
$('densityBtn').addEventListener('click', () => {
document.body.classList.toggle('compact')
localStorage.setItem('multi-simadmin-density', document.body.classList.contains('compact') ? 'compact' : 'normal')
})
$$('.phone-app[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))
renderInstances()
}))
$('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
if (!activeId) return
const password = $('passwordInput').value
$('passwordInput').value = ''
$('loginDialog').close()
const res = await fetch(`/api/instances/${encodeURIComponent(activeId)}/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ password }) })
if (!res.ok) showToast(`登录失败:HTTP ${res.status}`, 4200)
else 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') || 'dashboard')
}
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)
}