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 = `
${escapeHtml(item.detail)}` : ''}
${escapeHtml(row.text_body || '')}
${escapeHtml(row.raw_headers || '')}
未知类型
'; } } 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(); } }); }