增加手机上传图片的功能
This commit is contained in:
@@ -16,7 +16,8 @@
|
|||||||
├── web/
|
├── web/
|
||||||
│ ├── app.py # Web 服务入口
|
│ ├── app.py # Web 服务入口
|
||||||
│ ├── templates/
|
│ ├── templates/
|
||||||
│ │ └── index.html # Web 前端页面
|
│ │ ├── index.html # Web 前端页面
|
||||||
|
│ │ └── mobile_upload.html # 移动端扫码上传页面
|
||||||
│ └── uploads/ # 用户上传文件目录
|
│ └── uploads/ # 用户上传文件目录
|
||||||
├── *.pdf # 发票 PDF(按需放置)
|
├── *.pdf # 发票 PDF(按需放置)
|
||||||
├── *.png / *.jpg # 与 PDF 同名的支付截图
|
├── *.png / *.jpg # 与 PDF 同名的支付截图
|
||||||
@@ -133,6 +134,7 @@ python web/app.py
|
|||||||
| 实时日志 | 处理进度通过 SSE 实时推送 |
|
| 实时日志 | 处理进度通过 SSE 实时推送 |
|
||||||
| 下载 CSV | 处理后下载发票汇总表 |
|
| 下载 CSV | 处理后下载发票汇总表 |
|
||||||
| 配置上传 | 可上传 `config.json` 自动填充表单 |
|
| 配置上传 | 可上传 `config.json` 自动填充表单 |
|
||||||
|
| 手机扫码上传 | 支付截图区域显示二维码,手机端扫码后可拍照/选图上传,PC 端实时同步接收 |
|
||||||
|
|
||||||
> Web 模式下浏览器以无头模式运行,不会弹出窗口。
|
> Web 模式下浏览器以无头模式运行,不会弹出窗口。
|
||||||
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。
|
> 若未上传 PDF 附件,浏览器填报阶段将自动跳过附件上传步骤。
|
||||||
|
|||||||
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: ")
|
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__":
|
if __name__ == "__main__":
|
||||||
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
UPLOAD_BASE.mkdir(parents=True, exist_ok=True)
|
||||||
print(f"启动 Web 服务: http://localhost:5000")
|
print(f"启动 Web 服务: http://localhost:5000")
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ body { background: #f5f7fa; }
|
|||||||
|
|
||||||
<!-- 上传区域 -->
|
<!-- 上传区域 -->
|
||||||
<div class="row g-3 mb-4">
|
<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="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="upload-zone" id="pdf-zone" onclick="document.getElementById('pdf-input').click()">
|
||||||
<div class="icon">📁</div>
|
<div class="icon">📁</div>
|
||||||
@@ -46,7 +46,7 @@ body { background: #f5f7fa; }
|
|||||||
</div>
|
</div>
|
||||||
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
<input type="file" id="pdf-input" accept=".pdf" multiple hidden onchange="handleFiles(this, 'pdf')">
|
||||||
</div>
|
</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="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="upload-zone" id="img-zone" onclick="document.getElementById('img-input').click()">
|
||||||
<div class="icon">🖼️</div>
|
<div class="icon">🖼️</div>
|
||||||
@@ -55,6 +55,11 @@ body { background: #f5f7fa; }
|
|||||||
</div>
|
</div>
|
||||||
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
<input type="file" id="img-input" accept="image/*" multiple hidden onchange="handleFiles(this, 'img')">
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- CSV 快捷上传 -->
|
<!-- CSV 快捷上传 -->
|
||||||
@@ -126,6 +131,7 @@ body { background: #f5f7fa; }
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let sessionId = null;
|
let sessionId = null;
|
||||||
const pdfFiles = [], imgFiles = [];
|
const pdfFiles = [], imgFiles = [];
|
||||||
@@ -357,6 +363,76 @@ function showResult(r) {
|
|||||||
${mdLink}
|
${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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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