438 lines
17 KiB
HTML
438 lines
17 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>财务报销自动化</title>
|
||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||
<style>
|
||
body { background: #f5f7fa; }
|
||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 24px 0 20px; }
|
||
.upload-zone {
|
||
border: 2px dashed #ccc; border-radius: 12px; padding: 28px; text-align: center;
|
||
cursor: pointer; transition: all .2s; background: #fff; min-height: 100px;
|
||
}
|
||
.upload-zone:hover, .upload-zone.dragover { border-color: #667eea; background: #f0f2ff; }
|
||
.upload-zone.active { border-color: #28a745; background: #f0fff4; }
|
||
.upload-zone .icon { font-size: 32px; color: #aaa; margin-bottom: 8px; }
|
||
.file-tag { display: inline-block; background: #e8f0fe; border-radius: 4px; padding: 2px 10px; margin: 3px; font-size: 13px; }
|
||
.file-tag .remove { cursor: pointer; color: #c00; margin-left: 6px; font-weight: bold; }
|
||
.log-container { background: #1e1e1e; color: #d4d4d4; border-radius: 8px; padding: 14px; height: 360px; overflow-y: auto; font-family: Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
|
||
.log-container .empty { color: #666; font-style: italic; }
|
||
.section-title { font-size: 15px; font-weight: 600; color: #333; margin-bottom: 12px; }
|
||
.btn-process { font-size: 17px; padding: 10px 40px; }
|
||
.status-badge { font-size: 13px; }
|
||
.result-box { background: #fff; border-radius: 8px; padding: 16px; display: none; }
|
||
.result-box a { text-decoration: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="header text-center mb-4">
|
||
<h3>财务报销自动化</h3>
|
||
<p class="mb-0 opacity-75">上传发票 PDF 和支付截图,自动提取、OCR 识别并填报</p>
|
||
</div>
|
||
|
||
<div class="container" style="max-width:960px">
|
||
|
||
<!-- 上传区域 -->
|
||
<div class="row g-3 mb-4">
|
||
<div class="col-md-4">
|
||
<div class="section-title">📄 发票 PDF <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||
<div class="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||
<div class="icon">📁</div>
|
||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 PDF 文件</div>
|
||
<div id="pdf-list" class="mt-2"></div>
|
||
</div>
|
||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<div class="section-title">🖼️ 支付截图 <span class="text-muted fw-normal" style="font-size:12px">(多选)</span></div>
|
||
<div class="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||
<div class="icon">🖼️</div>
|
||
<div class="text-muted" style="font-size:13px">点击或拖拽上传图片文件</div>
|
||
<div id="img-list" class="mt-2"></div>
|
||
</div>
|
||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||
</div>
|
||
<div class="col-md-4 text-center">
|
||
<div class="section-title">📱 手机扫码上传</div>
|
||
<div id="qrcode" style="display:inline-block"></div>
|
||
<p class="text-muted mt-2 mb-0" style="font-size:12px">扫码即可拍照上传</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CSV 快捷上传 -->
|
||
<div class="mb-4">
|
||
<div class="section-title">📊 CSV 快捷上传 <span class="text-muted fw-normal" style="font-size:12px">(已有发票数据 CSV 可直接上传,跳过提取和 OCR)</span></div>
|
||
<div class="upload-zone" id="csv-zone" onclick="document.getElementById('csv-input').click()">
|
||
<div class="icon">📊</div>
|
||
<div class="text-muted" style="font-size:13px">点击或拖拽上传 CSV 文件</div>
|
||
<div id="csv-list" class="mt-2"></div>
|
||
</div>
|
||
<input type="file" id="csv-input" accept=".csv" hidden onchange="handleCsvFile(this)">
|
||
</div>
|
||
|
||
<!-- 配置表单 -->
|
||
<div class="card mb-4">
|
||
<div class="card-body">
|
||
<div class="section-title">⚙️ 配置
|
||
<span style="font-size:12px;font-weight:normal;cursor:pointer;color:#667eea;margin-left:8px" onclick="document.getElementById('config-upload').click()">
|
||
📤 上传 config.json
|
||
</span>
|
||
<input type="file" id="config-upload" accept=".json" hidden onchange="handleConfigUpload(this)">
|
||
</div>
|
||
<div class="row g-2">
|
||
<div class="col-sm-4">
|
||
<label class="form-label" style="font-size:12px;margin-bottom:2px">账号 (工号)</label>
|
||
<input type="text" class="form-control form-control-sm" id="cfg-username" placeholder="202xxxx">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label" style="font-size:12px;margin-bottom:2px">密码</label>
|
||
<input type="password" class="form-control form-control-sm" id="cfg-password" placeholder="登录密码">
|
||
</div>
|
||
<div class="col-sm-4">
|
||
<label class="form-label" style="font-size:12px;margin-bottom:2px">默认姓名</label>
|
||
<input type="text" class="form-control form-control-sm" id="cfg-name" placeholder="张三">
|
||
</div>
|
||
<div class="col-sm-3">
|
||
<label class="form-label" style="font-size:12px;margin-bottom:2px">公务卡号</label>
|
||
<input type="text" class="form-control form-control-sm" id="cfg-card" placeholder="628288...">
|
||
</div>
|
||
<div class="col-sm-3">
|
||
<label class="form-label" style="font-size:12px;margin-bottom:2px">人员编号</label>
|
||
<input type="text" class="form-control form-control-sm" id="cfg-person-id" placeholder="202xxxxx">
|
||
</div>
|
||
<div class="col-sm-6 d-flex align-items-end">
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="checkbox" id="cfg-submit">
|
||
<label class="form-check-label" for="cfg-submit" style="font-size:13px">同时提交到财务系统(需浏览器自动化)</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 操作按钮 -->
|
||
<div class="text-center mb-4">
|
||
<button class="btn btn-primary btn-process" id="btn-start" onclick="startProcess()">开始处理</button>
|
||
<span id="status" class="ms-3"></span>
|
||
</div>
|
||
|
||
<!-- 结果 -->
|
||
<div class="result-box mb-3" id="result-box">
|
||
<div class="section-title">📊 处理结果</div>
|
||
<div id="result-content"></div>
|
||
</div>
|
||
|
||
<!-- 日志 -->
|
||
<div class="section-title">📋 处理日志</div>
|
||
<div class="log-container mb-4" id="log-box"><div class="empty">等待开始...</div></div>
|
||
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||
<script>
|
||
let sessionId = null;
|
||
const pdfFiles = [], imgFiles = [];
|
||
let csvFile = null;
|
||
|
||
// ---- 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)) 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,
|
||
};
|
||
for (const [id, val] of Object.entries(map)) {
|
||
if (val) document.getElementById(id).value = val;
|
||
}
|
||
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 => { 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('result-box').style.display = 'none';
|
||
|
||
try {
|
||
await ensureSession();
|
||
|
||
if (isCsvMode) {
|
||
// CSV 模式:只上传 CSV 文件
|
||
const fd = new FormData();
|
||
fd.append('file', csvFile);
|
||
await fetch(`/api/upload-csv/${sessionId}`, { method: 'POST', body: fd });
|
||
} else {
|
||
// 传统模式:上传 PDF 和图片
|
||
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,
|
||
submit: document.getElementById('cfg-submit').checked,
|
||
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; }
|
||
// 尝试解析 JSON(完成信号),否则按日志文本处理
|
||
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) {
|
||
showResult(result);
|
||
} else {
|
||
document.getElementById('result-box').style.display = 'block';
|
||
document.getElementById('result-content').innerHTML = '<span class="text-danger">' + (result.error || '处理失败') + '</span>';
|
||
}
|
||
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 showResult(r) {
|
||
const box = document.getElementById('result-box');
|
||
box.style.display = 'block';
|
||
const mdLink = r.md_url ? `<a class="btn btn-sm btn-outline-secondary" href="${r.md_url}" download>下载 Markdown</a>` : '';
|
||
document.getElementById('result-content').innerHTML = `
|
||
<p>处理 <b>${r.invoice_count}</b> 张发票,耗时 ${r.elapsed}</p>
|
||
<a class="btn btn-sm btn-outline-primary me-2" href="${r.csv_url}" download>下载 CSV</a>
|
||
${mdLink}
|
||
`;
|
||
}
|
||
|
||
// ---- 二维码 / 手机扫码上传 ----
|
||
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 });
|
||
imgFiles.push(file);
|
||
}
|
||
}
|
||
|
||
// 检测本地有但服务器没有的图片(被手机端删除了)
|
||
for (const f of [...imgFiles]) {
|
||
if (!serverNames.has(f.name)) {
|
||
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();
|
||
</script>
|
||
</body>
|
||
</html> |