// 简易调试输出 const out = () => document.getElementById('ed-output'); const logLocal = msg => { const b = out(); if (!b) return; const t = 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) }; // 本地数据缓存 let expenseCache = []; let editingId = null; let expenseTypes = []; let expenseSubjects = []; const apiGetJson = async (url) => { const r = await fetch(url, { method: 'GET' }); let d = null; try { d = await r.json() } catch { d = { ok:false, error:'invalid_json' } } if (!r.ok || d.ok === false) throw new Error(d && d.error ? d.error : 'http_error'); return d; }; const apiSendJson = async (url, body, method) => { const r = await fetch(url, { method: method || 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}) }); let d = null; try { d = await r.json() } catch { d = { ok:false, error:'invalid_json' } } if (!r.ok || d.ok === false) throw new Error(d && d.error ? d.error : 'http_error'); return d; }; // Loading Modal Helpers const showLoading = (title) => { const overlay = document.getElementById('loading-overlay'); if (overlay) overlay.classList.add('active'); const t = document.getElementById('loading-title'); if (t) t.textContent = title || '处理中'; const logs = document.getElementById('loading-logs'); if (logs) logs.innerHTML = ''; const btn = document.getElementById('loading-close-btn'); if (btn) btn.style.display = 'none'; const spinner = document.getElementById('loading-spinner'); if (spinner) spinner.style.display = 'block'; const txt = document.getElementById('loading-text'); if (txt) { txt.textContent = '正在处理...'; txt.style.color = ''; } }; const appendLog = (msg, isError) => { const logs = document.getElementById('loading-logs'); if (!logs) return; const div = document.createElement('div'); div.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`; if (isError) div.style.color = '#ef4444'; logs.appendChild(div); logs.scrollTop = logs.scrollHeight; }; let loadingCountdown = null; const finishLoading = (success, errorMsg, autoCloseMs) => { if (loadingCountdown) { clearInterval(loadingCountdown); loadingCountdown = null; } const spinner = document.getElementById('loading-spinner'); if (spinner) spinner.style.display = 'none'; const btn = document.getElementById('loading-close-btn'); if (btn) btn.style.display = 'block'; const txt = document.getElementById('loading-text'); if (txt) { if (success) { if (autoCloseMs) { let secondsLeft = Math.ceil(autoCloseMs/1000); const updateText = () => { txt.textContent = `处理完成,${secondsLeft}s后自动关闭`; }; updateText(); loadingCountdown = setInterval(() => { secondsLeft--; if (secondsLeft > 0) { updateText(); } else { clearInterval(loadingCountdown); loadingCountdown = null; closeLoading(); } }, 1000); } else { txt.textContent = '处理完成'; } txt.style.color = 'var(--color-accent)'; } else { txt.textContent = errorMsg || '处理失败'; txt.style.color = '#ef4444'; } } }; const closeLoading = () => { if (loadingCountdown) { clearInterval(loadingCountdown); loadingCountdown = null; } const overlay = document.getElementById('loading-overlay'); if (overlay) overlay.classList.remove('active'); }; window.closeLoading = closeLoading; // 通用模态框辅助函数 window.showAlert = (msg, onClose) => { const overlay = document.getElementById('expense-modal-overlay'); const titleEl = document.getElementById('expense-modal-title'); const bodyEl = document.getElementById('expense-modal-body'); const footerEl = document.getElementById('expense-modal-footer'); titleEl.textContent = '提示'; bodyEl.innerHTML = `
${msg}
`; footerEl.innerHTML = ``; const okBtn = document.getElementById('alert-ok'); okBtn.onclick = () => { closeModal(); if (onClose) onClose(); }; // Focus logic setTimeout(() => okBtn.focus(), 50); overlay.classList.add('active'); }; window.showConfirm = (msg, onConfirm, onCancel) => { const overlay = document.getElementById('expense-modal-overlay'); const titleEl = document.getElementById('expense-modal-title'); const bodyEl = document.getElementById('expense-modal-body'); const footerEl = document.getElementById('expense-modal-footer'); titleEl.textContent = '确认'; bodyEl.innerHTML = `
${msg}
`; footerEl.innerHTML = ` `; document.getElementById('confirm-cancel').onclick = () => { closeModal(); if (onCancel) onCancel(); }; const okBtn = document.getElementById('confirm-ok'); okBtn.onclick = () => { closeModal(); if (onConfirm) onConfirm(); }; // Focus logic setTimeout(() => okBtn.focus(), 50); overlay.classList.add('active'); }; const loadConfig = async () => { try { const cfg = await apiGetJson('/api/expense/sys-config'); window.EXPENSE_SYS_CONFIG = cfg.config || {}; // Auto-update weekly endpoint if server config has one if (window.EXPENSE_SYS_CONFIG.weekly_api_url) { localStorage.setItem('expense_weekly_endpoint', window.EXPENSE_SYS_CONFIG.weekly_api_url); } } catch {} const [t, s] = await Promise.all([ apiGetJson('/api/expense/types'), apiGetJson('/api/expense/subjects') ]); expenseTypes = Array.isArray(t.rows) ? t.rows : []; expenseSubjects = Array.isArray(s.rows) ? s.rows : []; }; const nextTypeId = () => expenseTypes.reduce((m, r) => Math.max(m, r.id || 0), 0) + 1; const nextSubjectId = () => expenseSubjects.reduce((m, r) => Math.max(m, r.id || 0), 0) + 1; const createType = async (name, cost_type) => { const d = await apiSendJson('/api/expense/types', { name, cost_type }, 'POST'); return d && d.id }; const updateType = async (id, name, cost_type) => { await apiSendJson(`/api/expense/types/${id}`, { name, cost_type }, 'PUT') }; const createSubject = async (name, cost_type) => { const d = await apiSendJson('/api/expense/subjects', { name, cost_type }, 'POST'); return d && d.id }; const updateSubject = async (id, name, cost_type) => { await apiSendJson(`/api/expense/subjects/${id}`, { name, cost_type }, 'PUT') }; const LS_KEY = 'expense_rows'; const nextId = () => { const max = expenseCache.reduce((m, r)=>Math.max(m, r.id||0), 0); return max + 1 }; const fmtAmount = n => (Number(n||0)).toLocaleString('zh-CN', { minimumFractionDigits:2, maximumFractionDigits:2 }); const htmlEscape = s => String(s||'').replace(/&/g,'&').replace(//g,'>'); const optionsHtmlFromTypes = (selected=null, costFilter=null) => expenseTypes.filter(t => !costFilter || (t.cost_type||'个人费用') === costFilter).map(t => ``).join(''); const optionsHtmlFromSubjects = (selected=null, costFilter=null) => expenseSubjects.filter(s => !costFilter || (s.cost_type||'个人费用') === costFilter).map(s => ``).join(''); const loadExpenses = async () => { const d = await apiGetJson(`/api/expense/records?t=${Date.now()}`); expenseCache = Array.isArray(d.rows) ? d.rows : []; renderExpenses(expenseCache); }; const saveExpenses = () => {}; async function loadAttachments(expenseId) { const r = await apiGetJson(`/api/expense/attachments/${expenseId}?t=${Date.now()}`); return Array.isArray(r.rows) ? r.rows : []; } async function deleteAttachment(attId) { try { await apiSendJson(`/api/expense/attachments/${attId}`, {}, 'DELETE') } catch {} } // 排序逻辑 let currentSortColumn = 'date'; let currentSortDirection = 'desc'; window.handleSort = (col) => { if (currentSortColumn === col) { currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc'; } else { currentSortColumn = col; currentSortDirection = 'desc'; } renderExpenses(expenseCache); }; const updateSortHeaders = () => { const cols = ['date', 'cost_type', 'type', 'amount', 'subject', 'content', 'arrived']; cols.forEach(c => { const el = document.getElementById(`sort-icon-${c}`); if (el) { el.innerHTML = '🔻🔺'; if (currentSortColumn === c) { if (currentSortDirection === 'asc') el.innerHTML = '🔻🔺'; else el.innerHTML = '🔻🔺'; } } }); }; const renderExpenses = rows => { const body = document.getElementById('ex-body'); if (!body) return; body.innerHTML = ''; updateSortHeaders(); const filterNode = [...document.getElementsByName('filter-cost-type')].find(r=>r.checked); const filterVal = filterNode ? filterNode.value : '全部'; let displayRows = rows.filter(r => (filterVal === '全部') ? true : ((r.cost_type || '个人费用') === filterVal)); // Sort displayRows.sort((a, b) => { let valA = a[currentSortColumn]; let valB = b[currentSortColumn]; if (currentSortColumn === 'amount') { valA = Number(valA || 0); valB = Number(valB || 0); } else if (currentSortColumn === 'arrived') { valA = valA ? 1 : 0; valB = valB ? 1 : 0; } else { valA = String(valA || ''); valB = String(valB || ''); } if (valA < valB) return currentSortDirection === 'asc' ? -1 : 1; if (valA > valB) return currentSortDirection === 'asc' ? 1 : -1; return 0; }); displayRows.forEach(r => { const tr = document.createElement('tr'); const td1 = document.createElement('td'); td1.textContent = r.date || ''; const tdCost = document.createElement('td'); tdCost.textContent = r.cost_type || '个人费用'; const td2 = document.createElement('td'); td2.textContent = r.type || ''; const td3 = document.createElement('td'); td3.textContent = fmtAmount(r.amount || 0); const td4 = document.createElement('td'); td4.textContent = r.subject || ''; const td5 = document.createElement('td'); td5.style.whiteSpace = 'pre-wrap'; td5.textContent = r.content || ''; const td6 = document.createElement('td'); td6.textContent = r.arrived ? '已到账' : '未到账'; const td7 = document.createElement('td'); td7.className = 'col-actions'; const view = document.createElement('button'); view.className = 'action-btn view'; view.title='查看'; view.textContent='👁'; view.addEventListener('click', ()=>openModal('expense','view', r.id)); const edit = document.createElement('button'); edit.className = 'action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>openModal('expense','edit', r.id)); const del = document.createElement('button'); del.className = 'action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', ()=>{ showConfirm('确认删除该报销记录?', async () => { del.disabled = true; showLoading('删除记录中...'); try { appendLog(`正在删除本地记录 ID: ${r.id}...`); await apiSendJson(`/api/expense/records/${r.id}`, {}, 'DELETE'); appendLog(`本地记录删除成功`); appendLog(`正在同步删除周报...`); const rowCopy={...r}; try { await syncWeeklyEvent('delete', rowCopy, true); } catch (e) { appendLog(`⚠️ 周报同步删除失败: ${e.message}`); // Don't throw, just warn } appendLog(`刷新列表...`); try { await loadExpenses(); } catch (e) { appendLog(`⚠️ 列表刷新失败: ${e.message}`); } finishLoading(true, null, 3000); } catch (e) { appendLog(`❌ 删除失败: ${e.message}`, true); finishLoading(false, e.message); } finally { del.disabled = false } }); }); td7.appendChild(view); td7.appendChild(edit); td7.appendChild(del); tr.appendChild(td1); tr.appendChild(tdCost); tr.appendChild(td2); tr.appendChild(td3); tr.appendChild(td4); tr.appendChild(td5); tr.appendChild(td6); tr.appendChild(td7); body.appendChild(tr); }); }; // 字体大小切换 let gridFontMode = localStorage.getItem('expense_grid_font') || 'large'; function applyGridFontMode() { const tables = document.querySelectorAll('#expense-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('expense_grid_font', gridFontMode); applyGridFontMode(); }); } // 列表筛选监听 const filterRadios = document.getElementsByName('filter-cost-type'); filterRadios.forEach(r => { r.addEventListener('change', () => { renderExpenses(expenseCache); }); }); // 维护列表筛选监听 const maintRadios = document.getElementsByName('maint-cost-type'); maintRadios.forEach(r => { r.addEventListener('change', () => { renderConfigLists(); }); }); // 模态框 let currentModalType = ''; let currentModalAction = ''; let currentModalId = null; function openModal(type, action, id=null) { currentModalType = type; currentModalAction = action; currentModalId = id; const modalOverlay = document.getElementById('expense-modal-overlay'); const modalTitle = document.getElementById('expense-modal-title'); const modalBody = document.getElementById('expense-modal-body'); const modalFooter = document.getElementById('expense-modal-footer'); const actionText = action === 'add' ? '新增' : action === 'edit' ? '修改' : '查看'; const titles = { expense:'报销记录', cfgType:'报销类型', cfgSubject:'报销主体' }; modalTitle.textContent = `${actionText}${titles[type]||''}`; modalBody.innerHTML = generateModalForm(type, action); modalFooter.innerHTML = generateModalButtons(type, action); modalOverlay.classList.add('active'); if (action !== 'add' && id) fillModalForm(type, id); if (type === 'expense') { const fi = document.getElementById('modal-ex-files'); if (fi) fi.onchange = async () => { if (!currentModalId) return; await uploadSelectedFiles(currentModalId) }; const radios = document.getElementsByName('modal-ex-cost-type'); const updateSelects = () => { const node = [...document.getElementsByName('modal-ex-cost-type')].find(r=>r.checked); const ct = node ? node.value : '个人费用'; const selType = document.getElementById('modal-ex-type'); const selSubject = document.getElementById('modal-ex-subject'); if (selType) selType.innerHTML = optionsHtmlFromTypes(selType.value || null, ct); if (selSubject) selSubject.innerHTML = optionsHtmlFromSubjects(selSubject.value || null, ct); }; radios.forEach && radios.forEach(r => { r.addEventListener('change', updateSelects) }); updateSelects(); } } function closeModal() { const modalOverlay = document.getElementById('expense-modal-overlay'); modalOverlay.classList.remove('active'); currentModalType = ''; currentModalAction = ''; currentModalId = null; } function generateModalForm(type, action) { const isView = action === 'view'; const readonly = isView ? 'readonly' : ''; const disabled = isView ? 'disabled' : ''; if (type === 'expense') { const filterNode = [...document.getElementsByName('filter-cost-type')].find(r=>r.checked); let defaultCostType = filterNode ? filterNode.value : '全部'; if (defaultCostType === '全部') defaultCostType = '个人费用'; return `
`; } else if (type === 'cfgType') { const filterNode = [...document.getElementsByName('maint-cost-type')].find(r=>r.checked); let defaultCostType = filterNode ? filterNode.value : '全部'; if (defaultCostType === '全部') defaultCostType = '个人费用'; return `
`; } else if (type === 'cfgSubject') { const filterNode = [...document.getElementsByName('maint-cost-type')].find(r=>r.checked); let defaultCostType = filterNode ? filterNode.value : '全部'; if (defaultCostType === '全部') defaultCostType = '个人费用'; return `
`; } return '

未知类型

'; } function generateModalButtons(type, action) { const isView = action === 'view'; if (isView) return ``; return ` `; } function fillModalForm(type, id) { if (type === 'cfgType') { const row = expenseTypes.find(x=>x.id===id); if (!row) return; document.getElementById('modal-cfg-type-name').value = row.name || ''; const rs = document.getElementsByName('modal-cfg-type-cost'); rs.forEach && rs.forEach(r => { r.checked = (r.value === (row.cost_type||'个人费用')) }); return; } if (type === 'cfgSubject') { const row = expenseSubjects.find(x=>x.id===id); if (!row) return; document.getElementById('modal-cfg-subject-name').value = row.name || ''; const rs = document.getElementsByName('modal-cfg-subject-cost'); rs.forEach && rs.forEach(r => { r.checked = (r.value === (row.cost_type||'个人费用')) }); return; } const row = expenseCache.find(x=>x.id===id); if (!row) return; const costTypeRadios = document.getElementsByName('modal-ex-cost-type'); if (costTypeRadios) costTypeRadios.forEach(r => { r.checked = (r.value === (row.cost_type||'个人费用')) }); const selType = document.getElementById('modal-ex-type'); const selSubject = document.getElementById('modal-ex-subject'); if (selType) selType.innerHTML = optionsHtmlFromTypes(row.type || null, row.cost_type || '个人费用'); if (selSubject) selSubject.innerHTML = optionsHtmlFromSubjects(row.subject || null, row.cost_type || '个人费用'); document.getElementById('modal-ex-date').value = row.date || ''; document.getElementById('modal-ex-type').value = row.type || (expenseTypes[0]&&expenseTypes[0].name)||''; document.getElementById('modal-ex-amount').value = (row.amount||0); document.getElementById('modal-ex-subject').value = row.subject || (expenseSubjects[0]&&expenseSubjects[0].name)||''; document.getElementById('modal-ex-content').value = row.content || ''; const radios = document.getElementsByName('modal-ex-arrived'); radios.forEach && radios.forEach(r => { r.checked = (r.value === (row.arrived ? '1' : '0')) }); renderAttachmentList(id); } function getModalFormData() { const arrivedNode = [...document.getElementsByName('modal-ex-arrived')].find(r=>r.checked); const costTypeNode = [...document.getElementsByName('modal-ex-cost-type')].find(r=>r.checked); return { date: document.getElementById('modal-ex-date').value, cost_type: costTypeNode ? costTypeNode.value : '个人费用', type: document.getElementById('modal-ex-type').value, amount: parseFloat(document.getElementById('modal-ex-amount').value || '0'), subject: document.getElementById('modal-ex-subject').value, content: document.getElementById('modal-ex-content').value.trim(), arrived: arrivedNode ? arrivedNode.value === '1' : true }; } function validateExpenseRequired() { const date = (document.getElementById('modal-ex-date').value||'').trim(); const costTypeNode = [...document.getElementsByName('modal-ex-cost-type')].find(r=>r.checked); const type = (document.getElementById('modal-ex-type').value||'').trim(); const amountStr = (document.getElementById('modal-ex-amount').value||'').trim(); const subject = (document.getElementById('modal-ex-subject').value||'').trim(); const content = (document.getElementById('modal-ex-content').value||'').trim(); const arrivedNode = [...document.getElementsByName('modal-ex-arrived')].find(r=>r.checked); const missing = []; if (!date) missing.push('报销日期'); if (!costTypeNode) missing.push('费用类型'); if (!type) missing.push('报销类型'); if (amountStr === '') missing.push('报销金额'); if (!subject) missing.push('报销主体'); if (!content) missing.push('报销内容'); if (!arrivedNode) missing.push('是否到账'); return missing; } async function renderAttachmentList(expenseId) { const box = document.getElementById('modal-ex-att-list'); if (!box) return; try { const rows = await loadAttachments(expenseId); box.innerHTML = rows.map(a => `
${htmlEscape(a.filename)} (${(a.size_bytes/1024).toFixed(1)} KB)
`).join(''); } catch { box.textContent = '附件加载失败'; } } function handleDeleteAttachment(id, expenseId) { showConfirm('确认删除该附件?', async () => { try { await deleteAttachment(id); await renderAttachmentList(expenseId); showStatus('附件已删除'); hideStatusSoon(); } catch { showStatus('附件删除失败'); hideStatusSoon(); } }); } async function uploadSelectedFiles(expenseId, useLog=false) { const input = document.getElementById('modal-ex-files'); if (!input || !input.files || input.files.length === 0) return; const files = Array.from(input.files); const abToBase64 = (ab) => { const bytes = new Uint8Array(ab); const chunkSize = 0x2000; let binary = ''; for (let i = 0; i < bytes.length; i += chunkSize) { const sub = bytes.subarray(i, i + chunkSize); binary += String.fromCharCode.apply(null, sub); } return btoa(binary); }; for (const f of files) { try { if (useLog) appendLog(`正在上传附件: ${f.name} (${Math.ceil(f.size/1024)}KB)...`); const fileKey = `${expenseId}-${Date.now()}-${Math.random().toString(36).slice(2,7)}`; const chunkSize = 5 * 1024 * 1024; const totalChunks = Math.ceil(f.size / chunkSize); for (let i = 0; i < totalChunks; i++) { const start = i * chunkSize; const end = Math.min(start + chunkSize, f.size); const blob = f.slice(start, end); const buf = await blob.arrayBuffer(); const b64 = abToBase64(buf); await apiSendJson('/api/expense/attachments/chunk', { file_key: fileKey, filename: f.name, size: f.size, mime: f.type || '', chunk_index: i, total_chunks: totalChunks, data_base64: b64 }, 'POST'); } const d = await apiSendJson('/api/expense/attachments/commit', { file_key: fileKey, filename: f.name, size: f.size, mime: f.type || '', total_chunks: totalChunks, expense_id: expenseId }, 'POST'); if (useLog) appendLog(`附件上传成功: ${f.name}`); else { showStatus(`附件已上传:${f.name}`); hideStatusSoon(); } await renderAttachmentList(expenseId); } catch (e) { if (useLog) appendLog(`附件上传失败: ${f.name} - ${e.message}`, true); else { showStatus(`附件上传失败:${f.name}`); hideStatusSoon(); } if (useLog) throw e; } } await renderAttachmentList(expenseId); input.value = ''; } async function handleModalSubmit(e) { e.preventDefault(); const btn = e && e.target && e.target.querySelector('button[type="submit"]'); if (btn) btn.disabled = true; const action = currentModalAction; const type = currentModalType || 'expense'; const idToProcess = currentModalId; // Store ID before closing modal if (type === 'expense') { const missing = validateExpenseRequired(); if (missing.length) { showStatus(`以下字段不能为空:${missing.join('、')}`); hideStatusSoon(); if (btn) btn.disabled = false; return false; } } closeModal(); showLoading(`${action==='add'?'新增':'保存'}中...`); try { if (type === 'expense') { const payload = getModalFormData(); appendLog(`准备提交数据...`); let id = idToProcess; if (action === 'add') { appendLog(`正在创建本地记录...`); const resp = await apiSendJson('/api/expense/records', payload, 'POST'); id = resp && resp.id ? resp.id : nextId(); appendLog(`本地记录创建成功, ID: ${id}`); } else if (action === 'edit') { appendLog(`正在更新本地记录 ID: ${id}...`); await apiSendJson(`/api/expense/records/${id}`, payload, 'PUT'); appendLog(`本地记录更新成功`); } const row = { id, ...payload }; const fileInput = document.getElementById('modal-ex-files'); if (fileInput && fileInput.files && fileInput.files.length > 0) { appendLog(`开始处理附件 (${fileInput.files.length} 个)...`); await uploadSelectedFiles(id, true); } appendLog(`正在同步至周报系统...`); try { await syncWeeklyEvent(action === 'add' ? 'add' : 'edit', row, true); } catch (e) { appendLog(`⚠️ 同步警告: ${e.message}`); // Don't throw, just warn } appendLog(`刷新列表...`); try { await loadExpenses(); } catch (e) { appendLog(`⚠️ 列表刷新失败: ${e.message}`); } finishLoading(true, null, 3000); } else if (type === 'cfgType') { const name = (document.getElementById('modal-cfg-type-name').value||'').trim(); if (!name) { showStatus('请填写类型名称'); hideStatusSoon(); closeLoading(); return } const ctNode = [...document.getElementsByName('modal-cfg-type-cost')].find(r=>r.checked); const costType = ctNode ? ctNode.value : '个人费用'; appendLog(`保存类型: ${name} (${costType})...`); if (action === 'add') { await createType(name, costType); } else if (action === 'edit') { const id = idToProcess; await updateType(id, name, costType); } await loadConfig(); renderConfigLists(); finishLoading(true, null, 3000); } else if (type === 'cfgSubject') { const name = (document.getElementById('modal-cfg-subject-name').value||'').trim(); if (!name) { showStatus('请填写主体名称'); hideStatusSoon(); closeLoading(); return } const ctNode = [...document.getElementsByName('modal-cfg-subject-cost')].find(r=>r.checked); const costType = ctNode ? ctNode.value : '个人费用'; appendLog(`保存主体: ${name} (${costType})...`); if (action === 'add') { await createSubject(name, costType); } else if (action === 'edit') { const id = idToProcess; await updateSubject(id, name, costType); } await loadConfig(); renderConfigLists(); finishLoading(true, null, 3000); } } catch (e) { console.error(e); appendLog(`操作失败: ${e.message}`, true); finishLoading(false, e.message); } finally { if (btn) btn.disabled = false; } } function renderConfigLists() { const tBody = document.getElementById('cfg-type-body'); const sBody = document.getElementById('cfg-subject-body'); const node = [...document.getElementsByName('maint-cost-type')].find(r=>r.checked); const val = node ? node.value : '全部'; if (tBody) { tBody.innerHTML = ''; expenseTypes.filter(r => (val === '全部') ? true : ((r.cost_type || '个人费用') === val)).forEach(r => { const tr = document.createElement('tr'); const td1 = document.createElement('td'); td1.textContent = r.name || ''; const tdCost = document.createElement('td'); tdCost.textContent = r.cost_type || '个人费用'; const td2 = document.createElement('td'); td2.className='col-actions'; const edit = document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>openModal('cfgType','edit', r.id)); const del = document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', ()=>{ showConfirm('确认删除该类型及其相关报销记录?', async () => { del.disabled = true; try { await apiSendJson(`/api/expense/types/${r.id}`, {}, 'DELETE'); await loadConfig(); await loadExpenses(); renderConfigLists(); showStatus('类型已删除'); hideStatusSoon(); } catch { showStatus('删除失败'); hideStatusSoon(); } finally { del.disabled = false } }); }); td2.appendChild(edit); td2.appendChild(del); tr.appendChild(td1); tr.appendChild(tdCost); tr.appendChild(td2); tBody.appendChild(tr); }); } if (sBody) { sBody.innerHTML = ''; expenseSubjects.filter(r => (val === '全部') ? true : ((r.cost_type || '个人费用') === val)).forEach(r => { const tr = document.createElement('tr'); const td1 = document.createElement('td'); td1.textContent = r.name || ''; const tdCost = document.createElement('td'); tdCost.textContent = r.cost_type || '个人费用'; const td2 = document.createElement('td'); td2.className='col-actions'; const edit = document.createElement('button'); edit.className='action-btn edit'; edit.title='修改'; edit.textContent='✎'; edit.addEventListener('click', ()=>openModal('cfgSubject','edit', r.id)); const del = document.createElement('button'); del.className='action-btn delete'; del.title='删除'; del.textContent='🗑'; del.addEventListener('click', ()=>{ showConfirm('确认删除该主体及其相关报销记录?', async () => { del.disabled = true; try { await apiSendJson(`/api/expense/subjects/${r.id}`, {}, 'DELETE'); await loadConfig(); await loadExpenses(); renderConfigLists(); showStatus('主体已删除'); hideStatusSoon(); } catch { showStatus('删除失败'); hideStatusSoon(); } finally { del.disabled = false } }); }); td2.appendChild(edit); td2.appendChild(del); tr.appendChild(td1); tr.appendChild(tdCost); tr.appendChild(td2); sBody.appendChild(tr); }); } } function switchTab(name) { const tabs = ['expense','maint','stat']; 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); } const WEEKLY_ENDPOINT_DEFAULT = 'http://traesite.umersoft.com:8975/api/weekly/ext/expense'; const WEEKLY_DEFAULT_KEY = '93220'; const WEEKLY_DEFAULT_SOURCE = 'expense'; function getWeeklyConfig() { // Priority: 1. Server Config (global), 2. LocalStorage, 3. Default Constant let endpoint = ''; if (window.EXPENSE_SYS_CONFIG && window.EXPENSE_SYS_CONFIG.weekly_api_url) { endpoint = window.EXPENSE_SYS_CONFIG.weekly_api_url; } else { endpoint = localStorage.getItem('expense_weekly_endpoint'); } // Auto-fix: always force upgrade to new expense endpoint if it points to the old generic event endpoint const oldDefault = 'http://traesite.umersoft.com:8975/api/weekly/ext/event'; const oldDefault2 = 'http://traesite.umersoft.com:8975/api/weekly/ext/expense'; // Also catch the dead link if (!endpoint || endpoint === oldDefault || endpoint === oldDefault2 || endpoint.includes('/api/weekly/ext/event')) { // If server config is available, use it as default, otherwise fallback to WEEKLY_ENDPOINT_DEFAULT (which might be old, so be careful) // Actually WEEKLY_ENDPOINT_DEFAULT in code is the old one 'http://traesite.umersoft.com:8975/api/weekly/ext/expense' which is now dead. // We should update the hardcoded default too, or rely on sys config. // Let's rely on sys config if possible, or a new hardcoded IP if needed. // For now, if we have sys config, we use it. If not, we use the one in localStorage or default. if (window.EXPENSE_SYS_CONFIG && window.EXPENSE_SYS_CONFIG.weekly_api_url) { endpoint = window.EXPENSE_SYS_CONFIG.weekly_api_url; } else { // If no sys config, we might be in trouble if default is dead. // But user only asked to read from json. endpoint = WEEKLY_ENDPOINT_DEFAULT; } localStorage.setItem('expense_weekly_endpoint', endpoint); } const key = localStorage.getItem('expense_weekly_key') || WEEKLY_DEFAULT_KEY; const source = localStorage.getItem('expense_weekly_source') || WEEKLY_DEFAULT_SOURCE; return { endpoint, key, source }; } function buildExpenseHTMLTable() { const escape = s => String(s||'').replace(/&/g,'&').replace(//g,'>'); const rows = expenseCache.map(r=>` ${escape(r.date||'')} ${escape(r.cost_type||'')} ${escape(r.type||'')} ${fmtAmount(r.amount||0)} ${escape(r.subject||'')} ${escape(r.content||'')} ${r.arrived?'已到账':'未到账'} `).join(''); return `${rows}
报销日期费用类型报销类型报销金额报销主体报销内容是否到账
`; } function buildSingleExpenseHTMLTable(r) { const escape = s => String(s||'').replace(/&/g,'&').replace(//g,'>'); const rowHtml = ` ${escape(r.date||'')} ${escape(r.cost_type||'')} ${escape(r.type||'')} ${fmtAmount(r.amount||0)} ${escape(r.subject||'')} ${escape(r.content||'')} ${r.arrived?'已到账':'未到账'} `; return `${rowHtml}
报销日期费用类型报销类型报销金额报销主体报销内容是否到账
`; } async function buildExpenseHTMLTableFresh() { const escape = s => String(s||'').replace(/&/g,'&').replace(//g,'>'); const d = await apiGetJson(`/api/expense/records?t=${Date.now()}`); const list = Array.isArray(d.rows) ? d.rows : []; const rows = list.map(r=>` ${escape(r.date||'')} ${escape(r.cost_type||'')} ${escape(r.type||'')} ${fmtAmount(r.amount||0)} ${escape(r.subject||'')} ${escape(r.content||'')} ${r.arrived?'已到账':'未到账'} `).join(''); return `${rows}
报销日期费用类型报销类型报销金额报销主体报销内容是否到账
`; } async function postWeekly(payload) { // Use backend proxy to avoid CORS issues // The backend will read target URL from expense.json const proxyEndpoint = '/api/expense/proxy/weekly'; const r = await fetch(proxyEndpoint, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }); let d=null; try { d = await r.json() } catch { d = { ok:false, error:'invalid_json' } } if (!r.ok) throw new Error(d && d.error ? d.error : ('http_'+r.status)); if (d && d.ok===false) throw new Error(d.error||'unknown_error'); return d; } async function syncWeeklyEvent(action, row, useLog=false) { const { endpoint, key, source } = getWeeklyConfig(); if (useLog) appendLog(`同步目标(代理转发): ${endpoint}`); const external_id = `EXP-${row.id}`; if (action === 'delete') { const payload = { key, source, external_id, action:'delete' }; const d = await postWeekly(payload); if (useLog) appendLog(`周报已同步删除`); else { showStatus('周报已同步删除'); hideStatusSoon(); } return d; } else { const status_code = row.arrived ? 'done' : 'doing'; const status_label = row.arrived ? '已到账' : '进行中'; const title = `报销·${row.subject||''}·${row.type||''}`; if (useLog) appendLog(`生成报销表格...`); const content = buildSingleExpenseHTMLTable(row); const date = row.date || ''; const deadline = date ? `${date} 18:00` : ''; const payload = { key, source, external_id, title, content, status_code, status_label, date, deadline }; if (useLog) appendLog(`发送同步请求...`); const d = await postWeekly(payload); if (useLog) appendLog(action==='add'?'周报已同步新增':'周报已同步更新'); else { showStatus(action==='add'?'周报已同步新增':'周报已同步更新'); hideStatusSoon(); } return d; } } function fmtYuan(n) { return (Number(n||0)).toLocaleString('zh-CN', { minimumFractionDigits:2, maximumFractionDigits:2 }); } async function refreshStats() { try { const start = document.getElementById('stat-start').value || ''; const end = document.getElementById('stat-end').value || ''; const q = `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; const summary = await apiGetJson(`/api/expense/stats/summary${q}`); const byCostType = await apiGetJson(`/api/expense/stats/by-cost-type${q}`); const byType = await apiGetJson(`/api/expense/stats/by-type${q}`); const bySubject = await apiGetJson(`/api/expense/stats/by-subject${q}`); const monthly = await apiGetJson(`/api/expense/stats/monthly${q}`); const box = document.getElementById('stat-summary'); if (box && summary && summary.summary) { const s = summary.summary; box.innerHTML = `
总金额:${fmtYuan(s.total_amount)} 元
已到账:${fmtYuan(s.arrived_amount)} 元(${s.arrived_count} 条)
未到账:${fmtYuan(s.pending_amount)} 元(${s.pending_count} 条)
期间:${s.start} ~ ${s.end}
`; } const cBody = document.getElementById('stat-cost-type-body'); const tBody = document.getElementById('stat-type-body'); const sBody = document.getElementById('stat-subject-body'); const mBody = document.getElementById('stat-monthly-body'); if (cBody) { cBody.innerHTML = (byCostType.rows||[]).map(r => ` ${htmlEscape(r.cost_type||'')}${fmtYuan(r.amount)}${fmtYuan(r.amount_arrived)}${fmtYuan(r.amount_pending)}${r.count||0} `).join(''); } if (tBody) { tBody.innerHTML = (byType.rows||[]).map(r => ` ${htmlEscape(r.type||'')}${fmtYuan(r.amount)}${fmtYuan(r.amount_arrived)}${fmtYuan(r.amount_pending)}${r.count||0} `).join(''); } if (sBody) { sBody.innerHTML = (bySubject.rows||[]).map(r => ` ${htmlEscape(r.subject||'')}${fmtYuan(r.amount)}${fmtYuan(r.amount_arrived)}${fmtYuan(r.amount_pending)}${r.count||0} `).join(''); } if (mBody) { mBody.innerHTML = (monthly.rows||[]).map(r => ` ${htmlEscape(r.month||'')}${fmtYuan(r.amount)}${fmtYuan(r.amount_arrived)}${fmtYuan(r.amount_pending)}${r.count||0} `).join(''); } } catch (e) { showStatus('统计加载失败'); hideStatusSoon(); } } // 初始化 async function initExpense() { try { const activeTab = document.querySelector('.tab.active'); if (activeTab && activeTab.id !== 'tab-expense') activeTab.classList.remove('active'); const panel = document.getElementById('panel-expense'); if (panel) panel.style.display=''; const bcp=document.getElementById('ed-btn-copy'); if (bcp) bcp.addEventListener('click', async ()=>{ try { await navigator.clipboard.writeText(out().textContent||'') } catch {} }); const brf=document.getElementById('ed-btn-refresh'); if (brf) brf.addEventListener('click', async ()=>{ out().textContent=''; logLocal('刷新'); }); const bcl=document.getElementById('ed-btn-clear'); if (bcl) bcl.addEventListener('click', ()=>{ out().textContent='' }); document.getElementById('tab-expense').addEventListener('click', ()=>switchTab('expense')); document.getElementById('tab-maint').addEventListener('click', ()=>switchTab('maint')); const tabStat = document.getElementById('tab-stat'); if (tabStat) tabStat.addEventListener('click', ()=>switchTab('stat')); switchTab('expense'); await loadConfig(); renderConfigLists(); await loadExpenses(); applyGridFontMode(); const btnStat = document.getElementById('stat-refresh'); if (btnStat) btnStat.addEventListener('click', refreshStats); const btnRecordsExport = document.getElementById('records-export'); if (btnRecordsExport) btnRecordsExport.addEventListener('click', ()=>{ try { window.open('/api/expense/export.csv','_blank') } catch {} }); const btnStatsExport = document.getElementById('stats-export'); if (btnStatsExport) btnStatsExport.addEventListener('click', ()=>{ const start = document.getElementById('stat-start').value||''; const end = document.getElementById('stat-end').value||''; const q = `?start=${encodeURIComponent(start)}&end=${encodeURIComponent(end)}`; try { window.open(`/api/expense/stats/export.csv${q}`,'_blank') } catch {} }); const today = new Date(); const pad = n => String(n).padStart(2,'0'); const startDefault = new Date(today.getFullYear(), today.getMonth(), 1); const endDefault = new Date(today.getFullYear(), today.getMonth()+1, 0); const fmt = d => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; const statStart = document.getElementById('stat-start'); const statEnd = document.getElementById('stat-end'); if (statStart && !statStart.value) statStart.value = fmt(startDefault); if (statEnd && !statEnd.value) statEnd.value = fmt(endDefault); await refreshStats(); } catch (e) { logLocal('init error: '+(e&&e.message||e)); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initExpense); } else { initExpense(); }