添加 上传接口
This commit is contained in:
@@ -2,3 +2,4 @@
|
|||||||
.feishu_oauth_tokens.json
|
.feishu_oauth_tokens.json
|
||||||
inventory_sync/__pycache__
|
inventory_sync/__pycache__
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
batch_inventory_data/*
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local inventory file upload page.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
python3 upload_inventory_page.py
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
http://127.0.0.1:8080
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import cgi
|
||||||
|
from datetime import datetime
|
||||||
|
from html import escape
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from typing import Iterable
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
HOST = "127.0.0.1"
|
||||||
|
PORT = 8080
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
UPLOAD_DIR = PROJECT_ROOT / "batch_inventory_data"
|
||||||
|
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def safe_filename(filename: str) -> str:
|
||||||
|
name = Path(filename or "inventory_file").name.strip()
|
||||||
|
name = re.sub(r"[^\w.\-\u4e00-\u9fff()() ]+", "_", name)
|
||||||
|
name = re.sub(r"\s+", "_", name)
|
||||||
|
return name or "inventory_file"
|
||||||
|
|
||||||
|
|
||||||
|
def unique_upload_path(filename: str) -> Path:
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
base_name = safe_filename(filename)
|
||||||
|
candidate = UPLOAD_DIR / f"{timestamp}_{base_name}"
|
||||||
|
counter = 1
|
||||||
|
while candidate.exists():
|
||||||
|
candidate = UPLOAD_DIR / f"{timestamp}_{counter}_{base_name}"
|
||||||
|
counter += 1
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def list_uploaded_files(limit: int = 20) -> list[Path]:
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
files = [path for path in UPLOAD_DIR.iterdir() if path.is_file() and not path.name.startswith(".")]
|
||||||
|
return sorted(files, key=lambda item: item.stat().st_mtime, reverse=True)[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def human_size(size: int) -> str:
|
||||||
|
units = ["B", "KB", "MB", "GB"]
|
||||||
|
value = float(size)
|
||||||
|
for unit in units:
|
||||||
|
if value < 1024 or unit == units[-1]:
|
||||||
|
return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}"
|
||||||
|
value /= 1024
|
||||||
|
return f"{size} B"
|
||||||
|
|
||||||
|
|
||||||
|
def render_recent_files(files: Iterable[Path]) -> str:
|
||||||
|
rows = []
|
||||||
|
for path in files:
|
||||||
|
stat = path.stat()
|
||||||
|
uploaded_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
rows.append(
|
||||||
|
"<tr>"
|
||||||
|
f"<td>{escape(path.name)}</td>"
|
||||||
|
f"<td>{escape(human_size(stat.st_size))}</td>"
|
||||||
|
f"<td>{escape(uploaded_at)}</td>"
|
||||||
|
"</tr>"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return '<p class="empty">还没有上传文件。上传成功后,最近文件会显示在这里。</p>'
|
||||||
|
return (
|
||||||
|
'<table class="file-table">'
|
||||||
|
"<thead><tr><th>文件名</th><th>大小</th><th>上传时间</th></tr></thead>"
|
||||||
|
f"<tbody>{''.join(rows)}</tbody>"
|
||||||
|
"</table>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(message: str = "", is_error: bool = False) -> bytes:
|
||||||
|
message_html = ""
|
||||||
|
if message:
|
||||||
|
message_class = "message error" if is_error else "message success"
|
||||||
|
message_html = f'<div class="{message_class}">{escape(message)}</div>'
|
||||||
|
|
||||||
|
html = f"""<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>委外批次库存文件上传</title>
|
||||||
|
<style>
|
||||||
|
:root {{
|
||||||
|
--ink: #101828;
|
||||||
|
--muted: #667085;
|
||||||
|
--line: #e4e7ec;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--soft: #f7f4ec;
|
||||||
|
--dark: #172033;
|
||||||
|
--accent: #087f5b;
|
||||||
|
--accent-2: #b7791f;
|
||||||
|
--accent-soft: #ecfdf3;
|
||||||
|
--danger: #b42318;
|
||||||
|
--shadow: 0 24px 70px rgba(16, 24, 40, .14);
|
||||||
|
}}
|
||||||
|
|
||||||
|
* {{
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
|
||||||
|
body {{
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: "Avenir Next", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 8% 8%, rgba(183, 121, 31, .16), transparent 24rem),
|
||||||
|
radial-gradient(circle at 92% 6%, rgba(8, 127, 91, .14), transparent 26rem),
|
||||||
|
linear-gradient(180deg, #f8fafc 0%, #f3f0e8 100%);
|
||||||
|
}}
|
||||||
|
|
||||||
|
main {{
|
||||||
|
width: min(1180px, calc(100% - 36px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 28px 0 48px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.shell {{
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(228, 231, 236, .92);
|
||||||
|
border-radius: 34px;
|
||||||
|
background: rgba(255, 255, 255, .74);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header {{
|
||||||
|
position: relative;
|
||||||
|
padding: 20px 26px;
|
||||||
|
color: #fff;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(23, 32, 51, .98), rgba(15, 68, 65, .95)),
|
||||||
|
radial-gradient(circle at 88% 20%, rgba(183, 121, 31, .42), transparent 18rem);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header::after {{
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -70px;
|
||||||
|
bottom: -150px;
|
||||||
|
width: 210px;
|
||||||
|
height: 210px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .12);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: -42px -26px 0 rgba(255, 255, 255, .05);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.nav {{
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.brand {{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.brand-icon {{
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--dark);
|
||||||
|
background: #f2c46d;
|
||||||
|
font-weight: 900;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.usage {{
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .16);
|
||||||
|
border-radius: 999px;
|
||||||
|
color: rgba(255, 255, 255, .78);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.title-row {{
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}}
|
||||||
|
|
||||||
|
h1 {{
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(28px, 4vw, 42px);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -1.5px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.subtitle {{
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: rgba(255, 255, 255, .74);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.58;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.storage-card {{
|
||||||
|
width: min(330px, 100%);
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, .14);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, .09);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.storage-card span {{
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: rgba(255, 255, 255, .58);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .12em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.storage-card strong {{
|
||||||
|
display: block;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.workspace {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
|
||||||
|
gap: 24px;
|
||||||
|
padding: 26px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(248, 250, 252, .96), rgba(247, 244, 236, .96));
|
||||||
|
}}
|
||||||
|
|
||||||
|
.drop-panel, .side-panel, .recent {{
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 28px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 16px 44px rgba(16, 24, 40, .08);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.drop-panel {{
|
||||||
|
padding: 18px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.message {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.success {{
|
||||||
|
color: #05603a;
|
||||||
|
background: #ecfdf3;
|
||||||
|
border: 1px solid #abefc6;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.error {{
|
||||||
|
color: var(--danger);
|
||||||
|
background: #fef3f2;
|
||||||
|
border: 1px solid #fecdca;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone {{
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 470px;
|
||||||
|
padding: 44px;
|
||||||
|
border: 2px dashed #98a2b3;
|
||||||
|
border-radius: 24px;
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
linear-gradient(#fff, #fff) padding-box,
|
||||||
|
repeating-linear-gradient(45deg, rgba(8, 127, 91, .08) 0 10px, rgba(183, 121, 31, .08) 10px 20px);
|
||||||
|
transition: transform .18s ease, border-color .18s ease, box-shadow .18s ease, background .18s ease;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone.dragging {{
|
||||||
|
border-color: var(--accent);
|
||||||
|
background:
|
||||||
|
linear-gradient(#f0fdf4, #ffffff) padding-box,
|
||||||
|
repeating-linear-gradient(45deg, rgba(8, 127, 91, .16) 0 10px, rgba(183, 121, 31, .10) 10px 20px);
|
||||||
|
box-shadow: inset 0 0 0 8px rgba(8, 127, 91, .08);
|
||||||
|
transform: scale(.992);
|
||||||
|
}}
|
||||||
|
|
||||||
|
.drop-icon {{
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 108px;
|
||||||
|
height: 108px;
|
||||||
|
margin: 0 auto 26px;
|
||||||
|
border-radius: 32px;
|
||||||
|
color: #fff;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, var(--accent), #0f5f56 62%, var(--accent-2));
|
||||||
|
box-shadow: 0 18px 40px rgba(8, 127, 91, .28);
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: 900;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.drop-kicker {{
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: var(--accent-2);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: .18em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone h2 {{
|
||||||
|
margin: 0;
|
||||||
|
font-size: clamp(30px, 4vw, 48px);
|
||||||
|
line-height: 1.05;
|
||||||
|
letter-spacing: -1.6px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone p {{
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 16px auto 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.drop-status {{
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 220px;
|
||||||
|
margin-top: 28px;
|
||||||
|
padding: 13px 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #344054;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-weight: 900;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone.dragging .drop-status {{
|
||||||
|
color: #05603a;
|
||||||
|
background: #dcfae6;
|
||||||
|
border-color: #75e0a7;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.side-panel {{
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.side-panel h2, .recent h2 {{
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -.4px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.rule-list {{
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.rule-list li {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 34px 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fcfcfd;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.rule-index {{
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--dark);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 900;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.rule-list strong {{
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.rule-list span {{
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.recent {{
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 24px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.file-table {{
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #fff;
|
||||||
|
}}
|
||||||
|
|
||||||
|
th, td {{
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
th {{
|
||||||
|
color: #344054;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: .08em;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.empty {{
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {{
|
||||||
|
main {{
|
||||||
|
width: min(100% - 24px, 760px);
|
||||||
|
padding: 22px 0 36px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.header {{
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.nav, .title-row {{
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.nav {{
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.workspace {{
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
padding: 18px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
.dropzone {{
|
||||||
|
min-height: 380px;
|
||||||
|
padding: 28px;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div class="shell">
|
||||||
|
<section class="header">
|
||||||
|
<div class="nav">
|
||||||
|
<div class="brand"><span class="brand-icon">委</span><span>委外批次库存上传台</span></div>
|
||||||
|
<div class="usage">每周约 2 次使用</div>
|
||||||
|
</div>
|
||||||
|
<div class="title-row">
|
||||||
|
<div>
|
||||||
|
<h1>投递委外批次库存文件</h1>
|
||||||
|
<p class="subtitle">拖入委外仓、加工方或批次盘点文件,系统会按上传时间自动归档,便于后续同步、核对和追溯。</p>
|
||||||
|
</div>
|
||||||
|
<div class="storage-card">
|
||||||
|
<span>ARCHIVE PATH</span>
|
||||||
|
<strong>{escape(str(UPLOAD_DIR))}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="workspace">
|
||||||
|
<div class="drop-panel">
|
||||||
|
{message_html}
|
||||||
|
<div class="dropzone" id="dropzone">
|
||||||
|
<div>
|
||||||
|
<div class="drop-icon">⇣</div>
|
||||||
|
<div class="drop-kicker">DROP FILE HERE</div>
|
||||||
|
<h2>拖入文件,立即上传</h2>
|
||||||
|
<p>支持 Excel、CSV 等委外批次库存文件。松开鼠标后自动保存到 batch_inventory_data,无需点击选择文件。</p>
|
||||||
|
<div class="drop-status" id="dropStatus">等待拖入文件</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="side-panel">
|
||||||
|
<h2>上传规范</h2>
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li><span class="rule-index">01</span><div><strong>文件类型</strong><span>建议上传 Excel 或 CSV 格式的委外批次库存文件。</span></div></li>
|
||||||
|
<li><span class="rule-index">02</span><div><strong>文件命名</strong><span>系统会自动追加上传时间,不需要手动改名。</span></div></li>
|
||||||
|
<li><span class="rule-index">03</span><div><strong>大小限制</strong><span>单文件最大 {escape(human_size(MAX_UPLOAD_BYTES))}。</span></div></li>
|
||||||
|
<li><span class="rule-index">04</span><div><strong>保存位置</strong><span>上传后统一进入 batch_inventory_data 目录。</span></div></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="recent">
|
||||||
|
<h2>最近上传的委外批次文件</h2>
|
||||||
|
{render_recent_files(list_uploaded_files())}
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
const dropzone = document.getElementById("dropzone");
|
||||||
|
const dropStatus = document.getElementById("dropStatus");
|
||||||
|
|
||||||
|
function stopDefaults(event) {{
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
|
||||||
|
["dragenter", "dragover", "dragleave", "drop"].forEach((eventName) => {{
|
||||||
|
document.addEventListener(eventName, stopDefaults);
|
||||||
|
}});
|
||||||
|
|
||||||
|
["dragenter", "dragover"].forEach((eventName) => {{
|
||||||
|
dropzone.addEventListener(eventName, () => {{
|
||||||
|
dropzone.classList.add("dragging");
|
||||||
|
dropStatus.textContent = "松开鼠标开始上传";
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
["dragleave", "drop"].forEach((eventName) => {{
|
||||||
|
dropzone.addEventListener(eventName, () => {{
|
||||||
|
dropzone.classList.remove("dragging");
|
||||||
|
}});
|
||||||
|
}});
|
||||||
|
|
||||||
|
dropzone.addEventListener("drop", async (event) => {{
|
||||||
|
const files = event.dataTransfer && event.dataTransfer.files;
|
||||||
|
if (!files || !files.length) {{
|
||||||
|
dropStatus.textContent = "没有检测到文件,请重新拖入。";
|
||||||
|
return;
|
||||||
|
}}
|
||||||
|
|
||||||
|
dropStatus.textContent = `上传中:${{files[0].name}}`;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("inventory_file", files[0], files[0].name);
|
||||||
|
|
||||||
|
try {{
|
||||||
|
const response = await fetch("/upload", {{
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
}});
|
||||||
|
window.location.href = response.url || "/";
|
||||||
|
}} catch (error) {{
|
||||||
|
dropStatus.textContent = "上传失败,请重试。";
|
||||||
|
}}
|
||||||
|
}});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return html.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class UploadHandler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "InventoryUpload/1.0"
|
||||||
|
|
||||||
|
def do_GET(self) -> None:
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/favicon.ico":
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if parsed.path != "/":
|
||||||
|
self.send_error(404, "Not Found")
|
||||||
|
return
|
||||||
|
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
message = params.get("message", [""])[0]
|
||||||
|
is_error = params.get("error", ["0"])[0] == "1"
|
||||||
|
self.respond_html(render_page(message, is_error))
|
||||||
|
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path != "/upload":
|
||||||
|
self.send_error(404, "Not Found")
|
||||||
|
return
|
||||||
|
|
||||||
|
content_length = int(self.headers.get("Content-Length", "0") or "0")
|
||||||
|
if content_length <= 0:
|
||||||
|
self.redirect("/", "没有收到上传文件。", is_error=True)
|
||||||
|
return
|
||||||
|
if content_length > MAX_UPLOAD_BYTES:
|
||||||
|
self.redirect("/", f"文件太大,最大支持 {human_size(MAX_UPLOAD_BYTES)}。", is_error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={
|
||||||
|
"REQUEST_METHOD": "POST",
|
||||||
|
"CONTENT_TYPE": self.headers.get("Content-Type", ""),
|
||||||
|
"CONTENT_LENGTH": str(content_length),
|
||||||
|
})
|
||||||
|
file_item = form["inventory_file"] if "inventory_file" in form else None
|
||||||
|
if file_item is None or not getattr(file_item, "filename", ""):
|
||||||
|
self.redirect("/", "请选择需要上传的文件。", is_error=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
target_path = unique_upload_path(file_item.filename)
|
||||||
|
with target_path.open("wb") as output:
|
||||||
|
shutil.copyfileobj(file_item.file, output)
|
||||||
|
|
||||||
|
self.redirect("/", f"上传成功:{target_path.name}")
|
||||||
|
|
||||||
|
def respond_html(self, body: bytes) -> None:
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def redirect(self, path: str, message: str, is_error: bool = False) -> None:
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
query = urlencode({"message": message, "error": "1" if is_error else "0"})
|
||||||
|
self.send_response(303)
|
||||||
|
self.send_header("Location", f"{path}?{query}")
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
print(f"[{timestamp}] {self.address_string()} - {format % args}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
server = ThreadingHTTPServer((HOST, PORT), UploadHandler)
|
||||||
|
print(f"上传页面已启动: http://{HOST}:{PORT}")
|
||||||
|
print(f"文件保存目录: {UPLOAD_DIR}")
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n上传页面已停止。")
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user