Files
Toolbox/public/tools/expense/expense.js
T

1010 lines
48 KiB
JavaScript

// 简易调试输出
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 = `<div style="font-size:15px; line-height:1.5">${msg}</div>`;
footerEl.innerHTML = `<button class="expense-modal-btn primary" id="alert-ok">确定</button>`;
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 = `<div style="font-size:15px; line-height:1.5">${msg}</div>`;
footerEl.innerHTML = `
<button class="expense-modal-btn secondary" id="confirm-cancel">取消</button>
<button class="expense-modal-btn primary" id="confirm-ok">确定</button>
`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const optionsHtmlFromTypes = (selected=null, costFilter=null) => expenseTypes.filter(t => !costFilter || (t.cost_type||'个人费用') === costFilter).map(t => `<option value="${htmlEscape(t.name)}"${selected===t.name?' selected':''}>${htmlEscape(t.name)}</option>`).join('');
const optionsHtmlFromSubjects = (selected=null, costFilter=null) => expenseSubjects.filter(s => !costFilter || (s.cost_type||'个人费用') === costFilter).map(s => `<option value="${htmlEscape(s.name)}"${selected===s.name?' selected':''}>${htmlEscape(s.name)}</option>`).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 = '<span style="opacity:0.2">🔻🔺</span>';
if (currentSortColumn === c) {
if (currentSortDirection === 'asc') el.innerHTML = '<span style="opacity:0.2">🔻</span><span style="opacity:1">🔺</span>';
else el.innerHTML = '<span style="opacity:1">🔻</span><span style="opacity:0.2">🔺</span>';
}
}
});
};
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 `
<form class="expense-modal-form" onsubmit="return handleModalSubmit(event)">
<div class="expense-modal-form-row">
<label class="expense-modal-label">报销日期:</label>
<input type="date" class="expense-modal-input" id="modal-ex-date" ${readonly} required>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">费用类型:</label>
<div>
<label><input type="radio" name="modal-ex-cost-type" value="个人费用" ${disabled} ${defaultCostType==='个人费用'?'checked':''}> 个人费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-ex-cost-type" value="家庭费用" ${disabled} ${defaultCostType==='家庭费用'?'checked':''}> 家庭费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-ex-cost-type" value="公司费用" ${disabled} ${defaultCostType==='公司费用'?'checked':''}> 公司费用</label>
</div>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">报销类型:</label>
<select class="expense-modal-select" id="modal-ex-type" ${disabled} required>
${optionsHtmlFromTypes(null, defaultCostType)}
</select>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">报销金额:</label>
<input type="number" step="0.01" class="expense-modal-input" id="modal-ex-amount" placeholder="金额(元)" ${readonly} required>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">报销主体:</label>
<select class="expense-modal-select" id="modal-ex-subject" ${disabled} required>
${optionsHtmlFromSubjects(null, defaultCostType)}
</select>
</div>
<div class="expense-modal-form-row full-width">
<label class="expense-modal-label">报销内容:</label>
<textarea class="expense-modal-textarea large" id="modal-ex-content" placeholder="报销内容(多行文本)" ${readonly} required></textarea>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">是否到账:</label>
<div>
<label><input type="radio" name="modal-ex-arrived" value="1" ${disabled}> 已到账</label>
<label style="margin-left:12px"><input type="radio" name="modal-ex-arrived" value="0" ${disabled} checked> 未到账</label>
</div>
</div>
<div class="expense-modal-form-row full-width">
<label class="expense-modal-label">附件:</label>
<div>
<input type="file" id="modal-ex-files" ${isView?'disabled':''} multiple>
<div id="modal-ex-att-list" class="attachments-list" style="margin-top:8px"></div>
</div>
</div>
</form>
`;
} else if (type === 'cfgType') {
const filterNode = [...document.getElementsByName('maint-cost-type')].find(r=>r.checked);
let defaultCostType = filterNode ? filterNode.value : '全部';
if (defaultCostType === '全部') defaultCostType = '个人费用';
return `
<form class="expense-modal-form" onsubmit="return handleModalSubmit(event)">
<div class="expense-modal-form-row">
<label class="expense-modal-label">类型名称:</label>
<input type="text" class="expense-modal-input" id="modal-cfg-type-name" placeholder="类型名称" ${readonly} required>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">费用类型:</label>
<div>
<label><input type="radio" name="modal-cfg-type-cost" value="个人费用" ${disabled} ${defaultCostType==='个人费用'?'checked':''}> 个人费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-cfg-type-cost" value="家庭费用" ${disabled} ${defaultCostType==='家庭费用'?'checked':''}> 家庭费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-cfg-type-cost" value="公司费用" ${disabled} ${defaultCostType==='公司费用'?'checked':''}> 公司费用</label>
</div>
</div>
</form>
`;
} else if (type === 'cfgSubject') {
const filterNode = [...document.getElementsByName('maint-cost-type')].find(r=>r.checked);
let defaultCostType = filterNode ? filterNode.value : '全部';
if (defaultCostType === '全部') defaultCostType = '个人费用';
return `
<form class="expense-modal-form" onsubmit="return handleModalSubmit(event)">
<div class="expense-modal-form-row">
<label class="expense-modal-label">主体名称:</label>
<input type="text" class="expense-modal-input" id="modal-cfg-subject-name" placeholder="主体名称" ${readonly} required>
</div>
<div class="expense-modal-form-row">
<label class="expense-modal-label">费用类型:</label>
<div>
<label><input type="radio" name="modal-cfg-subject-cost" value="个人费用" ${disabled} ${defaultCostType==='个人费用'?'checked':''}> 个人费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-cfg-subject-cost" value="家庭费用" ${disabled} ${defaultCostType==='家庭费用'?'checked':''}> 家庭费用</label>
<label style="margin-left:12px"><input type="radio" name="modal-cfg-subject-cost" value="公司费用" ${disabled} ${defaultCostType==='公司费用'?'checked':''}> 公司费用</label>
</div>
</div>
</form>
`;
}
return '<p>未知类型</p>';
}
function generateModalButtons(type, action) {
const isView = action === 'view';
if (isView) return `<button class="expense-modal-btn secondary" onclick="closeModal()">关闭</button>`;
return `
<button type="button" class="expense-modal-btn secondary" onclick="closeModal()">取消</button>
<button type="submit" class="expense-modal-btn primary" onclick="handleModalSubmit(event)">${action==='add'?'新增':'保存'}</button>
`;
}
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 => `
<div class="att-row">
<a href="${a.url}" target="_blank" rel="noopener">${htmlEscape(a.filename)} (${(a.size_bytes/1024).toFixed(1)} KB)</a>
<button type="button" class="action-btn delete" title="删除" onclick="handleDeleteAttachment(${a.id}, ${expenseId})">🗑</button>
</div>
`).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const rows = expenseCache.map(r=>`
<tr>
<td>${escape(r.date||'')}</td>
<td>${escape(r.cost_type||'')}</td>
<td>${escape(r.type||'')}</td>
<td>${fmtAmount(r.amount||0)}</td>
<td>${escape(r.subject||'')}</td>
<td style="white-space:pre-wrap">${escape(r.content||'')}</td>
<td>${r.arrived?'已到账':'未到账'}</td>
</tr>
`).join('');
return `<table border="1" cellspacing="0" cellpadding="6"><thead><tr><th>报销日期</th><th>费用类型</th><th>报销类型</th><th>报销金额</th><th>报销主体</th><th>报销内容</th><th>是否到账</th></tr></thead><tbody>${rows}</tbody></table>`;
}
function buildSingleExpenseHTMLTable(r) {
const escape = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const rowHtml = `
<tr>
<td>${escape(r.date||'')}</td>
<td>${escape(r.cost_type||'')}</td>
<td>${escape(r.type||'')}</td>
<td>${fmtAmount(r.amount||0)}</td>
<td>${escape(r.subject||'')}</td>
<td style="white-space:pre-wrap">${escape(r.content||'')}</td>
<td>${r.arrived?'已到账':'未到账'}</td>
</tr>
`;
return `<table border="1" cellspacing="0" cellpadding="6"><thead><tr><th>报销日期</th><th>费用类型</th><th>报销类型</th><th>报销金额</th><th>报销主体</th><th>报销内容</th><th>是否到账</th></tr></thead><tbody>${rowHtml}</tbody></table>`;
}
async function buildExpenseHTMLTableFresh() {
const escape = s => String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
const d = await apiGetJson(`/api/expense/records?t=${Date.now()}`);
const list = Array.isArray(d.rows) ? d.rows : [];
const rows = list.map(r=>`
<tr>
<td>${escape(r.date||'')}</td>
<td>${escape(r.cost_type||'')}</td>
<td>${escape(r.type||'')}</td>
<td>${fmtAmount(r.amount||0)}</td>
<td>${escape(r.subject||'')}</td>
<td style="white-space:pre-wrap">${escape(r.content||'')}</td>
<td>${r.arrived?'已到账':'未到账'}</td>
</tr>
`).join('');
return `<table border="1" cellspacing="0" cellpadding="6"><thead><tr><th>报销日期</th><th>费用类型</th><th>报销类型</th><th>报销金额</th><th>报销主体</th><th>报销内容</th><th>是否到账</th></tr></thead><tbody>${rows}</tbody></table>`;
}
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 = `
<div class="stats-grid">
<div>总金额:<b>${fmtYuan(s.total_amount)}</b> 元</div>
<div>已到账:<b>${fmtYuan(s.arrived_amount)}</b> 元(${s.arrived_count} 条)</div>
<div>未到账:<b>${fmtYuan(s.pending_amount)}</b> 元(${s.pending_count} 条)</div>
<div>期间:${s.start} ~ ${s.end}</div>
</div>
`;
}
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 => `
<tr><td>${htmlEscape(r.cost_type||'')}</td><td>${fmtYuan(r.amount)}</td><td>${fmtYuan(r.amount_arrived)}</td><td>${fmtYuan(r.amount_pending)}</td><td>${r.count||0}</td></tr>
`).join('');
}
if (tBody) {
tBody.innerHTML = (byType.rows||[]).map(r => `
<tr><td>${htmlEscape(r.type||'')}</td><td>${fmtYuan(r.amount)}</td><td>${fmtYuan(r.amount_arrived)}</td><td>${fmtYuan(r.amount_pending)}</td><td>${r.count||0}</td></tr>
`).join('');
}
if (sBody) {
sBody.innerHTML = (bySubject.rows||[]).map(r => `
<tr><td>${htmlEscape(r.subject||'')}</td><td>${fmtYuan(r.amount)}</td><td>${fmtYuan(r.amount_arrived)}</td><td>${fmtYuan(r.amount_pending)}</td><td>${r.count||0}</td></tr>
`).join('');
}
if (mBody) {
mBody.innerHTML = (monthly.rows||[]).map(r => `
<tr><td>${htmlEscape(r.month||'')}</td><td>${fmtYuan(r.amount)}</td><td>${fmtYuan(r.amount_arrived)}</td><td>${fmtYuan(r.amount_pending)}</td><td>${r.count||0}</td></tr>
`).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();
}