This commit is contained in:
2026-06-12 14:27:19 +08:00
commit d8dccfc04c
12 changed files with 1796 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
.env
.git
.gitignore
uploads/*
__pycache__
*.pyc

View File

@@ -0,0 +1,28 @@
# ── 文件上传网站配置 ──────────────────────────────────────
# 复制本文件为 .env 并修改对应值cp .env.example .env
# Flask 密钥(生产环境务必更换为随机字符串)
SECRET_KEY=file-upload-site-secret-key-change-me
# NAS 文件存储根目录(绝对路径)
# Docker 部署时保持默认 /app/uploads通过 docker-compose volumes 映射宿主机目录
# 直接部署时改为 NAS 实际路径
# UPLOAD_ROOT=/mnt/nas/文字稿
# 单文件大小限制(字节),默认 20MB
# MAX_FILE_SIZE=20971520
# 单次最多上传文件数
# MAX_FILES_PER_UPLOAD=9
# Rate Limiting单 IP 每小时最多上传次数
# RATE_LIMIT_PER_HOUR=20
# 验证码过期时间(秒)
# CAPTCHA_EXPIRY=300
# 服务监听端口Docker 部署时端口映射在 docker-compose.yml 中配置)
# PORT=5000
# 是否开启 debug 模式true/false
# DEBUG=false

8
file-upload-site/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.env
uploads/
__pycache__/
*.pyc
*.pyo
.venv/
venv/
.DS_Store

View File

@@ -0,0 +1,22 @@
FROM python:3.10-slim
# 系统依赖python-magic 需要 libmagic
RUN apt-get update && \
apt-get install -y --no-install-recommends libmagic1 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
# 先复制依赖文件,利用 Docker 缓存
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 复制应用代码
COPY . .
# 创建默认上传目录
RUN mkdir -p /app/uploads
EXPOSE 5000
CMD ["python3", "app.py"]

300
file-upload-site/app.py Normal file
View File

@@ -0,0 +1,300 @@
"""
文件上传网站 - 后端服务
基于 Flask提供文件上传、图形验证码、Rate Limiting 等功能
"""
import os
import re
import time
import random
import string
from io import BytesIO
from datetime import datetime
from flask import Flask, request, jsonify, render_template, session, send_file
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from PIL import Image, ImageDraw, ImageFont
import magic
from config import Config
# ── 初始化 Flask 应用 ──────────────────────────────────────────────
app = Flask(__name__)
app.config.from_object(Config)
app.secret_key = Config.SECRET_KEY
# ── Rate Limiter ───────────────────────────────────────────────────
limiter = Limiter(
app=app,
key_func=get_remote_address,
default_limits=[],
storage_uri="memory://"
)
# ── 常量 ───────────────────────────────────────────────────────────
# 姓名校验中文、英文字母、数字、下划线2-20 字符
NAME_REGEX = re.compile(r'^[\u4e00-\u9fa5a-zA-Z0-9_]{2,20}$')
# 文件名中不安全的字符
UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
# ── 工具函数 ────────────────────────────────────────────────────────
def sanitize_name(name: str) -> str | None:
"""校验并清洗姓名,防止路径穿越攻击"""
name = name.strip()
# 移除路径穿越字符
name = name.replace('..', '').replace('/', '').replace('\\', '')
# 校验格式
if not NAME_REGEX.match(name):
return None
return name
def sanitize_filename(filename: str) -> str:
"""清洗文件名,移除不安全字符"""
# 只保留文件名部分(去掉路径)
filename = os.path.basename(filename)
# 移除不安全字符
filename = UNSAFE_FILENAME_CHARS.sub('', filename)
# 去掉首尾空格和点
filename = filename.strip(' .')
# 限制长度(保留扩展名)
if len(filename) > 200:
name_part, ext = os.path.splitext(filename)
filename = name_part[:200 - len(ext)] + ext
# 如果文件名为空,使用默认名
return filename if filename else 'unnamed.txt'
def verify_file_type(file_data: bytes, filename: str) -> tuple[bool, str | None]:
"""通过 Magic Bytes 校验文件真实类型"""
try:
mime = magic.from_buffer(file_data, mime=True)
except Exception:
return False, '无法识别文件类型'
if mime not in Config.ALLOWED_MIME_TYPES:
return False, f'不支持的文件类型 ({mime}),请上传文字类文档'
return True, None
def generate_captcha_text(length: int = 4) -> str:
"""生成随机验证码文本(去掉易混淆字符)"""
chars = string.ascii_uppercase + string.digits
for c in 'O0I1L':
chars = chars.replace(c, '')
return ''.join(random.choices(chars, k=length))
def generate_captcha_image(text: str) -> Image.Image:
"""生成验证码图片(含干扰线和噪点)"""
width, height = 140, 48
# 浅色随机背景
bg_color = (random.randint(235, 255), random.randint(235, 255), random.randint(235, 255))
image = Image.new('RGB', (width, height), bg_color)
draw = ImageDraw.Draw(image)
# 干扰线
for _ in range(6):
x1, y1 = random.randint(0, width), random.randint(0, height)
x2, y2 = random.randint(0, width), random.randint(0, height)
line_color = (random.randint(160, 210), random.randint(160, 210), random.randint(160, 210))
draw.line([(x1, y1), (x2, y2)], fill=line_color, width=1)
# 噪点
for _ in range(80):
x, y = random.randint(0, width - 1), random.randint(0, height - 1)
dot_color = (random.randint(140, 200), random.randint(140, 200), random.randint(140, 200))
draw.point((x, y), fill=dot_color)
# 绘制文字
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 30)
except OSError:
font = ImageFont.load_default()
for i, char in enumerate(text):
x = 12 + i * 30
y = random.randint(4, 10)
color = (random.randint(0, 80), random.randint(0, 80), random.randint(0, 80))
draw.text((x, y), char, font=font, fill=color)
return image
def update_upload_log(user_dir: str, name: str, results: list, timestamp: str):
"""更新用户文件夹中的 Markdown 上传日志"""
log_file = os.path.join(user_dir, '00_上传日志.md')
# 统计累计文件数(排除日志文件)
total_files = len([f for f in os.listdir(user_dir) if not f.startswith('00_')])
# 构建日志条目
now_str = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
lines = [
f"\n### {now_str}",
f"- 上传者:{name}",
f"- 本次上传文件数:{len(results)}",
"| 原文件名 | 存储文件名 |",
"|---------|----------|",
]
for r in results:
lines.append(f"| {r['filename']} | {r['storage_filename']} |")
lines.append(f"- 累计总文件数:{total_files}")
entry = '\n'.join(lines) + '\n'
# 写入或追加
if os.path.exists(log_file):
with open(log_file, 'a', encoding='utf-8') as f:
f.write(entry)
else:
with open(log_file, 'w', encoding='utf-8') as f:
f.write("## 上传记录\n")
f.write(entry)
# ── 路由 ────────────────────────────────────────────────────────────
@app.route('/')
def index():
"""渲染上传页面"""
return render_template('index.html')
@app.route('/api/captcha')
def get_captcha():
"""生成并返回新的验证码图片"""
text = generate_captcha_text()
session['captcha'] = text.lower()
session['captcha_time'] = time.time()
image = generate_captcha_image(text)
buf = BytesIO()
image.save(buf, format='PNG')
buf.seek(0)
response = send_file(buf, mimetype='image/png')
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
return response
@app.route('/api/upload', methods=['POST'])
@limiter.limit(f"{Config.RATE_LIMIT_PER_HOUR}/hour")
def upload_file():
"""处理文件上传"""
# ── 1. 校验验证码 ──────────────────────────────────────────
captcha_input = request.form.get('captcha', '').strip().lower()
captcha_expected = session.get('captcha')
captcha_time = session.get('captcha_time', 0)
if not captcha_expected:
return jsonify({'error': '请先获取验证码'}), 400
if time.time() - captcha_time > Config.CAPTCHA_EXPIRY:
session.pop('captcha', None)
session.pop('captcha_time', None)
return jsonify({'error': '验证码已过期,请刷新'}), 400
if captcha_input != captcha_expected:
return jsonify({'error': '验证码错误'}), 400
# 验证码使用后立即清除(一次性)
session.pop('captcha', None)
session.pop('captcha_time', None)
# ── 2. 校验姓名 ────────────────────────────────────────────
raw_name = request.form.get('name', '')
name = sanitize_name(raw_name)
if not name:
return jsonify({'error': '姓名格式不正确2-20个字符仅支持中文、英文字母、数字和下划线'}), 400
# ── 3. 校验文件列表 ────────────────────────────────────────
files = request.files.getlist('files')
if not files or all(f.filename == '' for f in files):
return jsonify({'error': '请选择要上传的文件'}), 400
valid_files = [f for f in files if f.filename]
if len(valid_files) > Config.MAX_FILES_PER_UPLOAD:
return jsonify({'error': f'单次最多上传 {Config.MAX_FILES_PER_UPLOAD} 个文件'}), 400
# ── 4. 创建用户目录 ────────────────────────────────────────
user_dir = os.path.join(Config.UPLOAD_ROOT, name)
os.makedirs(user_dir, exist_ok=True)
# ── 5. 逐个处理文件 ────────────────────────────────────────
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
results = []
for file in valid_files:
original_filename = file.filename
file_data = file.read()
# 5a. 文件大小校验
if len(file_data) > Config.MAX_FILE_SIZE:
results.append({
'filename': original_filename,
'success': False,
'error': f'文件超过 {Config.MAX_FILE_SIZE // (1024 * 1024)}MB 限制'
})
continue
# 5b. 文件类型校验Magic Bytes
valid, error_msg = verify_file_type(file_data, original_filename)
if not valid:
results.append({
'filename': original_filename,
'success': False,
'error': error_msg
})
continue
# 5c. 清洗文件名 + 生成存储文件名
safe_name = sanitize_filename(original_filename)
storage_filename = f"{timestamp}_{safe_name}"
filepath = os.path.join(user_dir, storage_filename)
# 处理同名文件(同一秒内上传的)
counter = 1
while os.path.exists(filepath):
name_part, ext = os.path.splitext(safe_name)
storage_filename = f"{timestamp}_{name_part}_{counter}{ext}"
filepath = os.path.join(user_dir, storage_filename)
counter += 1
# 5d. 写入文件
with open(filepath, 'wb') as f:
f.write(file_data)
results.append({
'filename': original_filename,
'storage_filename': storage_filename,
'success': True
})
# ── 6. 更新上传日志 ────────────────────────────────────────
success_results = [r for r in results if r['success']]
if success_results:
update_upload_log(user_dir, name, success_results, timestamp)
# ── 7. 返回结果 ────────────────────────────────────────────
total_files = len([f for f in os.listdir(user_dir) if not f.startswith('00_')])
return jsonify({
'success': len(success_results) > 0,
'results': results,
'success_count': len(success_results),
'fail_count': len(results) - len(success_results),
'total_files': total_files,
'storage_path': f"文字稿/{name}/"
})
# ── 自定义 Rate Limit 错误处理 ──────────────────────────────────────
@app.errorhandler(429)
def ratelimit_handler(e):
return jsonify({'error': '上传过于频繁,请稍后再试(每小时最多 20 次上传)'}), 429
# ── 启动 ────────────────────────────────────────────────────────────
if __name__ == '__main__':
os.makedirs(Config.UPLOAD_ROOT, exist_ok=True)
app.run(host='0.0.0.0', port=Config.PORT, debug=Config.DEBUG)

View File

@@ -0,0 +1,48 @@
import os
from dotenv import load_dotenv
# 加载 .env 文件
load_dotenv(os.path.join(os.path.dirname(__file__), '.env'))
class Config:
"""应用配置(全部从环境变量 / .env 文件读取)"""
# Flask 密钥
SECRET_KEY = os.getenv('SECRET_KEY', 'file-upload-site-secret-key-change-me')
# NAS 文件存储根目录
UPLOAD_ROOT = os.getenv('UPLOAD_ROOT', os.path.join(os.path.dirname(__file__), 'uploads'))
# 单文件大小限制(字节),默认 20MB
MAX_FILE_SIZE = int(os.getenv('MAX_FILE_SIZE', str(20 * 1024 * 1024)))
# 单次最多上传文件数
MAX_FILES_PER_UPLOAD = int(os.getenv('MAX_FILES_PER_UPLOAD', '9'))
# Rate Limiting单 IP 每小时最多上传次数
RATE_LIMIT_PER_HOUR = int(os.getenv('RATE_LIMIT_PER_HOUR', '20'))
# 验证码过期时间(秒)
CAPTCHA_EXPIRY = int(os.getenv('CAPTCHA_EXPIRY', '300'))
# 服务监听端口
PORT = int(os.getenv('PORT', '5000'))
# 是否开启 debug 模式
DEBUG = os.getenv('DEBUG', 'false').lower() in ('true', '1', 'yes')
# 允许的文件 MIME 类型
ALLOWED_MIME_TYPES = {
'text/plain': '.txt',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/pdf': '.pdf',
'application/rtf': '.rtf',
'text/rtf': '.rtf',
'application/vnd.oasis.opendocument.text': '.odt',
'text/html': '.html',
'text/csv': '.csv',
'application/vnd.ms-excel': '.xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
}

View File

@@ -0,0 +1,12 @@
services:
file-upload:
build: .
container_name: file-upload-site
restart: unless-stopped
ports:
- "7001:5000"
env_file:
- .env
volumes:
# 映射 NAS 目录到容器内(按实际 NAS 挂载路径修改冒号左侧)
- /volume1/文字稿:/app/uploads

View File

@@ -0,0 +1,5 @@
Flask==3.1.1
Flask-Limiter==3.12
Pillow==11.2.1
python-magic==0.4.27
python-dotenv==1.1.0

View File

@@ -0,0 +1,742 @@
/* ── 基础变量 ───────────────────────────────────────────── */
:root {
--bg: #faf9f7;
--bg-card: #ffffff;
--bg-hover: #f5f4f2;
--ink: #1a1816;
--muted: #7a756e;
--border: #e8e5e0;
--border-focus: #c4a882;
--accent: #b45309;
--accent-hover: #92400e;
--accent-light: #fef3c7;
--success: #0f766e;
--success-bg: #f0fdfa;
--error: #dc2626;
--error-bg: #fef2f2;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--shadow-lg: 0 10px 40px rgba(0,0,0,0.06), 0 2px 10px rgba(0,0,0,0.04);
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-mono: "SF Mono", "Fira Code", "Fira Mono", "Roboto Mono", monospace;
--transition: 0.2s ease;
}
/* ── 重置 ───────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--font);
color: var(--ink);
background: var(--bg);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem 1rem;
line-height: 1.6;
}
/* ── 容器 ───────────────────────────────────────────────── */
.container {
width: 100%;
max-width: 520px;
}
/* ── 头部 ───────────────────────────────────────────────── */
.header {
text-align: center;
margin-bottom: 2rem;
}
.header-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent), #d97706);
color: white;
margin-bottom: 1rem;
box-shadow: 0 4px 14px rgba(180, 83, 9, 0.25);
}
.header h1 {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--ink);
margin-bottom: 0.25rem;
}
.subtitle {
font-size: 0.9rem;
color: var(--muted);
}
/* ── 表单卡片 ───────────────────────────────────────────── */
.form {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.75rem;
box-shadow: var(--shadow);
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group:last-of-type {
margin-bottom: 1rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
font-weight: 600;
color: var(--ink);
margin-bottom: 0.5rem;
}
.form-group label svg {
color: var(--muted);
}
.form-hint {
display: block;
font-size: 0.75rem;
color: var(--muted);
margin-top: 0.35rem;
}
/* ── 输入框 ─────────────────────────────────────────────── */
input[type="text"] {
width: 100%;
padding: 0.65rem 0.9rem;
font-size: 0.9rem;
font-family: var(--font);
color: var(--ink);
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
}
input[type="text"]::placeholder {
color: #b5b0a8;
}
input[type="text"]:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(196, 168, 130, 0.15);
}
input[type="text"].input-error {
border-color: var(--error);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.1);
}
/* ── 验证码行 ───────────────────────────────────────────── */
.captcha-row {
display: flex;
gap: 10px;
align-items: stretch;
}
.captcha-row input {
flex: 1;
min-width: 0;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.captcha-img {
height: 42px;
width: 140px;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border);
cursor: pointer;
object-fit: cover;
transition: opacity var(--transition);
flex-shrink: 0;
}
.captcha-img:hover {
opacity: 0.85;
}
.btn-icon {
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--muted);
cursor: pointer;
transition: all var(--transition);
flex-shrink: 0;
}
.btn-icon:hover {
border-color: var(--border-focus);
color: var(--accent);
background: var(--accent-light);
}
/* ── 拖拽区 ─────────────────────────────────────────────── */
.dropzone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 2rem 1.5rem;
text-align: center;
cursor: pointer;
transition: all var(--transition);
background: var(--bg);
}
.dropzone:hover,
.dropzone.dragover {
border-color: var(--accent);
background: var(--accent-light);
}
.dropzone.dragover {
transform: scale(1.01);
}
.dropzone-content {
pointer-events: none;
}
.dropzone-icon {
color: var(--muted);
margin-bottom: 0.5rem;
transition: color var(--transition);
}
.dropzone:hover .dropzone-icon,
.dropzone.dragover .dropzone-icon {
color: var(--accent);
}
.dropzone-text {
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 0.35rem;
}
.dropzone-link {
color: var(--accent);
font-weight: 600;
text-decoration: underline;
text-decoration-thickness: 1.5px;
text-underline-offset: 2px;
}
.dropzone-hint {
font-size: 0.75rem;
color: #b5b0a8;
line-height: 1.5;
}
/* ── 文件列表 ───────────────────────────────────────────── */
.file-list {
margin-bottom: 1rem;
}
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 0.6rem 0.85rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 6px;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.file-item-icon {
flex-shrink: 0;
color: var(--muted);
}
.file-item-info {
flex: 1;
min-width: 0;
}
.file-item-name {
font-size: 0.85rem;
font-weight: 500;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-item-meta {
font-size: 0.72rem;
color: var(--muted);
margin-top: 1px;
}
.file-item-status {
flex-shrink: 0;
font-size: 0.78rem;
font-weight: 600;
}
.file-item-status.status-waiting {
color: var(--muted);
}
.file-item-status.status-uploading {
color: var(--accent);
}
.file-item-status.status-done {
color: var(--success);
}
.file-item-status.status-error {
color: var(--error);
}
.file-item-remove {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all var(--transition);
}
.file-item-remove:hover {
background: var(--error-bg);
color: var(--error);
}
/* 进度条 */
.progress-bar {
width: 100%;
height: 3px;
background: var(--border);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), #d97706);
border-radius: 2px;
transition: width 0.3s ease;
width: 0%;
}
/* ── 按钮 ───────────────────────────────────────────────── */
.form-actions {
display: flex;
gap: 10px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0.7rem 1.5rem;
font-size: 0.9rem;
font-weight: 600;
font-family: var(--font);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
flex: 1;
background: linear-gradient(135deg, var(--accent), #d97706);
color: white;
box-shadow: 0 2px 8px rgba(180, 83, 9, 0.2);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(180, 83, 9, 0.35);
transform: translateY(-1px);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-secondary {
background: var(--bg);
color: var(--muted);
border: 1.5px solid var(--border);
}
.btn-secondary:hover {
border-color: var(--border-focus);
color: var(--ink);
background: var(--bg-hover);
}
/* ── 结果面板 ───────────────────────────────────────────── */
.result-panel {
margin-top: 1.25rem;
animation: slideIn 0.3s ease;
}
.result-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
box-shadow: var(--shadow);
}
.result-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 0.75rem;
}
.result-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
flex-shrink: 0;
}
.result-icon.icon-success {
background: var(--success-bg);
color: var(--success);
}
.result-icon.icon-error {
background: var(--error-bg);
color: var(--error);
}
.result-title {
font-size: 1rem;
font-weight: 700;
}
.result-title.text-success {
color: var(--success);
}
.result-title.text-error {
color: var(--error);
}
.result-detail {
font-size: 0.85rem;
color: var(--muted);
line-height: 1.7;
}
.result-detail strong {
color: var(--ink);
font-weight: 600;
}
.result-files {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.result-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 0.35rem 0;
font-size: 0.82rem;
}
.result-file-item svg {
flex-shrink: 0;
}
.result-file-item.file-success {
color: var(--success);
}
.result-file-item.file-error {
color: var(--error);
}
/* ── 弹窗 ───────────────────────────────────────────────── */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 1rem;
}
.modal-overlay.active {
display: flex;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.modal {
background: var(--bg-card);
border-radius: 14px;
width: 100%;
max-width: 380px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 4px 20px rgba(0, 0, 0, 0.08);
animation: modalSlideUp 0.25s ease;
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.1rem 1.25rem 0;
}
.modal-header h3 {
font-size: 1.05rem;
font-weight: 700;
color: var(--ink);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all var(--transition);
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--ink);
}
.modal-body {
padding: 1rem 1.25rem;
}
.modal-desc {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 1rem;
}
.modal-captcha-row {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 0.75rem;
}
.modal-captcha-img {
height: 46px;
flex: 1;
border-radius: var(--radius-sm);
border: 1.5px solid var(--border);
cursor: pointer;
object-fit: contain;
background: var(--bg);
transition: opacity var(--transition);
}
.modal-captcha-img:hover {
opacity: 0.85;
}
.modal-captcha-input {
width: 100%;
padding: 0.7rem 0.9rem;
font-size: 1.1rem;
font-family: var(--font);
color: var(--ink);
background: var(--bg);
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
outline: none;
letter-spacing: 0.2em;
text-transform: uppercase;
text-align: center;
transition: border-color var(--transition), box-shadow var(--transition);
}
.modal-captcha-input::placeholder {
letter-spacing: normal;
text-transform: none;
font-size: 0.85rem;
color: #b5b0a8;
}
.modal-captcha-input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px rgba(196, 168, 130, 0.15);
}
.modal-error {
font-size: 0.8rem;
color: var(--error);
min-height: 1.2rem;
margin-top: 0.4rem;
}
.modal-footer {
display: flex;
gap: 10px;
padding: 0 1.25rem 1.25rem;
}
.modal-footer .btn {
flex: 1;
padding: 0.65rem 1rem;
}
/* ── 页脚 ───────────────────────────────────────────────── */
.footer {
margin-top: 2rem;
text-align: center;
font-size: 0.78rem;
color: #b5b0a8;
}
/* ── 响应式 ─────────────────────────────────────────────── */
@media (max-width: 560px) {
body {
padding: 1rem 0.75rem;
}
.header {
margin-bottom: 1rem;
}
.header-icon {
width: 40px;
height: 40px;
border-radius: 10px;
margin-bottom: 0.5rem;
}
.header-icon svg {
width: 22px;
height: 22px;
}
.header h1 {
font-size: 1.2rem;
}
.subtitle {
font-size: 0.8rem;
}
.form {
padding: 1.25rem;
}
.dropzone {
padding: 1.5rem 1rem;
}
.btn-icon {
width: 38px;
height: 38px;
}
.form-actions {
flex-direction: column;
}
}
/* ── 工具类 ─────────────────────────────────────────────── */
.hidden {
display: none !important;
}
.shake {
animation: shake 0.4s ease;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-6px); }
40%, 80% { transform: translateX(6px); }
}

View File

@@ -0,0 +1,493 @@
/**
* 文件上传网站 - 前端交互逻辑
*/
(function () {
'use strict';
// ── 常量 ────────────────────────────────────────────────
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
const MAX_FILES = 9;
const ALLOWED_EXTENSIONS = ['.txt', '.doc', '.docx', '.pdf', '.rtf', '.odt', '.html', '.csv', '.xls', '.xlsx'];
// ── DOM 元素 ────────────────────────────────────────────
const $name = document.getElementById('name');
const $dropzone = document.getElementById('dropzone');
const $fileInput = document.getElementById('fileInput');
const $fileList = document.getElementById('fileList');
const $uploadBtn = document.getElementById('uploadBtn');
const $clearBtn = document.getElementById('clearBtn');
const $uploadForm = document.getElementById('uploadForm');
const $resultPanel = document.getElementById('resultPanel');
// 弹窗相关
const $modal = document.getElementById('captchaModal');
const $modalClose = document.getElementById('modalClose');
const $modalCancel = document.getElementById('modalCancel');
const $modalConfirm = document.getElementById('modalConfirm');
const $captchaImg = document.getElementById('captchaImg');
const $refreshCaptcha = document.getElementById('refreshCaptcha');
const $captchaInput = document.getElementById('captchaInput');
const $captchaError = document.getElementById('captchaError');
// ── 状态 ────────────────────────────────────────────────
let selectedFiles = []; // { file, id, status, error }
let fileIdCounter = 0;
let isUploading = false;
// ── 工具函数 ────────────────────────────────────────────
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function getFileExt(filename) {
const idx = filename.lastIndexOf('.');
return idx >= 0 ? filename.substring(idx).toLowerCase() : '';
}
function validateFile(file) {
const ext = getFileExt(file.name);
if (!ALLOWED_EXTENSIONS.includes(ext)) {
return '不支持的文件格式';
}
if (file.size > MAX_FILE_SIZE) {
return '文件超过 20MB 限制';
}
if (file.size === 0) {
return '文件为空';
}
return null;
}
// ── 弹窗控制 ────────────────────────────────────────────
function openModal() {
$captchaInput.value = '';
$captchaError.textContent = '';
refreshCaptcha();
$modal.classList.add('active');
setTimeout(() => $captchaInput.focus(), 200);
}
function closeModal() {
$modal.classList.remove('active');
}
function refreshCaptcha() {
$captchaImg.src = '/api/captcha?' + Date.now();
$captchaInput.value = '';
$captchaError.textContent = '';
}
// 弹窗事件绑定
$modalClose.addEventListener('click', closeModal);
$modalCancel.addEventListener('click', closeModal);
$captchaImg.addEventListener('click', refreshCaptcha);
$refreshCaptcha.addEventListener('click', refreshCaptcha);
// 点击遮罩层关闭
$modal.addEventListener('click', function (e) {
if (e.target === $modal) closeModal();
});
// ESC 关闭弹窗
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && $modal.classList.contains('active')) {
closeModal();
}
});
// 弹窗内回车 = 确认
$captchaInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
$modalConfirm.click();
}
});
// 确认上传按钮
$modalConfirm.addEventListener('click', function () {
const captchaVal = $captchaInput.value.trim();
if (!captchaVal) {
$captchaError.textContent = '请输入验证码';
shakeElement($captchaInput);
return;
}
const nameVal = $name.value.trim();
const validFiles = selectedFiles.filter(f => f.status !== 'error');
closeModal();
startUpload(nameVal, captchaVal, validFiles);
});
// ── 文件选择(点击) ────────────────────────────────────
$dropzone.addEventListener('click', function () {
if (!isUploading) {
$fileInput.click();
}
});
$fileInput.addEventListener('change', function () {
addFiles(Array.from($fileInput.files));
$fileInput.value = ''; // 清空以便重复选择同名文件
});
// ── 文件拖拽 ────────────────────────────────────────────
$dropzone.addEventListener('dragover', function (e) {
e.preventDefault();
if (!isUploading) {
$dropzone.classList.add('dragover');
}
});
$dropzone.addEventListener('dragleave', function (e) {
e.preventDefault();
$dropzone.classList.remove('dragover');
});
$dropzone.addEventListener('drop', function (e) {
e.preventDefault();
$dropzone.classList.remove('dragover');
if (!isUploading) {
addFiles(Array.from(e.dataTransfer.files));
}
});
// ── 添加文件到列表 ──────────────────────────────────────
function addFiles(files) {
if (isUploading) return;
for (const file of files) {
if (selectedFiles.length >= MAX_FILES) {
showToast(`单次最多上传 ${MAX_FILES} 个文件`);
break;
}
const error = validateFile(file);
const item = {
file: file,
id: ++fileIdCounter,
status: error ? 'error' : 'waiting',
error: error,
progress: 0
};
selectedFiles.push(item);
}
renderFileList();
updateButtonState();
}
// ── 渲染文件列表 ────────────────────────────────────────
function renderFileList() {
if (selectedFiles.length === 0) {
$fileList.innerHTML = '';
return;
}
$fileList.innerHTML = selectedFiles.map(item => {
const statusText = {
waiting: '等待中',
uploading: `${item.progress}%`,
done: '已完成',
error: item.error || '失败'
}[item.status];
const statusClass = {
waiting: 'status-waiting',
uploading: 'status-uploading',
done: 'status-done',
error: 'status-error'
}[item.status];
const iconSvg = item.status === 'done'
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#0f766e" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
: item.status === 'error'
? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#dc2626" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
: '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#7a756e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
const progressBar = item.status === 'uploading'
? `<div class="progress-bar"><div class="progress-bar-fill" style="width:${item.progress}%"></div></div>`
: '';
const removeBtn = !isUploading
? `<button type="button" class="file-item-remove" data-id="${item.id}" title="移除">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>`
: '';
return `
<div class="file-item" data-id="${item.id}">
<span class="file-item-icon">${iconSvg}</span>
<div class="file-item-info">
<div class="file-item-name" title="${item.file.name}">${item.file.name}</div>
<div class="file-item-meta">${formatSize(item.file.size)}</div>
${progressBar}
</div>
<span class="file-item-status ${statusClass}">${statusText}</span>
${removeBtn}
</div>
`;
}).join('');
// 绑定移除按钮
$fileList.querySelectorAll('.file-item-remove').forEach(btn => {
btn.addEventListener('click', function (e) {
e.stopPropagation();
const id = parseInt(this.dataset.id);
selectedFiles = selectedFiles.filter(f => f.id !== id);
renderFileList();
updateButtonState();
});
});
}
// ── 按钮状态 ────────────────────────────────────────────
function updateButtonState() {
const nameValid = $name.value.trim().length >= 2;
const hasFiles = selectedFiles.some(f => f.status !== 'error');
$uploadBtn.disabled = !nameValid || !hasFiles || isUploading;
}
$name.addEventListener('input', updateButtonState);
// ── 清空列表 ────────────────────────────────────────────
$clearBtn.addEventListener('click', function () {
if (isUploading) return;
selectedFiles = [];
renderFileList();
updateButtonState();
$resultPanel.innerHTML = '';
});
// ── 点击"开始上传" → 打开验证码弹窗 ─────────────────────
$uploadForm.addEventListener('submit', function (e) {
e.preventDefault();
if (isUploading) return;
// 前端校验姓名
const nameVal = $name.value.trim();
if (nameVal.length < 2) {
shakeElement($name);
return;
}
// 校验文件
const validFiles = selectedFiles.filter(f => f.status !== 'error');
if (validFiles.length === 0) {
showToast('没有可上传的文件');
return;
}
// 打开验证码弹窗
openModal();
});
// ── 执行上传 ────────────────────────────────────────────
function startUpload(name, captcha, files) {
isUploading = true;
$uploadBtn.disabled = true;
$clearBtn.disabled = true;
$resultPanel.innerHTML = '';
// 重置文件状态
files.forEach(f => {
f.status = 'uploading';
f.progress = 0;
});
renderFileList();
const formData = new FormData();
formData.append('name', name);
formData.append('captcha', captcha);
files.forEach(f => formData.append('files', f.file));
const xhr = new XMLHttpRequest();
// 进度事件
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
files.forEach(f => {
f.progress = pct;
});
renderFileList();
}
});
// 完成事件
xhr.addEventListener('load', function () {
isUploading = false;
$clearBtn.disabled = false;
if (xhr.status === 200) {
const data = JSON.parse(xhr.responseText);
handleUploadSuccess(data, files);
} else {
let errMsg = '上传失败,请重试';
try {
const errData = JSON.parse(xhr.responseText);
errMsg = errData.error || errMsg;
} catch (_) { /* ignore */ }
// 如果是验证码错误,重新弹窗让用户输入
if (errMsg.includes('验证码')) {
files.forEach(f => {
f.status = 'waiting';
f.progress = 0;
});
renderFileList();
updateButtonState();
showToast(errMsg);
// 延迟一下再弹窗,让用户看到 toast
setTimeout(openModal, 600);
} else {
handleUploadError(errMsg, files);
}
}
updateButtonState();
});
xhr.addEventListener('error', function () {
isUploading = false;
$clearBtn.disabled = false;
handleUploadError('网络错误,请检查连接后重试', files);
updateButtonState();
});
xhr.open('POST', '/api/upload');
xhr.send(formData);
}
// ── 上传成功处理 ────────────────────────────────────────
function handleUploadSuccess(data, files) {
// 更新每个文件的状态
data.results.forEach((result, idx) => {
if (idx < files.length) {
files[idx].status = result.success ? 'done' : 'error';
files[idx].error = result.success ? null : result.error;
files[idx].progress = 100;
}
});
renderFileList();
// 显示结果面板
const hasSuccess = data.success_count > 0;
const hasFail = data.fail_count > 0;
let resultHtml = `
<div class="result-card">
<div class="result-header">
<div class="result-icon ${hasSuccess ? 'icon-success' : 'icon-error'}">
${hasSuccess
? '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>'
}
</div>
<span class="result-title ${hasSuccess ? 'text-success' : 'text-error'}">
${hasSuccess && !hasFail ? '上传成功' : hasSuccess ? '部分上传成功' : '上传失败'}
</span>
</div>
<div class="result-detail">
${hasSuccess ? `<p>已上传 <strong>${data.success_count}</strong> 个文件至 <strong>${data.storage_path}</strong></p>` : ''}
${hasFail ? `<p>${data.fail_count} 个文件上传失败</p>` : ''}
${hasSuccess ? `<p>该目录累计文件数:<strong>${data.total_files}</strong></p>` : ''}
</div>
`;
// 文件详情
if (data.results.length > 0) {
resultHtml += '<div class="result-files">';
data.results.forEach(r => {
if (r.success) {
resultHtml += `
<div class="result-file-item file-success">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
<span>${r.filename}${r.storage_filename}</span>
</div>`;
} else {
resultHtml += `
<div class="result-file-item file-error">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<span>${r.filename}${r.error}</span>
</div>`;
}
});
resultHtml += '</div>';
}
resultHtml += '</div>';
$resultPanel.innerHTML = resultHtml;
}
// ── 上传失败处理 ────────────────────────────────────────
function handleUploadError(message, files) {
files.forEach(f => {
f.status = 'error';
f.error = message;
});
renderFileList();
$resultPanel.innerHTML = `
<div class="result-card">
<div class="result-header">
<div class="result-icon icon-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
</div>
<span class="result-title text-error">上传失败</span>
</div>
<div class="result-detail">
<p>${message}</p>
</div>
</div>
`;
}
// ── 抖动效果 ────────────────────────────────────────────
function shakeElement(el) {
el.classList.add('shake', 'input-error');
setTimeout(() => {
el.classList.remove('shake');
}, 400);
setTimeout(() => {
el.classList.remove('input-error');
}, 2000);
}
// ── Toast 提示 ──────────────────────────────────────────
function showToast(message) {
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #1a1816;
color: white;
padding: 0.6rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
z-index: 1100;
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s';
setTimeout(() => toast.remove(), 300);
}, 2500);
}
// ── 初始化 ──────────────────────────────────────────────
updateButtonState();
})();

View File

@@ -0,0 +1,132 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文字稿上传</title>
<link rel="stylesheet" href="/static/css/style.css">
</head>
<body>
<div class="container">
<!-- 头部 -->
<header class="header">
<div class="header-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<h1>文字稿上传</h1>
<p class="subtitle">上传腾讯会议文字稿,自动归档到对应目录</p>
</header>
<!-- 上传表单 -->
<form id="uploadForm" class="form">
<!-- 姓名输入 -->
<div class="form-group">
<label for="name">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
上传者姓名
</label>
<input type="text" id="name" name="name" placeholder="请输入您的姓名" maxlength="20" autocomplete="off">
<span class="form-hint">2-20 个字符,支持中文、英文、数字和下划线</span>
</div>
<!-- 文件拖拽区 -->
<div class="form-group">
<label>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="12" y1="18" x2="12" y2="12"/>
<line x1="9" y1="15" x2="15" y2="15"/>
</svg>
选择文件
</label>
<div id="dropzone" class="dropzone">
<input type="file" id="fileInput" multiple hidden>
<div class="dropzone-content">
<div class="dropzone-icon">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
</div>
<p class="dropzone-text">拖拽文件到此处,或 <span class="dropzone-link">点击选择</span></p>
<p class="dropzone-hint">支持 .txt .doc .docx .pdf .rtf .odt 等文字类文档<br>单文件 ≤ 20MB单次最多 9 个文件</p>
</div>
</div>
</div>
<!-- 文件列表 -->
<div id="fileList" class="file-list"></div>
<!-- 操作按钮 -->
<div class="form-actions">
<button type="submit" id="uploadBtn" class="btn btn-primary" disabled>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
开始上传
</button>
<button type="button" id="clearBtn" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
清空列表
</button>
</div>
</form>
<!-- 上传结果 -->
<div id="resultPanel" class="result-panel"></div>
</div>
<!-- 页脚 -->
<footer class="footer">
<p>文件上传后自动归档至 NAS 对应目录</p>
</footer>
<!-- 验证码弹窗 -->
<div id="captchaModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h3>安全验证</h3>
<button type="button" id="modalClose" class="modal-close">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<p class="modal-desc">请输入图中验证码以继续上传</p>
<div class="modal-captcha-row">
<img id="captchaImg" class="modal-captcha-img" src="/api/captcha" alt="验证码" title="点击刷新">
<button type="button" id="refreshCaptcha" class="btn-icon" title="换一张">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
</div>
<input type="text" id="captchaInput" class="modal-captcha-input" placeholder="请输入验证码" maxlength="4" autocomplete="off">
<p id="captchaError" class="modal-error"></p>
</div>
<div class="modal-footer">
<button type="button" id="modalCancel" class="btn btn-secondary">取消</button>
<button type="button" id="modalConfirm" class="btn btn-primary">确认上传</button>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>