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 = ` # ${fields.map(f => `${f}`).join('')} `; // 表体:每行首列为序号,其后为各字段 input const rows = data.map((row, i) => { const cells = fields.map(f => { let onChangeStr = `invoiceData[${i}]['${f.replace(/'/g, "\\'")}']=this.value`; // 如果该字段参与配置区同步,额外调用 syncTableToConfig if (CONFIG_SYNC[f]) { onChangeStr += `;syncTableToConfig('${f.replace(/'/g, "\\'")}', this.value)`; } return ``; }).join(''); return `${i + 1}${cells}`; }).join(''); document.getElementById('invoice-tbody').innerHTML = rows; } // 从表格修改同步回配置区 function syncTableToConfig(field, value) { const inputId = CONFIG_SYNC[field]; if (inputId) { document.getElementById(inputId).value = value; } } function escapeHtml(s) { return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // 将当前表格编辑内容写回服务器(内部调用) async function saveInvoiceData() { try { const r = await fetch(`/api/save/${sessionId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: invoiceData, csv_filename: csvFilename }), }); const d = await r.json(); if (d.doc_url || d.doc_ok === false) { showDownloadLinks({ csv_url: lastDownloadUrls['invoice_summary.csv'], md_url: lastDownloadUrls['invoice_summary.md'], doc_url: d.doc_url, doc_ok: d.doc_ok, doc_error: d.doc_error, }); } } catch (e) { console.warn('自动保存失败,继续提交:', e); } } // ---- 提交到财务系统 ---- async function submitFinancial() { const btn = document.getElementById('btn-submit'); btn.disabled = true; btn.textContent = '提交中...'; document.getElementById('log-box').innerHTML = ''; try { // 提交前先自动保存当前表格编辑内容 await saveInvoiceData(); await fetch(`/api/submit-financial/${sessionId}`, { method: 'POST' }); // 监听日志流(复用 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(); btn.disabled = false; const result = msg.result; if (result && result.submit_ok) { btn.textContent = '✅ 提交完成'; setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 3000); } else { const errorMsg = result?.submit_error || result?.error || '未知错误'; btn.textContent = '❌ 提交失败'; logBox.innerHTML += `
❌ 提交失败:${errorMsg}
`; logBox.scrollTop = logBox.scrollHeight; console.warn('提交失败:', errorMsg); setTimeout(() => { btn.textContent = '🚀 提交到财务系统'; }, 5000); } return; } } catch (err) {} logBox.innerHTML += e.data; logBox.scrollTop = logBox.scrollHeight; }); es.onerror = () => { es.close(); btn.disabled = false; btn.textContent = '🚀 提交到财务系统'; }; } catch (e) { alert('提交失败: ' + e.message); btn.disabled = false; btn.textContent = '🚀 提交到财务系统'; } } // ---- 二维码 / 手机扫码上传 ---- let qrGenerated = false; let syncTimer = null; async function generateQr() { await ensureSession(); const mobileUrl = window.location.origin + '/mobile/' + sessionId; const box = document.getElementById('qrcode'); box.innerHTML = ''; new QRCode(box, { text: mobileUrl, width: 120, height: 120, colorDark: '#333', colorLight: '#fff', }); qrGenerated = true; } // ---- 实时同步:轮询服务器文件列表,检测手机端上传的新图片 ---- async function startSync() { if (syncTimer) return; await syncFiles(); syncTimer = setInterval(syncFiles, 3000); } function stopSync() { if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } } async function syncFiles() { if (!sessionId) return; try { const r = await fetch(`/api/files/${sessionId}`); const d = await r.json(); const serverNames = new Set(d.images || []); const localNames = new Set(imgFiles.map(f => f.name)); for (const name of serverNames) { if (!localNames.has(name)) { const resp = await fetch(`/api/download/${sessionId}/${encodeURIComponent(name)}`); const blob = await resp.blob(); const file = new File([blob], name, { type: blob.type }); file.__source = 'server'; // 标记为扫码上传 imgFiles.push(file); } } // 只清理来自服务器但已不存在的文件,保留本地手动选择的文件 for (const f of [...imgFiles]) { if (!serverNames.has(f.name) && f.__source !== 'local') { const idx = imgFiles.indexOf(f); if (idx > -1) imgFiles.splice(idx, 1); } } renderFileList('img'); if (imgFiles.length) { document.getElementById('img-zone').classList.add('active'); } else { document.getElementById('img-zone').classList.remove('active'); } } catch (e) { /* ignore */ } } generateQr(); startSync(); // ---- 配置区 → 表格的双向同步绑定 ---- Object.values(CONFIG_SYNC).forEach(inputId => { const el = document.getElementById(inputId); if (el) { el.addEventListener('input', () => { // 根据 inputId 反查对应的字段名 const field = Object.entries(CONFIG_SYNC).find(([, id]) => id === inputId)?.[0]; if (field) syncConfigToTable(field); }); } });