|
|
|
@@ -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()
|
|
|
|
|
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(),
|
|
|
|
|