- 新增 fill_consumable_doc,根据 CSV 填写 Word 出库单(宋体五号) - Web 处理完成后自动生成出库单并提供下载 - 前端拆分为 static 资源,支持在线编辑 CSV 与分步提交财务系统 - 补充 API.md、README(含 Mermaid 数据流)及 config.example.json Co-authored-by: Cursor <cursoragent@cursor.com>
517 lines
17 KiB
JavaScript
517 lines
17 KiB
JavaScript
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) =>
|
||
`<span class="file-tag">${f.name}<span class="remove" onclick="event.stopPropagation();removeFile('${type}',${i})">×</span></span>`
|
||
).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 =
|
||
`<span class="file-tag">${file.name}<span class="remove" onclick="event.stopPropagation();removeCsvFile()">×</span></span>`;
|
||
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 = '<span class="badge bg-warning status-badge">上传中...</span>';
|
||
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 = '<span class="badge bg-info status-badge">处理中...</span>';
|
||
|
||
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
|
||
? '<span class="badge bg-success status-badge">完成</span>'
|
||
: '<span class="badge bg-danger status-badge">失败</span>';
|
||
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 = '<span class="badge bg-danger status-badge">连接中断</span>';
|
||
btn.disabled = false;
|
||
btn.textContent = '开始处理';
|
||
};
|
||
|
||
} catch (e) {
|
||
alert('请求失败: ' + (e.message || '未知错误'));
|
||
btn.disabled = false;
|
||
btn.textContent = '开始处理';
|
||
document.getElementById('status').innerHTML = '<span class="badge bg-danger status-badge">失败</span>';
|
||
}
|
||
}
|
||
|
||
// ---- 下载链接 ----
|
||
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 =>
|
||
`<a class="btn btn-outline-primary btn-sm" href="${it.url}" download>${it.label}</a>`
|
||
).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 = `
|
||
<tr>
|
||
<th style="width:40px">#</th>
|
||
${fields.map(f => `<th>${f}</th>`).join('')}
|
||
</tr>
|
||
`;
|
||
|
||
// 表体:每行首列为序号,其后为各字段 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 `<td><input class="form-control form-control-sm" value="${escapeHtml(String(row[f] || ''))}"
|
||
onchange="${onChangeStr}"></td>`;
|
||
}).join('');
|
||
return `<tr><td style="width:40px;text-align:center">${i + 1}</td>${cells}</tr>`;
|
||
}).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,'>').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 += `<div style="color: #e74c3c; font-weight: bold;">❌ 提交失败:${errorMsg}</div>`;
|
||
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);
|
||
});
|
||
}
|
||
}); |