Files

1678 lines
85 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }
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) }
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 {}
}
const switchTab = name => {
currentTab = name
const tabs = ['ops','ecs','vas','renew','notes','balance']
tabs.forEach(t => { const tb = document.getElementById(`tab-${t}`); const pn = document.getElementById(`panel-${t}`); if (tb) tb.classList.remove('active'); if (pn) pn.style.display='none' })
const tb = document.getElementById(`tab-${name}`); const pn = document.getElementById(`panel-${name}`); if (tb) tb.classList.add('active'); if (pn) pn.style.display=''
const fs=document.getElementById('font-size-toggle'); if (pn && fs) pn.appendChild(fs)
if (window.CloudTheme && typeof window.CloudTheme.onSectionSwitch === 'function') { try { window.CloudTheme.onSectionSwitch(name) } catch {} }
if (name === 'balance') {
loadBalanceAlerts(false)
startBalanceAutoRefresh()
} else {
stopBalanceAutoRefresh()
}
}
const fmtCents = c => ((c||0)/100).toLocaleString('zh-CN', { minimumFractionDigits:2, maximumFractionDigits:2 })
const parseAmountToCents = s => { const x = String(s||'').replace(/[,\s]/g,''); const v = Number(x); if (!Number.isFinite(v)) return null; return Math.round(v*100) }
const logEvent = async (payload) => { try { await fetch('/api/debug/event', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(Object.assign({ source:'cloud' }, payload||{})) }) } catch {} }
const parseUtcLikeDate = value => {
const raw = String(value || '').trim()
if (!raw) return null
const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T')
const withZone = /(?:Z|[+\-]\d{2}:\d{2})$/i.test(normalized) ? normalized : `${normalized}Z`
const date = new Date(withZone)
return Number.isNaN(date.getTime()) ? null : date
}
const fmtBeijingDateTime = value => {
const date = parseUtcLikeDate(value)
if (!date) return String(value || '')
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date).replace(/\//g, '-')
}
const createExpandableCell = (text, className) => {
const td = document.createElement('td')
td.className = className ? `${className} expandable` : 'expandable'
const content = document.createElement('div')
content.className = 'expandable-content'
content.textContent = text || ''
const toggle = document.createElement('span')
toggle.className = 'expandable-toggle'
toggle.textContent = '👇展开'
toggle.addEventListener('click', () => {
const expanded = td.classList.toggle('expanded')
toggle.textContent = expanded ? '👆收缩' : '👇展开'
})
td.appendChild(content)
td.appendChild(toggle)
return td
}
let opMap = {}
let opsCache = []
let ecsCache = []
let vasCache = []
let editingOpId = null
let editingEcsId = null
let editingVasId = null
let editingRnId = null
let editingNoteId = null
let currentRenewInvoicePath = ''
let currentTab = 'ops'
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 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 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 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 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 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(); } }
const updateRenewSubjectOptions = () => { const sel=document.getElementById('rn-subject'); if (!sel) return; const tp=document.getElementById('rn-type').value; sel.innerHTML=''; const ph=document.createElement('option'); ph.value=''; ph.textContent='请选择主体'; sel.appendChild(ph); if (tp==='ecs') { ecsCache.forEach(e=>{ const op=document.createElement('option'); op.value=String(e.id); op.textContent=`${e.server_name||''} | ${e.rdp_ip||''}`; sel.appendChild(op) }) } else { vasCache.forEach(v=>{ const op=document.createElement('option'); op.value=String(v.id); op.textContent=`${opMap[v.operator_id]||''} | ${v.name||''}`; sel.appendChild(op) }) } }
const updateNoteSubjectOptions = () => {
const sel = document.getElementById('nt-subject');
if (!sel) return;
const tp = document.getElementById('nt-type').value;
sel.innerHTML = '';
const ph = document.createElement('option');
ph.value = '';
ph.textContent = '请选择主体';
sel.appendChild(ph);
if (tp === 'ecs') {
ecsCache.forEach(e => {
const op = document.createElement('option');
op.value = String(e.id);
op.textContent = `${e.server_name || ''} | ${e.rdp_ip || ''}`;
sel.appendChild(op);
});
} else {
vasCache.forEach(v => {
const op = document.createElement('option');
op.value = String(v.id);
op.textContent = `${opMap[v.operator_id] || ''} | ${v.name || ''}`;
sel.appendChild(op);
});
}
}
// 恢复上传功能
function showResumeUploads() {
const pendingUploads = [];
for (const [fileKey, state] of uploadStates.entries()) {
const [noteId, fileName] = fileKey.split('_');
const progress = Math.round((state.completedChunks / state.totalChunks) * 100);
pendingUploads.push({
fileKey,
noteId,
fileName,
progress,
completedChunks: state.completedChunks,
totalChunks: state.totalChunks,
error: state.error,
lastErrorTime: state.lastErrorTime
});
}
if (pendingUploads.length === 0) {
showStatus('没有发现未完成的上传');
return;
}
// 创建恢复上传的模态框
const modalContent = document.createElement('div');
modalContent.className = 'resume-upload-modal';
modalContent.innerHTML = `
<h3>未完成的上传</h3>
<div class="resume-upload-list">
${pendingUploads.map(upload => `
<div class="resume-upload-item">
<div class="upload-info">
<span class="file-name">${upload.fileName}</span>
<span class="upload-progress">${upload.progress}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${upload.progress}%"></div>
</div>
${upload.error ? `<div class="upload-error">错误: ${upload.error}</div>` : ''}
<div class="upload-actions">
<button class="action-btn resume" onclick="resumeUpload('${upload.fileKey}')">恢复上传</button>
<button class="action-btn cancel" onclick="cancelUpload('${upload.fileKey}')">取消上传</button>
</div>
</div>
`).join('')}
</div>
<div class="modal-actions">
<button class="modal-btn primary" onclick="closeResumeModal()">关闭</button>
</div>
`;
// 添加到页面
const existingModal = document.getElementById('resume-upload-modal');
if (existingModal) {
document.body.removeChild(existingModal);
}
modalContent.id = 'resume-upload-modal';
modalContent.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
z-index: 10000;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
`;
document.body.appendChild(modalContent);
}
function closeResumeModal() {
const modal = document.getElementById('resume-upload-modal');
if (modal) {
document.body.removeChild(modal);
}
}
function resumeUpload(fileKey) {
const state = uploadStates.get(fileKey);
if (!state) return;
showStatus(`准备恢复上传 ${state.fileName}...`);
// 创建文件选择对话框让用户重新选择文件
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '*/*';
fileInput.style.display = 'none';
fileInput.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.name !== state.fileName) {
showStatus('请选择相同的文件进行恢复上传');
return;
}
// 模拟上传过程
showStatus(`恢复上传 ${file.name}...`);
// 这里应该调用实际的恢复上传逻辑
// 由于浏览器安全限制,无法直接访问之前选择的文件
// 需要用户重新选择文件
try {
const CHUNK_SIZE = 1024 * 1024;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let chunkIdx = state.completedChunks; chunkIdx < totalChunks; chunkIdx++) {
const start = chunkIdx * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const slice = file.slice(start, end);
const buf = await slice.arrayBuffer();
showStatus(`正在上传 ${file.name} (${chunkIdx + 1}/${totalChunks})...`);
const r = await fetch(`/api/cloud/notes/attachment?note_id=${fileKey.split('_')[0]}&filename=${encodeURIComponent(file.name)}&chunk_idx=${chunkIdx}&total_chunks=${totalChunks}&upload_id=${state.uploadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: buf
});
if (!r.ok) throw new Error(`Chunk ${chunkIdx} failed`);
// 更新上传状态
uploadStates.set(fileKey, {
...state,
completedChunks: chunkIdx + 1
});
}
// 上传完成
uploadStates.delete(fileKey);
showStatus(`文件 ${file.name} 上传成功`);
closeResumeModal();
} catch (e) {
console.error('Resume upload failed:', e);
showStatus(`恢复上传失败: ${e.message}`);
// 更新错误状态
uploadStates.set(fileKey, {
...state,
error: e.message,
lastErrorTime: Date.now()
});
}
};
document.body.appendChild(fileInput);
fileInput.click();
document.body.removeChild(fileInput);
}
function cancelUpload(fileKey) {
if (confirm('确定要取消这个上传吗?')) {
uploadStates.delete(fileKey);
showStatus('上传已取消');
closeResumeModal();
showResumeUploads(); // 刷新列表
}
}
// 添加快捷键检查未完成的上传
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'u') {
e.preventDefault();
showResumeUploads();
}
});;
const renderNotes = rows => {
const body = document.getElementById('nt-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.textContent = findSubjectName(r.subject_type, r.subject_id) || `#${r.subject_id}`;
const td3 = createExpandableCell(r.issue_text || '', 'note-text');
const td4 = createExpandableCell(r.solution_text || '', 'note-text');
const td5 = document.createElement('td');
if (r.attachments && r.attachments.length) {
r.attachments.forEach(att => {
const div = document.createElement('div');
const a = document.createElement('a');
a.href = att.file_path;
a.textContent = att.filename;
a.target = '_blank';
div.appendChild(a);
const delAtt = document.createElement('span');
delAtt.textContent = ' ❌';
delAtt.style.cursor = 'pointer';
delAtt.title = '删除附件';
delAtt.onclick = async (e) => {
e.preventDefault();
if (!confirm(`确认删除附件 ${att.filename}?`)) return;
await fetch(`/api/cloud/notes/attachment/${att.id}`, { method: 'DELETE' });
await loadNotes();
};
div.appendChild(delAtt);
td5.appendChild(div);
});
}
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('notes', 'view', r.id);
});
const edit = document.createElement('button');
edit.className = 'action-btn edit';
edit.title = '修改';
edit.textContent = '✎';
edit.addEventListener('click', () => {
openModal('notes', 'edit', r.id);
});
const del = document.createElement('button');
del.className = 'action-btn delete';
del.title = '删除';
del.textContent = '🗑';
del.addEventListener('click', async () => {
if (!confirm('确认删除该笔记?')) return;
await fetch(`/api/cloud/notes/${r.id}`, { method: 'DELETE' });
showStatus('删除成功');
hideStatusSoon();
await loadNotes();
});
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 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);
} catch (e) {
console.error(e);
}
if (shouldShowStatus) hideStatusSoon()
};
const setButtonLoading = (button, loadingText) => {
if (!button) return () => {}
const oldText = button.textContent
button.disabled = true
button.textContent = loadingText
return () => {
button.disabled = false
button.textContent = oldText
}
}
const fmtBalanceStatus = (status, errorText) => {
if (status === 'ok') return '已同步'
if (status === 'disabled') return '已关闭'
if (status === 'pending') return '待同步'
if (status === 'error') return `失败${errorText ? `: ${errorText}` : ''}`
return status || '未知'
}
const createStatusBadge = (status, errorText) => {
const span = document.createElement('span')
span.className = 'badge'
if (status === 'ok') span.classList.add('success')
else if (status === 'error') span.classList.add('danger')
else span.classList.add('muted')
span.textContent = fmtBalanceStatus(status, errorText)
if (errorText) span.title = errorText
return span
}
const getRiskMeta = level => {
if (level === 'high') return { text: '高', className: 'danger' }
if (level === 'low') return { text: '低', className: 'success' }
return { text: '中', className: 'warning' }
}
const formatJsonBlock = value => {
try { return JSON.stringify(value, null, 2) } catch { return String(value || '') }
}
const explainBalanceScanResponse = data => {
const d = data || {}
if (d.message === 'scan_in_progress') {
return formatJsonBlock({
reason: '8977 自动服务正在执行补扫,本次点击没有重复启动新任务',
current_port: d.current_port || '未知',
automation_port: d.automation_port || 8977,
note: '如果邮件正文此前为空,本次扫描完成后或刷新列表时会显示从 HTML 正文转换出的文本',
runtime: d.runtime || {}
})
}
if (d.message === 'scan_started') {
return formatJsonBlock({
reason: '补扫任务已提交到 8977 自动服务执行',
current_port: d.current_port || '未知',
automation_port: d.automation_port || 8977,
runtime: d.runtime || {}
})
}
if (d.reason === 'not_automation_port') {
return formatJsonBlock({
reason: '当前页面请求落在非自动服务端口,补扫未在本实例执行',
current_port: d.current_port || '未知',
automation_port: d.automation_port || 8977,
suggestion: '现在已加入 8976 -> 8977 自动转发;重启服务后再试一次'
})
}
if (d.error || d.detail || d.route) {
return formatJsonBlock({
error: d.error || '',
detail: d.detail || '',
route: d.route || '',
proxied_to_automation: !!d.proxied_to_automation
})
}
return formatJsonBlock(d)
}
const openBalanceTraceDialog = () => {
const modalOverlay = document.getElementById('modal-overlay')
const modalTitle = document.getElementById('modal-title')
const modalBody = document.getElementById('modal-body')
const modalFooter = document.getElementById('modal-footer')
balanceScanTrace = []
modalTitle.textContent = '补扫详情'
modalBody.innerHTML = '<div id="balance-trace-body" class="balance-trace-body"></div>'
modalFooter.innerHTML = '<button class="modal-btn secondary" onclick="copyBalanceTrace()">复制</button><button class="modal-btn secondary" onclick="closeModal()">关闭</button>'
modalOverlay.style.display = 'flex'
}
const renderBalanceTraceDialog = () => {
const el = document.getElementById('balance-trace-body')
if (!el) return
el.innerHTML = balanceScanTrace.map(item => `
<div class="balance-trace-item ${escapeHtml(item.level || 'info')}">
<div class="balance-trace-head">
<strong>${escapeHtml(item.title || '')}</strong>
<span>${escapeHtml(item.time || '')}</span>
</div>
${item.detail ? `<pre class="balance-detail-pre">${escapeHtml(item.detail)}</pre>` : ''}
</div>
`).join('')
}
const appendBalanceTrace = (title, detail, level = 'info') => {
const item = { title, detail: detail ? String(detail) : '', level, time: fmtBeijingDateTime(new Date().toISOString()) }
balanceScanTrace.push(item)
renderBalanceTraceDialog()
}
async function copyBalanceTrace() {
try {
await navigator.clipboard.writeText(balanceScanTrace.map(item => `[${item.time}] ${item.title}\n${item.detail || ''}`).join('\n\n'))
showStatus('补扫详情已复制')
hideStatusSoon()
} catch {}
}
const escapeHtml = value => String(value == null ? '' : value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
const renderBalanceStatus = data => {
balanceStatusCache = data || null
const box = document.getElementById('balance-status-cards')
if (!box) return
const status = data || {}
const listener = status.listener || {}
const summary = status.summary || {}
const config = status.config || {}
const healthyClass = listener.healthy ? 'success' : 'danger'
const healthyText = listener.healthy ? '监听正常' : '监听待检查'
box.innerHTML = `
<div class="balance-card">
<div class="balance-card-label">监听状态</div>
<div class="balance-card-value ${healthyClass}">${healthyText}</div>
<div class="balance-card-note">端口 ${config.current_port || '-'} / 自动端 ${config.automation_port || '-'}</div>
</div>
<div class="balance-card">
<div class="balance-card-label">累计告警</div>
<div class="balance-card-value">${summary.total || 0}</div>
<div class="balance-card-note">高风险 ${summary.high_risk_count || 0}</div>
</div>
<div class="balance-card">
<div class="balance-card-label">日历同步</div>
<div class="balance-card-value ${(summary.calendar_error_count || 0) > 0 ? 'danger' : 'success'}">${summary.calendar_ok_count || 0}</div>
<div class="balance-card-note">成功 ${summary.calendar_ok_count || 0} / 失败 ${summary.calendar_error_count || 0}</div>
</div>
<div class="balance-card balance-card-wide">
<div class="balance-card-label">最后扫描</div>
<div class="balance-card-value">${listener.last_scan_finished_at ? fmtBeijingDateTime(listener.last_scan_finished_at) : '暂无'}</div>
<div class="balance-card-note">${listener.last_error ? `异常: ${escapeHtml(listener.last_error)}` : `邮箱: ${escapeHtml(config.mailbox || '')} | 周报失败 ${summary.weekly_error_count || 0}`}</div>
</div>
`
}
const renderBalancePagination = data => {
const box = document.getElementById('balance-pagination')
if (!box) return
const total = Number(data && data.total || 0)
const page = Number(data && data.page || 1)
const pageSize = Number(data && data.page_size || balanceListState.pageSize || 10)
const pageCount = Math.max(1, Math.ceil(total / pageSize))
box.innerHTML = `
<button class="modal-btn secondary" ${page <= 1 ? 'disabled' : ''} onclick="changeBalancePage(-1)">上一页</button>
<span class="balance-page-text">第 ${page} / ${pageCount} 页,共 ${total} 条</span>
<button class="modal-btn secondary" ${page >= pageCount ? 'disabled' : ''} onclick="changeBalancePage(1)">下一页</button>
`
}
const renderBalanceAlerts = data => {
const body = document.getElementById('balance-body')
if (!body) return
const rows = data && data.rows ? data.rows : []
body.innerHTML = ''
rows.forEach(row => {
const tr = document.createElement('tr')
const tdTime = document.createElement('td')
tdTime.textContent = fmtBeijingDateTime(row.received_at || '')
const tdRisk = document.createElement('td')
const riskBadge = document.createElement('span')
const riskMeta = getRiskMeta(row.risk_level)
riskBadge.className = `badge ${riskMeta.className}`
riskBadge.textContent = riskMeta.text
tdRisk.appendChild(riskBadge)
const tdSubject = createExpandableCell(row.subject || '', 'balance-subject')
const tdBody = createExpandableCell(row.text_body || '', 'balance-body-text')
const tdSender = document.createElement('td')
tdSender.className = 'balance-sender-cell'
tdSender.textContent = row.sender || ''
tdSender.title = row.sender || ''
const tdCalendar = document.createElement('td')
tdCalendar.className = 'balance-sync-cell'
tdCalendar.appendChild(createStatusBadge(row.calendar_sync_status, row.calendar_sync_error))
const tdWeekly = document.createElement('td')
tdWeekly.className = 'balance-sync-cell'
tdWeekly.appendChild(createStatusBadge(row.weekly_sync_status, row.weekly_sync_error))
const tdActions = document.createElement('td')
tdActions.className = 'col-actions'
const view = document.createElement('button')
view.className = 'action-btn view'
view.textContent = '👁'
view.title = '查看'
view.addEventListener('click', () => openBalanceDetail(row.id))
const retryCalendar = document.createElement('button')
retryCalendar.className = 'action-btn'
retryCalendar.textContent = '📅'
retryCalendar.title = '重推日历'
retryCalendar.addEventListener('click', () => retryBalanceCalendar(row.id, retryCalendar))
const retryWeekly = document.createElement('button')
retryWeekly.className = 'action-btn'
retryWeekly.textContent = '📰'
retryWeekly.title = '重推周报预留'
retryWeekly.addEventListener('click', () => retryBalanceWeekly(row.id, retryWeekly))
tdActions.appendChild(view)
tdActions.appendChild(retryCalendar)
tdActions.appendChild(retryWeekly)
tr.appendChild(tdTime)
tr.appendChild(tdRisk)
tr.appendChild(tdSubject)
tr.appendChild(tdBody)
tr.appendChild(tdSender)
tr.appendChild(tdCalendar)
tr.appendChild(tdWeekly)
tr.appendChild(tdActions)
body.appendChild(tr)
})
renderBalancePagination(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)
}
const loadBalanceAlerts = async (showLoading = true) => {
if (showLoading) showStatus('正在查询欠费通知...')
try {
balanceListState.keyword = (document.getElementById('balance-keyword') && document.getElementById('balance-keyword').value || '').trim()
const params = new URLSearchParams({
page: String(balanceListState.page),
page_size: String(balanceListState.pageSize),
keyword: balanceListState.keyword
})
const [listRes] = await Promise.all([
fetch(`/api/cloud/balance_alerts?${params.toString()}`, { credentials: 'include', cache: 'no-store' }),
loadBalanceStatus()
])
const data = await listRes.json()
if (data.ok) renderBalanceAlerts(data)
} catch (e) {
console.error(e)
showStatus('欠费通知加载失败: ' + (e.message || e))
}
hideStatusSoon()
}
const startBalanceAutoRefresh = () => {
if (balanceAutoRefreshTimer) return
balanceAutoRefreshTimer = setInterval(() => {
if (currentTab === 'balance') loadBalanceAlerts(false)
}, 5000)
}
const stopBalanceAutoRefresh = () => {
if (!balanceAutoRefreshTimer) return
clearInterval(balanceAutoRefreshTimer)
balanceAutoRefreshTimer = null
}
async function triggerBalanceScan(buttonEl) {
const reset = setButtonLoading(buttonEl, '补扫中...')
openBalanceTraceDialog()
appendBalanceTrace('开始补扫', '触发动作:点击“立即补扫”按钮')
try {
const requestMeta = {
url: '/api/cloud/balance_alerts/scan',
method: 'POST',
current_time: fmtBeijingDateTime(new Date().toISOString())
}
appendBalanceTrace('准备请求后端', formatJsonBlock(requestMeta))
const r = await fetch('/api/cloud/balance_alerts/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
})
appendBalanceTrace('收到后端响应', formatJsonBlock({ status: r.status, ok: r.ok, statusText: r.statusText }))
const rawText = await r.text()
appendBalanceTrace('响应原文', rawText || '(空)')
let d = {}
try {
d = rawText ? JSON.parse(rawText) : {}
} catch (parseErr) {
appendBalanceTrace('响应解析失败', String(parseErr && parseErr.message ? parseErr.message : parseErr), 'error')
throw parseErr
}
appendBalanceTrace('响应JSON', explainBalanceScanResponse(d))
if (d.message === 'scan_in_progress') {
appendBalanceTrace('补扫任务状态', explainBalanceScanResponse(d), 'info')
showStatus('补扫任务已在 8977 执行中,请稍后刷新列表')
await loadBalanceStatus()
return
}
if (d.message === 'scan_started') {
appendBalanceTrace('补扫任务已提交', explainBalanceScanResponse(d), 'success')
showStatus('补扫任务已提交到 8977,稍后将自动刷新状态')
await loadBalanceStatus()
return
}
if (!r.ok || !d.ok) {
appendBalanceTrace('补扫失败', formatJsonBlock({
error: d.error || '补扫失败',
reason: d.reason || '',
detail: d.detail || '',
current_port: d.current_port || '',
automation_port: d.automation_port || '',
fetched: d.fetched,
matched: d.matched,
inserted: d.inserted,
updated: d.updated,
last_uid: d.last_uid
}), 'error')
throw new Error(d.error || '补扫失败')
}
appendBalanceTrace('补扫完成', formatJsonBlock({
fetched: d.fetched || 0,
matched: d.matched || 0,
inserted: d.inserted || 0,
updated: d.updated || 0,
last_uid: d.last_uid || 0
}), 'success')
showStatus(`补扫完成,匹配 ${d.matched || 0} 封`)
await loadBalanceAlerts(false)
} catch (e) {
appendBalanceTrace('异常中断', String(e && e.message ? e.message : e), 'error')
showStatus('补扫失败: ' + (e.message || e))
} finally {
reset()
if (!balanceScanTrace.some(item => item.level === 'error')) hideStatusSoon()
}
}
async function retryBalanceCalendar(id, buttonEl) {
const reset = setButtonLoading(buttonEl, '重推中...')
try {
const r = await fetch(`/api/cloud/balance_alerts/${id}/retry_calendar`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
})
const d = await r.json()
if (!r.ok || !d.ok || d.ok === false) throw new Error(d.error || '日历重推失败')
showStatus('日历重推完成')
await loadBalanceAlerts(false)
} catch (e) {
showStatus('日历重推失败: ' + (e.message || e))
} finally {
reset()
hideStatusSoon()
}
}
async function retryBalanceWeekly(id, buttonEl) {
const reset = setButtonLoading(buttonEl, '预留推送中...')
try {
const r = await fetch(`/api/cloud/balance_alerts/${id}/retry_weekly`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include'
})
const d = await r.json()
if (!r.ok || !d.ok || d.ok === false) throw new Error(d.error || '周报预留推送失败')
showStatus(d.skipped ? '周报预留当前未开启' : '周报预留推送完成')
await loadBalanceAlerts(false)
} catch (e) {
showStatus('周报预留推送失败: ' + (e.message || e))
} finally {
reset()
hideStatusSoon()
}
}
async function openBalanceDetail(id) {
const modalOverlay = document.getElementById('modal-overlay')
const modalTitle = document.getElementById('modal-title')
const modalBody = document.getElementById('modal-body')
const modalFooter = document.getElementById('modal-footer')
modalTitle.textContent = '查看欠费通知'
modalBody.innerHTML = '<div class="balance-loading">正在加载详情...</div>'
modalFooter.innerHTML = '<button class="modal-btn secondary" onclick="closeModal()">关闭</button>'
modalOverlay.style.display = 'flex'
try {
const r = await fetch(`/api/cloud/balance_alerts/${id}`, { credentials: 'include', cache: 'no-store' })
const d = await r.json()
if (!r.ok || !d.ok || !d.row) throw new Error(d.error || '加载失败')
const row = d.row
modalBody.innerHTML = `
<div class="balance-detail">
<div class="balance-detail-row"><span>接收时间</span><strong>${escapeHtml(fmtBeijingDateTime(row.received_at || ''))}(北京时间)</strong></div>
<div class="balance-detail-row"><span>发件人</span><strong>${escapeHtml(row.sender || '')}</strong></div>
<div class="balance-detail-row"><span>风险级别</span><strong>${escapeHtml(row.risk_level || '')}</strong></div>
<div class="balance-detail-row"><span>标题</span><strong>${escapeHtml(row.subject || '')}</strong></div>
<div class="balance-detail-row"><span>匹配原因</span><strong>${escapeHtml(row.match_reason || '')}</strong></div>
<div class="balance-detail-row"><span>日历主键</span><strong>${escapeHtml(row.calendar_event_id || '')}</strong></div>
<div class="balance-detail-row"><span>周报主键</span><strong>${escapeHtml(row.weekly_external_id || '')}</strong></div>
<div class="balance-detail-row"><span>日历同步</span><strong>${escapeHtml(fmtBalanceStatus(row.calendar_sync_status, row.calendar_sync_error))}</strong></div>
<div class="balance-detail-row"><span>周报预留</span><strong>${escapeHtml(fmtBalanceStatus(row.weekly_sync_status, row.weekly_sync_error))}</strong></div>
<div class="balance-detail-title">正文</div>
<pre class="balance-detail-pre">${escapeHtml(row.text_body || '')}</pre>
<div class="balance-detail-title">头信息</div>
<pre class="balance-detail-pre">${escapeHtml(row.raw_headers || '')}</pre>
</div>
`
} catch (e) {
modalBody.innerHTML = `<div class="balance-loading">详情加载失败: ${escapeHtml(e.message || e)}</div>`
}
}
function changeBalancePage(delta) {
balanceListState.page = Math.max(1, balanceListState.page + delta)
loadBalanceAlerts(false)
}
// 存储上传状态
const uploadStates = new Map();
const uploadNoteAttachments = async (noteId) => {
const fEl = document.getElementById('nt-files');
if (!fEl || !fEl.files || fEl.files.length === 0) return;
showStatus('正在上传附件...');
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
for (let i = 0; i < fEl.files.length; i++) {
const file = fEl.files[i];
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const uploadId = `${Date.now()}_${file.name}`;
// 检查是否有之前的上传状态
const fileKey = `${noteId}_${file.name}`;
let startChunk = 0;
if (uploadStates.has(fileKey)) {
const state = uploadStates.get(fileKey);
startChunk = state.completedChunks;
showStatus(`恢复上传 ${file.name} (从第 ${startChunk + 1}/${totalChunks} 块开始)...`);
} else {
// 初始化上传状态
uploadStates.set(fileKey, {
uploadId,
completedChunks: 0,
totalChunks,
fileSize: file.size,
fileName: file.name
});
}
try {
if (totalChunks <= 1) {
const buf = await file.arrayBuffer();
const r = await fetch(`/api/cloud/notes/attachment?note_id=${noteId}&filename=${encodeURIComponent(file.name)}&upload_id=${uploadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
credentials: 'include',
body: buf
});
if (r.ok) {
uploadStates.set(fileKey, {
...uploadStates.get(fileKey),
completedChunks: 1
});
}
} else {
for (let chunkIdx = startChunk; chunkIdx < totalChunks; chunkIdx++) {
const start = chunkIdx * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const slice = file.slice(start, end);
const buf = await slice.arrayBuffer();
showStatus(`正在上传附件 ${file.name} (${chunkIdx + 1}/${totalChunks})...`);
const r = await fetch(`/api/cloud/notes/attachment?note_id=${noteId}&filename=${encodeURIComponent(file.name)}&chunk_idx=${chunkIdx}&total_chunks=${totalChunks}&upload_id=${uploadId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
credentials: 'include',
body: buf
});
if (!r.ok) {
// 上传失败,保存当前状态以便恢复
throw new Error(`Chunk ${chunkIdx} failed`);
}
// 更新上传状态
const currentState = uploadStates.get(fileKey);
uploadStates.set(fileKey, {
...currentState,
completedChunks: chunkIdx + 1
});
}
}
// 上传完成,清除状态
uploadStates.delete(fileKey);
showStatus(`附件 ${file.name} 上传成功`);
} catch (e) {
console.error('Upload failed for', file.name, e);
showStatus(`附件 ${file.name} 上传失败: ${e.message}`);
// 保存错误信息到状态
const currentState = uploadStates.get(fileKey);
if (currentState) {
uploadStates.set(fileKey, {
...currentState,
error: e.message,
lastErrorTime: Date.now()
});
}
}
}
fEl.value = '';
};
// 恢复上传功能
const resumeUploads = async (noteId) => {
for (const [fileKey, state] of uploadStates.entries()) {
if (fileKey.startsWith(`${noteId}_`)) {
showStatus(`发现未完成的上传: ${state.fileName}`);
// 创建虚拟文件输入元素来恢复上传
const tempInput = document.createElement('input');
tempInput.type = 'file';
tempInput.id = 'temp-resume-file';
// 这里需要用户重新选择文件来恢复上传
// 在实际应用中,可能需要更复杂的恢复机制
showStatus(`请重新选择文件 ${state.fileName} 来恢复上传`);
}
}
};
const addNote = async () => {
const subject_type = document.getElementById('nt-type').value;
const subject_id = parseInt(document.getElementById('nt-subject').value || '0', 10);
const issue_text = document.getElementById('nt-issue').value;
const solution_text = document.getElementById('nt-solution').value;
if (!subject_id) {
showStatus('请选择主体');
hideStatusSoon();
return;
}
const r = await fetch('/api/cloud/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ subject_type, subject_id, issue_text, solution_text })
});
const d = await r.json();
if (d.ok && d.id) {
await uploadNoteAttachments(d.id);
showStatus('新增成功');
hideStatusSoon();
await loadNotes(true);
document.getElementById('nt-issue').value = '';
document.getElementById('nt-solution').value = '';
document.getElementById('nt-files').value = '';
} else {
showStatus('新增失败: ' + (d.error || '未知错误'));
}
};
const saveNote = async () => {
if (!editingNoteId) {
showStatus('请先在列表中点击修改');
hideStatusSoon();
return;
}
const subject_type = document.getElementById('nt-type').value;
const subject_id = parseInt(document.getElementById('nt-subject').value || '0', 10);
const issue_text = document.getElementById('nt-issue').value;
const solution_text = document.getElementById('nt-solution').value;
if (!subject_id) {
showStatus('请选择主体');
hideStatusSoon();
return;
}
await fetch(`/api/cloud/notes/${editingNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ id: editingNoteId, subject_type, subject_id, issue_text, solution_text })
});
await uploadNoteAttachments(editingNoteId);
editingNoteId = null;
showStatus('保存成功');
hideStatusSoon();
await loadNotes();
document.getElementById('nt-issue').value = '';
document.getElementById('nt-solution').value = '';
document.getElementById('nt-files').value = '';
};
const initCloud = async () => {
try {
// 立即初始化标签页状态
const activeTab=document.querySelector('.tab.active');
const name=activeTab?activeTab.id.replace('tab-',''):'ops';
switchTab(name);
loadFlags()
document.getElementById('tab-ops').addEventListener('click', ()=>switchTab('ops'))
document.getElementById('tab-ecs').addEventListener('click', ()=>switchTab('ecs'))
document.getElementById('tab-vas').addEventListener('click', ()=>switchTab('vas'))
document.getElementById('tab-renew').addEventListener('click', ()=>switchTab('renew'))
document.getElementById('tab-notes').addEventListener('click', ()=>switchTab('notes'))
document.getElementById('tab-balance').addEventListener('click', ()=>switchTab('balance'))
const balanceScanBtn = document.getElementById('balance-btn-scan'); if (balanceScanBtn) balanceScanBtn.addEventListener('click', () => triggerBalanceScan(balanceScanBtn))
const balanceRefreshBtn = document.getElementById('balance-btn-refresh'); if (balanceRefreshBtn) balanceRefreshBtn.addEventListener('click', () => loadBalanceAlerts(true))
const balanceKeyword = document.getElementById('balance-keyword'); if (balanceKeyword) balanceKeyword.addEventListener('keydown', e => { if (e.key === 'Enter') { balanceListState.page = 1; loadBalanceAlerts(true) } })
const bcp=document.getElementById('cd-btn-copy'); if (bcp) bcp.addEventListener('click', async ()=>{ try { await navigator.clipboard.writeText(out().textContent||'') } catch {} })
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();
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 {}
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCloud)
} else {
initCloud()
}
// 模态框功能
let currentModalType = '';
let currentModalAction = '';
let currentModalId = null;
// 初始化模态框下拉选项
function renderSubjectOptions(prefix, subType, selectedValue = null) {
const subjectSelect = document.getElementById(`${prefix}-subject`);
if (!subjectSelect) return;
subjectSelect.innerHTML = '<option value="">请选择主体</option>';
let list = [];
if (subType === 'ecs') list = ecsCache || [];
else if (subType === 'vas') list = vasCache || [];
list.forEach(r => {
const op = document.createElement('option');
op.value = String(r.id);
const opName = opMap[r.operator_id] || '';
const name = subType === 'ecs' ? r.server_name : r.name;
op.textContent = `${opName ? `[${opName}] ` : ''}${name || `#${r.id}`}`;
if (selectedValue && String(r.id) === String(selectedValue)) {
op.selected = true;
}
subjectSelect.appendChild(op);
});
}
function initModalSelects(type) {
if (type === 'ecs' || type === 'vas') {
const el = document.getElementById(`modal-${type}-op`);
if (el) {
el.innerHTML = '<option value="">请选择运营商</option>';
(opsCache || []).forEach(r => {
const op = document.createElement('option');
op.value = String(r.id);
op.textContent = r.name || (`#${r.id}`);
el.appendChild(op);
});
}
} else if (type === 'renew' || type === 'notes') {
const prefix = type === 'renew' ? 'modal-rn' : 'modal-nt';
const typeSelect = document.getElementById(`${prefix}-type`);
if (typeSelect) {
typeSelect.addEventListener('change', () => {
renderSubjectOptions(prefix, typeSelect.value);
});
// Initial population
renderSubjectOptions(prefix, typeSelect.value);
}
}
}
function openModal(type, action, id = null) {
currentModalType = type;
currentModalAction = action;
currentModalId = id;
if (type === 'renew' && action === 'add') currentRenewInvoicePath = '';
const modalOverlay = document.getElementById('modal-overlay');
const modalTitle = document.getElementById('modal-title');
const modalBody = document.getElementById('modal-body');
const modalFooter = document.getElementById('modal-footer');
// 设置标题
const titles = {
'ops': '运营商',
'ecs': 'ECS服务器',
'vas': '增值服务',
'renew': '续费记录',
'notes': '学习笔记'
};
const actionText = action === 'add' ? '新增' : action === 'edit' ? '修改' : '查看';
modalTitle.textContent = `${actionText}${titles[type]}`;
// 生成表单内容
modalBody.innerHTML = generateModalForm(type, action, id);
// 生成底部按钮
modalFooter.innerHTML = generateModalButtons(type, action);
// 初始化下拉框
initModalSelects(type);
// 显示模态框
modalOverlay.style.display = 'flex';
// 如果是编辑或查看模式,加载数据
if (action !== 'add' && id) {
loadModalData(type, id);
}
}
function closeModal() {
const modalOverlay = document.getElementById('modal-overlay');
modalOverlay.style.display = 'none';
currentModalType = '';
currentModalAction = '';
currentModalId = null;
currentRenewInvoicePath = '';
}
function generateModalForm(type, action, id) {
const isView = action === 'view';
const readonly = isView ? 'readonly' : '';
const disabled = isView ? 'disabled' : '';
switch (type) {
case 'ops':
return `
<form class="modal-form" onsubmit="return handleModalSubmit(event)">
<div class="modal-form-row">
<label class="modal-label">名称:</label>
<input type="text" class="modal-input" id="modal-op-name" placeholder="运营商名称" ${readonly} required>
</div>
<div class="modal-form-row">
<label class="modal-label">登录账号:</label>
<input type="text" class="modal-input" id="modal-op-account" placeholder="登录账号" ${readonly}>
</div>
<div class="modal-form-row">
<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="tel" class="modal-input" id="modal-op-mobile" placeholder="关联手机" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">关联人:</label>
<input type="text" class="modal-input" id="modal-op-contact" placeholder="关联人" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">备注:</label>
<textarea class="modal-textarea large" id="modal-op-remark" placeholder="备注信息" ${readonly}></textarea>
</div>
<div class="modal-form-row">
<label class="modal-label"></label>
<label><input type="checkbox" id="modal-op-verified" ${disabled} ${readonly}> 公司认证</label>
</div>
</form>
`;
case 'ecs':
return `
<form class="modal-form" onsubmit="return handleModalSubmit(event)">
<div class="modal-form-row">
<label class="modal-label">运营商:</label>
<select class="modal-select" id="modal-ecs-op" ${disabled} required>
<option value="">请选择运营商</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">服务器名称:</label>
<input type="text" class="modal-input" id="modal-ecs-name" placeholder="服务器名称" ${readonly} required>
</div>
<div class="modal-form-row">
<label class="modal-label">所在地区:</label>
<input type="text" class="modal-input" id="modal-ecs-region" placeholder="所在地区" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">所在区域:</label>
<input type="text" class="modal-input" id="modal-ecs-zone" placeholder="所在区域" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">远程桌面IP:</label>
<input type="text" class="modal-input" id="modal-ecs-ip" placeholder="远程桌面IP" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">远程账号:</label>
<input type="text" class="modal-input" id="modal-ecs-user" placeholder="远程账号" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">远程密码:</label>
<input type="text" class="modal-input" id="modal-ecs-pass" placeholder="远程密码" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">服务器配置:</label>
<textarea class="modal-textarea large" id="modal-ecs-spec" placeholder="服务器配置" ${readonly}></textarea>
</div>
<div class="modal-form-row">
<label class="modal-label">开放端口:</label>
<textarea class="modal-textarea" id="modal-ecs-ports" placeholder="开放端口范围" ${readonly}></textarea>
</div>
</form>
`;
case 'vas':
return `
<form class="modal-form" onsubmit="return handleModalSubmit(event)">
<div class="modal-form-row">
<label class="modal-label">运营商:</label>
<select class="modal-select" id="modal-vas-op" ${disabled} required>
<option value="">请选择运营商</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">服务名称:</label>
<input type="text" class="modal-input" id="modal-vas-name" placeholder="增值服务名称" ${readonly} required>
</div>
<div class="modal-form-row">
<label class="modal-label">服务配置:</label>
<textarea class="modal-textarea large" id="modal-vas-config" placeholder="增值服务配置" ${readonly}></textarea>
</div>
<div class="modal-form-row">
<label class="modal-label">Access信息:</label>
<textarea class="modal-textarea large" id="modal-vas-access" placeholder="Access信息" ${readonly}></textarea>
</div>
</form>
`;
case 'renew':
return `
<form class="modal-form" onsubmit="return handleModalSubmit(event)">
<div class="modal-form-row">
<label class="modal-label">类型:</label>
<select class="modal-select" id="modal-rn-type" ${disabled} required>
<option value="ecs">ECS</option>
<option value="vas">增值服务</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">主体:</label>
<select class="modal-select" id="modal-rn-subject" ${disabled} required>
<option value="">请选择主体</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">缴费日期:</label>
<input type="date" class="modal-input" id="modal-rn-paid" ${readonly} required>
</div>
<div class="modal-form-row">
<label class="modal-label">缴费金额:</label>
<input type="number" step="0.01" class="modal-input" id="modal-rn-amount" placeholder="缴费金额" ${readonly} required>
</div>
<div class="modal-form-row">
<label class="modal-label">发票文件:</label>
<input type="file" class="modal-input" id="modal-rn-invoice-file" ${disabled}>
</div>
<div class="modal-form-row">
<label class="modal-label">公司缴费人:</label>
<input type="text" class="modal-input" id="modal-rn-payer" placeholder="公司缴费人" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">到期日期:</label>
<input type="date" class="modal-input" id="modal-rn-expire" ${readonly}>
</div>
<div class="modal-form-row">
<label class="modal-label">下期费用:</label>
<input type="number" step="0.01" class="modal-input" id="modal-rn-next" placeholder="下期预计费用" ${readonly}>
</div>
</form>
`;
case 'notes':
return `
<form class="modal-form" onsubmit="return handleModalSubmit(event)">
<div class="modal-form-row">
<label class="modal-label">类型:</label>
<select class="modal-select" id="modal-nt-type" ${disabled} required>
<option value="ecs">ECS</option>
<option value="vas">增值服务</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">主体:</label>
<select class="modal-select" id="modal-nt-subject" ${disabled} required>
<option value="">请选择主体</option>
</select>
</div>
<div class="modal-form-row">
<label class="modal-label">问题和报错:</label>
<textarea class="modal-textarea large small-text-area" id="modal-nt-issue" placeholder="问题和报错(多行文本)" ${readonly}></textarea>
</div>
<div class="modal-form-row">
<label class="modal-label">解决方案:</label>
<textarea class="modal-textarea large small-text-area" id="modal-nt-solution" placeholder="解决方案(多行文本)" ${readonly}></textarea>
</div>
<div class="modal-form-row">
<label class="modal-label">附件:</label>
<input type="file" class="modal-input" id="modal-nt-files" multiple ${disabled}>
</div>
</form>
`;
default:
return '<p>未知类型</p>';
}
}
function generateModalButtons(type, action) {
const isView = action === 'view';
if (isView) {
return `
<button class="modal-btn secondary" onclick="closeModal()">关闭</button>
`;
}
return `
<button type="button" class="modal-btn secondary" onclick="closeModal()">取消</button>
<button type="submit" class="modal-btn primary" onclick="handleModalSubmit(event)">${action === 'add' ? '新增' : '保存'}</button>
`;
}
async function loadModalData(type, id) {
try {
showStatus('正在加载数据...');
let row = null;
if (type === 'ops') {
const r = await fetch('/api/cloud/operators', { credentials: 'include' });
const d = await r.json();
if (d.ok) row = (d.rows || []).find(x => x.id === id);
} else if (type === 'ecs') {
row = (ecsCache || []).find(x => x.id === id) || null;
if (!row) {
const r = await fetch('/api/cloud/ecs', { credentials: 'include' });
const d = await r.json();
if (d.ok) { ecsCache = d.rows || []; row = ecsCache.find(x => x.id === id) }
}
} else if (type === 'vas') {
row = (vasCache || []).find(x => x.id === id) || null;
if (!row) {
const r = await fetch('/api/cloud/vas', { credentials: 'include' });
const d = await r.json();
if (d.ok) { vasCache = d.rows || []; row = vasCache.find(x => x.id === id) }
}
} else if (type === 'renew') {
const r = await fetch('/api/cloud/renew', { credentials: 'include' });
const d = await r.json();
if (d.ok) row = (d.rows || []).find(x => x.id === id);
} else if (type === 'notes') {
const r = await fetch('/api/cloud/notes', { credentials: 'include' });
const d = await r.json();
if (d.ok) row = (d.rows || []).find(x => x.id === id);
}
if (row) fillModalForm(type, row);
hideStatusSoon();
} catch (error) {
showStatus('加载数据失败');
hideStatusSoon();
}
}
function fillModalForm(type, data) {
switch (type) {
case 'ops':
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-mobile').value = data.mobile || '';
document.getElementById('modal-op-contact').value = data.contact_person || '';
document.getElementById('modal-op-remark').value = data.remark || '';
document.getElementById('modal-op-verified').checked = !!data.is_company_verified;
break;
case 'ecs':
document.getElementById('modal-ecs-op').value = data.operator_id || '';
document.getElementById('modal-ecs-name').value = data.server_name || '';
document.getElementById('modal-ecs-region').value = data.region || '';
document.getElementById('modal-ecs-zone').value = data.zone || '';
document.getElementById('modal-ecs-ip').value = data.rdp_ip || '';
document.getElementById('modal-ecs-user').value = data.rdp_user || '';
document.getElementById('modal-ecs-pass').value = data.rdp_password || '';
document.getElementById('modal-ecs-spec').value = data.spec_text || '';
document.getElementById('modal-ecs-ports').value = data.open_ports_text || '';
break;
case 'vas':
document.getElementById('modal-vas-op').value = data.operator_id || '';
document.getElementById('modal-vas-name').value = data.name || '';
document.getElementById('modal-vas-config').value = data.config_text || '';
document.getElementById('modal-vas-access').value = data.access_text || '';
break;
case 'renew':
currentRenewInvoicePath = data.invoice_path || '';
document.getElementById('modal-rn-type').value = data.subject_type || 'ecs';
// 手动触发联动更新,并选中对应的主体
renderSubjectOptions('modal-rn', data.subject_type || 'ecs', data.subject_id);
document.getElementById('modal-rn-paid').value = data.paid_date || '';
document.getElementById('modal-rn-amount').value = ((data.amount_cents || 0) / 100).toFixed(2);
document.getElementById('modal-rn-payer').value = data.payer_company || '';
document.getElementById('modal-rn-expire').value = data.expire_date || '';
document.getElementById('modal-rn-next').value = ((data.next_expected_amount_cents || 0) / 100).toFixed(2);
break;
case 'notes':
document.getElementById('modal-nt-type').value = data.subject_type || 'ecs';
// 手动触发联动更新,并选中对应的主体
renderSubjectOptions('modal-nt', data.subject_type || 'ecs', data.subject_id);
document.getElementById('modal-nt-issue').value = data.issue_text || '';
document.getElementById('modal-nt-solution').value = data.solution_text || '';
break;
}
}
async function handleModalSubmit(event) {
event.preventDefault();
const { type, action, id } = { type: currentModalType, action: currentModalAction, id: currentModalId };
try {
if (action === 'add') {
await handleModalAdd(type);
} else if (action === 'edit') {
await handleModalEdit(type, id);
}
closeModal();
} catch (error) {
console.error(error);
showStatus('操作失败: ' + (error && error.message ? error.message : error));
hideStatusSoon();
}
}
async function handleModalAdd(type) {
const payload = getModalFormData(type);
if (!payload) return;
let url = '';
switch (type) {
case 'ops': url = '/api/cloud/operators'; break;
case 'ecs': url = '/api/cloud/ecs'; break;
case 'vas': url = '/api/cloud/vas'; break;
case 'renew': url = '/api/cloud/renew'; break;
case 'notes': url = '/api/cloud/notes'; break;
}
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
let data = {};
try { data = await response.json(); } catch { data = {}; }
if (!response.ok || !data.ok) {
const msg = data && data.error ? data.error : ('HTTP ' + response.status);
showStatus('操作失败: ' + msg);
hideStatusSoon();
return;
}
showStatus('新增成功');
switch (type) {
case 'ops': await loadOps(); break;
case 'ecs': await loadEcs(); break;
case 'vas': await loadVas(); break;
case 'renew': await loadRenew(); break;
case 'notes': await loadNotes(false); break;
}
}
async function handleModalEdit(type, id) {
const payload = getModalFormData(type);
if (!payload) return;
payload.id = id;
let url = '';
switch (type) {
case 'ops': url = `/api/cloud/operators/${id}`; break;
case 'ecs': url = `/api/cloud/ecs/${id}`; break;
case 'vas': url = `/api/cloud/vas/${id}`; break;
case 'renew': url = `/api/cloud/renew/${id}`; break;
case 'notes': url = `/api/cloud/notes/${id}`; break;
}
const response = await fetch(url, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload)
});
let data = {};
try { data = await response.json(); } catch { data = {}; }
if (!response.ok || !data.ok) {
const msg = data && data.error ? data.error : ('HTTP ' + response.status);
showStatus('操作失败: ' + msg);
hideStatusSoon();
return;
}
showStatus('修改成功');
switch (type) {
case 'ops': await loadOps(); break;
case 'ecs': await loadEcs(); break;
case 'vas': await loadVas(); break;
case 'renew': await loadRenew(); break;
case 'notes': await loadNotes(false); break;
}
}
function getModalFormData(type) {
switch (type) {
case 'ops':
return {
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(),
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(),
is_company_verified: document.getElementById('modal-op-verified').checked ? 1 : 0
};
case 'ecs':
return {
operator_id: parseInt(document.getElementById('modal-ecs-op').value || '0', 10),
server_name: document.getElementById('modal-ecs-name').value.trim(),
region: document.getElementById('modal-ecs-region').value.trim(),
zone: document.getElementById('modal-ecs-zone').value.trim(),
rdp_ip: document.getElementById('modal-ecs-ip').value.trim(),
rdp_user: document.getElementById('modal-ecs-user').value.trim(),
rdp_password: document.getElementById('modal-ecs-pass').value.trim(),
spec_text: document.getElementById('modal-ecs-spec').value.trim(),
open_ports_text: document.getElementById('modal-ecs-ports').value.trim()
};
case 'vas':
return {
operator_id: parseInt(document.getElementById('modal-vas-op').value || '0', 10),
name: document.getElementById('modal-vas-name').value.trim(),
config_text: document.getElementById('modal-vas-config').value.trim(),
access_text: document.getElementById('modal-vas-access').value.trim()
};
case 'renew':
{
const subject_type = document.getElementById('modal-rn-type').value;
const subject_id = parseInt(document.getElementById('modal-rn-subject').value || '0', 10);
const paid_date = document.getElementById('modal-rn-paid').value;
const amountText = document.getElementById('modal-rn-amount').value || '';
const payer_company = document.getElementById('modal-rn-payer').value.trim();
const expire_date = document.getElementById('modal-rn-expire').value;
const nextText = document.getElementById('modal-rn-next').value || '';
if (!subject_id || !paid_date || !amountText) {
showStatus('请完整选择主体并填写缴费日期和金额');
hideStatusSoon();
return null;
}
const amount_cents = parseAmountToCents(amountText);
if (amount_cents === null) {
showStatus('金额格式不正确');
hideStatusSoon();
return null;
}
let next_expected_amount_cents = 0;
if (nextText) {
const n = parseAmountToCents(nextText);
if (n === null) {
showStatus('下期费用金额格式不正确');
hideStatusSoon();
return null;
}
next_expected_amount_cents = n;
}
const invoice_path = currentRenewInvoicePath || '';
return { subject_type, subject_id, paid_date, amount_cents, payer_company, expire_date, next_expected_amount_cents, invoice_path };
}
case 'notes':
return {
subject_type: document.getElementById('modal-nt-type').value,
subject_id: parseInt(document.getElementById('modal-nt-subject').value || '0', 10),
issue_text: document.getElementById('modal-nt-issue').value.trim(),
solution_text: document.getElementById('modal-nt-solution').value.trim()
};
}
}
// 字体大小切换功能
let gridFontMode = localStorage.getItem('cloud_grid_font') || 'large';
function applyGridFontMode() {
const tables = document.querySelectorAll('#cloud-content .grid');
tables.forEach(t => {
t.classList.remove('small','large');
t.classList.add(gridFontMode === 'large' ? 'large' : 'small');
});
const btn = document.getElementById('font-size-toggle');
if (btn) {
btn.textContent = gridFontMode === 'large' ? '🆎' : '🔠';
btn.setAttribute('aria-label', gridFontMode === 'large' ? '切换为小字体' : '切换为大字体');
btn.title = gridFontMode === 'large' ? '切换为小字体' : '切换为大字体';
}
}
const fontBtn = document.getElementById('font-size-toggle');
if (fontBtn) {
fontBtn.addEventListener('click', function() {
gridFontMode = gridFontMode === 'large' ? 'small' : 'large';
localStorage.setItem('cloud_grid_font', gridFontMode);
applyGridFontMode();
});
}
// 点击模态框外部关闭
const modalOverlay = document.getElementById('modal-overlay');
if (modalOverlay) {
modalOverlay.addEventListener('click', function(event) {
if (event.target === modalOverlay) {
closeModal();
}
});
}