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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 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 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]) => `
${num}${label}
`).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 = '

未配置实例

请编辑 config.json 添加 instances,并重启服务。

' return } const list = filteredInstances() if (!list.length) { root.innerHTML = '

没有匹配设备

调整搜索关键字或状态筛选。

' return } root.innerHTML = list.map(item => { const s = statuses.get(item.id) const sum = s?.summary || {} const network = sum.network || {} const sim = sum.sim || {} return `
${escapeHtml(item.name || item.id)}
${escapeHtml(item.url)}
${escapeHtml(statusText(s))}
${item.description ? `

${escapeHtml(item.description)}

` : ''}
认证${item.auth?.hasPassword ? '密码会话' : '无密码'} 运营商${escapeHtml(value(network.operator || sim.operator))} 网络${escapeHtml(value(network.accessTechnology || network.registration))} ICCID${escapeHtml(value(sim.iccid))}
` }).join('') $$('.instance-card', root).forEach(card => card.addEventListener('click', () => selectInstance(card.dataset.id))) } function renderHero() { const item = activeInstance() const s = activeStatus() const sum = s?.summary || {} if (!item) { $('activeKicker').textContent = 'NO DEVICE SELECTED' $('activeName').textContent = '选择一个 SimAdmin 实例' $('activeMeta').textContent = '统一查看设备、SIM、网络、短信、eSIM、OTA 与原始管理页面。' $('signalPanel').innerHTML = [ ['状态', '等待选择'], ['当前实例', '-'], ['会话', '-'], ['刷新', '-'], ].map(([k, v]) => `
${k}${v}
`).join('') return } $('activeKicker').textContent = item.id $('activeName').textContent = item.name || item.id $('activeMeta').textContent = item.description ? `${item.description} · ${item.url}` : item.url $('signalPanel').innerHTML = [ ['状态', statusText(s)], ['设备', value(sum.device?.model || sum.device?.name)], ['运营商', value(sum.network?.operator || sum.sim?.operator)], ['网络', value(sum.network?.accessTechnology || sum.network?.registration)], ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], ['版本', value(sum.ota?.currentVersion || sum.device?.firmwareVersion)], ].map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join('') } function renderOverview() { const item = activeInstance() const s = activeStatus() if (!item) { $('overview').innerHTML = '
提示先选择左侧设备
' renderHero() return } const sum = s?.summary || {} const metrics = [ ['状态', s ? (s.reachable ? (s.authenticated === false ? '在线/未登录' : '在线') : `离线`) : '未知'], ['延迟', s?.latencyMs != null ? `${s.latencyMs}ms` : '-'], ['型号', sum.device?.model || sum.device?.name], ['IMEI', sum.device?.imei], ['ICCID', sum.sim?.iccid], ['号码', sum.sim?.phoneNumber || sum.sim?.msisdn], ['运营商', sum.network?.operator || sum.sim?.operator], ['网络', sum.network?.accessTechnology || sum.network?.registration], ['数据连接', sum.data ? `${boolText(sum.data.enabled)} / ${boolText(sum.data.connected)}` : '-'], ['短信', sum.sms ? `总 ${value(sum.sms.total)} / 未读 ${value(sum.sms.unread)}` : '-'], ] $('overview').innerHTML = metrics.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(value(v))}
`).join('') renderHero() } function renderModuleTabs() { const tabs = [{ id: 'all', title: '全部' }, ...modules.map(m => ({ id: m.id, title: m.title.split(/[ /]/)[0] }))] $('moduleTabs').innerHTML = tabs.map(t => ``).join('') $$('button[data-module]', $('moduleTabs')).forEach(btn => btn.addEventListener('click', () => { activeModule = btn.dataset.module renderFeatures() })) } function renderFeatures() { renderModuleTabs() const disabled = activeId ? '' : ' disabled' const visible = activeModule === 'all' ? modules : modules.filter(m => m.id === activeModule) $('featureGrid').innerHTML = visible.map(m => `

${escapeHtml(m.id)}

${escapeHtml(m.title)}

${m.endpoints.length} API

${escapeHtml(m.desc)}

${m.tone === 'danger' ? '
包含写操作/网络切换类接口,执行前确认目标设备。
' : ''}
${m.endpoints.map(ep => `${escapeHtml(ep)}`).join('')}
`).join('') $$('button[data-ep]', $('featureGrid')).forEach(btn => btn.addEventListener('click', () => { switchView('api') $('endpointInput').value = btn.dataset.ep $('methodSelect').value = inferMethod(btn.dataset.ep) runEndpoint() })) } function renderEndpointList() { const groups = catalog.length ? catalog : modules.map(m => ({ id: m.id, name: m.title, endpoints: m.endpoints })) $('endpointList').innerHTML = groups.map(group => `

${escapeHtml(group.name || group.id)}

${(group.endpoints || []).map(ep => ``).join('')}
`).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) { $$('.rail-btn[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() } async function loadConfig() { 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 = localStorage.getItem('multi-simadmin-active') if (saved && instances.some(x => x.id === saved)) activeId = saved 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()) $('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') }) $$('.rail-btn[data-view]').forEach(btn => btn.addEventListener('click', () => switchView(btn.dataset.view))) $$('#statusFilters .chip').forEach(btn => btn.addEventListener('click', () => { activeFilter = btn.dataset.filter $$('#statusFilters .chip').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 }) }) $('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) }