1010 lines
48 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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,'&').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=>`
|
|
<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();
|
|
}
|