${escapeHtml(title)}
- ${rows.map(([k, v]) => `
- ${escapeHtml(k)}
- ${escapeHtml(value(v))}
diff --git a/README.md b/README.md index 2a26743..5ef863c 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ npm start - `auth.mode`:`none` 表示无密码;`password` 表示由本地聚合服务代登录。 - `auth.password`:可选。本地保存后可自动刷新会话;不要提交真实密码。 +### 为什么只接受 IP 地址? + +聚合服务会代理设备管理 API。为避免 DNS 重绑定将已校验主机名切换到本机或元数据地址,设备 URL 必须使用明确的 IPv4/IPv6 字面地址;局域网 IP(如 `192.168.x.x`)可正常使用。 + ## 常用命令 ```bash @@ -89,7 +93,7 @@ npm audit --omit=dev --audit-level=high git diff --check ``` -第一阶段只提供架构端口和基础安全不变量。控制面完整鉴权、CSRF、完整 SSRF(DNS 解析/重绑定与私网 CIDR policy)、审计/权限,以及前端 CRUD 可达性和整体 UI 重构留到第二阶段。默认监听 `127.0.0.1`;显式配置 `0.0.0.0` 会保持,但这会暴露当前尚未鉴权的控制面。 +第二阶段安全加固后,服务仅允许监听 loopback;显式配置 `0.0.0.0`、LAN 地址或非 loopback 主机将拒绝启动。实例目标必须为允许的 IP 字面地址,拒绝凭据、loopback、unspecified、link-local、metadata、multicast 及主机名,以避免 DNS 重绑定。生产代理使用精确路径/方法白名单,写操作要求短期一次性确认令牌。 ## 注意 diff --git a/public/app.js b/public/app.js index c8d6a5b..5e56334 100644 --- a/public/app.js +++ b/public/app.js @@ -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 `
点击“添加设备”新增 SimAdmin 地址后,这里会出现设备卡片。
调整搜索关键字或状态筛选。
${escapeHtml(item.id)}
-${copy[1]}
${escapeHtml(item.id)}
${escapeHtml(item.url)}
${escapeHtml(item.description || '暂无描述')}
没有匹配接口
'; $$('.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() diff --git a/public/index.html b/public/index.html index 7869044..ca4557b 100644 --- a/public/index.html +++ b/public/index.html @@ -1,168 +1,63 @@ - - -MULTI SIMADMIN
-MULTI SIMADMIN
集中巡检你的 SimAdmin 设备