[verified] refactor: establish service and frontend boundaries

This commit is contained in:
chick
2026-07-15 02:43:41 +08:00
parent 0ac6faa6a3
commit e04a16e817
29 changed files with 917 additions and 598 deletions
+48 -52
View File
@@ -1,9 +1,13 @@
import { escapeHtml, value, formatBytes } from './domain/formatters.js'
import { createApiClient, normalizeTargetUrl } from './infrastructure/api-client.js'
import { RequestCoordinator } from './state/request-coordinator.js'
import { createViewRouter, resolveView } from './router/view-router.js'
let instances = []
let statuses = new Map()
let catalog = []
let activeId = null
let activeFilter = 'all'
let activeModule = 'all'
let configPath = 'config.json'
let autoRefreshTimer = null
@@ -20,10 +24,10 @@ const modules = [
{ 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) }
const requests = new RequestCoordinator()
const api = createApiClient()
const routeView = createViewRouter({ controls: () => $$('[data-view]'), panels: () => $$('.view') })
function boolText(v) {
if (v === true) return '开启'
if (v === false) return '关闭'
@@ -79,27 +83,10 @@ 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)) {
@@ -236,6 +223,7 @@ 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
@@ -252,6 +240,7 @@ function openDeviceDetail(id) {
}
function setActiveHome() {
requests.invalidate('api-console'); requests.invalidate('login')
activeId = null
localStorage.removeItem('multi-simadmin-active')
$('loginBtn').disabled = true
@@ -364,10 +353,8 @@ function renderEndpointList() {
}
function switchView(view) {
if (!['home', 'detail', 'rawpage', 'api'].includes(view)) view = activeId ? 'detail' : 'home'
$$('[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)
view = resolveView(view, Boolean(activeId))
routeView(view)
}
function selectInstance(id) { openDeviceDetail(id) }
@@ -399,11 +386,8 @@ async function reloadConfigAndRefresh({ selectId = activeId, 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}`)
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 ? '设备地址已更新' : '设备地址已添加' })
}
@@ -412,12 +396,7 @@ 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
}
try { await api.deleteInstance(id) } catch (error) { showToast(error.message, 4200); return }
await reloadConfigAndRefresh({ selectId: null, toast: '设备地址已删除' })
}
@@ -429,13 +408,11 @@ async function deleteCurrentInstanceFromDialog() {
}
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()
const [data, catalogData] = await Promise.all([api.config(), api.catalog().catch(() => ({ groups: [] }))])
instances = data.instances || []
configPath = data.configPath || 'config.json'
$('configPath').textContent = configPath
if (catalogRes.ok) catalog = (await catalogRes.json()).groups || []
catalog = catalogData.groups || []
const saved = preserveSelection || localStorage.getItem('multi-simadmin-active')
activeId = saved && instances.some(x => x.id === saved) ? saved : null
renderEndpointList()
@@ -447,9 +424,12 @@ 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()
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
}
const data = result.value
statuses = new Map((data.instances || []).map(item => [item.id, item]))
renderHomeCards()
if (activeId) {
@@ -471,13 +451,16 @@ async function loginActive() {
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 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)
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)
}
@@ -492,6 +475,7 @@ async function runEndpoint() {
showToast('请先在首页选择设备卡片')
return
}
const requestOwnerId = activeId
const endpoint = $('endpointInput').value.trim()
const method = $('methodSelect').value
if (!endpoint.startsWith('/')) {
@@ -507,9 +491,19 @@ async function runEndpoint() {
if (body !== undefined) init.body = JSON.stringify(body)
}
const started = performance.now()
const res = await fetch(proxiedUrl(endpoint), init)
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)
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}`
@@ -539,13 +533,15 @@ function bindEvents() {
}))
$('loginForm').addEventListener('submit', async (event) => {
event.preventDefault()
if (!activeId) return
const loginInstanceId = $('loginDialog').dataset.instanceId
if (!loginInstanceId) 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('会话已刷新')
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) => {