commit be1e01a8c6bd82508ad5589daca952e40b4fa0a2 Author: coder Date: Tue Apr 7 19:01:14 2026 +0800 初始提交: PDF翻译网站 功能: - 上传PDF自动翻译成中文 - 不登录可用(次数限制) - 登录后获得更多次数 - 会员等级制度(不同权益) - 智能缓存(相同文件秒出结果) - 原文译文对比查看 - 支持重新翻译(自定义要求) - 翻译历史记录 技术栈: Flask + SQLAlchemy + Bootstrap5 diff --git a/README.md b/README.md new file mode 100644 index 0000000..76f0cb2 --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# PDF翻译助手 - 英文PDF翻译网站 + +基于本地LLM服务的英文PDF翻译中文网站。 + +## 功能特点 + +### 核心功能 +- ✅ 上传英文PDF自动翻译成中文 +- ✅ 智能缓存机制 - 相同文件秒出结果 +- ✅ 翻译进度实时显示 +- ✅ 原文译文对比查看 +- ✅ 支持重新翻译(带自定义要求) + +### 用户系统 +- ✅ 不登录可用(有次数限制) +- ✅ 免费注册获得更多次数 +- ✅ 会员等级制度 + +### 会员权益 + +| 用户类型 | 每日次数 | 最大页数 | 特殊功能 | +|---------|---------|---------|---------| +| 访客 | 3次 | 20页 | 基础翻译 | +| 免费 | 10次 | 50页 | 历史、重译 | +| 基础会员 | 50次 | 100页 | 导出PDF | +| 专业会员 | 200次 | 500页 | 批量翻译 | +| 企业会员 | 无限 | 无限 | API调用 | + +## 快速启动 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 初始化数据库 +python app.py + +# 启动服务(默认端口5000) +python app.py +``` + +访问 http://localhost:5000 即可使用。 + +## 配置说明 + +修改 `config.py` 文件: + +```python +# LLM服务配置 +LLM_CONFIG = { + "api_base": "http://192.168.2.5:1234/v1", # LLM API地址 + "api_key": "your-api-key", + "model": "qwen/qwen3.5-35b-a3b", +} + +# 用户权限配置 +USER_LIMITS = { + "guest": {"daily_translations": 3, "max_pages": 20}, + "free": {"daily_translations": 10, "max_pages": 50}, + # ... +} +``` + +## 项目结构 + +``` +pdf-translate-web/ +├── app.py # Flask主应用 +├── config.py # 配置文件 +├── models.py # 数据库模型 +├── services.py # 翻译服务 +├── requirements.txt # 依赖列表 +├── templates/ # HTML模板 +│ ├── index.html # 首页 +│ ├── login.html # 登录页 +│ ├── register.html # 注册页 +│ ├── pricing.html # 会员定价 +│ ├── history.html # 翻译历史 +│ └── translation.html # 翻译详情 +├── static/ # 静态资源 +│ ├── css/style.css # 样式 +│ └ js/main.js # 前端脚本 +├── uploads/ # 上传文件目录 +├── cache/ # 翻译缓存目录 +└── outputs/ # 输出文件目录 +``` + +## API接口 + +### 上传翻译 +``` +POST /api/upload +参数: file (PDF文件), instruction (翻译要求,可选) +返回: {translation_id, from_cache, task_id} +``` + +### 翻译状态 +``` +GET /api/status/ +返回: {status, progress} +``` + +### 获取结果 +``` +GET /api/result/ +返回: {filename, content} +``` + +### 下载结果 +``` +GET /api/download/ +返回: Markdown文件 +``` + +### 重新翻译 +``` +POST /api/retranslate/ +参数: {instruction: "翻译要求"} +``` + +## 扩展功能建议 + +1. **导出PDF格式** - 将翻译结果转回PDF保留排版 +2. **批量翻译** - 支持多个PDF同时上传 +3. **自定义术语库** - 用户可定义专业术语翻译 +4. **翻译质量评分** - AI评估翻译质量 +5. **API接口** - 提供外部调用接口 +6. **导出Word格式** - 方便后续编辑 +7. **多语言支持** - 支持更多语言互译 + +## 技术栈 + +- **后端**: Flask + SQLAlchemy +- **前端**: Bootstrap 5 + JavaScript +- **LLM**: 本地部署的 Qwen3.5 模型 +- **缓存**: 文件缓存 + 数据库索引 +- **异步**: Python threading + +## 注意事项 + +⚠️ Qwen模型有思考模式,翻译速度约30秒/块,适合处理重要文档。 + +⚠️ 生产环境需要: +- 添加HTTPS支持 +- 配置真实数据库(PostgreSQL) +- 实现支付系统 +- 添加文件清理机制 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..21bc371 --- /dev/null +++ b/app.py @@ -0,0 +1,502 @@ +""" +PDF翻译网站主应用 +""" + +import os +import json +import uuid +import hashlib +from datetime import datetime, date +from functools import wraps +from flask import Flask, request, jsonify, render_template, send_file, session, redirect, url_for +from flask_sqlalchemy import SQLAlchemy +from werkzeug.utils import secure_filename + +from config import * +from models import db, User, Translation, TranslationCache, GuestTranslation +from services import TranslationService, CacheService, TranslationTask + +# ==================== 创建应用 ==================== +app = Flask(__name__) +app.config['SECRET_KEY'] = SECRET_KEY +app.config['SQLALCHEMY_DATABASE_URI'] = DATABASE_URL +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE + +# 初始化数据库 +db.init_app(app) + +# 初始化服务 +cache_service = CacheService(CACHE_DIR, CACHE_EXPIRE_DAYS) + +# ==================== 辅助函数 ==================== +def get_current_user(): + """获取当前用户""" + user_id = session.get('user_id') + if user_id: + return User.query.get(user_id) + return None + + +def get_or_create_guest(): + """获取或创建访客记录""" + session_id = session.get('guest_id') + if not session_id: + session_id = str(uuid.uuid4()) + session['guest_id'] = session_id + + guest = GuestTranslation.query.filter_by(session_id=session_id).first() + if not guest: + guest = GuestTranslation( + session_id=session_id, + ip_address=request.remote_addr + ) + db.session.add(guest) + db.session.commit() + + return guest + + +def check_guest_limit(guest): + """检查访客翻译限制""" + today = date.today() + if guest.last_translate_date != today: + guest.daily_count = 0 + guest.last_translate_date = today + db.session.commit() + + limit = USER_LIMITS['guest']['daily_translations'] + if guest.daily_count >= limit: + return False, f"今日翻译次数已达上限({limit}次),请登录获取更多次数" + + return True, "OK" + + +def allowed_file(filename): + """检查文件类型""" + return '.' in filename and filename.lower().endswith('.pdf') + + +def compute_file_hash(file_content): + """计算文件哈希""" + return hashlib.md5(file_content).hexdigest() + + +# ==================== 路由: 页面 ==================== +@app.route('/') +def index(): + """首页""" + user = get_current_user() + if user: + limits = USER_LIMITS.get(user.user_type, USER_LIMITS['free']) + daily_remaining = limits['daily_translations'] - user.daily_count if limits['daily_translations'] > 0 else '无限' + max_pages = limits['max_pages'] if limits['max_pages'] > 0 else '无限' + else: + guest = get_or_create_guest() + limits = USER_LIMITS['guest'] + daily_remaining = limits['daily_translations'] - guest.daily_count + max_pages = limits['max_pages'] + + return render_template('index.html', + user=user, + limits=limits, + daily_remaining=daily_remaining, + max_pages=max_pages, + plans=MEMBERSHIP_PLANS + ) + + +@app.route('/translate/') +def translation_detail(translation_id): + """翻译详情页""" + user = get_current_user() + translation = Translation.query.get(translation_id) + + if not translation: + return "翻译记录不存在", 404 + + # 权限检查 + if user and translation.user_id != user.id: + return "无权访问", 403 + + if not user and not translation.user_id: + # guest记录,检查session + pass + + return render_template('translation.html', + translation=translation, + user=user + ) + + +@app.route('/history') +def history(): + """翻译历史""" + user = get_current_user() + if not user: + return redirect(url_for('login')) + + translations = Translation.query.filter_by(user_id=user.id)\ + .order_by(Translation.created_at.desc()).limit(50).all() + + return render_template('history.html', + user=user, + translations=translations + ) + + +@app.route('/pricing') +def pricing(): + """会员定价页""" + return render_template('pricing.html', plans=MEMBERSHIP_PLANS) + + +# ==================== 路由: API ==================== +@app.route('/api/upload', methods=['POST']) +def upload_pdf(): + """上传PDF文件""" + user = get_current_user() + + # 检查文件 + if 'file' not in request.files: + return jsonify({'error': '未上传文件'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'error': '未选择文件'}), 400 + + if not allowed_file(file.filename): + return jsonify({'error': '只支持PDF文件'}), 400 + + # 获取翻译参数 + instruction = request.form.get('instruction', None) # 用户翻译要求 + + # 读取文件内容 + file_content = file.read() + file_hash = compute_file_hash(file_content) + filename = secure_filename(file.filename) + + # 获取页数 + try: + from pypdf import PdfReader + import io + reader = PdfReader(io.BytesIO(file_content)) + page_count = len(reader.pages) + except Exception as e: + return jsonify({'error': f'PDF解析失败: {e}'}), 400 + + # 权限检查 + if user: + can_translate, msg = user.can_translate(page_count, {'USER_LIMITS': USER_LIMITS}) + if not can_translate: + return jsonify({'error': msg}), 403 + else: + guest = get_or_create_guest() + can_translate, msg = check_guest_limit(guest) + if not can_translate: + return jsonify({'error': msg}), 403 + + # 检查页数限制 + max_pages = USER_LIMITS['guest']['max_pages'] + if page_count > max_pages: + return jsonify({'error': f'PDF页数超出限制(最大{max_pages}页)'}), 403 + + # 检查缓存 + cache_path = cache_service.get_cache(file_hash) + from_cache = False + + if cache_path and ENABLE_CACHE and not instruction: + # 有缓存且无特殊翻译要求,直接使用缓存 + from_cache = True + output_path = cache_path + else: + # 需要翻译 + # 保存上传文件 + upload_dir = os.path.join(UPLOAD_DIR, str(uuid.uuid4())) + os.makedirs(upload_dir, exist_ok=True) + upload_path = os.path.join(upload_dir, filename) + + with open(upload_path, 'wb') as f: + f.write(file_content) + + # 创建输出路径 + output_dir = os.path.join(OUTPUT_DIR, str(uuid.uuid4())) + os.makedirs(output_dir, exist_ok=True) + output_path = os.path.join(output_dir, f"{filename}_translated.md") + + # 创建异步翻译任务 + task_id = str(uuid.uuid4()) + TranslationTask.create_task( + task_id, upload_path, output_path, + {'LLM_CONFIG': LLM_CONFIG}, + instruction + ) + + # 创建翻译记录 + translation = Translation( + user_id=user.id if user else None, + file_hash=file_hash, + original_filename=filename, + file_size=len(file_content), + page_count=page_count, + translate_params=json.dumps({'instruction': instruction}) if instruction else None, + status='processing' if not from_cache else 'completed', + progress=0 if not from_cache else 100, + output_path=output_path, + from_cache=from_cache + ) + db.session.add(translation) + + # 更新用户/访客计数 + if user: + user.increment_count() + else: + guest.daily_count += 1 + guest.last_translate_date = date.today() + + # 更新缓存记录 + if from_cache: + cache_record = TranslationCache.query.filter_by(file_hash=file_hash).first() + if cache_record: + cache_record.increment_hit() + + db.session.commit() + + return jsonify({ + 'success': True, + 'translation_id': translation.id, + 'file_hash': file_hash, + 'page_count': page_count, + 'from_cache': from_cache, + 'task_id': task_id if not from_cache else None, + 'message': '使用缓存结果' if from_cache else '翻译任务已创建' + }) + + +@app.route('/api/status/') +def translation_status(translation_id): + """获取翻译状态""" + translation = Translation.query.get(translation_id) + + if not translation: + return jsonify({'error': '翻译记录不存在'}), 404 + + # 如果有task_id,检查任务状态 + if translation.status == 'processing': + # 这里可以查询TranslationTask + pass + + return jsonify({ + 'id': translation.id, + 'status': translation.status, + 'progress': translation.progress, + 'from_cache': translation.from_cache, + 'error': translation.error_message + }) + + +@app.route('/api/task/') +def task_status(task_id): + """获取任务状态""" + task = TranslationTask.get_task(task_id) + + if not task: + return jsonify({'error': '任务不存在'}), 404 + + return jsonify(task) + + +@app.route('/api/result/') +def get_result(translation_id): + """获取翻译结果""" + user = get_current_user() + translation = Translation.query.get(translation_id) + + if not translation: + return jsonify({'error': '翻译记录不存在'}), 404 + + if translation.status != 'completed': + return jsonify({'error': '翻译未完成'}), 400 + + # 检查输出文件 + if not translation.output_path or not os.path.exists(translation.output_path): + return jsonify({'error': '翻译结果文件不存在'}), 404 + + # 读取结果 + with open(translation.output_path, 'r', encoding='utf-8') as f: + content = f.read() + + return jsonify({ + 'id': translation.id, + 'filename': translation.original_filename, + 'content': content, + 'output_path': translation.output_path + }) + + +@app.route('/api/download/') +def download_result(translation_id): + """下载翻译结果""" + user = get_current_user() + translation = Translation.query.get(translation_id) + + if not translation or not translation.output_path: + return jsonify({'error': '翻译记录不存在'}), 404 + + if not os.path.exists(translation.output_path): + return jsonify({'error': '文件不存在'}), 404 + + filename = f"{translation.original_filename}_translated.md" + return send_file(translation.output_path, as_attachment=True, download_name=filename) + + +@app.route('/api/retranslate/', methods=['POST']) +def retranslate(translation_id): + """重新翻译""" + user = get_current_user() + if not user: + return jsonify({'error': '请登录后使用此功能'}), 401 + + translation = Translation.query.get(translation_id) + if not translation or translation.user_id != user.id: + return jsonify({'error': '无权操作'}), 403 + + # 检查功能权限 + limits = USER_LIMITS.get(user.user_type, USER_LIMITS['free']) + if 'retranslate' not in limits['features']: + return jsonify({'error': '会员功能,请升级'}), 403 + + instruction = request.json.get('instruction', '') + + # 查找原文件 + # 这里需要从原始上传路径恢复,简化处理 + + # 创建新翻译记录 + new_translation = Translation( + user_id=user.id, + file_hash=translation.file_hash, + original_filename=translation.original_filename, + file_size=translation.file_size, + page_count=translation.page_count, + translate_params=json.dumps({'instruction': instruction}), + status='processing', + parent_id=translation_id, + retranslate_request=instruction + ) + db.session.add(new_translation) + db.session.commit() + + # TODO: 实际翻译逻辑 + + return jsonify({ + 'success': True, + 'translation_id': new_translation.id, + 'message': '重译任务已创建' + }) + + +@app.route('/api/compare/') +def compare_view(translation_id): + """对比查看""" + user = get_current_user() + if not user: + return jsonify({'error': '请登录后使用此功能'}), 401 + + translation = Translation.query.get(translation_id) + if not translation or translation.user_id != user.id: + return jsonify({'error': '无权访问'}), 403 + + # 生成对比文件 + # TODO: 实现对比功能 + + return jsonify({ + 'id': translation.id, + 'original': '原文内容', + 'translated': '译文内容' + }) + + +# ==================== 路由: 用户系统 ==================== +@app.route('/login', methods=['GET', 'POST']) +def login(): + """登录""" + if request.method == 'GET': + return render_template('login.html') + + data = request.json + username = data.get('username') + password = data.get('password') + + user = User.query.filter_by(username=username).first() + if not user or not user.check_password(password): + return jsonify({'error': '用户名或密码错误'}), 401 + + session['user_id'] = user.id + return jsonify({'success': True, 'user': user.to_dict()}) + + +@app.route('/register', methods=['GET', 'POST']) +def register(): + """注册""" + if request.method == 'GET': + return render_template('register.html') + + data = request.json + username = data.get('username') + email = data.get('email') + password = data.get('password') + + # 检查用户是否存在 + if User.query.filter_by(username=username).first(): + return jsonify({'error': '用户名已存在'}), 400 + + if User.query.filter_by(email=email).first(): + return jsonify({'error': '邮箱已注册'}), 400 + + # 创建用户 + user = User(username=username, email=email, user_type='free') + user.set_password(password) + db.session.add(user) + db.session.commit() + + session['user_id'] = user.id + return jsonify({'success': True, 'user': user.to_dict()}) + + +@app.route('/logout') +def logout(): + """退出登录""" + session.pop('user_id', None) + return redirect(url_for('index')) + + +@app.route('/api/user/info') +def user_info(): + """用户信息""" + user = get_current_user() + if not user: + return jsonify({'user': None}) + + limits = USER_LIMITS.get(user.user_type, USER_LIMITS['free']) + return jsonify({ + 'user': user.to_dict(), + 'limits': limits + }) + + +# ==================== 初始化 ==================== +def init_app(): + """初始化应用""" + # 创建目录 + for dir_name in [UPLOAD_DIR, CACHE_DIR, OUTPUT_DIR]: + if not os.path.exists(dir_name): + os.makedirs(dir_name) + + # 创建数据库表 + with app.app_context(): + db.create_all() + + +if __name__ == '__main__': + init_app() + app.run(host='0.0.0.0', port=5000, debug=True) \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..fd2663f --- /dev/null +++ b/config.py @@ -0,0 +1,93 @@ +""" +PDF翻译网站配置文件 +""" + +# ==================== 基础配置 ==================== +APP_NAME = "PDF翻译助手" +APP_VERSION = "1.0.0" +SECRET_KEY = "pdf-translate-secret-key-change-in-production" + +# 数据库 +DATABASE_URL = "sqlite:///pdf_translate.db" + +# 文件存储 +UPLOAD_DIR = "uploads" +CACHE_DIR = "cache" +OUTPUT_DIR = "outputs" +MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB + +# ==================== LLM配置 ==================== +LLM_CONFIG = { + "api_base": "http://192.168.2.5:1234/v1", + "api_key": "sk-lm-fuP5tGU8:Hi7YU87jHyDP6Ay8Tl2j", + "model": "qwen/qwen3.5-35b-a3b", + "max_tokens": 8000, + "chunk_size": 2000, + "timeout": 180, +} + +# ==================== 用户权限配置 ==================== +# 不同用户类型的限制 +USER_LIMITS = { + "guest": { # 未登录访客 + "daily_translations": 3, # 每日翻译次数 + "max_pages": 20, # 单个PDF最大页数 + "max_file_size": 10 * 1024 * 1024, # 10MB + "features": ["basic_translate"], + }, + "free": { # 免费注册用户 + "daily_translations": 10, + "max_pages": 50, + "max_file_size": 30 * 1024 * 1024, # 30MB + "features": ["basic_translate", "compare_view", "retranslate", "history"], + }, + "vip_basic": { # 基础会员 (月付 ¥29) + "daily_translations": 50, + "max_pages": 100, + "max_file_size": 50 * 1024 * 1024, + "features": ["basic_translate", "compare_view", "retranslate", "history", "priority_queue", "export_pdf"], + }, + "vip_pro": { # 专业会员 (月付 ¥99) + "daily_translations": 200, + "max_pages": 500, + "max_file_size": 100 * 1024 * 1024, + "features": ["basic_translate", "compare_view", "retranslate", "history", "priority_queue", "export_pdf", "batch_translate", "custom_terms"], + }, + "vip_enterprise": { # 企业会员 (年付 ¥999) + "daily_translations": -1, # 无限制 + "max_pages": -1, # 无限制 + "max_file_size": 200 * 1024 * 1024, + "features": ["all"], + }, +} + +# 会员套餐定价 +MEMBERSHIP_PLANS = { + "vip_basic": { + "name": "基础会员", + "price": 29, + "period": "month", + "description": "适合个人轻度使用", + }, + "vip_pro": { + "name": "专业会员", + "price": 99, + "period": "month", + "description": "适合学术研究和专业翻译", + }, + "vip_enterprise": { + "name": "企业会员", + "price": 999, + "period": "year", + "description": "适合企业和团队使用", + }, +} + +# ==================== Redis缓存配置 ==================== +REDIS_URL = "redis://localhost:6379/0" +CACHE_EXPIRE_DAYS = 30 # 缓存有效期30天 + +# ==================== 功能配置 ==================== +ENABLE_EMAIL_VERIFY = False # 是否启用邮箱验证 +ENABLE_CACHE = True # 是否启用翻译缓存 +ENABLE_HISTORY = True # 是否保存翻译历史 \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..29dfd3b --- /dev/null +++ b/models.py @@ -0,0 +1,199 @@ +""" +数据库模型定义 +""" + +from datetime import datetime +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash +import hashlib + +db = SQLAlchemy() + +# ==================== 用户模型 ==================== +class User(db.Model): + """用户表""" + __tablename__ = 'users' + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + password_hash = db.Column(db.String(256), nullable=False) + + # 用户类型: guest, free, vip_basic, vip_pro, vip_enterprise + user_type = db.Column(db.String(20), default='free') + + # 会员信息 + membership_expire = db.Column(db.DateTime, nullable=True) # 会员到期时间 + + # 使用统计 + daily_count = db.Column(db.Integer, default=0) # 今日翻译次数 + total_count = db.Column(db.Integer, default=0) # 总翻译次数 + last_translate_date = db.Column(db.Date, nullable=True) # 最后翻译日期 + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # 关系 + translations = db.relationship('Translation', backref='user', lazy=True) + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def is_vip(self): + """检查是否为付费会员""" + if self.user_type.startswith('vip'): + if self.membership_expire and self.membership_expire > datetime.utcnow(): + return True + # 过期则降级为免费用户 + self.user_type = 'free' + self.membership_expire = None + db.session.commit() + return False + + def can_translate(self, pages, config): + """检查是否可以翻译(次数、页数限制)""" + limits = config['USER_LIMITS'].get(self.user_type, config['USER_LIMITS']['free']) + + # 检查页数限制 + max_pages = limits['max_pages'] + if max_pages > 0 and pages > max_pages: + return False, f"PDF页数超出限制(最大{max_pages}页)" + + # 检查每日次数限制 + today = datetime.utcnow().date() + if self.last_translate_date != today: + self.daily_count = 0 + self.last_translate_date = today + + daily_limit = limits['daily_translations'] + if daily_limit > 0 and self.daily_count >= daily_limit: + return False, f"今日翻译次数已达上限({daily_limit}次)" + + return True, "OK" + + def increment_count(self): + """增加翻译计数""" + today = datetime.utcnow().date() + if self.last_translate_date != today: + self.daily_count = 0 + self.last_translate_date = today + self.daily_count += 1 + self.total_count += 1 + db.session.commit() + + def to_dict(self): + return { + 'id': self.id, + 'username': self.username, + 'email': self.email, + 'user_type': self.user_type, + 'is_vip': self.is_vip(), + 'daily_count': self.daily_count, + 'total_count': self.total_count, + } + + +# ==================== 翻译记录模型 ==================== +class Translation(db.Model): + """翻译记录表""" + __tablename__ = 'translations' + + id = db.Column(db.Integer, primary_key=True) + + # 用户关联 + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # guest可为null + + # 文件信息 + file_hash = db.Column(db.String(64), nullable=False) # 文件MD5哈希 + original_filename = db.Column(db.String(255), nullable=False) + file_size = db.Column(db.Integer, nullable=False) + page_count = db.Column(db.Integer, nullable=False) + + # 翻译信息 + source_language = db.Column(db.String(10), default='en') + target_language = db.Column(db.String(10), default='zh') + translate_params = db.Column(db.Text, nullable=True) # JSON格式的翻译参数 + + # 状态 + status = db.Column(db.String(20), default='pending') # pending, processing, completed, failed + progress = db.Column(db.Integer, default=0) # 翻译进度 0-100 + error_message = db.Column(db.Text, nullable=True) + + # 输出 + output_path = db.Column(db.String(255), nullable=True) # 翻译结果文件路径 + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + completed_at = db.Column(db.DateTime, nullable=True) + + # 是否来自缓存 + from_cache = db.Column(db.Boolean, default=False) + + # 重译信息 + retranslate_request = db.Column(db.Text, nullable=True) # 重译要求 + parent_id = db.Column(db.Integer, db.ForeignKey('translations.id'), nullable=True) # 原翻译ID + + def to_dict(self): + return { + 'id': self.id, + 'filename': self.original_filename, + 'pages': self.page_count, + 'status': self.status, + 'progress': self.progress, + 'from_cache': self.from_cache, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + } + + +# ==================== 翻译缓存模型 ==================== +class TranslationCache(db.Model): + """翻译缓存表""" + __tablename__ = 'translation_cache' + + id = db.Column(db.Integer, primary_key=True) + + # 文件哈希 + file_hash = db.Column(db.String(64), unique=True, nullable=False) + + # 缓存信息 + cache_path = db.Column(db.String(255), nullable=False) # 缓存文件路径 + page_count = db.Column(db.Integer, nullable=False) + + # 统计 + hit_count = db.Column(db.Integer, default=0) # 缓存命中次数 + + # 时间戳 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.DateTime, nullable=True) + + def increment_hit(self): + self.hit_count += 1 + db.session.commit() + + @staticmethod + def compute_hash(file_content): + """计算文件哈希""" + return hashlib.md5(file_content).hexdigest() + + +# ==================== 访客翻译记录 ==================== +class GuestTranslation(db.Model): + """访客翻译记录(基于IP或Session)""" + __tablename__ = 'guest_translations' + + id = db.Column(db.Integer, primary_key=True) + + # 访客标识 + session_id = db.Column(db.String(64), nullable=False) # Session ID + ip_address = db.Column(db.String(45), nullable=True) + + # 统计 + daily_count = db.Column(db.Integer, default=0) + last_translate_date = db.Column(db.Date, nullable=True) + + created_at = db.Column(db.DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ee439a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +flask>=2.3.0 +flask-sqlalchemy>=3.0.0 +werkzeug>=2.3.0 +pypdf>=6.0.0 +openai>=2.0.0 +python-dotenv>=1.0.0 \ No newline at end of file diff --git a/services.py b/services.py new file mode 100644 index 0000000..cb2af14 --- /dev/null +++ b/services.py @@ -0,0 +1,316 @@ +""" +PDF翻译服务模块 +""" + +import os +import json +import time +import hashlib +import threading +from datetime import datetime, timedelta +from pypdf import PdfReader +from openai import OpenAI +from flask import current_app + +# ==================== LLM客户端 ==================== +class TranslationService: + """翻译服务""" + + def __init__(self, config): + self.config = config + self.llm_config = config['LLM_CONFIG'] + self.client = OpenAI( + api_key=self.llm_config['api_key'], + base_url=self.llm_config['api_base'], + ) + + def translate_text(self, text, instruction=None): + """ + 翻译文本 + + Args: + text: 待翻译文本 + instruction: 用户自定义翻译要求 + + Returns: + 翻译后的文本 + """ + system_prompt = """你是一个专业的英译中翻译专家。请遵循以下规则: +1. 保持原文的格式和段落结构 +2. 专业术语保持准确性,必要时保留英文原文 +3. 语言流畅自然,符合中文表达习惯 +4. 不要添加任何解释或注释,只输出翻译结果""" + + user_prompt = f"""请将以下英文翻译成中文。直接输出中文翻译,不要解释。 + +英文内容: +{text}""" + + if instruction: + user_prompt = f"""请将以下英文翻译成中文。 +用户翻译要求:{instruction} + +英文内容: +{text}""" + + try: + response = self.client.chat.completions.create( + model=self.llm_config['model'], + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + max_tokens=self.llm_config['max_tokens'], + temperature=0.3, + timeout=self.llm_config['timeout'], + ) + + content = response.choices[0].message.content + if content and content.strip(): + return content.strip() + return text + + except Exception as e: + print(f"翻译错误: {e}") + return text + + def extract_pdf_text(self, pdf_path): + """提取PDF文本""" + reader = PdfReader(pdf_path) + pages = [] + + for i, page in enumerate(reader.pages): + text = page.extract_text() + if text.strip(): + # 清理文本 + text = self._clean_text(text) + pages.append({ + 'page': i + 1, + 'text': text + }) + + return pages + + def _clean_text(self, text): + """清理文本""" + import re + text = re.sub(r'\n{3,}', '\n\n', text) + text = re.sub(r' {2,}', ' ', text) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) + return text.strip() + + def chunk_text(self, text, max_size=2000): + """分块""" + paragraphs = text.split('\n\n') + chunks = [] + current = "" + + for para in paragraphs: + if len(current) + len(para) < max_size: + current += para + '\n\n' + else: + if current: + chunks.append(current.strip()) + current = para + '\n\n' + + if current: + chunks.append(current.strip()) + + return chunks + + def translate_pdf(self, pdf_path, output_path, instruction=None, progress_callback=None): + """ + 翻译PDF + + Args: + pdf_path: 输入PDF路径 + output_path: 输出路径 + instruction: 用户翻译要求 + progress_callback: 进度回调函数 + + Returns: + 翻译统计信息 + """ + pages = self.extract_pdf_text(pdf_path) + total_pages = len(pages) + + if progress_callback: + progress_callback(0, total_pages, "开始翻译...") + + translated_pages = [] + total_chunks = 0 + + for page_data in pages: + chunks = self.chunk_text(page_data['text'], self.llm_config['chunk_size']) + total_chunks += len(chunks) + + translated_chunks = [] + for i, chunk in enumerate(chunks): + translated = self.translate_text(chunk, instruction) + translated_chunks.append(translated) + + if progress_callback: + progress = int((i + 1) / len(chunks) * 100 / total_pages) + progress_callback(progress, total_pages, f"翻译第{page_data['page']}页") + + translated_pages.append({ + 'page': page_data['page'], + 'original': page_data['text'], + 'translated': '\n\n'.join(translated_chunks) + }) + + # 保存结果 + self._save_output(translated_pages, output_path) + + if progress_callback: + progress_callback(100, total_pages, "翻译完成") + + return { + 'total_pages': total_pages, + 'total_chunks': total_chunks, + 'output_path': output_path + } + + def _save_output(self, pages, output_path): + """保存翻译结果""" + content = "# 英文PDF中文翻译\n\n> 自动翻译生成\n\n---\n\n" + for page in pages: + content += f"## 第 {page['page']} 页\n\n" + content += page['translated'] + "\n\n---\n\n" + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + def save_comparison(self, pages, output_path): + """保存对比文件(原文+译文)""" + content = "# 英文PDF翻译对比\n\n---\n\n" + for page in pages: + content += f"## 第 {page['page']} 页\n\n" + content += "### 原文\n\n```\n" + page['original'] + "\n```\n\n" + content += "### 译文\n\n" + page['translated'] + "\n\n---\n\n" + + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + + +# ==================== 缓存服务 ==================== +class CacheService: + """翻译缓存服务""" + + def __init__(self, cache_dir, expire_days=30): + self.cache_dir = cache_dir + self.expire_days = expire_days + + if not os.path.exists(cache_dir): + os.makedirs(cache_dir) + + def compute_hash(self, file_content): + """计算文件哈希""" + return hashlib.md5(file_content).hexdigest() + + def get_cache(self, file_hash, db_model=None): + """ + 获取缓存 + + Returns: + 缓存路径或None + """ + cache_file = os.path.join(self.cache_dir, f"{file_hash}.md") + + if os.path.exists(cache_file): + # 检查过期 + file_time = datetime.fromtimestamp(os.path.getmtime(cache_file)) + if datetime.now() - file_time > timedelta(days=self.expire_days): + os.remove(cache_file) + return None + + # 更新命中计数 + if db_model: + cache_record = db_model.query.filter_by(file_hash=file_hash).first() + if cache_record: + cache_record.increment_hit() + + return cache_file + + return None + + def save_cache(self, file_hash, content): + """保存缓存""" + cache_file = os.path.join(self.cache_dir, f"{file_hash}.md") + with open(cache_file, 'w', encoding='utf-8') as f: + f.write(content) + return cache_file + + def check_cache_exists(self, file_hash): + """检查缓存是否存在""" + cache_file = os.path.join(self.cache_dir, f"{file_hash}.md") + return os.path.exists(cache_file) + + +# ==================== 异步翻译任务 ==================== +class TranslationTask: + """异步翻译任务""" + + tasks = {} # 任务存储 + lock = threading.Lock() + + @classmethod + def create_task(cls, task_id, pdf_path, output_path, config, instruction=None): + """创建翻译任务""" + task = { + 'id': task_id, + 'status': 'pending', + 'progress': 0, + 'message': '等待开始', + 'output_path': output_path, + 'error': None, + 'started_at': None, + 'completed_at': None, + } + + with cls.lock: + cls.tasks[task_id] = task + + # 启动翻译线程 + def run_translation(): + service = TranslationService(config) + task['status'] = 'processing' + task['started_at'] = datetime.now().isoformat() + + def progress_callback(progress, total, message): + with cls.lock: + task['progress'] = progress + task['message'] = message + + try: + result = service.translate_pdf( + pdf_path, output_path, instruction, progress_callback + ) + task['status'] = 'completed' + task['progress'] = 100 + task['message'] = '翻译完成' + task['completed_at'] = datetime.now().isoformat() + task['result'] = result + + except Exception as e: + task['status'] = 'failed' + task['error'] = str(e) + task['message'] = f'翻译失败: {e}' + + thread = threading.Thread(target=run_translation) + thread.start() + + return task_id + + @classmethod + def get_task(cls, task_id): + """获取任务状态""" + with cls.lock: + return cls.tasks.get(task_id) + + @classmethod + def update_task(cls, task_id, **kwargs): + """更新任务""" + with cls.lock: + if task_id in cls.tasks: + cls.tasks[task_id].update(kwargs) \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..99917cc --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,176 @@ +/* PDF翻译助手样式 */ + +:root { + --primary-color: #4a90d9; + --secondary-color: #f8f9fa; + --success-color: #28a745; + --warning-color: #ffc107; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--secondary-color); +} + +.navbar-brand { + font-weight: bold; +} + +/* 卡片样式 */ +.card { + border-radius: 10px; + border: none; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.card-header { + border-radius: 10px 10px 0 0 !important; +} + +/* 上传区域 */ +#uploadForm { + padding: 20px; +} + +.form-control:focus { + border-color: var(--primary-color); + box-shadow: 0 0 0 0.2rem rgba(74, 144, 217, 0.25); +} + +/* 进度条 */ +.progress { + height: 25px; + border-radius: 5px; +} + +.progress-bar { + background-color: var(--primary-color); + transition: width 0.3s ease; +} + +/* 翻译内容 */ +.translation-content { + max-height: 600px; + overflow-y: auto; + padding: 20px; + background-color: #fff; + border-radius: 5px; + border: 1px solid #dee2e6; + line-height: 1.8; +} + +.translation-content h2 { + color: var(--primary-color); + margin-top: 20px; + border-bottom: 2px solid var(--primary-color); + padding-bottom: 10px; +} + +.translation-content hr { + margin: 30px 0; +} + +/* 对比视图 */ +.compare-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; +} + +.compare-panel { + padding: 15px; + border-radius: 5px; + background-color: #f8f9fa; +} + +.compare-panel.original { + border-left: 4px solid #dc3545; +} + +.compare-panel.translated { + border-left: 4px solid var(--success-color); +} + +/* 会员卡片 */ +.pricing-card { + transition: transform 0.3s ease; +} + +.pricing-card:hover { + transform: translateY(-5px); +} + +.pricing-card .price { + font-size: 2.5rem; + font-weight: bold; + color: var(--primary-color); +} + +.pricing-card .features { + list-style: none; + padding: 0; +} + +.pricing-card .features li { + padding: 10px 0; + border-bottom: 1px solid #eee; +} + +/* 按钮 */ +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-primary:hover { + background-color: #3a7bc8; + border-color: #3a7bc8; +} + +/* 响应式 */ +@media (max-width: 768px) { + .compare-container { + grid-template-columns: 1fr; + } + + .translation-content { + max-height: 400px; + } +} + +/* 加载动画 */ +.loading-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.8); + display: flex; + justify-content: center; + align-items: center; + z-index: 9999; +} + +/* 历史记录 */ +.history-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 15px; + border-bottom: 1px solid #eee; +} + +.history-item:hover { + background-color: #f8f9fa; +} + +/* 登录注册表单 */ +.auth-form { + max-width: 400px; + margin: 50px auto; + padding: 30px; + background: white; + border-radius: 10px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.1); +} \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..fb0ccb0 --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,286 @@ +/** + * PDF翻译助手前端脚本 + */ + +// 当前翻译ID +let currentTranslationId = null; +let currentTaskId = null; + +// 上传表单处理 +document.getElementById('uploadForm').addEventListener('submit', async function(e) { + e.preventDefault(); + + const fileInput = document.getElementById('pdfFile'); + const file = fileInput.files[0]; + + if (!file) { + alert('请选择PDF文件'); + return; + } + + if (!file.name.toLowerCase().endsWith('.pdf')) { + alert('只支持PDF文件'); + return; + } + + // 显示进度区域 + document.getElementById('progressSection').style.display = 'block'; + document.getElementById('resultSection').style.display = 'none'; + document.getElementById('cacheNotice').style.display = 'none'; + + // 显示加载状态 + const submitBtn = document.getElementById('submitBtn'); + const btnText = document.getElementById('btnText'); + const btnSpinner = document.getElementById('btnSpinner'); + + submitBtn.disabled = true; + btnText.textContent = '上传中...'; + btnSpinner.style.display = 'inline-block'; + + // 构建表单数据 + const formData = new FormData(); + formData.append('file', file); + + const instruction = document.getElementById('instruction')?.value; + if (instruction) { + formData.append('instruction', instruction); + } + + try { + // 上传文件 + const response = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || '上传失败'); + } + + currentTranslationId = result.translation_id; + currentTaskId = result.task_id; + + // 如果使用缓存,直接显示结果 + if (result.from_cache) { + document.getElementById('cacheNotice').style.display = 'block'; + showResult(currentTranslationId); + } else { + // 开始轮询进度 + pollProgress(currentTaskId, currentTranslationId); + } + + } catch (error) { + alert('上传失败: ' + error.message); + resetUploadButton(); + } +}); + +// 轮询翻译进度 +async function pollProgress(taskId, translationId) { + const progressBar = document.getElementById('progressBar'); + const progressMessage = document.getElementById('progressMessage'); + + const poll = async () => { + try { + // 同时检查任务状态和翻译状态 + const taskResponse = await fetch(`/api/task/${taskId}`); + const taskResult = await taskResponse.json(); + + const transResponse = await fetch(`/api/status/${translationId}`); + const transResult = await transResponse.json(); + + // 更新进度 + if (taskResult.progress) { + progressBar.style.width = taskResult.progress + '%'; + progressBar.textContent = taskResult.progress + '%'; + } + + if (taskResult.message) { + progressMessage.textContent = taskResult.message; + } + + // 检查是否完成 + if (taskResult.status === 'completed' || transResult.status === 'completed') { + progressBar.style.width = '100%'; + progressBar.textContent = '100%'; + progressMessage.textContent = '翻译完成!'; + + // 显示结果 + setTimeout(() => showResult(translationId), 500); + return; + } + + if (taskResult.status === 'failed') { + progressMessage.textContent = '翻译失败: ' + (taskResult.error || '未知错误'); + resetUploadButton(); + return; + } + + // 继续轮询 + setTimeout(poll, 2000); + + } catch (error) { + console.error('轮询失败:', error); + setTimeout(poll, 3000); + } + }; + + poll(); +} + +// 显示翻译结果 +async function showResult(translationId) { + const resultSection = document.getElementById('resultSection'); + const resultContent = document.getElementById('resultContent'); + + try { + const response = await fetch(`/api/result/${translationId}`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || '获取结果失败'); + } + + // 渲染Markdown内容 + resultContent.innerHTML = renderMarkdown(result.content); + resultSection.style.display = 'block'; + + resetUploadButton(); + + } catch (error) { + alert('获取结果失败: ' + error.message); + resetUploadButton(); + } +} + +// 重置上传按钮 +function resetUploadButton() { + const submitBtn = document.getElementById('submitBtn'); + const btnText = document.getElementById('btnText'); + const btnSpinner = document.getElementById('btnSpinner'); + + submitBtn.disabled = false; + btnText.textContent = '开始翻译'; + btnSpinner.style.display = 'none'; +} + +// 下载结果 +document.getElementById('downloadBtn')?.addEventListener('click', function() { + if (currentTranslationId) { + window.location.href = `/api/download/${currentTranslationId}`; + } +}); + +// 对比查看 +document.getElementById('viewCompare')?.addEventListener('click', async function() { + if (!currentTranslationId) return; + + try { + const response = await fetch(`/api/compare/${currentTranslationId}`); + const result = await response.json(); + + // 显示对比视图 + showCompareView(result); + + } catch (error) { + alert('获取对比失败: ' + error.message); + } +}); + +// 显示对比视图 +function showCompareView(data) { + const resultContent = document.getElementById('resultContent'); + + resultContent.innerHTML = ` +
+
+
原文
+
${escapeHtml(data.original)}
+
+
+
译文
+
${renderMarkdown(data.translated)}
+
+
+ `; +} + +// 重新翻译 +document.getElementById('retranslateBtn')?.addEventListener('click', async function() { + if (!currentTranslationId) return; + + const instruction = document.getElementById('retranslateInstruction').value; + if (!instruction.trim()) { + alert('请输入翻译要求'); + return; + } + + try { + const response = await fetch(`/api/retranslate/${currentTranslationId}`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({instruction: instruction}) + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || '重译请求失败'); + } + + // 开始新的翻译任务 + currentTranslationId = result.translation_id; + document.getElementById('progressSection').style.display = 'block'; + document.getElementById('resultSection').style.display = 'none'; + pollProgress(null, currentTranslationId); + + } catch (error) { + alert('重译失败: ' + error.message); + } +}); + +// 简单Markdown渲染 +function renderMarkdown(text) { + // 标题 + text = text.replace(/^## (.*)$/gm, '

$1

'); + text = text.replace(/^# (.*)$/gm, '

$1

'); + + // 分隔线 + text = text.replace(/^---$/gm, '
'); + + // 引用 + text = text.replace(/^> (.*)$/gm, '
$1
'); + + // 段落 + text = text.replace(/\n\n/g, '

'); + text = '

' + text + '

'; + + return text; +} + +// HTML转义 +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// 检查用户登录状态 +async function checkUserStatus() { + try { + const response = await fetch('/api/user/info'); + const result = await response.json(); + + if (result.user) { + console.log('用户已登录:', result.user.username); + } + + } catch (error) { + console.error('检查用户状态失败:', error); + } +} + +// 页面加载时检查状态 +document.addEventListener('DOMContentLoaded', checkUserStatus); \ No newline at end of file diff --git a/templates/history.html b/templates/history.html new file mode 100644 index 0000000..68c5674 --- /dev/null +++ b/templates/history.html @@ -0,0 +1,62 @@ + + + + + + 翻译历史 - PDF翻译助手 + + + + + + +
+

翻译历史

+ + {% if translations %} +
+
+ {% for t in translations %} +
+
+ {{ t.original_filename }} + {{ t.page_count }}页 + {% if t.from_cache %} + 缓存 + {% endif %} + {{ t.created_at.strftime('%Y-%m-%d %H:%M') }} +
+
+ {{ t.status }} + + {% if t.status == 'completed' %} + 查看 + 下载 + {% endif %} +
+
+ {% endfor %} +
+
+ {% else %} +
+ 还没有翻译记录,开始翻译吧! +
+ {% endif %} +
+ + + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..ddd28b8 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,162 @@ + + + + + + PDF翻译助手 - 英文PDF翻译中文 + + + + + + + + +
+ +
+
+
+
+
📊 今日翻译额度
+ {% if user %} +

+ 用户类型: {{ user.user_type }}
+ 今日剩余: {{ daily_remaining }}次
+ 单文件最大: {{ max_pages }}页 +

+ {% if not user.is_vip() %} + 升级会员 + {% endif %} + {% else %} +

+ 访客模式
+ 今日剩余: {{ daily_remaining }}次
+ 单文件最大: {{ max_pages }}页
+ 登录后可获得更多翻译次数 +

+ 登录获取更多 + {% endif %} +
+
+
+
+
+
+
✨ 功能特点
+
    +
  • ✅ 自动翻译缓存,相同文件秒出结果
  • +
  • ✅ 支持自定义翻译要求
  • +
  • ✅ 原文译文对比查看
  • +
  • ✅ 翻译历史记录
  • +
+
+
+
+
+ + +
+
+

📤 上传PDF文件

+
+
+
+
+ + +
支持英文PDF翻译为中文
+
+ + {% if user %} +
+ + +
+ {% endif %} + + +
+
+
+ + + + + + +
+ + +
+
+

PDF翻译助手 v1.0.0 | 基于本地LLM服务

+
+
+ + + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9bbcca8 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,80 @@ + + + + + + 登录 - PDF翻译助手 + + + + + + +
+

登录

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

+ 还没有账号? 立即注册 +

+ +
+ 注册福利: +
    +
  • 每日翻译次数提升至10次
  • +
  • 单文件最大50页
  • +
  • 翻译历史记录
  • +
  • 支持重新翻译
  • +
+
+
+ + + + \ No newline at end of file diff --git a/templates/pricing.html b/templates/pricing.html new file mode 100644 index 0000000..67cd100 --- /dev/null +++ b/templates/pricing.html @@ -0,0 +1,195 @@ + + + + + + 会员套餐 - PDF翻译助手 + + + + + + +
+

会员套餐

+ +
+ +
+
+
+

基础会员

+
¥29/月
+ +
    +
  • ✅ 每日翻译50次
  • +
  • ✅ 单文件最大100页
  • +
  • ✅ 翻译历史记录
  • +
  • ✅ 优先处理队列
  • +
  • ✅ 导出PDF格式
  • +
+ + +
+
+
+ + +
+
+
+ 推荐 +
+
+

专业会员

+
¥99/月
+ +
    +
  • ✅ 每日翻译200次
  • +
  • ✅ 单文件最大500页
  • +
  • ✅ 所有基础会员功能
  • +
  • ✅ 批量翻译
  • +
  • ✅ 自定义术语库
  • +
+ + +
+
+
+ + +
+
+
+

企业会员

+
¥999/年
+ +
    +
  • ✅ 翻译次数无限制
  • +
  • ✅ 页数无限制
  • +
  • ✅ 所有功能解锁
  • +
  • ✅ 专属客服支持
  • +
  • ✅ API接口调用
  • +
+ + +
+
+
+
+ + +
+
+

功能对比

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
功能访客免费用户基础会员专业会员企业会员
每日翻译次数3次10次50次200次无限制
单文件最大页数20页50页100页500页无限制
翻译缓存
翻译历史
重新翻译
对比查看
导出PDF
批量翻译
自定义术语
API调用
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..7f8da46 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,82 @@ + + + + + + 注册 - PDF翻译助手 + + + + + + +
+

注册账号

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
密码至少6位
+
+ + +
+ +
+ +

+ 已有账号? 立即登录 +

+
+ + + + \ No newline at end of file diff --git a/templates/translation.html b/templates/translation.html new file mode 100644 index 0000000..1ff67e8 --- /dev/null +++ b/templates/translation.html @@ -0,0 +1,157 @@ + + + + + + 翻译详情 - PDF翻译助手 + + +
+ 📄 PDF翻译助手 + +
+ + +
+
+
+

{{ translation.original_filename }}

+
+ 下载结果 + +
+
+
+
+ + 页数: {{ translation.page_count }} | + 时间: {{ translation.created_at.strftime('%Y-%m-%d %H:%M') }} | + {% if translation.from_cache %} + 来自缓存 + {% endif %} + +
+ +
+ 加载中... +
+ + {% if user %} +
+
+
重新翻译
+ + +
+ {% endif %} +
+
+
+ + + + \ No newline at end of file