feat: 重构定投评估工具并优化Cloud工具体验

- 重构市场定投评估逻辑:优先读取本地估值快照,必要时通过探针补抓海外数据,后端接口新增force参数支持强制刷新,更新前后端注释与交互文案
- 优化Cloud工具全链路:
  - 新增运营商登录页地址字段,支持一键复制账号密码并打开对应登录页
  - 缓存密钥派生结果,避免重复阻塞事件循环,提升列表加载性能
  - 完善接口错误处理,新增面板级加载失败提示与自动重试功能
  - 修复云资源代理转发时的Cookie缺失问题
  - 并行加载所有面板数据,优化首屏渲染速度
  - 新增登录按钮样式与错误提示样式
- 新增4个Cloud工具调试脚本,用于性能测试、解密审计、回归验证与加载耗时统计
- 启动定投评估工具的定时调度任务
This commit is contained in:
yangxiangyuan
2026-08-17 16:33:41 +08:00
parent 4016636153
commit e4f84caa23
10 changed files with 1149 additions and 237 deletions
+12
View File
@@ -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;
+122 -17
View File
@@ -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 ''
}
// 一键登录:复制「账号<Tab>密码」到剪贴板,并打开运营商登录页
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 = /<!DOCTYPE|<html/i.test(String(text).slice(0, 200))
throw new Error(isHtml ? `收到HTML错误页(HTTP ${r.status},服务可能正在重启)` : `非JSON响应(HTTP ${r.status})`)
}
// 失败自动重试(noRetry 错误不重试)
const withRetry = async fn => {
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) {
<label class="modal-label">登录密码:</label>
<input type="text" class="modal-input" id="modal-op-password" placeholder="登录密码" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">登录页地址:</label>
<input type="text" class="modal-input" id="modal-op-login-url" placeholder="https://…(点🔐一键登录时优先用它打开)" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">关联手机:</label>
<input type="tel" class="modal-input" id="modal-op-mobile" placeholder="关联手机" ${readonly}>
@@ -1416,6 +1519,7 @@ function fillModalForm(type, data) {
document.getElementById('modal-op-name').value = data.name || '';
document.getElementById('modal-op-account').value = data.login_account || '';
document.getElementById('modal-op-password').value = data.login_password || '';
document.getElementById('modal-op-login-url').value = data.login_url || '';
document.getElementById('modal-op-mobile').value = data.mobile || '';
document.getElementById('modal-op-contact').value = data.contact_person || '';
document.getElementById('modal-op-remark').value = data.remark || '';
@@ -1571,6 +1675,7 @@ function getModalFormData(type) {
name: document.getElementById('modal-op-name').value.trim(),
login_account: document.getElementById('modal-op-account').value.trim(),
login_password: document.getElementById('modal-op-password').value.trim(),
login_url: document.getElementById('modal-op-login-url').value.trim(),
mobile: document.getElementById('modal-op-mobile').value.trim(),
contact_person: document.getElementById('modal-op-contact').value.trim(),
remark: document.getElementById('modal-op-remark').value.trim(),
+3 -3
View File
@@ -82,14 +82,14 @@ const runDipEval = async () => {
try {
const data = await runTrackedInteraction({
title: '建行定投评估',
action: '抓取四大指数 PE-TTM 与近10年分位并判档',
summary: '按需抓取:中证指数官网 / 蛋卷基金(备源 multpl.com)...',
action: '读取本地估值快照并判档,必要时通过探针补抓海外数据',
summary: 'A股走中证指数官网;标普走 multpl / DQYDJ 探针;纳指走 Yahoo+QQQ 探针自算,蛋卷兜底。',
button: btn,
loadingText: '评估中…',
request: { url: '/api/markets/dip_eval', method: 'GET' },
successText: d => `评估完成:${(d && Array.isArray(d.rows)) ? d.rows.length : 0} 个指数,${((d && d.errors) || []).length} 条异常`
}, async ctx => {
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '按需抓取,无定时任务' })
return await ctx.fetchJson('/api/markets/dip_eval', {}, { 说明: '优先读取本地快照,必要时补抓海外探针数据' })
})
renderDipReport(data || {})
} catch (e) {