diff --git a/dev_test_scripts/debug/debug_cloud_decrypt_audit.js b/dev_test_scripts/debug/debug_cloud_decrypt_audit.js new file mode 100644 index 0000000..e28de11 --- /dev/null +++ b/dev_test_scripts/debug/debug_cloud_decrypt_audit.js @@ -0,0 +1,53 @@ +// 调试脚本:逐行审计 cloud ecs/vas 加密字段,区分"明文原本为空"与"解密失败" +// 用法:node dev_test_scripts/debug/debug_cloud_decrypt_audit.js +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') +const os = require('os') + +// 与 index.js 相同的凭据加载逻辑 +const userProfile = process.env.USERPROFILE || process.env.HOME || os.homedir() +const credsPath = path.join(userProfile, 'Toolbox_local_creds.env.local') +if (fs.existsSync(credsPath)) { + fs.readFileSync(credsPath, 'utf-8').split(/\r?\n/).forEach(line => { + const t = line.trim() + if (!t || t.startsWith('#')) return + const i = t.indexOf('=') + if (i <= 0) return + const k = t.slice(0, i).trim() + const v = t.slice(i + 1).trim() + if (k && !Object.prototype.hasOwnProperty.call(process.env, k)) process.env[k] = v + }) + console.log('creds loaded from', credsPath) +} else { + console.log('WARN: creds file not found') +} + +const { db } = require('../../src/server/db') +const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'config', 'cloud.json'), 'utf-8')) +const saltStr = String(cfg.machine_salt || '') +const passphrase = process.env.CLOUD_PASSPHRASE || process.env.SYSTEM_AUTH_PASS_93220 || '' +console.log('passphrase present:', !!passphrase) +const key = crypto.scryptSync(passphrase + saltStr, Buffer.from(saltStr || 'default', 'utf-8'), 32) + +const audit = (table, ec, ivc, tagc) => { + const rows = db.prepare(`SELECT id, ${ec}, ${ivc}, ${tagc} FROM ${table}`).all() + let empty = 0 + let fail = 0 + let ok = 0 + for (const r of rows) { + if (!r[ec] || !r[ivc] || !r[tagc]) continue + try { + const d = crypto.createDecipheriv('aes-256-gcm', key, r[ivc]) + d.setAuthTag(r[tagc]) + const pt = Buffer.concat([d.update(r[ec]), d.final()]).toString('utf-8') + if (pt === '') { empty++; console.log(`${table} id=${r.id}: 解密成功但明文为空(原本就没填)`) } else ok++ + } catch (e) { fail++; console.log(`${table} id=${r.id}: 解密失败 ${String(e.message)}`) } + } + console.log(`${table}: ok_non_empty=${ok} empty_plaintext=${empty} auth_fail=${fail}`) +} + +audit('cloud_operators', 'login_password_enc', 'login_password_iv', 'login_password_tag') +audit('cloud_ecs', 'rdp_password_enc', 'rdp_password_iv', 'rdp_password_tag') +audit('cloud_vas', 'access_text_enc', 'access_text_iv', 'access_text_tag') +console.log('audit done') diff --git a/dev_test_scripts/debug/debug_cloud_load_timing.js b/dev_test_scripts/debug/debug_cloud_load_timing.js new file mode 100644 index 0000000..ea9e36b --- /dev/null +++ b/dev_test_scripts/debug/debug_cloud_load_timing.js @@ -0,0 +1,80 @@ +// 调试脚本:测量 cloud 工具初始化各接口耗时 + 拉取前端加载日志,定位刷新"顿一下"的卡点 +// 用法:node dev_test_scripts/debug/debug_cloud_load_timing.js +const BASE = process.env.CLOUD_TIMING_BASE || 'http://localhost:8976' + +const getCookieFromGo = async () => { + const r1 = await fetch(`${BASE}/go?systemId=Tools-cloud`, { redirect: 'manual' }) + const loc = r1.headers.get('location') + if (!loc) throw new Error('no location from /go, status=' + r1.status) + const r2 = await fetch(new URL(loc, BASE).toString(), { redirect: 'manual' }) + const setc = String(r2.headers.get('set-cookie') || '') + if (!/cloud_gate=1/.test(setc)) throw new Error('no cloud_gate cookie, status=' + r2.status + ' set-cookie=' + setc) + return 'cloud_gate=1' +} + +const timeIt = async (name, fn) => { + const t0 = Date.now() + try { + const r = await fn() + await r.text() + return { name, ms: Date.now() - t0, status: r.status } + } catch (e) { + return { name, ms: Date.now() - t0, error: String((e && e.message) || e) } + } +} + +const main = async () => { + const cookie = await getCookieFromGo() + const get = url => fetch(`${BASE}${url}`, { headers: { Cookie: cookie }, cache: 'no-store' }) + const post = url => fetch(`${BASE}${url}`, { method: 'POST', headers: { Cookie: cookie, 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'cloud', action: 'timing_probe' }) }) + + // 1) 拉取前端真实加载日志(用户刷新时上报的 start/done/error 序列) + try { + const logs = await (await get('/api/debug/logs?limit=150&channel=cloud')).json() + console.log('=== 最近 cloud 前端事件(旧→新) ===') + ;(logs.lines || []).forEach(l => { + const o = typeof l === 'string' ? (() => { try { return JSON.parse(l) } catch { return { raw: l } } })() : l + console.log(`${o.ts || o.time || o.t || ''} | ${o.action || o.type || o.event || ''} | count=${o.count != null ? o.count : ''} ${o.error ? 'err=' + String(o.error).slice(0, 80) : ''}`) + }) + } catch (e) { console.log('读取 debug logs 失败:', e.message) } + + // 2) 顺序测量各接口(2 轮) + const endpoints = [ + ['GET /api/cloud/operators', () => get('/api/cloud/operators')], + ['GET /api/cloud/ecs', () => get('/api/cloud/ecs')], + ['GET /api/cloud/vas', () => get('/api/cloud/vas')], + ['GET /api/cloud/renew', () => get('/api/cloud/renew')], + ['GET /api/cloud/notes', () => get('/api/cloud/notes')], + ['GET /api/cloud/balance_alerts/status', () => get('/api/cloud/balance_alerts/status')], + ['POST /api/debug/event', () => post('/api/debug/event')], + ['GET /api/flags', () => get('/api/flags')] + ] + for (let round = 1; round <= 2; round++) { + console.log(`=== 顺序测量 第${round}轮 ===`) + for (const [name, fn] of endpoints) { + const r = await timeIt(name, fn) + console.log(`${String(r.ms).padStart(5)}ms ${r.status || ''} ${r.error || ''} ${name}`) + } + } + + // 3) 模拟浏览器初始化并发突发(2 轮) + for (let round = 1; round <= 2; round++) { + console.log(`=== 并发突发 第${round}轮 ===`) + const t0 = Date.now() + const results = await Promise.all([ + timeIt('POST boot', () => post('/api/debug/event')), + timeIt('POST ops_start', () => post('/api/debug/event')), + timeIt('GET operators', () => get('/api/cloud/operators')), + timeIt('GET ecs', () => get('/api/cloud/ecs')), + timeIt('GET vas', () => get('/api/cloud/vas')), + timeIt('GET renew', () => get('/api/cloud/renew')), + timeIt('GET notes', () => get('/api/cloud/notes')), + timeIt('GET balance_status', () => get('/api/cloud/balance_alerts/status')), + timeIt('GET flags', () => get('/api/flags')) + ]) + results.sort((a, b) => a.ms - b.ms).forEach(r => console.log(`${String(r.ms).padStart(5)}ms ${r.status || ''} ${r.error || ''} ${r.name}`)) + console.log(`突发总耗时: ${Date.now() - t0}ms`) + } +} + +main().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/dev_test_scripts/debug/debug_cloud_regression.js b/dev_test_scripts/debug/debug_cloud_regression.js new file mode 100644 index 0000000..fecadf8 --- /dev/null +++ b/dev_test_scripts/debug/debug_cloud_regression.js @@ -0,0 +1,46 @@ +// 调试脚本:cloud 重启后鉴权链路 + 解密数据完整性回归 +// 用法:node dev_test_scripts/debug/debug_cloud_regression.js +const BASE = 'http://localhost:8976' + +const main = async () => { + const r1 = await fetch(BASE + '/go?systemId=Tools-cloud', { redirect: 'manual' }) + console.log('entry:', r1.status, r1.headers.get('location') ? '(redirect ok)' : '(NO LOCATION)') + const r2 = await fetch(new URL(r1.headers.get('location'), BASE), { redirect: 'manual' }) + const setc = String(r2.headers.get('set-cookie') || '') + console.log('index hop:', r2.status, /cloud_gate=1/.test(setc) ? '(cookie ok)' : '(NO COOKIE)') + const r3 = await fetch(BASE + '/tools/cloud/index.html', { headers: { Cookie: 'cloud_gate=1' } }) + console.log('index.html:', r3.status) + const r4 = await fetch(BASE + '/tools/cloud/cloud.js', { headers: { Cookie: 'cloud_gate=1' } }) + console.log('cloud.js:', r4.status) + + const H = { Cookie: 'cloud_gate=1' } + const j = async u => (await fetch(BASE + u, { headers: H })).json() + + const ops = await j('/api/cloud/operators') + const opsEnc = (ops.rows || []).filter(r => r.login_password_enc) + const opsDecOk = opsEnc.filter(r => String(r.login_password || '') !== '').length + console.log(`operators: ok=${ops.ok} rows=${(ops.rows || []).length} enc=${opsEnc.length} decrypted_non_empty=${opsDecOk}`) + + const ecs = await j('/api/cloud/ecs') + const ecsEnc = (ecs.rows || []).filter(r => r.rdp_password_enc) + const ecsDecOk = ecsEnc.filter(r => String(r.rdp_password || '') !== '').length + console.log(`ecs: ok=${ecs.ok} rows=${(ecs.rows || []).length} enc=${ecsEnc.length} decrypted_non_empty=${ecsDecOk}`) + + const vas = await j('/api/cloud/vas') + const vasEnc = (vas.rows || []).filter(r => r.access_text_enc) + const vasDecOk = vasEnc.filter(r => String(r.access_text || '') !== '').length + console.log(`vas: ok=${vas.ok} rows=${(vas.rows || []).length} enc=${vasEnc.length} decrypted_non_empty=${vasDecOk}`) + + const rn = await j('/api/cloud/renew') + console.log(`renew: ok=${rn.ok} rows=${(rn.rows || []).length}`) + const nt = await j('/api/cloud/notes') + console.log(`notes: ok=${nt.ok} rows=${(nt.rows || []).length}`) + const bs = await j('/api/cloud/balance_alerts/status') + console.log(`balance status: ok=${bs.ok} proxied=${!!bs.proxied_to_automation} listener=${bs.data && bs.data.listener ? 'present' : 'n/a'}`) + + const bad = (opsEnc.length !== opsDecOk) || (ecsEnc.length !== ecsDecOk) || (vasEnc.length !== vasDecOk) + console.log(bad ? 'REGRESSION FAIL: 存在解密为空的行' : 'REGRESSION PASS') + process.exit(bad ? 1 : 0) +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1) }) diff --git a/dev_test_scripts/debug/debug_cloud_timing_probe.js b/dev_test_scripts/debug/debug_cloud_timing_probe.js new file mode 100644 index 0000000..ff14be4 --- /dev/null +++ b/dev_test_scripts/debug/debug_cloud_timing_probe.js @@ -0,0 +1,29 @@ +// 调试脚本:对指定 BASE 做 cloud 接口顺序+并发耗时复测 +// 用法:node dev_test_scripts/debug/debug_cloud_timing_probe.js [base_url] +const BASE = process.argv[2] || 'http://localhost:8976' + +const main = async () => { + const r1 = await fetch(BASE + '/go?systemId=Tools-cloud', { redirect: 'manual' }) + const loc = r1.headers.get('location') + if (!loc) throw new Error('no location, status=' + r1.status) + await fetch(new URL(loc, BASE), { redirect: 'manual' }) + const H = { Cookie: 'cloud_gate=1' } + const t = async n => { + const t0 = Date.now() + const r = await fetch(BASE + n, { headers: H }) + await r.text() + return `${String(Date.now() - t0).padStart(5)}ms ${r.status} ${n}` + } + console.log('-- sequential --') + for (const u of ['/api/cloud/operators', '/api/cloud/ecs', '/api/cloud/vas', '/api/cloud/renew', '/api/cloud/notes', '/api/cloud/balance_alerts/status']) { + console.log(await t(u)) + } + console.log('-- burst --') + const t0 = Date.now() + const urls = ['/api/cloud/operators', '/api/cloud/ecs', '/api/cloud/vas', '/api/cloud/renew', '/api/cloud/notes', '/api/cloud/balance_alerts/status', '/api/flags'] + const rs = await Promise.all(urls.map(u => fetch(BASE + u, { headers: H }).then(async r => { await r.text(); return `${String(Date.now() - t0).padStart(5)}ms ${r.status} ${u}` }))) + rs.forEach(x => console.log(x)) + console.log('burst total', Date.now() - t0, 'ms') +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1) }) diff --git a/public/tools/cloud/cloud.css b/public/tools/cloud/cloud.css index 117a6c8..5a59000 100644 --- a/public/tools/cloud/cloud.css +++ b/public/tools/cloud/cloud.css @@ -44,6 +44,8 @@ h2 { margin: 12px 0 8px } .btn { background: var(--cloud-accent); color: #fff; border: none; border-radius: 8px; padding: 6px 12px; cursor: pointer } .btn:hover { background: #1f5a88 } .status { background: #fff8e1; border: 1px solid #ffe08a; color: #7a5900; border-radius: 8px; padding: 6px 10px; margin: 8px 0 } +.cd-load-error { display: flex; align-items: center; gap: 12px; margin: 8px 0; padding: 8px 12px; border: 1px solid #e5a4a4; background: #fdecec; color: #a33; border-radius: 8px; font-size: 13px } +body.theme-cloud[data-theme="dark"] .cd-load-error { background: #3a1d1d; border-color: #7a4444; color: #f0b9b9 } /* Note specific styles */ .small-text-area { font-size: 10px !important; line-height: 1.2; font-family: inherit; } @@ -316,6 +318,16 @@ body.theme-cloud .panel.card { position: relative; } color: white; } +.action-btn.login { + border-color: #6f42c1; + color: #6f42c1; +} + +.action-btn.login:hover { + background: #6f42c1; + color: white; +} + /* 列表操作列内按钮圆形化 */ .grid td.col-actions .action-btn { width: 28px; diff --git a/public/tools/cloud/cloud.js b/public/tools/cloud/cloud.js index f940eae..d114b4e 100644 --- a/public/tools/cloud/cloud.js +++ b/public/tools/cloud/cloud.js @@ -1,9 +1,107 @@ const out = () => document.getElementById('cd-output') const logLocal = msg => { const b = out(); if (!b) return; const t = window.fmtCNDateTime?fmtCNDateTime(new Date()):new Date().toLocaleString('zh-CN'); b.textContent = (b.textContent||'') + (b.textContent?"\n":"") + `${t} ${msg}`; b.scrollTop = b.scrollHeight } +// 常用运营商登录页地址(按运营商名称关键字匹配;记录里已填 login_url 时优先) +const QUICK_LOGIN_URL_TABLE = [ + { match: /阿里/, url: 'https://account.aliyun.com/login/login.htm' }, + { match: /racknerd/i, url: 'https://my.racknerd.com/clientarea.php' }, + { match: /namecheap/i, url: 'https://www.namecheap.com/myaccount/login/' }, + { match: /华为/, url: 'https://auth.huaweicloud.com/authui/login.html' } +] +const quickLoginUrlOf = name => { + const n = String(name || '') + for (const item of QUICK_LOGIN_URL_TABLE) { + if (item.match.test(n)) return item.url + } + return '' +} + +// 一键登录:复制「账号密码」到剪贴板,并打开运营商登录页 +async function quickLoginOperator(row) { + if (!row) return + const account = String(row.login_account || '').trim() + const password = String(row.login_password || '') + const loginUrl = String(row.login_url || '').trim() || quickLoginUrlOf(row.name) + if (!account) { showStatus('该运营商未填写登录账号'); hideStatusSoon(); return } + const text = `${account}\t${password}` + try { + await navigator.clipboard.writeText(text) + } catch { + try { + const ta = document.createElement('textarea') + ta.value = text + ta.style.position = 'fixed' + ta.style.opacity = '0' + document.body.appendChild(ta) + ta.select() + document.execCommand('copy') + document.body.removeChild(ta) + } catch { showStatus('复制失败,请手动复制'); hideStatusSoon(); return } + } + showStatus(`账号密码已复制到剪贴板,正在打开 ${row.name} 登录页…`) + if (loginUrl) { + window.open(loginUrl, '_blank', 'noopener') + } else { + showStatus(`账号密码已复制。未配置 ${row.name} 登录页地址,请手动打开登录页后再粘贴(账号框内 Ctrl+V)`) + } + hideStatusSoon() +} + const showStatus = s => { const el = document.getElementById('status'); if (!el) return; el.style.display=''; el.textContent=s } const hideStatusSoon = () => { const el = document.getElementById('status'); if (!el) return; setTimeout(()=>{ el.style.display='none' }, 1200) } +// 健壮 JSON 加载器:识别 HTML 错误页(IIS 502/服务重启)与未登录,给出可读错误 +const fetchJson = async url => { + const r = await fetch(url, { credentials: 'include', cache: 'no-store' }) + const text = await r.text() + let d = null + try { d = JSON.parse(text) } catch {} + if (d && typeof d === 'object') { + if (d.error === 'unauthorized') { const e = new Error('未登录或登录已过期,请从首页九宫格重新进入'); e.noRetry = true; throw e } + return d + } + const isHtml = / { + const delays = [800, 2000, 4000] + let lastErr = null + for (let i = 0; i <= delays.length; i++) { + try { return await fn() } catch (e) { + lastErr = e + if (e && e.noRetry) throw e + if (i < delays.length) await new Promise(res => setTimeout(res, delays[i])) + } + } + throw lastErr +} +// 面板级失败横幅:不再静默空表,提供一键重试 +const clearPanelError = panelId => { const old = document.getElementById(`${panelId}-load-error`); if (old) old.remove() } +const showPanelError = (panelId, label, err, retryFn) => { + const pn = document.getElementById(panelId) + if (!pn) return + clearPanelError(panelId) + const div = document.createElement('div') + div.id = `${panelId}-load-error` + div.className = 'cd-load-error' + const msg = document.createElement('span') + msg.textContent = `${label}加载失败:${String(err && err.message || err)}(已自动重试多次)` + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'modal-btn secondary' + btn.textContent = '重试' + btn.addEventListener('click', async () => { + btn.disabled = true + btn.textContent = '重试中…' + try { await retryFn() } finally { if (document.getElementById(div.id)) { btn.disabled = false; btn.textContent = '重试' } } + }) + div.appendChild(msg) + div.appendChild(btn) + const anchor = pn.querySelector('.list-card') || pn.querySelector('table') || pn.firstChild + pn.insertBefore(div, anchor) +} + const loadFlags = async () => { try { const r = await fetch('/api/flags'); const d = await r.json(); if (d && d.debug && d.debug.cloud) { const sec = document.getElementById('cloud-debug'); if (sec) sec.style.display = '' } } catch {} } @@ -69,6 +167,8 @@ let opMap = {} let opsCache = [] let ecsCache = [] let vasCache = [] +let renewCache = [] +let notesCache = [] let editingOpId = null let editingEcsId = null let editingVasId = null @@ -80,25 +180,25 @@ let balanceListState = { page: 1, pageSize: 10, keyword: '' } let balanceStatusCache = null let balanceAutoRefreshTimer = null let balanceScanTrace = [] -const renderOps = rows => { const body = document.getElementById('ops-body'); if (!body) return; body.innerHTML=''; rows.forEach(r => { opMap[r.id] = r.name || (`#${r.id}`); const tr = document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.name; const td2=document.createElement('td'); td2.textContent=r.login_account||''; const td3=document.createElement('td'); td3.textContent=r.login_password||''; const td4=document.createElement('td'); td4.textContent=r.mobile||''; const td5=document.createElement('td'); td5.textContent=r.contact_person||''; const td6=createExpandableCell(r.remark||'', 'remark-text'); const td7=document.createElement('td'); td7.className='col-cert'; const badge=document.createElement('span'); badge.className = r.is_company_verified ? 'badge success' : 'badge'; badge.textContent = r.is_company_verified ? '已认证' : '未认证'; td7.appendChild(badge); const td8=document.createElement('td'); td8.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ops', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ops', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除运营商?')) return; await fetch(`/api/cloud/operators/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadOps() }); td8.appendChild(view); td8.appendChild(edit); td8.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); body.appendChild(tr) }) } +const renderOps = rows => { const body = document.getElementById('ops-body'); if (!body) return; body.innerHTML=''; rows.forEach(r => { opMap[r.id] = r.name || (`#${r.id}`); const tr = document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.name; const td2=document.createElement('td'); td2.textContent=r.login_account||''; const td3=document.createElement('td'); td3.textContent=r.login_password||''; const td4=document.createElement('td'); td4.textContent=r.mobile||''; const td5=document.createElement('td'); td5.textContent=r.contact_person||''; const td6=createExpandableCell(r.remark||'', 'remark-text'); const td7=document.createElement('td'); td7.className='col-cert'; const badge=document.createElement('span'); badge.className = r.is_company_verified ? 'badge success' : 'badge'; badge.textContent = r.is_company_verified ? '已认证' : '未认证'; td7.appendChild(badge); const td8=document.createElement('td'); td8.className='col-actions'; const login=document.createElement('button'); login.className='action-btn login'; login.title='一键登录(复制账号密码并打开登录页)'; login.textContent='🔐'; login.addEventListener('click', (e)=>{ e.preventDefault(); e.stopPropagation(); quickLoginOperator({ id:r.id, name:r.name, login_account:r.login_account, login_password:r.login_password, login_url:r.login_url||'' }) }); const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ops', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ops', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除运营商?')) return; await fetch(`/api/cloud/operators/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadOps() }); td8.appendChild(login); td8.appendChild(view); td8.appendChild(edit); td8.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); body.appendChild(tr) }) } const setOpOptions = rows => { const fill = id => { const el = document.getElementById(id); if (!el) return; el.innerHTML=''; const ph=document.createElement('option'); ph.value=''; ph.textContent='请选择运营商'; el.appendChild(ph); rows.forEach(r=>{ const op=document.createElement('option'); op.value=String(r.id); op.textContent=r.name||(`#${r.id}`); el.appendChild(op) }) }; fill('ecs-op'); fill('vas-op'); opMap = {}; rows.forEach(r=>{ opMap[r.id] = r.name||(`#${r.id}`) }) } -const loadOps = async () => { showStatus('正在查询运营商...'); try { await logEvent({ action:'ops_load_start' }); const r = await fetch('/api/cloud/operators', { credentials: 'include' }); const d = await r.json(); console.log('运营商数据:', d); if (d.ok) { opsCache = d.rows || []; renderOps(d.rows); setOpOptions(d.rows); await logEvent({ action:'ops_load_done', count:(d.rows||[]).length }) } else { console.error('运营商数据返回错误:', d); await logEvent({ action:'ops_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载运营商数据失败:', e); await logEvent({ action:'ops_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } +const loadOps = async () => { showStatus('正在查询运营商...'); try { await logEvent({ action:'ops_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/operators')); if (d.ok) { opsCache = d.rows || []; renderOps(opsCache); setOpOptions(opsCache); clearPanelError('panel-ops'); await logEvent({ action:'ops_load_done', count: opsCache.length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载运营商数据失败:', e); showPanelError('panel-ops', '运营商', e, loadOps); await logEvent({ action:'ops_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } const addOp = async () => { try { const name=document.getElementById('op-name').value.trim(); const login_account=document.getElementById('op-account').value.trim(); const login_password=document.getElementById('op-password').value.trim(); const mobile=document.getElementById('op-mobile').value.trim(); const contact_person=document.getElementById('op-contact').value.trim(); const remark=(document.getElementById('op-remark')&&document.getElementById('op-remark').value)||''; const is_company_verified=document.getElementById('op-verified').checked?1:0; if (!name) { showStatus('请填写名称'); hideStatusSoon(); return } const resp = await fetch('/api/cloud/operators', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ name, login_account, login_password, mobile, contact_person, remark, is_company_verified }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadOps() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } const saveOp = async () => { try { if (!editingOpId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const name=document.getElementById('op-name').value.trim(); const login_account=document.getElementById('op-account').value.trim(); const login_password=document.getElementById('op-password').value.trim(); const mobile=document.getElementById('op-mobile').value.trim(); const contact_person=document.getElementById('op-contact').value.trim(); const remark=(document.getElementById('op-remark')&&document.getElementById('op-remark').value)||''; const is_company_verified=document.getElementById('op-verified').checked?1:0; if (!name) { showStatus('请填写名称'); hideStatusSoon(); return } const resp = await fetch(`/api/cloud/operators/${editingOpId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingOpId, name, login_account, login_password, mobile, contact_person, remark, is_company_verified }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingOpId=null; showStatus('保存成功'); hideStatusSoon(); await loadOps() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } const renderEcs = rows => { ecsCache = rows || []; const body=document.getElementById('ecs-body'); if (!body) return; body.innerHTML=''; ecsCache.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.id; const td2=document.createElement('td'); td2.textContent=opMap[r.operator_id]||String(r.operator_id||''); const td2b=document.createElement('td'); td2b.textContent=r.server_name||''; const td3=document.createElement('td'); td3.textContent=r.region||''; const td4=document.createElement('td'); td4.textContent=r.zone||''; const td5=document.createElement('td'); td5.textContent=r.rdp_ip||''; const td6=document.createElement('td'); td6.textContent=r.rdp_user||''; const td7=document.createElement('td'); td7.textContent=r.rdp_password||''; const td8=createExpandableCell(r.spec_text||'', 'spec-text'); const td9=createExpandableCell(r.open_ports_text||'', 'ports-text'); const td10=document.createElement('td'); td10.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('ecs', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('ecs', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除服务器?')) return; await fetch(`/api/cloud/ecs/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadEcs() }); td10.appendChild(view); td10.appendChild(edit); td10.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td2b); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); tr.appendChild(td9); tr.appendChild(td10); body.appendChild(tr) }) } -const loadEcs = async () => { showStatus('正在查询服务器...'); try { await logEvent({ action:'ecs_load_start' }); const r = await fetch('/api/cloud/ecs', { credentials: 'include' }); const d = await r.json(); console.log('ECS数据:', d); if (d.ok) { renderEcs(d.rows); await logEvent({ action:'ecs_load_done', count:(d.rows||[]).length }) } else { console.error('ECS返回错误:', d); await logEvent({ action:'ecs_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载ECS失败:', e); await logEvent({ action:'ecs_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } +const loadEcs = async () => { showStatus('正在查询服务器...'); try { await logEvent({ action:'ecs_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/ecs')); if (d.ok) { renderEcs(d.rows || []); clearPanelError('panel-ecs'); await logEvent({ action:'ecs_load_done', count:(d.rows||[]).length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载ECS失败:', e); showPanelError('panel-ecs', 'ECS服务器', e, loadEcs); await logEvent({ action:'ecs_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } const addEcs = async () => { try { const operator_id=parseInt(document.getElementById('ecs-op').value||'0',10); const server_name=document.getElementById('ecs-name').value||''; const spec_text=document.getElementById('ecs-spec').value||''; const region=document.getElementById('ecs-region').value||''; const zone=document.getElementById('ecs-zone').value||''; const rdp_ip=document.getElementById('ecs-ip').value||''; const rdp_user=document.getElementById('ecs-user').value||''; const rdp_password=document.getElementById('ecs-pass').value||''; const open_ports_text=document.getElementById('ecs-ports').value||''; if (!operator_id) { showStatus('请选择运营商'); hideStatusSoon(); return } const resp = await fetch('/api/cloud/ecs', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadEcs() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } const saveEcs = async () => { try { if (!editingEcsId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const operator_id=parseInt(document.getElementById('ecs-op').value||'0',10); const server_name=document.getElementById('ecs-name').value||''; const spec_text=document.getElementById('ecs-spec').value||''; const region=document.getElementById('ecs-region').value||''; const zone=document.getElementById('ecs-zone').value||''; const rdp_ip=document.getElementById('ecs-ip').value||''; const rdp_user=document.getElementById('ecs-user').value||''; const rdp_password=document.getElementById('ecs-pass').value||''; const open_ports_text=document.getElementById('ecs-ports').value||''; if (!operator_id) { showStatus('请选择运营商'); hideStatusSoon(); return } const resp = await fetch(`/api/cloud/ecs/${editingEcsId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingEcsId, operator_id, server_name, spec_text, region, zone, rdp_ip, rdp_user, rdp_password, open_ports_text }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingEcsId=null; showStatus('保存成功'); hideStatusSoon(); await loadEcs() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } const renderVas = rows => { vasCache = rows || []; const body=document.getElementById('vas-body'); if (!body) return; body.innerHTML=''; vasCache.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=r.id; const td2=document.createElement('td'); td2.textContent=opMap[r.operator_id]||String(r.operator_id||''); const td3=document.createElement('td'); td3.textContent=r.name||''; const td4=createExpandableCell(r.config_text||'', 'vas-config'); const td5=createExpandableCell(r.access_text||'', 'vas-access'); const td6=document.createElement('td'); td6.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('vas', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('vas', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除增值服务?')) return; await fetch(`/api/cloud/vas/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadVas() }); td6.appendChild(view); td6.appendChild(edit); td6.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); body.appendChild(tr) }) } -const loadVas = async () => { showStatus('正在查询增值服务...'); try { await logEvent({ action:'vas_load_start' }); const r = await fetch('/api/cloud/vas', { credentials: 'include' }); const d = await r.json(); console.log('增值服务数据:', d); if (d.ok) { renderVas(d.rows); await logEvent({ action:'vas_load_done', count:(d.rows||[]).length }) } else { console.error('增值服务返回错误:', d); await logEvent({ action:'vas_load_fail', error: JSON.stringify(d) }) } } catch (e) { console.error('加载增值服务失败:', e); await logEvent({ action:'vas_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } +const loadVas = async () => { showStatus('正在查询增值服务...'); try { await logEvent({ action:'vas_load_start' }); const d = await withRetry(() => fetchJson('/api/cloud/vas')); if (d.ok) { renderVas(d.rows || []); clearPanelError('panel-vas'); await logEvent({ action:'vas_load_done', count:(d.rows||[]).length }) } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载增值服务失败:', e); showPanelError('panel-vas', '增值服务', e, loadVas); await logEvent({ action:'vas_load_error', error:String(e&&e.message||e) }) } hideStatusSoon() } const addVas = async () => { const operator_id=parseInt(document.getElementById('vas-op').value||'0',10); const name=document.getElementById('vas-name').value||''; const config_text=document.getElementById('vas-config').value||''; const access_text=document.getElementById('vas-access').value||''; if (!operator_id || !name) { showStatus('请选择运营商并填写名称'); hideStatusSoon(); return } await fetch('/api/cloud/vas', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ operator_id, name, config_text, access_text }) }); showStatus('新增成功'); hideStatusSoon(); await loadVas() } const saveVas = async () => { if (!editingVasId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const operator_id=parseInt(document.getElementById('vas-op').value||'0',10); const name=document.getElementById('vas-name').value||''; const config_text=document.getElementById('vas-config').value||''; const access_text=document.getElementById('vas-access').value||''; if (!operator_id || !name) { showStatus('请选择运营商并填写名称'); hideStatusSoon(); return } await fetch(`/api/cloud/vas/${editingVasId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingVasId, operator_id, name, config_text, access_text }) }); editingVasId=null; showStatus('保存成功'); hideStatusSoon(); await loadVas() } const findSubjectName = (type, id) => { if (type==='ecs') { const t=ecsCache.find(x=>x.id===id); if (t) return `${t.server_name||''}` } else if (type==='vas') { const v=vasCache.find(x=>x.id===id); if (v) return `${v.name||''}` } return '' } const renderRenew = rows => { const body=document.getElementById('rn-body'); if (!body) return; body.innerHTML=''; rows.forEach(r=>{ const tr=document.createElement('tr'); const td1=document.createElement('td'); td1.textContent=(r.subject_type==='ecs'?'ECS':'增值服务'); const td2=document.createElement('td'); td2.className='rn-text'; td2.textContent=findSubjectName(r.subject_type, r.subject_id)||''; const td3=document.createElement('td'); td3.textContent=r.subject_id; const td4=document.createElement('td'); td4.textContent=r.paid_date||''; const td5=document.createElement('td'); td5.textContent=fmtCents(r.amount_cents); const td6=document.createElement('td'); td6.className='rn-text'; if (r.invoice_path) { const a=document.createElement('a'); a.href=r.invoice_path; a.textContent='下载'; a.target='_blank'; td6.appendChild(a) } else { td6.textContent='' } const td7=document.createElement('td'); td7.textContent=r.payer_company||''; const td8=document.createElement('td'); td8.textContent=r.expire_date||''; const td9=document.createElement('td'); td9.textContent=fmtCents(r.next_expected_amount_cents||0); const td10=document.createElement('td'); td10.className='col-actions'; const view=document.createElement('button'); view.className='action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>{ openModal('renew', 'view', r.id) }); const edit=document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>{ openModal('renew', 'edit', r.id) }); const del=document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', async ()=>{ if (!window.confirm('确认删除续费记录?')) return; await fetch(`/api/cloud/renew/${r.id}`, { method:'DELETE' }); showStatus('删除成功'); hideStatusSoon(); await loadRenew() }); td10.appendChild(view); td10.appendChild(edit); td10.appendChild(del); tr.appendChild(td1); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); tr.appendChild(td8); tr.appendChild(td9); tr.appendChild(td10); body.appendChild(tr) }) } -const loadRenew = async () => { showStatus('正在查询续费情况...'); const r = await fetch('/api/cloud/renew', { credentials: 'include' }); const d = await r.json(); if (d.ok) renderRenew(d.rows); hideStatusSoon() } +const loadRenew = async () => { showStatus('正在查询续费情况...'); try { const d = await withRetry(() => fetchJson('/api/cloud/renew')); if (d.ok) { renewCache = d.rows || []; renderRenew(renewCache); clearPanelError('panel-renew') } else { throw new Error(d.error || '接口返回错误') } } catch (e) { console.error('加载续费情况失败:', e); showPanelError('panel-renew', '续费情况', e, loadRenew) } hideStatusSoon() } const addRenew = async () => { try { const subject_type=document.getElementById('rn-type').value; const subject_id=parseInt(document.getElementById('rn-subject').value||'0',10); const paid_date=document.getElementById('rn-paid').value; const amount=parseFloat(document.getElementById('rn-amount').value||'0'); const amount_cents=Math.round(amount*100); let invoice_path=document.getElementById('rn-invoice').value||''; const payer_company=document.getElementById('rn-payer').value||''; const expire_date=document.getElementById('rn-expire').value||''; const next=parseFloat(document.getElementById('rn-next').value||'0'); const next_expected_amount_cents=Math.round(next*100); if (!subject_id || !paid_date || !amount_cents) { showStatus('请完整选择主体、填写缴费日期与金额'); hideStatusSoon(); return } const fEl=document.getElementById('rn-invoice-file'); if (fEl && fEl.files && fEl.files[0]) { try { const file=fEl.files[0]; const buf=await file.arrayBuffer(); const rr=await fetch(`/api/cloud/renew/invoice?filename=${encodeURIComponent(file.name)}`, { method:'POST', headers:{'Content-Type':'application/octet-stream'}, credentials: 'include', body: buf }); const dd=await rr.json(); if (dd && dd.ok && dd.path) invoice_path=dd.path } catch {} } const resp = await fetch('/api/cloud/renew', { method:'POST', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); showStatus('新增成功'); hideStatusSoon(); await loadRenew() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } const saveRenew = async () => { try { if (!editingRnId) { showStatus('请先在列表中点击修改'); hideStatusSoon(); return } const subject_type=document.getElementById('rn-type').value; const subject_id=parseInt(document.getElementById('rn-subject').value||'0',10); const paid_date=document.getElementById('rn-paid').value; const amount=parseFloat(document.getElementById('rn-amount').value||'0'); const amount_cents=Math.round(amount*100); let invoice_path=document.getElementById('rn-invoice').value||''; const payer_company=document.getElementById('rn-payer').value||''; const expire_date=document.getElementById('rn-expire').value||''; const next=parseFloat(document.getElementById('rn-next').value||'0'); const next_expected_amount_cents=Math.round(next*100); if (!subject_id || !paid_date || !amount_cents) { showStatus('请完整选择主体、填写缴费日期与金额'); hideStatusSoon(); return } const fEl=document.getElementById('rn-invoice-file'); if (fEl && fEl.files && fEl.files[0]) { try { const file=fEl.files[0]; const buf=await file.arrayBuffer(); const rr=await fetch(`/api/cloud/renew/invoice?filename=${encodeURIComponent(file.name)}`, { method:'POST', headers:{'Content-Type':'application/octet-stream'}, credentials: 'include', body: buf }); const dd=await rr.json(); if (dd && dd.ok && dd.path) invoice_path=dd.path } catch {} } const resp = await fetch(`/api/cloud/renew/${editingRnId}`, { method:'PUT', headers:{'Content-Type':'application/json'}, credentials: 'include', body: JSON.stringify({ id: editingRnId, subject_type, subject_id, paid_date, amount_cents, invoice_path, payer_company, expire_date, next_expected_amount_cents }) }); if (!resp.ok) throw new Error((await resp.json().catch(()=>({}))).error || '保存失败'); editingRnId=null; showStatus('保存成功'); hideStatusSoon(); await loadRenew() } catch(e) { showStatus('保存失败: ' + (e.message||e)); hideStatusSoon(); } } @@ -403,11 +503,11 @@ const loadNotes = async (silent = false) => { const shouldShowStatus = !silent && currentTab === 'notes' if (shouldShowStatus) showStatus('正在查询学习笔记...') try { - const r = await fetch('/api/cloud/notes', { credentials: 'include' }); - const d = await r.json(); - if (d.ok) renderNotes(d.rows); + const d = await withRetry(() => fetchJson('/api/cloud/notes')) + if (d.ok) { notesCache = d.rows || []; renderNotes(notesCache); clearPanelError('panel-notes') } else { throw new Error(d.error || '接口返回错误') } } catch (e) { - console.error(e); + console.error(e) + showPanelError('panel-notes', '学习笔记', e, () => loadNotes(false)) } if (shouldShowStatus) hideStatusSoon() }; @@ -645,9 +745,10 @@ const renderBalanceAlerts = data => { } const loadBalanceStatus = async () => { - const r = await fetch('/api/cloud/balance_alerts/status', { credentials: 'include', cache: 'no-store' }) - const d = await r.json() - if (d.ok) renderBalanceStatus(d.data) + try { + const d = await fetchJson('/api/cloud/balance_alerts/status') + if (d.ok) renderBalanceStatus(d.data) + } catch (e) { console.error(e) } } const loadBalanceAlerts = async (showLoading = true) => { @@ -1048,11 +1149,9 @@ const initCloud = async () => { const brf=document.getElementById('cd-btn-refresh'); if (brf) brf.addEventListener('click', async ()=>{ try { const r=await fetch('/api/debug/logs?limit=5000&channel=cloud'); const d=await r.json(); if (d.ok) { out().textContent=(d.lines||[]).join('\n'); out().scrollTop=out().scrollHeight } } catch {} }) const bcl=document.getElementById('cd-btn-clear'); if (bcl) bcl.addEventListener('click', async ()=>{ try { await fetch('/api/debug/clear?channel=cloud', { method:'POST' }) } catch {}; out().textContent='' }) await logEvent({ action:'boot' }); - await loadOps(); - await Promise.all([loadEcs(), loadVas()]); - await loadRenew(); - await loadNotes(); - await loadBalanceStatus(); + await Promise.all([loadOps(), loadEcs(), loadVas(), loadRenew(), loadNotes(), loadBalanceStatus()]); + // 并行加载全部完成后统一重渲染一次,补齐运营商/主体等关联名称 + renderEcs(ecsCache); renderVas(vasCache); renderRenew(renewCache); renderNotes(notesCache); applyGridFontMode(); } catch (e) { try { fetch('/api/debug/event', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ source:'cloud', action:'init_error', error:String(e&&e.message||e) }) }) } catch {} @@ -1190,6 +1289,10 @@ function generateModalForm(type, action, id) { +