let sessionId = null; const pdfFiles = [], imgFiles = []; let csvFile = null; let invoiceData = []; // 当前编辑数据 [{__row, ...fields}] let csvFilename = ''; // 当前 CSV 文件名 let lastDownloadUrls = {}; // 最近一次可下载文件链接 // ---- Session ---- async function ensureSession() { if (sessionId) return sessionId; const r = await fetch('/api/session', { method: 'POST' }); const d = await r.json(); sessionId = d.session_id; return sessionId; } // ---- 上传 ---- function handleFiles(input, type) { const files = Array.from(input.files); const list = type === 'pdf' ? pdfFiles : imgFiles; const listId = type === 'pdf' ? 'pdf-list' : 'img-list'; const zoneId = type === 'pdf' ? 'pdf-zone' : 'img-zone'; files.forEach(f => { if (!list.find(x => x.name === f.name)) { f.__source = 'local'; // 标记为本地手动选择 list.push(f); } }); renderFileList(type); document.getElementById(zoneId).classList.add('active'); input.value = ''; } function removeFile(type, index) { const list = type === 'pdf' ? pdfFiles : imgFiles; list.splice(index, 1); renderFileList(type); if (list.length === 0) { document.getElementById(type === 'pdf' ? 'pdf-zone' : 'img-zone').classList.remove('active'); } } function renderFileList(type) { const list = type === 'pdf' ? pdfFiles : imgFiles; const box = document.getElementById(type === 'pdf' ? 'pdf-list' : 'img-list'); box.innerHTML = list.map((f, i) => `${f.name}×` ).join(''); } // ---- CSV 上传 ---- function handleCsvFile(input) { const file = input.files[0]; if (!file) return; csvFile = file; document.getElementById('csv-zone').classList.add('active'); document.getElementById('csv-list').innerHTML = `${file.name}×`; input.value = ''; } function removeCsvFile() { csvFile = null; document.getElementById('csv-zone').classList.remove('active'); document.getElementById('csv-list').innerHTML = ''; } // ---- 配置上传 ---- function handleConfigUpload(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { try { const cfg = JSON.parse(e.target.result); const map = { 'cfg-username': cfg.username, 'cfg-password': cfg.password, 'cfg-name': cfg.default_name, 'cfg-card': cfg.default_card_no, 'cfg-person-id': cfg.default_person_id, 'cfg-storage': cfg.consumable_storage, }; for (const [id, val] of Object.entries(map)) { if (val) document.getElementById(id).value = val; } // 同步 config.json 中的工号和公务卡号到表格 if (cfg.username) syncConfigToTable('工号'); if (cfg.default_card_no) syncConfigToTable('公务卡号'); alert('配置已加载'); } catch (err) { alert('config.json 解析失败: ' + err.message); } }; reader.readAsText(file); input.value = ''; } // ---- 拖拽 ---- ['pdf','img'].forEach(type => { const zone = document.getElementById(type + '-zone'); zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('dragover'); const files = Array.from(e.dataTransfer.files).filter(f => { if (type === 'pdf') return f.name.toLowerCase().endsWith('.pdf'); /\.(png|jpe?g|bmp|webp)$/i.test(f.name); }); if (files.length) { const list = type === 'pdf' ? pdfFiles : imgFiles; files.forEach(f => { f.__source = 'local'; // 标记为本地拖拽 if (!list.find(x => x.name === f.name)) list.push(f); }); renderFileList(type); zone.classList.add('active'); } }); }); // CSV 拖拽 const csvZone = document.getElementById('csv-zone'); csvZone.addEventListener('dragover', e => { e.preventDefault(); csvZone.classList.add('dragover'); }); csvZone.addEventListener('dragleave', () => csvZone.classList.remove('dragover')); csvZone.addEventListener('drop', e => { e.preventDefault(); csvZone.classList.remove('dragover'); const file = Array.from(e.dataTransfer.files).find(f => f.name.toLowerCase().endsWith('.csv')); if (file) handleCsvFile({ files: [file] }); }); // ---- 处理 ---- async function startProcess() { const isCsvMode = !!csvFile; if (!isCsvMode && !pdfFiles.length && !imgFiles.length) { alert('请先上传文件或 CSV'); return; } const btn = document.getElementById('btn-start'); btn.disabled = true; btn.textContent = '处理中...'; document.getElementById('status').innerHTML = '上传中...'; document.getElementById('log-box').innerHTML = ''; document.getElementById('edit-section').style.display = 'none'; document.getElementById('download-section').style.display = 'none'; try { await ensureSession(); if (isCsvMode) { const fd = new FormData(); fd.append('file', csvFile); await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd }); } else { const allFiles = [...pdfFiles.map(f => ({f, t:'pdf'})), ...imgFiles.map(f => ({f, t:'img'}))]; for (const {f} of allFiles) { const fd = new FormData(); fd.append('file', f); await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd }); } } document.getElementById('status').innerHTML = '处理中...'; const cfg = { username: document.getElementById('cfg-username').value, password: document.getElementById('cfg-password').value, default_name: document.getElementById('cfg-name').value, default_card_no: document.getElementById('cfg-card').value, default_person_id: document.getElementById('cfg-person-id').value, consumable_storage: document.getElementById('cfg-storage').value, mode: isCsvMode ? 'csv' : 'auto', }; await fetch(`/api/process/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(cfg), }); // 监听 SSE 日志 const es = new EventSource(`/api/logs/${sessionId}`); const logBox = document.getElementById('log-box'); let firstLine = true; es.addEventListener('message', e => { if (firstLine) { logBox.innerHTML = ''; firstLine = false; } try { const msg = JSON.parse(e.data); if (msg.type === 'done') { es.close(); const result = msg.result; document.getElementById('status').innerHTML = result.ok ? '完成' : '失败'; btn.disabled = false; btn.textContent = '开始处理'; if (result.ok) { showDownloadLinks(result); loadInvoiceData(); // 加载可编辑数据 } else { alert('处理失败: ' + (result.error || '未知错误')); } return; } } catch (err) {} logBox.innerHTML += e.data; logBox.scrollTop = logBox.scrollHeight; }); es.onerror = () => { es.close(); document.getElementById('status').innerHTML = '连接中断'; btn.disabled = false; btn.textContent = '开始处理'; }; } catch (e) { alert('请求失败: ' + (e.message || '未知错误')); btn.disabled = false; btn.textContent = '开始处理'; document.getElementById('status').innerHTML = '失败'; } } // ---- 下载链接 ---- function showDownloadLinks(result) { const section = document.getElementById('download-section'); const box = document.getElementById('download-links'); const warn = document.getElementById('doc-fill-warning'); if (!section || !box) return; const items = []; if (result.csv_url) items.push({ label: 'invoice_summary.csv', url: result.csv_url }); if (result.md_url) items.push({ label: 'invoice_summary.md', url: result.md_url }); if (result.doc_url) items.push({ label: '易耗品、出库单.doc', url: result.doc_url }); lastDownloadUrls = {}; items.forEach(it => { lastDownloadUrls[it.label] = it.url; }); box.innerHTML = items.map(it => `${it.label}` ).join(''); if (warn) { if (result.doc_ok === false && result.doc_error) { warn.style.display = 'block'; warn.textContent = '出库单未生成:' + result.doc_error; } else { warn.style.display = 'none'; warn.textContent = ''; } } section.style.display = items.length || (result.doc_ok === false) ? 'block' : 'none'; } // ---- 发票数据编辑 ---- async function loadInvoiceData() { try { const r = await fetch(`/api/data/${sessionId}`); const d = await r.json(); if (d.error) return; csvFilename = d.csv_filename || 'invoice_summary.csv'; invoiceData = d.data || []; const fields = d.fields || Object.keys(invoiceData[0] || {}).filter(k => !k.startsWith('__')); renderTable(invoiceData, fields); // 渲染后自动将配置区的工号/公务卡号同步到表格 syncConfigToTable('工号'); syncConfigToTable('公务卡号'); } catch (e) { /* ignore */ } } // 配置区 → 表格的同步映射:工号 ↔ cfg-username,公务卡号 ↔ cfg-card const CONFIG_SYNC = { '工号': 'cfg-username', '公务卡号': 'cfg-card', }; // 从配置区输入框的值更新到所有表格行 function syncConfigToTable(field) { const inputId = CONFIG_SYNC[field]; if (!inputId) return; const val = document.getElementById(inputId).value || ''; invoiceData.forEach((row, i) => { row[field] = val; }); // 更新表格中对应单元格的显示 const tbody = document.getElementById('invoice-tbody'); if (!tbody) return; const rows = tbody.querySelectorAll('tr'); rows.forEach((tr, i) => { const inputs = tr.querySelectorAll('input'); fieldsCache.forEach((f, colIdx) => { if (f === field && inputs[colIdx]) { inputs[colIdx].value = val; } }); }); } let fieldsCache = []; // renderTable 渲染后的字段列表,用于定位列索引 function renderTable(data, fields) { const section = document.getElementById('edit-section'); if (!data.length) { section.style.display = 'none'; return; } section.style.display = 'block'; // 使用后端返回的字段顺序, fallback 到 Object.keys if (!fields || !fields.length) { fields = Object.keys(data[0]).filter(k => !k.startsWith('__')); } fieldsCache = fields; // 表头 document.getElementById('invoice-thead').innerHTML = `