commit d8dccfc04c9e2003f0c47101d19da4e97a18db35 Author: Nelson <1475262689@qq.com> Date: Fri Jun 12 14:27:19 2026 +0800 init diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5459385 Binary files /dev/null and b/.DS_Store differ diff --git a/file-upload-site/.dockerignore b/file-upload-site/.dockerignore new file mode 100644 index 0000000..78bce5a --- /dev/null +++ b/file-upload-site/.dockerignore @@ -0,0 +1,6 @@ +.env +.git +.gitignore +uploads/* +__pycache__ +*.pyc diff --git a/file-upload-site/.env.example b/file-upload-site/.env.example new file mode 100644 index 0000000..9325414 --- /dev/null +++ b/file-upload-site/.env.example @@ -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 diff --git a/file-upload-site/.gitignore b/file-upload-site/.gitignore new file mode 100644 index 0000000..5d18687 --- /dev/null +++ b/file-upload-site/.gitignore @@ -0,0 +1,8 @@ +.env +uploads/ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.DS_Store diff --git a/file-upload-site/Dockerfile b/file-upload-site/Dockerfile new file mode 100644 index 0000000..29777ab --- /dev/null +++ b/file-upload-site/Dockerfile @@ -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"] diff --git a/file-upload-site/app.py b/file-upload-site/app.py new file mode 100644 index 0000000..0e6b913 --- /dev/null +++ b/file-upload-site/app.py @@ -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) diff --git a/file-upload-site/config.py b/file-upload-site/config.py new file mode 100644 index 0000000..de55562 --- /dev/null +++ b/file-upload-site/config.py @@ -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', + } diff --git a/file-upload-site/docker-compose.yml b/file-upload-site/docker-compose.yml new file mode 100644 index 0000000..e365218 --- /dev/null +++ b/file-upload-site/docker-compose.yml @@ -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 diff --git a/file-upload-site/requirements.txt b/file-upload-site/requirements.txt new file mode 100644 index 0000000..5803a99 --- /dev/null +++ b/file-upload-site/requirements.txt @@ -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 diff --git a/file-upload-site/static/css/style.css b/file-upload-site/static/css/style.css new file mode 100644 index 0000000..ce57675 --- /dev/null +++ b/file-upload-site/static/css/style.css @@ -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); } +} diff --git a/file-upload-site/static/js/app.js b/file-upload-site/static/js/app.js new file mode 100644 index 0000000..de38441 --- /dev/null +++ b/file-upload-site/static/js/app.js @@ -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' + ? '' + : item.status === 'error' + ? '' + : ''; + + const progressBar = item.status === 'uploading' + ? `
` + : ''; + + const removeBtn = !isUploading + ? `` + : ''; + + return ` +
+ ${iconSvg} +
+
${item.file.name}
+
${formatSize(item.file.size)}
+ ${progressBar} +
+ ${statusText} + ${removeBtn} +
+ `; + }).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 = ` +
+
+
+ ${hasSuccess + ? '' + : '' + } +
+ + ${hasSuccess && !hasFail ? '上传成功' : hasSuccess ? '部分上传成功' : '上传失败'} + +
+
+ ${hasSuccess ? `

已上传 ${data.success_count} 个文件至 ${data.storage_path}

` : ''} + ${hasFail ? `

${data.fail_count} 个文件上传失败

` : ''} + ${hasSuccess ? `

该目录累计文件数:${data.total_files}

` : ''} +
+ `; + + // 文件详情 + if (data.results.length > 0) { + resultHtml += '
'; + data.results.forEach(r => { + if (r.success) { + resultHtml += ` +
+ + ${r.filename} → ${r.storage_filename} +
`; + } else { + resultHtml += ` +
+ + ${r.filename} — ${r.error} +
`; + } + }); + resultHtml += '
'; + } + + resultHtml += '
'; + $resultPanel.innerHTML = resultHtml; + } + + // ── 上传失败处理 ──────────────────────────────────────── + function handleUploadError(message, files) { + files.forEach(f => { + f.status = 'error'; + f.error = message; + }); + renderFileList(); + + $resultPanel.innerHTML = ` +
+
+
+ +
+ 上传失败 +
+
+

${message}

+
+
+ `; + } + + // ── 抖动效果 ──────────────────────────────────────────── + 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(); + +})(); diff --git a/file-upload-site/templates/index.html b/file-upload-site/templates/index.html new file mode 100644 index 0000000..133023e --- /dev/null +++ b/file-upload-site/templates/index.html @@ -0,0 +1,132 @@ + + + + + + 文字稿上传 + + + +
+ +
+
+ + + + + +
+

文字稿上传

+

上传腾讯会议文字稿,自动归档到对应目录

+
+ + +
+ +
+ + + 2-20 个字符,支持中文、英文、数字和下划线 +
+ + +
+ +
+ +
+
+ + + + + +
+

拖拽文件到此处,或 点击选择

+

支持 .txt .doc .docx .pdf .rtf .odt 等文字类文档
单文件 ≤ 20MB,单次最多 9 个文件

+
+
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + + + + + + + + +