Compare commits
2 Commits
f754386695
...
70f61be3aa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f61be3aa | ||
|
|
02e2ca90a7 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,6 +23,7 @@ images/
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
BIN
1. 电容一批.pdf
BIN
1. 电容一批.pdf
Binary file not shown.
BIN
1. 电容一批.png
BIN
1. 电容一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
BIN
2. 电阻一批.pdf
BIN
2. 电阻一批.pdf
Binary file not shown.
BIN
2. 电阻一批.png
BIN
2. 电阻一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 201 KiB |
BIN
3. LED一批.pdf
BIN
3. LED一批.pdf
Binary file not shown.
BIN
3. LED一批.png
BIN
3. LED一批.png
Binary file not shown.
|
Before Width: | Height: | Size: 197 KiB |
BIN
4. 元器件盒.pdf
BIN
4. 元器件盒.pdf
Binary file not shown.
BIN
4. 元器件盒.png
BIN
4. 元器件盒.png
Binary file not shown.
|
Before Width: | Height: | Size: 200 KiB |
@@ -16,7 +16,8 @@
|
||||
├── web/
|
||||
│ ├── app.py # Web 服务入口
|
||||
│ ├── templates/
|
||||
│ │ └── index.html # Web 前端页面
|
||||
│ │ ├── index.html # Web 前端页面
|
||||
│ │ └── mobile_upload.html # 移动端扫码上传页面
|
||||
│ └── uploads/ # 用户上传文件目录
|
||||
├── *.pdf # 发票 PDF(按需放置)
|
||||
├── *.png / *.jpg # 与 PDF 同名的支付截图
|
||||
@@ -133,6 +134,7 @@ python web/app.py
|
||||
| 实时日志 | 处理进度通过 SSE 实时推送 |
|
||||
| 下载 CSV | 处理后下载发票汇总表 |
|
||||
| 配置上传 | 可上传 `config.json` 自动填充表单 |
|
||||
| 手机扫码上传 | 支付截图区域显示二维码,手机端扫码后可拍照/选图上传,PC 端实时同步接收 |
|
||||
|
||||
> Web 模式下浏览器以无头模式运行,不会弹出窗口。
|
||||
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。
|
||||
|
||||
11
config.json
11
config.json
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"username": "202407021",
|
||||
"password": "wang!1624155937",
|
||||
"sso_login_url": "https://tyrz.fynu.edu.cn/sso/login",
|
||||
"portal_url": "https://tyrz.fynu.edu.cn/oshall",
|
||||
"reimburse_url": "http://210.45.32.214:8081",
|
||||
"reimburse_page": "/expen/common/common?v=4.0",
|
||||
"default_name": "王建锋",
|
||||
"default_card_no": "6282880139161682",
|
||||
"default_person_id": "202407021"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
序号,发票号码,开票日期,项目名称,规格型号,价税合计,销售方名称,人员姓名,刷卡日期,公务卡号,刷卡金额,备注,工号
|
||||
1,26442000005432755951,2026/05/18,*电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2850.00,,
|
||||
2,26442000005432652421,2026/05/18,*电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71,2900.00,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2880.00,,
|
||||
3,26442000005432937661,2026/05/18,*集成电路*集成电路 LED一批 个 2638.92 1% 26.39,集成电路 LED一批 个 2638.92 1% 26.39,2665.31,佛山市泓宇芯科技有限公司,陈陈,2026/04/28,,2661.61,,
|
||||
4,26442000005468940571,2026/05/18,*电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48,96.00,东莞市长安顺淘电子工具经营部,陈陈,2026/05/07,,96.00,,
|
||||
|
@@ -1,11 +0,0 @@
|
||||
# 发票信息汇总表
|
||||
|
||||
| 序号 | 发票号码 | 开票日期 | 项目名称 | 规格型号 | 价税合计 | 销售方名称 | 人员姓名 | 刷卡日期 | 公务卡号 | 刷卡金额 | 备注 | 工号 |
|
||||
|------|------|------|------|------|------|------|------|------|------|------|------|------|
|
||||
| 1 | 26442000005432755951 | 2026/05/18 | *电子元件*电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电容器 电容一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,850.00 | | |
|
||||
| 2 | 26442000005432652421 | 2026/05/18 | *电子元件*电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | 电阻 电阻一批 个 500000 0.0057425742574 2871.29 1% 28.71 | ¥2,900.00 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,880.00 | | |
|
||||
| 3 | 26442000005432937661 | 2026/05/18 | *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 | 集成电路 LED一批 个 2638.92 1% 26.39 | ¥2,665.31 | 佛山市泓宇芯科技有限公司 | 陈陈 | 2026/04/28 | | ¥2,661.61 | | |
|
||||
| 4 | 26442000005468940571 | 2026/05/18 | *电子工业设备*元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | 元件盒 1# 个 1 47.5247524752475 47.52 1% 0.48 | ¥96.00 | 东莞市长安顺淘电子工具经营部 | 陈陈 | 2026/05/07 | | ¥96.00 | | |
|
||||
|
||||
**价税合计总计: ¥8,561.31**
|
||||
**刷卡金额总计: ¥8,487.61**
|
||||
84
run_all.py
84
run_all.py
@@ -1,84 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
财务报销全流程编排脚本
|
||||
|
||||
依次执行:
|
||||
1. extract_invoice.py — 从 PDF 发票提取信息,生成 invoice_summary.csv
|
||||
2. extract_image_ocr.py — 从支付截图 OCR 识别刷卡信息,更新 CSV
|
||||
3. reimburse.py — 打开浏览器登录财务系统并自动填报
|
||||
|
||||
用法:
|
||||
python run_all.py # 默认执行全部三步
|
||||
python run_all.py --step invoice # 仅执行第 1 步
|
||||
python run_all.py --step ocr # 仅执行第 2 步
|
||||
python run_all.py --step submit # 仅执行第 3 步
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent.resolve()
|
||||
PYTHON = sys.executable
|
||||
|
||||
|
||||
def step(name: str, module: str, args: list[str]) -> bool:
|
||||
"""运行单个步骤,返回是否成功"""
|
||||
cmd = [PYTHON, str(PROJECT_DIR / module), *args]
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f" [{name}] {module}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
result = subprocess.run(cmd, cwd=str(PROJECT_DIR))
|
||||
ok = result.returncode == 0
|
||||
|
||||
if ok:
|
||||
print(f"\n[{name}] 完成")
|
||||
else:
|
||||
print(f"\n[错误] {name} 失败 (exit code: {result.returncode})")
|
||||
|
||||
return ok
|
||||
|
||||
|
||||
def run_all() -> int:
|
||||
print("=" * 60)
|
||||
print(" 财务报销自动化流程")
|
||||
print(f" 工作目录: {PROJECT_DIR}")
|
||||
print(f" Python: {PYTHON}")
|
||||
print("=" * 60)
|
||||
|
||||
if not step("发票提取", "extract_invoice.py", []):
|
||||
return 1
|
||||
|
||||
if not step("OCR 识别", "extract_image_ocr.py", []):
|
||||
return 1
|
||||
|
||||
if not step("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]):
|
||||
return 1
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(" 全流程执行完毕")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) >= 3 and sys.argv[1] == "--step":
|
||||
name = sys.argv[2]
|
||||
steps = {
|
||||
"invoice": ("发票提取", "extract_invoice.py", []),
|
||||
"ocr": ("OCR 识别", "extract_image_ocr.py", []),
|
||||
"submit": ("报销提交", "reimburse.py", ["--data", str(PROJECT_DIR / "invoice_summary.csv")]),
|
||||
}
|
||||
if name not in steps:
|
||||
print(f"未知步骤: {name},可选: {', '.join(steps)}")
|
||||
return 1
|
||||
label, module, args = steps[name]
|
||||
return 0 if step(label, module, args) else 1
|
||||
|
||||
return run_all()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,314 +0,0 @@
|
||||
<!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;">📂</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)) ? '📄' : '🖼'}</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})">×</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
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>
|
||||
15
web/app.py
15
web/app.py
@@ -369,6 +369,21 @@ def _escape_sse(text: str) -> str:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\ndata: ")
|
||||
|
||||
|
||||
@app.route("/mobile/<session_id>")
|
||||
def mobile_upload(session_id: str):
|
||||
"""移动端上传页面"""
|
||||
session_dir = UPLOAD_BASE / session_id
|
||||
if not session_dir.exists():
|
||||
return render_template("mobile_upload.html", error="会话不存在"), 404
|
||||
return render_template("mobile_upload.html", session_id=session_id)
|
||||
|
||||
|
||||
@app.route("/api/mobile-upload/<session_id>", methods=["POST"])
|
||||
def mobile_upload_file(session_id: str):
|
||||
"""移动端上传图片(复用 PC 上传逻辑)"""
|
||||
return upload_file(session_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||
print(f"启动 Web 服务: http://localhost:5000")
|
||||
|
||||
@@ -37,7 +37,7 @@ body { background: #f5f7fa; }
|
||||
|
||||
<!-- 上传区域 -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
@@ -46,7 +46,7 @@ body { background: #f5f7fa; }
|
||||
</div>
|
||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
@@ -55,6 +55,11 @@ body { background: #f5f7fa; }
|
||||
</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 快捷上传 -->
|
||||
@@ -126,6 +131,7 @@ body { background: #f5f7fa; }
|
||||
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
<script>
|
||||
let sessionId = null;
|
||||
const pdfFiles = [], imgFiles = [];
|
||||
@@ -357,6 +363,76 @@ function showResult(r) {
|
||||
${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>
|
||||
124
web/templates/mobile_upload.html
Normal file
124
web/templates/mobile_upload.html
Normal file
@@ -0,0 +1,124 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>支付截图上传</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { background: #f5f7fa; padding-bottom: 40px; }
|
||||
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; padding: 20px 16px; text-align: center; }
|
||||
.upload-btn { width: 100%; padding: 18px; font-size: 16px; border-radius: 12px; margin-top: 16px; }
|
||||
.img-preview { width: 100px; height: 100px; object-fit: cover; border-radius: 8px; margin: 4px; }
|
||||
.file-card { background: #fff; border-radius: 8px; padding: 8px; margin-bottom: 8px; display: flex; align-items: center; gap: 10px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.file-card img { width: 60px; height: 60px; object-fit: cover; border-radius: 6px; }
|
||||
.file-card .info { flex: 1; min-width: 0; }
|
||||
.file-card .name { font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.file-card .remove { color: #c00; cursor: pointer; padding: 4px 8px; }
|
||||
.status-msg { text-align: center; padding: 12px; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
{% if error %}
|
||||
<div class="header"><h5>错误</h5><p>{{ error }}</p></div>
|
||||
{% else %}
|
||||
|
||||
<div class="header">
|
||||
<h5>📸 支付截图上传</h5>
|
||||
<p class="mb-0 opacity-75" style="font-size:13px">拍照或从相册选择图片</p>
|
||||
</div>
|
||||
|
||||
<div class="container" style="max-width:480px">
|
||||
|
||||
<!-- 上传按钮 -->
|
||||
<input type="file" id="file-input" accept="image/*" multiple hidden onchange="handleSelect(this)">
|
||||
|
||||
<button class="btn btn-primary upload-btn" onclick="document.getElementById('file-input').click()">
|
||||
📷 选择图片 / 拍照
|
||||
</button>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<div id="upload-status" class="status-msg mt-3"></div>
|
||||
|
||||
<!-- 已上传图片列表 -->
|
||||
<h6 class="mt-3 mb-2">已上传 ({{ server_count | default(0) }})</h6>
|
||||
<div id="file-list"></div>
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const sessionId = "{{ session_id }}";
|
||||
|
||||
// ---- 加载已有文件 ----
|
||||
async function loadFiles() {
|
||||
try {
|
||||
const r = await fetch(`/api/files/${sessionId}`);
|
||||
const d = await r.json();
|
||||
renderList(d.images || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(images) {
|
||||
const box = document.getElementById('file-list');
|
||||
if (!images.length) {
|
||||
box.innerHTML = '<p class="text-muted text-center" style="font-size:13px">暂无图片</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = images.map(name => `
|
||||
<div class="file-card">
|
||||
<img src="/api/download/${sessionId}/${encodeURIComponent(name)}#t=image/png" alt="">
|
||||
<div class="info"><div class="name">${name}</div></div>
|
||||
<span class="remove" onclick="removeFile('${name}')">×</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// ---- 上传 ----
|
||||
async function handleSelect(input) {
|
||||
const files = Array.from(input.files);
|
||||
if (!files.length) return;
|
||||
|
||||
const status = document.getElementById('upload-status');
|
||||
let done = 0;
|
||||
status.innerHTML = `<div class="text-primary">上传中... 0/${files.length}</div>`;
|
||||
|
||||
for (const f of files) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
await fetch(`/api/upload/${sessionId}`, { method: 'POST', body: fd });
|
||||
done++;
|
||||
status.innerHTML = `<div class="text-primary">上传中... ${done}/${files.length}</div>`;
|
||||
} catch (e) {
|
||||
status.innerHTML = `<div class="text-danger">${f.name} 上传失败</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
if (done === files.length) {
|
||||
status.innerHTML = `<div class="text-success">✅ ${done} 张图片上传成功</div>`;
|
||||
} else {
|
||||
status.innerHTML = `<div class="text-warning">${done}/${files.length} 张上传成功</div>`;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// ---- 删除(前端视觉移除,实际保留在服务器)----
|
||||
function removeFile(name) {
|
||||
// 移动端暂不支持删除,提示用户回到 PC 端管理
|
||||
if (confirm('文件已上传到服务器。如需删除请回到电脑端操作。')) {
|
||||
loadFiles();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 定时刷新列表(同步 PC 端新增的文件)----
|
||||
setInterval(loadFiles, 5000);
|
||||
loadFiles();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user