Compare commits

..

2 Commits

Author SHA1 Message Date
wandering
70f61be3aa 清理示例文件和敏感数据 2026-05-25 13:51:57 +08:00
wandering
02e2ca90a7 增加手机上传图片的功能 2026-05-25 12:00:06 +08:00
18 changed files with 221 additions and 428 deletions

1
.gitignore vendored
View File

@@ -23,6 +23,7 @@ images/
# IDE
.vscode/
.idea/
.cursor/
# OS
.DS_Store

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

View File

@@ -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 附件,浏览器填报阶段将自动跳过附件上传步骤。

View File

@@ -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"
}

View File

@@ -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 序号 发票号码 开票日期 项目名称 规格型号 价税合计 销售方名称 人员姓名 刷卡日期 公务卡号 刷卡金额 备注 工号
2 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
3 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
4 3 26442000005432937661 2026/05/18 *集成电路*集成电路 LED一批 个 2638.92 1% 26.39 集成电路 LED一批 个 2638.92 1% 26.39 2665.31 佛山市泓宇芯科技有限公司 陈陈 2026/04/28 2661.61
5 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

View File

@@ -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**

View File

@@ -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())

View File

@@ -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;">&#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>

View File

@@ -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")

View File

@@ -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>

View 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}')">&times;</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>