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 = `

未完成的上传

${pendingUploads.map(upload => `
${upload.fileName} ${upload.progress}%
${upload.error ? `
错误: ${upload.error}
` : ''}
`).join('')}
`; // 添加到页面 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 = '
' modalFooter.innerHTML = '' modalOverlay.style.display = 'flex' } const renderBalanceTraceDialog = () => { const el = document.getElementById('balance-trace-body') if (!el) return el.innerHTML = balanceScanTrace.map(item => `
${escapeHtml(item.title || '')} ${escapeHtml(item.time || '')}
${item.detail ? `
${escapeHtml(item.detail)}
` : ''}
`).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, '"') 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 = `
监听状态
${healthyText}
端口 ${config.current_port || '-'} / 自动端 ${config.automation_port || '-'}
累计告警
${summary.total || 0}
高风险 ${summary.high_risk_count || 0}
日历同步
${summary.calendar_ok_count || 0}
成功 ${summary.calendar_ok_count || 0} / 失败 ${summary.calendar_error_count || 0}
最后扫描
${listener.last_scan_finished_at ? fmtBeijingDateTime(listener.last_scan_finished_at) : '暂无'}
${listener.last_error ? `异常: ${escapeHtml(listener.last_error)}` : `邮箱: ${escapeHtml(config.mailbox || '')} | 周报失败 ${summary.weekly_error_count || 0}`}
` } 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 = ` 第 ${page} / ${pageCount} 页,共 ${total} 条 ` } 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 = '
正在加载详情...
' modalFooter.innerHTML = '' 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 = `
接收时间${escapeHtml(fmtBeijingDateTime(row.received_at || ''))}(北京时间)
发件人${escapeHtml(row.sender || '')}
风险级别${escapeHtml(row.risk_level || '')}
标题${escapeHtml(row.subject || '')}
匹配原因${escapeHtml(row.match_reason || '')}
日历主键${escapeHtml(row.calendar_event_id || '')}
周报主键${escapeHtml(row.weekly_external_id || '')}
日历同步${escapeHtml(fmtBalanceStatus(row.calendar_sync_status, row.calendar_sync_error))}
周报预留${escapeHtml(fmtBalanceStatus(row.weekly_sync_status, row.weekly_sync_error))}
正文
${escapeHtml(row.text_body || '')}
头信息
${escapeHtml(row.raw_headers || '')}
` } catch (e) { modalBody.innerHTML = `
详情加载失败: ${escapeHtml(e.message || e)}
` } } 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 = ''; 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 = ''; (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 ` `; case 'ecs': return ` `; case 'vas': return ` `; case 'renew': return ` `; case 'notes': return ` `; default: return '

未知类型

'; } } function generateModalButtons(type, action) { const isView = action === 'view'; if (isView) { return ` `; } return ` `; } 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(); } }); }