1678 lines
85 KiB
JavaScript
1678 lines
85 KiB
JavaScript
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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
|
||
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();
|
||
}
|
||
});
|
||
}
|