Files
Auto-Finance/templates/index.html

314 lines
12 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>
<style>
:root {
--bg: #f5f5f5;
--card: #fff;
--text: #333;
--muted: #888;
--primary: #4f46e5;
--primary-hover: #4338ca;
--success: #16a34a;
--danger: #dc2626;
--warning: #d97706;
--border: #e5e7eb;
--radius: 8px;
--shadow: 0 1px 3px rgba(0,0,0,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: var(--bg); color: var(--text); line-height: 1.6; }
.container { max-width: 960px; margin: 0 auto; padding: 24px 16px; }
.card { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); padding: 24px; margin-bottom: 16px; }
.card h2 { font-size: 18px; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.card h2 .badge { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: var(--primary); color: #fff; }
.upload-zone { border: 2px dashed var(--border); border-radius: var(--radius); padding: 40px 20px; text-align: center; cursor: pointer; transition: border-color .2s; }
.upload-zone:hover, .upload-zone.dragover { border-color: var(--primary); background: #f8f8ff; }
.upload-zone p { color: var(--muted); margin-top: 8px; }
.file-list { margin-top: 12px; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: #f9fafb; border-radius: 6px; margin-bottom: 4px; font-size: 14px; }
.file-item .remove { margin-left: auto; cursor: pointer; color: var(--danger); font-size: 16px; }
label { display: block; font-size: 14px; font-weight: 500; margin-bottom: 4px; }
input, textarea { width: 100%; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; font-size: 14px; outline: none; transition: border-color .2s; }
input:focus, textarea:focus { border-color: var(--primary); }
textarea { font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; height: 160px; resize: vertical; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 10px 20px; border: none; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background .2s; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { background: #15803d; }
.btn-success:disabled { opacity: .5; cursor: not-allowed; }
.actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.log-panel { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius); padding: 16px; max-height: 380px; overflow-y: auto; font-family: "SF Mono", "Fira Code", monospace; font-size: 13px; }
.log-panel .entry { margin-bottom: 2px; white-space: pre-wrap; word-break: break-all; }
.log-panel .INFO { color: #6a9955; }
.log-panel .WARNING { color: #d97706; }
.log-panel .ERROR { color: #f87171; }
.log-panel .DEBUG { color: #8b8b8b; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.status-badge.uploaded { background: #e5e7eb; color: #6b7280; }
.status-badge.extracting { background: #dbeafe; color: #2563eb; }
.status-badge.done { background: #dcfce7; color: #16a34a; }
.status-badge.submitting { background: #fef3c7; color: #d97706; }
.status-badge.submitted { background: #dcfce7; color: #16a34a; }
.status-badge.error { background: #fee2e2; color: #dc2626; }
.result-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 8px; }
.result-table th, .result-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; }
.result-table th { background: #f9fafb; font-weight: 500; }
.hidden { display: none !important; }
</style>
</head>
<body>
<div class="container">
<div class="card">
<h2>财务报销自动化</h2>
<p style="color:var(--muted);font-size:14px;">上传 PDF 发票和支付截图,自动提取信息并填报报销系统</p>
</div>
<!-- 文件上传 -->
<div class="card">
<h2>1. 上传文件</h2>
<div class="upload-zone" id="uploadZone">
<div style="font-size:36px;margin-bottom:4px;">&#128194;</div>
<p>拖拽 PDF 和图片到此处,或点击选择文件</p>
<input type="file" id="fileInput" multiple accept=".pdf,.png,.jpg,.jpeg,.bmp,.webp" style="display:none">
</div>
<div class="file-list" id="fileList"></div>
</div>
<!-- 配置 -->
<div class="card">
<h2>2. 配置</h2>
<div class="form-row">
<div><label>用户名</label><input id="cfgUser" placeholder="工号/学号"></div>
<div><label>密码</label><input id="cfgPass" type="password" placeholder="登录密码"></div>
</div>
<div class="form-row">
<div><label>默认姓名</label><input id="cfgName" placeholder="报销人姓名"></div>
<div><label>默认公务卡号</label><input id="cfgCard" placeholder="公务卡号后四位或完整号"></div>
</div>
<div><label>默认人员编号</label><input id="cfgPid" placeholder="人员编号"></div>
<details style="margin-top:8px;">
<summary style="font-size:14px;color:var(--muted);cursor:pointer;">高级配置 (JSON)</summary>
<textarea id="cfgJson" style="margin-top:8px;">{}</textarea>
</details>
</div>
<!-- 进度 -->
<div class="card">
<h2>3. 处理 <span class="status-badge" id="statusBadge" style="display:none"></span></h2>
<div class="log-panel" id="logPanel">
<div class="entry" style="color:var(--muted)">等待上传文件...</div>
</div>
<div class="actions">
<button class="btn btn-primary" id="btnStart" disabled>开始处理</button>
<button class="btn btn-success hidden" id="btnSubmit">提交报销</button>
<button class="btn hidden" id="btnDownload" style="background:#6b7280;color:#fff;">下载 CSV</button>
</div>
<div id="resultArea" class="hidden" style="margin-top:12px;"></div>
</div>
</div>
<script>
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
let sessionId = null;
let files = [];
let evtSource = null;
// ---- 文件选择 ----
const uploadZone = $('#uploadZone');
const fileInput = $('#fileInput');
const fileList = $('#fileList');
uploadZone.addEventListener('click', () => fileInput.click());
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
uploadZone.addEventListener('drop', e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
addFiles(Array.from(e.dataTransfer.files));
});
fileInput.addEventListener('change', () => addFiles(Array.from(fileInput.files)));
function addFiles(newFiles) {
for (const f of newFiles) {
if (!files.find(x => x.name === f.name && x.size === f.size)) {
files.push(f);
}
}
renderFiles();
checkReady();
}
function removeFile(idx) {
files.splice(idx, 1);
renderFiles();
checkReady();
}
function renderFiles() {
fileList.innerHTML = files.length === 0 ? '' : files.map((f, i) =>
`<div class="file-item">
<span>${['.pdf','.PDF'].some(e => f.name.endsWith(e)) ? '&#128196;' : '&#128444;'}</span>
<span>${f.name}</span>
<span style="color:var(--muted);font-size:12px;">${(f.size/1024).toFixed(1)} KB</span>
<span class="remove" onclick="removeFile(${i})">&times;</span>
</div>`
).join('');
}
// ---- 配置 ----
const cfgUser = $('#cfgUser');
const cfgPass = $('#cfgPass');
const cfgName = $('#cfgName');
const cfgCard = $('#cfgCard');
const cfgPid = $('#cfgPid');
const cfgJson = $('#cfgJson');
[cfgUser, cfgPass, cfgName, cfgCard, cfgPid, cfgJson].forEach(el =>
el.addEventListener('input', checkReady)
);
function getConfig() {
let base = {};
try { base = JSON.parse(cfgJson.value || '{}'); } catch (e) {}
if (cfgUser.value) base.username = cfgUser.value;
if (cfgPass.value) base.password = cfgPass.value;
if (cfgName.value) base.default_name = cfgName.value;
if (cfgCard.value) base.default_card_no = cfgCard.value;
if (cfgPid.value) base.default_person_id = cfgPid.value;
return base;
}
function checkReady() {
const hasFiles = files.length > 0;
const hasUser = !!(cfgUser.value && cfgPass.value);
$('#btnStart').disabled = !(hasFiles && hasUser);
}
// ---- 开始处理 ----
const btnStart = $('#btnStart');
const btnSubmit = $('#btnSubmit');
const btnDownload = $('#btnDownload');
const statusBadge = $('#statusBadge');
const logPanel = $('#logPanel');
const resultArea = $('#resultArea');
btnStart.addEventListener('click', async () => {
btnStart.disabled = true;
statusBadge.style.display = 'inline-block';
statusBadge.textContent = '上传中...';
statusBadge.className = 'status-badge extracting';
const formData = new FormData();
for (const f of files) formData.append('files', f);
const config = getConfig();
formData.append('config', JSON.stringify(config));
try {
const resp = await fetch('/api/start', { method: 'POST', body: formData });
const data = await resp.json();
if (data.error) { alert(data.error); btnStart.disabled = false; return; }
sessionId = data.session_id;
startStream();
btnSubmit.classList.remove('hidden');
btnDownload.classList.remove('hidden');
} catch (e) {
alert('请求失败: ' + e.message);
btnStart.disabled = false;
}
});
function startStream() {
logPanel.innerHTML = '';
if (evtSource) evtSource.close();
evtSource = new EventSource(`/api/stream/${sessionId}`);
evtSource.addEventListener('message', e => {
const data = JSON.parse(e.data);
if (data.type === 'log') {
const lvl = (data.msg.match(/\[(\w+)\]/) || [])[1] || '';
logPanel.innerHTML += `<div class="entry ${lvl}">${escHtml(data.msg)}</div>`;
logPanel.scrollTop = logPanel.scrollHeight;
}
if (data.type === 'heartbeat' || data.type === 'status') {
statusBadge.textContent = data.status;
statusBadge.className = `status-badge ${data.status}`;
}
if (data.type === 'complete') {
statusBadge.textContent = data.status;
statusBadge.className = `status-badge ${data.status}`;
evtSource.close();
if (data.status === 'done') {
btnSubmit.classList.remove('hidden');
loadResults();
}
btnStart.disabled = data.status !== 'done';
}
});
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
async function loadResults() {
try {
const resp = await fetch(`/api/status/${sessionId}`);
const data = await resp.json();
if (!data.preview) return;
let html = '<table class="result-table"><thead><tr>';
const keys = Object.keys(data.preview[0]);
for (const k of keys) html += `<th>${k}</th>`;
html += '</tr></thead><tbody>';
for (const row of data.preview) {
html += '<tr>';
for (const k of keys) html += `<td>${row[k] || ''}</td>`;
html += '</tr>';
}
html += '</tbody></table>';
html += `<p style="margin-top:8px;color:var(--muted);">共 ${data.total} 条记录</p>`;
resultArea.innerHTML = html;
resultArea.classList.remove('hidden');
} catch (e) {}
}
// ---- 浏览器填报 ----
btnSubmit.addEventListener('click', async () => {
if (!sessionId) return;
btnSubmit.disabled = true;
statusBadge.textContent = 'submitting';
statusBadge.className = 'status-badge submitting';
try {
await fetch(`/api/submit/${sessionId}`, { method: 'POST' });
startStream();
} catch (e) {
alert('提交失败: ' + e.message);
btnSubmit.disabled = false;
}
});
// ---- 下载 CSV ----
btnDownload.addEventListener('click', () => {
if (sessionId) window.open(`/api/download/${sessionId}/csv`, '_blank');
});
</script>
</body>
</html>