commit 2ef5e6da872e2fd94f5fc837444f88d0555b8a26 Author: coder Date: Tue Apr 7 23:26:53 2026 +0800 V2.0.0: 新增用户权限动态配置、会员套餐配置、数据包购买功能 新功能: - 用户权限动态配置(翻译次数、页数限制) - 会员套餐动态配置(名称、价格、周期) - 数据包购买套餐管理 - 收入统计功能 - 数据包销售排行 技术更新: - 新增 DynamicConfig 模型支持动态配置 - 新增 DataPackage 和 UserPackage 模型 - 后台管理增加数据包管理模块 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d006b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +instance/ +*.db +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..93f99d3 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# PDF翻译助手 V2.0 + +英文PDF翻译中文网站,支持用户系统、会员体系和数据包购买。 + +## V2.0 新功能 + +### 新增功能 +- ✅ **用户权限动态配置** - 在后台页面设置不同用户类型的翻译次数、页数限制 +- ✅ **会员套餐动态配置** - 在后台页面设置会员套餐名称、价格、周期 +- ✅ **数据包购买套餐** - 管理员可添加/编辑/删除数据包套餐 +- ✅ **收入统计** - 新增收入趋势图和数据包销售排行 + +### 数据包套餐 +支持创建多种数据包套餐: +- 固定翻译次数(如100次、500次) +- 有效期限制(如30天、永久) +- 价格和折扣设置 +- 推荐套餐高亮 + +## 快速启动 + +```bash +# 安装依赖 +pip install -r requirements.txt + +# 启动服务 +python app.py +``` + +- 前台地址: http://localhost:19000 +- 后台地址: http://localhost:19000/admin +- 管理员账号: admin / admin123 + +## 功能列表 + +| 模块 | 功能 | +|------|------| +| PDF翻译 | 上传PDF自动翻译成中文 | +| 用户系统 | 注册/登录,不同用户类型 | +| 会员体系 | 基础/专业/企业会员 | +| 数据包购买 | 按需购买翻译次数 | +| 智能缓存 | 相同文件秒出结果 | +| 后台管理 | 用户管理、翻译记录、统计报表 | + +## 版本历史 + +### V2.0.0 (2026-04-07) +- 新增用户权限动态配置 +- 新增会员套餐动态配置 +- 新增数据包购买功能 +- 新增收入统计功能 + +### V1.0.0 (2026-04-07) +- 初始版本 +- PDF翻译核心功能 +- 用户系统和会员体系 +- 后台管理 \ No newline at end of file diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..bbfbb2e --- /dev/null +++ b/admin.py @@ -0,0 +1,664 @@ +""" +后台管理模块 V2 +""" + +from datetime import datetime, date, timedelta +from functools import wraps +from flask import Blueprint, render_template, request, jsonify, redirect, url_for, session, flash +from sqlalchemy import func, desc +import json + +from models import (db, User, Translation, TranslationCache, GuestTranslation, + SystemConfig, OperationLog, DataPackage, UserPackage, DynamicConfig) +from config import USER_LIMITS, MEMBERSHIP_PLANS + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + + +# ==================== 权限装饰器 ==================== +def admin_required(f): + """管理员权限装饰器""" + @wraps(f) + def decorated_function(*args, **kwargs): + user_id = session.get('user_id') + if not user_id: + return redirect(url_for('login', next=request.url)) + + user = User.query.get(user_id) + if not user or not user.is_admin: + flash('需要管理员权限', 'error') + return redirect(url_for('index')) + + return f(*args, **kwargs) + return decorated_function + + +# ==================== 后台首页 ==================== +@admin_bp.route('/') +@admin_required +def dashboard(): + """后台首页 - 数据概览""" + # 用户统计 + total_users = User.query.count() + new_users_today = User.query.filter( + func.date(User.created_at) == date.today() + ).count() + vip_users = User.query.filter(User.user_type.startswith('vip')).count() + + # 翻译统计 + total_translations = Translation.query.count() + today_translations = Translation.query.filter( + func.date(Translation.created_at) == date.today() + ).count() + + # 缓存统计 + total_cache = TranslationCache.query.count() + total_cache_hits = db.session.query(func.sum(TranslationCache.hit_count)).scalar() or 0 + total_cache_size = db.session.query(func.sum(TranslationCache.file_size)).scalar() or 0 + + # 数据包销售统计 + total_packages_sold = UserPackage.query.filter_by(payment_status='paid').count() + total_revenue = db.session.query(func.sum(UserPackage.price_paid)).filter( + UserPackage.payment_status == 'paid' + ).scalar() or 0 + + # 最近翻译 + recent_translations = Translation.query.order_by(desc(Translation.created_at)).limit(10).all() + + # 最近用户 + recent_users = User.query.order_by(desc(User.created_at)).limit(10).all() + + # 每日翻译趋势(最近7天) + daily_stats = [] + for i in range(6, -1, -1): + d = date.today() - timedelta(days=i) + count = Translation.query.filter(func.date(Translation.created_at) == d).count() + daily_stats.append({'date': d.strftime('%m-%d'), 'count': count}) + + return render_template('admin/dashboard.html', + total_users=total_users, + new_users_today=new_users_today, + vip_users=vip_users, + total_translations=total_translations, + today_translations=today_translations, + total_cache=total_cache, + total_cache_hits=total_cache_hits, + total_cache_size=total_cache_size, + total_packages_sold=total_packages_sold, + total_revenue=total_revenue, + recent_translations=recent_translations, + recent_users=recent_users, + daily_stats=daily_stats, + ) + + +# ==================== 用户管理 ==================== +@admin_bp.route('/users') +@admin_required +def users(): + """用户列表""" + page = request.args.get('page', 1, type=int) + search = request.args.get('search', '') + user_type = request.args.get('type', '') + + query = User.query + + if search: + query = query.filter( + (User.username.contains(search)) | (User.email.contains(search)) + ) + + if user_type: + query = query.filter_by(user_type=user_type) + + users = query.order_by(desc(User.created_at)).paginate(page=page, per_page=20) + + return render_template('admin/users.html', + users=users, + search=search, + user_type=user_type, + user_types=['free', 'vip_basic', 'vip_pro', 'vip_enterprise', 'admin'] + ) + + +@admin_bp.route('/user/', methods=['GET', 'POST']) +@admin_required +def user_detail(user_id): + """用户详情/编辑""" + user = User.query.get_or_404(user_id) + + if request.method == 'POST': + data = request.json if request.is_json else request.form + + user.username = data.get('username', user.username) + user.email = data.get('email', user.email) + user.user_type = data.get('user_type', user.user_type) + user.is_active = data.get('is_active', user.is_active) == 'true' or data.get('is_active') == True + user.is_admin = data.get('is_admin', user.is_admin) == 'true' or data.get('is_admin') == True + + # 会员到期时间 + expire_str = data.get('membership_expire') + if expire_str: + try: + user.membership_expire = datetime.fromisoformat(expire_str) + except: + pass + + # 密码 + new_password = data.get('new_password') + if new_password: + user.set_password(new_password) + + db.session.commit() + + # 记录日志 + log = OperationLog( + user_id=session.get('user_id'), + username='admin', + action='edit_user', + target=user.username, + detail=json.dumps(data, ensure_ascii=False) if isinstance(data, dict) else str(data) + ) + db.session.add(log) + db.session.commit() + + if request.is_json: + return jsonify({'success': True, 'user': user.to_dict()}) + flash('用户信息已更新', 'success') + return redirect(url_for('admin.user_detail', user_id=user_id)) + + translations = Translation.query.filter_by(user_id=user_id).order_by(desc(Translation.created_at)).limit(20).all() + packages = UserPackage.query.filter_by(user_id=user_id).order_by(desc(UserPackage.purchased_at)).all() + + return render_template('admin/user_detail.html', user=user, translations=translations, packages=packages) + + +@admin_bp.route('/user//delete', methods=['POST']) +@admin_required +def delete_user(user_id): + """删除用户""" + user = User.query.get_or_404(user_id) + + if user.is_admin and User.query.filter_by(is_admin=True).count() <= 1: + return jsonify({'error': '不能删除最后一个管理员'}), 400 + + username = user.username + db.session.delete(user) + db.session.commit() + + log = OperationLog( + user_id=session.get('user_id'), + username='admin', + action='delete_user', + target=username + ) + db.session.add(log) + db.session.commit() + + return jsonify({'success': True}) + + +# ==================== 翻译记录管理 ==================== +@admin_bp.route('/translations') +@admin_required +def translations(): + """翻译记录列表""" + page = request.args.get('page', 1, type=int) + status = request.args.get('status', '') + search = request.args.get('search', '') + + query = Translation.query + + if status: + query = query.filter_by(status=status) + + if search: + query = query.filter(Translation.original_filename.contains(search)) + + translations = query.order_by(desc(Translation.created_at)).paginate(page=page, per_page=20) + + return render_template('admin/translations.html', + translations=translations, + status=status, + search=search + ) + + +@admin_bp.route('/translation/') +@admin_required +def translation_detail(trans_id): + """翻译详情""" + translation = Translation.query.get_or_404(trans_id) + user = User.query.get(translation.user_id) if translation.user_id else None + + return render_template('admin/translation_detail.html', + translation=translation, + user=user + ) + + +@admin_bp.route('/translation//delete', methods=['POST']) +@admin_required +def delete_translation(trans_id): + """删除翻译记录""" + translation = Translation.query.get_or_404(trans_id) + + if translation.output_path: + import os + if os.path.exists(translation.output_path): + os.remove(translation.output_path) + + db.session.delete(translation) + db.session.commit() + + return jsonify({'success': True}) + + +# ==================== 缓存管理 ==================== +@admin_bp.route('/cache') +@admin_required +def cache_list(): + """缓存列表""" + page = request.args.get('page', 1, type=int) + + caches = TranslationCache.query.order_by(desc(TranslationCache.hit_count)).paginate(page=page, per_page=20) + + total_size = db.session.query(func.sum(TranslationCache.file_size)).scalar() or 0 + total_hits = db.session.query(func.sum(TranslationCache.hit_count)).scalar() or 0 + + return render_template('admin/cache.html', + caches=caches, + total_size=total_size, + total_hits=total_hits + ) + + +@admin_bp.route('/cache//delete', methods=['POST']) +@admin_required +def delete_cache(cache_id): + """删除缓存""" + cache = TranslationCache.query.get_or_404(cache_id) + + if cache.cache_path: + import os + if os.path.exists(cache.cache_path): + os.remove(cache.cache_path) + + db.session.delete(cache) + db.session.commit() + + return jsonify({'success': True}) + + +@admin_bp.route('/cache/clear', methods=['POST']) +@admin_required +def clear_cache(): + """清空缓存""" + import os + caches = TranslationCache.query.all() + + for cache in caches: + if cache.cache_path and os.path.exists(cache.cache_path): + os.remove(cache.cache_path) + + TranslationCache.query.delete() + db.session.commit() + + return jsonify({'success': True, 'deleted': len(caches)}) + + +# ==================== 系统配置 V2 ==================== +@admin_bp.route('/settings', methods=['GET', 'POST']) +@admin_required +def settings(): + """系统配置""" + if request.method == 'POST': + data = request.json if request.is_json else request.form + + for key, value in data.items(): + SystemConfig.set(key, value) + + if request.is_json: + return jsonify({'success': True}) + flash('配置已保存', 'success') + + # 获取所有配置 + configs = SystemConfig.query.all() + config_dict = {c.key: c.value for c in configs} + + # 获取动态配置 + dynamic_configs = DynamicConfig.query.all() + + return render_template('admin/settings.html', + configs=config_dict, + dynamic_configs=dynamic_configs, + user_limits=USER_LIMITS, + membership_plans=MEMBERSHIP_PLANS + ) + + +# ==================== 用户权限配置 ==================== +@admin_bp.route('/settings/user-limits', methods=['GET', 'POST']) +@admin_required +def user_limits_settings(): + """用户权限配置""" + if request.method == 'POST': + data = request.json + + # 保存每个用户类型的配置 + for user_type, limits in data.items(): + for key, value in limits.items(): + config_key = f"user_limit_{user_type}_{key}" + DynamicConfig.set( + config_key, + value, + category='user_limits', + value_type='int' if isinstance(value, int) else 'string', + user_id=session.get('user_id') + ) + + return jsonify({'success': True}) + + # 获取当前配置 + limits_config = {} + for user_type in ['guest', 'free', 'vip_basic', 'vip_pro', 'vip_enterprise']: + limits_config[user_type] = { + 'daily_translations': DynamicConfig.get(f'user_limit_{user_type}_daily_translations', + USER_LIMITS.get(user_type, {}).get('daily_translations', 10)), + 'max_pages': DynamicConfig.get(f'user_limit_{user_type}_max_pages', + USER_LIMITS.get(user_type, {}).get('max_pages', 50)), + 'max_file_size': DynamicConfig.get(f'user_limit_{user_type}_max_file_size', + USER_LIMITS.get(user_type, {}).get('max_file_size', 30*1024*1024)), + } + + return render_template('admin/user_limits.html', + limits_config=limits_config, + default_limits=USER_LIMITS + ) + + +# ==================== 会员套餐配置 ==================== +@admin_bp.route('/settings/membership', methods=['GET', 'POST']) +@admin_required +def membership_settings(): + """会员套餐配置""" + if request.method == 'POST': + data = request.json + + for plan_key, plan_data in data.items(): + for key, value in plan_data.items(): + config_key = f"membership_{plan_key}_{key}" + value_type = 'int' if isinstance(value, int) else 'float' if isinstance(value, float) else 'string' + DynamicConfig.set( + config_key, + value, + category='membership', + value_type=value_type, + user_id=session.get('user_id') + ) + + return jsonify({'success': True}) + + # 获取当前配置 + plans_config = {} + for plan_key in ['vip_basic', 'vip_pro', 'vip_enterprise']: + default = MEMBERSHIP_PLANS.get(plan_key, {}) + plans_config[plan_key] = { + 'name': DynamicConfig.get(f'membership_{plan_key}_name', default.get('name', plan_key)), + 'price': DynamicConfig.get(f'membership_{plan_key}_price', default.get('price', 0)), + 'period': DynamicConfig.get(f'membership_{plan_key}_period', default.get('period', 'month')), + 'description': DynamicConfig.get(f'membership_{plan_key}_description', default.get('description', '')), + } + + return render_template('admin/membership.html', + plans_config=plans_config, + default_plans=MEMBERSHIP_PLANS + ) + + +# ==================== 数据包套餐管理 ==================== +@admin_bp.route('/packages') +@admin_required +def packages(): + """数据包套餐列表""" + packages = DataPackage.query.order_by(DataPackage.sort_order).all() + return render_template('admin/packages.html', packages=packages) + + +@admin_bp.route('/package/add', methods=['GET', 'POST']) +@admin_required +def add_package(): + """添加数据包套餐""" + if request.method == 'POST': + data = request.json if request.is_json else request.form + + package = DataPackage( + name=data.get('name'), + description=data.get('description'), + translation_count=int(data.get('translation_count', 0)), + price=float(data.get('price', 0)), + original_price=float(data.get('original_price')) if data.get('original_price') else None, + valid_days=int(data.get('valid_days', 30)), + sort_order=int(data.get('sort_order', 0)), + is_active=data.get('is_active', 'true') == 'true' if isinstance(data.get('is_active'), str) else data.get('is_active', True), + is_recommended=data.get('is_recommended', 'true') == 'true' if isinstance(data.get('is_recommended'), str) else data.get('is_recommended', False), + ) + + db.session.add(package) + db.session.commit() + + if request.is_json: + return jsonify({'success': True, 'package': package.to_dict()}) + flash('数据包套餐已添加', 'success') + return redirect(url_for('admin.packages')) + + return render_template('admin/package_form.html', package=None) + + +@admin_bp.route('/package//edit', methods=['GET', 'POST']) +@admin_required +def edit_package(package_id): + """编辑数据包套餐""" + package = DataPackage.query.get_or_404(package_id) + + if request.method == 'POST': + data = request.json if request.is_json else request.form + + package.name = data.get('name', package.name) + package.description = data.get('description', package.description) + package.translation_count = int(data.get('translation_count', package.translation_count)) + package.price = float(data.get('price', package.price)) + package.original_price = float(data.get('original_price')) if data.get('original_price') else None + package.valid_days = int(data.get('valid_days', package.valid_days)) + package.sort_order = int(data.get('sort_order', package.sort_order)) + package.is_active = data.get('is_active', 'true') == 'true' if isinstance(data.get('is_active'), str) else data.get('is_active', True) + package.is_recommended = data.get('is_recommended', 'true') == 'true' if isinstance(data.get('is_recommended'), str) else data.get('is_recommended', False) + + db.session.commit() + + if request.is_json: + return jsonify({'success': True, 'package': package.to_dict()}) + flash('数据包套餐已更新', 'success') + return redirect(url_for('admin.packages')) + + return render_template('admin/package_form.html', package=package) + + +@admin_bp.route('/package//delete', methods=['POST']) +@admin_required +def delete_package(package_id): + """删除数据包套餐""" + package = DataPackage.query.get_or_404(package_id) + db.session.delete(package) + db.session.commit() + + return jsonify({'success': True}) + + +@admin_bp.route('/package//toggle', methods=['POST']) +@admin_required +def toggle_package(package_id): + """切换数据包套餐状态""" + package = DataPackage.query.get_or_404(package_id) + package.is_active = not package.is_active + db.session.commit() + + return jsonify({'success': True, 'is_active': package.is_active}) + + +# ==================== 操作日志 ==================== +@admin_bp.route('/logs') +@admin_required +def logs(): + """操作日志""" + page = request.args.get('page', 1, type=int) + action = request.args.get('action', '') + + query = OperationLog.query + + if action: + query = query.filter_by(action=action) + + logs = query.order_by(desc(OperationLog.created_at)).paginate(page=page, per_page=50) + + return render_template('admin/logs.html', logs=logs, action=action) + + +# ==================== 统计报表 ==================== +@admin_bp.route('/stats') +@admin_required +def stats(): + """统计报表""" + # 用户增长趋势(最近30天) + user_growth = [] + for i in range(29, -1, -1): + d = date.today() - timedelta(days=i) + count = User.query.filter(func.date(User.created_at) == d).count() + user_growth.append({'date': d.strftime('%Y-%m-%d'), 'count': count}) + + # 翻译量趋势(最近30天) + translation_growth = [] + for i in range(29, -1, -1): + d = date.today() - timedelta(days=i) + count = Translation.query.filter(func.date(Translation.created_at) == d).count() + translation_growth.append({'date': d.strftime('%Y-%m-%d'), 'count': count}) + + # 收入趋势(最近30天) + revenue_growth = [] + for i in range(29, -1, -1): + d = date.today() - timedelta(days=i) + revenue = db.session.query(func.sum(UserPackage.price_paid)).filter( + UserPackage.payment_status == 'paid', + func.date(UserPackage.purchased_at) == d + ).scalar() or 0 + revenue_growth.append({'date': d.strftime('%Y-%m-%d'), 'revenue': float(revenue)}) + + # 用户类型分布 + user_distribution = db.session.query( + User.user_type, func.count(User.id) + ).group_by(User.user_type).all() + + # 翻译状态分布 + status_distribution = db.session.query( + Translation.status, func.count(Translation.id) + ).group_by(Translation.status).all() + + # Top用户 + top_users = db.session.query( + User.username, func.count(Translation.id).label('count') + ).join(Translation).group_by(User.id).order_by(desc('count')).limit(10).all() + + # 数据包销售排行 + top_packages = db.session.query( + DataPackage.name, func.count(UserPackage.id).label('count'), + func.sum(UserPackage.price_paid).label('revenue') + ).join(UserPackage).filter( + UserPackage.payment_status == 'paid' + ).group_by(DataPackage.id).order_by(desc('count')).limit(10).all() + + return render_template('admin/stats.html', + user_growth=user_growth, + translation_growth=translation_growth, + revenue_growth=revenue_growth, + user_distribution=user_distribution, + status_distribution=status_distribution, + top_users=top_users, + top_packages=top_packages + ) + + +# ==================== API接口 ==================== +@admin_bp.route('/api/stats/summary') +@admin_required +def api_stats_summary(): + """API: 统计摘要""" + return jsonify({ + 'total_users': User.query.count(), + 'today_users': User.query.filter(func.date(User.created_at) == date.today()).count(), + 'total_translations': Translation.query.count(), + 'today_translations': Translation.query.filter(func.date(Translation.created_at) == date.today()).count(), + 'total_cache': TranslationCache.query.count(), + 'cache_hits': db.session.query(func.sum(TranslationCache.hit_count)).scalar() or 0, + 'total_packages': DataPackage.query.filter_by(is_active=True).count(), + 'packages_sold': UserPackage.query.filter_by(payment_status='paid').count(), + 'total_revenue': float(db.session.query(func.sum(UserPackage.price_paid)).filter( + UserPackage.payment_status == 'paid' + ).scalar() or 0), + }) + + +@admin_bp.route('/api/user//upgrade', methods=['POST']) +@admin_required +def api_user_upgrade(user_id): + """API: 升级用户会员""" + user = User.query.get_or_404(user_id) + data = request.json + + user_type = data.get('user_type') + months = data.get('months', 1) + + valid_types = ['free', 'vip_basic', 'vip_pro', 'vip_enterprise', 'admin'] + if user_type not in valid_types: + return jsonify({'error': '无效的用户类型'}), 400 + + user.user_type = user_type + if user_type.startswith('vip'): + user.membership_expire = datetime.utcnow() + timedelta(days=30 * months) + + db.session.commit() + + return jsonify({'success': True, 'user': user.to_dict()}) + + +@admin_bp.route('/api/user//add-package', methods=['POST']) +@admin_required +def api_user_add_package(user_id): + """API: 为用户添加数据包""" + user = User.query.get_or_404(user_id) + data = request.json + + package_id = data.get('package_id') + package = DataPackage.query.get(package_id) + + if not package: + return jsonify({'error': '数据包不存在'}), 404 + + user_package = UserPackage( + user_id=user.id, + package_id=package.id, + package_name=package.name, + translation_count=package.translation_count, + remaining_count=package.translation_count, + expire_at=datetime.utcnow() + timedelta(days=package.valid_days) if package.valid_days > 0 else None, + price_paid=0, # 管理员赠送 + payment_status='paid' + ) + + db.session.add(user_package) + db.session.commit() + + return jsonify({'success': True, 'package': { + 'id': user_package.id, + 'name': user_package.package_name, + 'remaining': user_package.remaining_count + }}) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..2c9f661 --- /dev/null +++ b/app.py @@ -0,0 +1,535 @@ +""" +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, + DataPackage, UserPackage, DynamicConfig) +from services import TranslationService, CacheService, TranslationTask +from admin import admin_bp + +# ==================== 创建应用 ==================== +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) + +# 注册后台管理蓝图 +app.register_blueprint(admin_bp) + +# 初始化服务 +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() + + # 创建默认管理员账号 + admin = User.query.filter_by(username='admin').first() + if not admin: + admin = User( + username='admin', + email='admin@tphai.com', + user_type='admin', + is_admin=True, + is_active=True + ) + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + print("✅ 默认管理员账号已创建: admin / admin123") + + # 创建示例数据包套餐 + if DataPackage.query.count() == 0: + packages = [ + DataPackage(name='入门包', description='适合轻度使用', translation_count=100, price=9.9, original_price=19.9, valid_days=30, sort_order=1), + DataPackage(name='标准包', description='日常使用首选', translation_count=500, price=39.9, original_price=59.9, valid_days=30, sort_order=2, is_recommended=True), + DataPackage(name='专业包', description='高频使用更划算', translation_count=2000, price=99.9, original_price=199.9, valid_days=30, sort_order=3), + DataPackage(name='无限包', description='畅享无限翻译', translation_count=0, price=199.9, original_price=399.9, valid_days=30, sort_order=4), + ] + for pkg in packages: + db.session.add(pkg) + db.session.commit() + print("✅ 示例数据包套餐已创建") + + +if __name__ == '__main__': + init_app() + app.run(host='0.0.0.0', port=19000, 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..5e1898a --- /dev/null +++ b/models.py @@ -0,0 +1,416 @@ +""" +数据库模型定义 +""" + +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, admin + 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) + + # 状态 + is_active = db.Column(db.Boolean, default=True) # 是否启用 + is_admin = db.Column(db.Boolean, default=False) # 是否管理员 + + # 关系 + 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(), + 'is_admin': self.is_admin, + 'is_active': self.is_active, + 'daily_count': self.daily_count, + 'total_count': self.total_count, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'membership_expire': self.membership_expire.isoformat() if self.membership_expire else None, + } + + +# ==================== 翻译记录模型 ==================== +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, + 'file_size': self.file_size, + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'user_id': self.user_id, + } + + +# ==================== 翻译缓存模型 ==================== +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) + file_size = db.Column(db.Integer, default=0) + + # 统计 + 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) + 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) + + +# ==================== 系统配置模型 ==================== +class SystemConfig(db.Model): + """系统配置表""" + __tablename__ = 'system_config' + + id = db.Column(db.Integer, primary_key=True) + key = db.Column(db.String(100), unique=True, nullable=False) + value = db.Column(db.Text, nullable=True) + description = db.Column(db.String(255), nullable=True) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + @staticmethod + def get(key, default=None): + config = SystemConfig.query.filter_by(key=key).first() + return config.value if config else default + + @staticmethod + def set(key, value, description=None): + config = SystemConfig.query.filter_by(key=key).first() + if config: + config.value = value + else: + config = SystemConfig(key=key, value=value, description=description) + db.session.add(config) + db.session.commit() + + +# ==================== 操作日志模型 ==================== +class OperationLog(db.Model): + """操作日志表""" + __tablename__ = 'operation_logs' + + id = db.Column(db.Integer, primary_key=True) + + # 操作者 + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + username = db.Column(db.String(80), nullable=True) + + # 操作信息 + action = db.Column(db.String(50), nullable=False) # login, translate, register, etc. + target = db.Column(db.String(100), nullable=True) # 操作对象 + detail = db.Column(db.Text, nullable=True) # 详细信息(JSON) + + # IP地址 + ip_address = db.Column(db.String(45), nullable=True) + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'username': self.username, + 'action': self.action, + 'target': self.target, + 'ip_address': self.ip_address, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + +# ==================== 数据包套餐模型 ==================== +class DataPackage(db.Model): + """数据包购买套餐""" + __tablename__ = 'data_packages' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) # 套餐名称 + description = db.Column(db.String(255), nullable=True) # 描述 + + # 翻译次数 + translation_count = db.Column(db.Integer, default=0) # 翻译次数(-1表示无限) + + # 价格 + price = db.Column(db.Float, default=0) # 价格 + original_price = db.Column(db.Float, nullable=True) # 原价(用于显示折扣) + + # 有效期 + valid_days = db.Column(db.Integer, default=30) # 有效天数(0表示永久) + + # 排序和状态 + sort_order = db.Column(db.Integer, default=0) # 排序 + is_active = db.Column(db.Boolean, default=True) # 是否上架 + is_recommended = db.Column(db.Boolean, default=False) # 是否推荐 + + # 时间 + created_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'translation_count': self.translation_count, + 'price': self.price, + 'original_price': self.original_price, + 'valid_days': self.valid_days, + 'is_active': self.is_active, + 'is_recommended': self.is_recommended, + } + + +# ==================== 用户数据包购买记录 ==================== +class UserPackage(db.Model): + """用户购买的数据包""" + __tablename__ = 'user_packages' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + package_id = db.Column(db.Integer, db.ForeignKey('data_packages.id'), nullable=False) + + # 套餐信息快照 + package_name = db.Column(db.String(100), nullable=False) + translation_count = db.Column(db.Integer, default=0) # 总次数 + remaining_count = db.Column(db.Integer, default=0) # 剩余次数 + + # 有效期 + expire_at = db.Column(db.DateTime, nullable=True) # 过期时间(None表示永久) + + # 购买信息 + price_paid = db.Column(db.Float, default=0) # 实付金额 + payment_method = db.Column(db.String(20), nullable=True) # 支付方式 + payment_status = db.Column(db.String(20), default='pending') # pending, paid, refunded + + # 时间 + purchased_at = db.Column(db.DateTime, default=datetime.utcnow) + + # 关系 + user = db.relationship('User', backref=db.backref('packages', lazy=True)) + package = db.relationship('DataPackage', backref=db.backref('purchases', lazy=True)) + + def is_valid(self): + """检查数据包是否有效""" + if self.remaining_count <= 0: + return False + if self.expire_at and self.expire_at < datetime.utcnow(): + return False + return True + + +# ==================== 系统配置(动态) ==================== +class DynamicConfig(db.Model): + """动态系统配置(可在页面修改)""" + __tablename__ = 'dynamic_config' + + id = db.Column(db.Integer, primary_key=True) + category = db.Column(db.String(50), default='general') # 分类: general, user_limits, membership + key = db.Column(db.String(100), unique=True, nullable=False) + value = db.Column(db.Text, nullable=True) + value_type = db.Column(db.String(20), default='string') # string, int, float, bool, json + description = db.Column(db.String(255), nullable=True) + + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + updated_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + + @staticmethod + def get(key, default=None): + config = DynamicConfig.query.filter_by(key=key).first() + if not config: + return default + + # 类型转换 + if config.value_type == 'int': + return int(config.value) if config.value else 0 + elif config.value_type == 'float': + return float(config.value) if config.value else 0.0 + elif config.value_type == 'bool': + return config.value.lower() in ('true', '1', 'yes') + elif config.value_type == 'json': + import json + return json.loads(config.value) if config.value else {} + return config.value + + @staticmethod + def set(key, value, category='general', value_type='string', description=None, user_id=None): + config = DynamicConfig.query.filter_by(key=key).first() + + # 类型转换 + if value_type == 'json': + import json + value = json.dumps(value, ensure_ascii=False) + elif value_type == 'bool': + value = 'true' if value else 'false' + else: + value = str(value) if value is not None else None + + if config: + config.value = value + config.value_type = value_type + config.updated_by = user_id + else: + config = DynamicConfig( + category=category, + key=key, + value=value, + value_type=value_type, + description=description, + updated_by=user_id + ) + db.session.add(config) + + db.session.commit() + return config \ 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/admin/cache.html b/templates/admin/cache.html new file mode 100644 index 0000000..f29363a --- /dev/null +++ b/templates/admin/cache.html @@ -0,0 +1,140 @@ + + + + + 缓存管理 - 后台管理 + + + + + + + +
+ +
+
+
+
+
缓存总数
+

{{ caches.total }}

+
+
+
+
+
+
+
缓存大小
+

{{ (total_size / 1024 / 1024)|round(2) }} MB

+
+
+
+
+
+
+
总命中次数
+

{{ total_hits }}

+
+
+
+
+ +
+
+
缓存列表
+ +
+
+ + + + + + + + + + + + + {% for c in caches.items %} + + + + + + + + + {% else %} + + {% endfor %} + +
文件哈希页数大小命中次数创建时间操作
{{ c.file_hash[:16] }}...{{ c.page_count }}{{ (c.file_size / 1024)|round(1) }}KB{{ c.hit_count }}{{ c.created_at.strftime('%m-%d %H:%M') }} + +
暂无缓存
+
+ +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/cache.html.bak b/templates/admin/cache.html.bak new file mode 100644 index 0000000..f29363a --- /dev/null +++ b/templates/admin/cache.html.bak @@ -0,0 +1,140 @@ + + + + + 缓存管理 - 后台管理 + + + + + + + +
+ +
+
+
+
+
缓存总数
+

{{ caches.total }}

+
+
+
+
+
+
+
缓存大小
+

{{ (total_size / 1024 / 1024)|round(2) }} MB

+
+
+
+
+
+
+
总命中次数
+

{{ total_hits }}

+
+
+
+
+ +
+
+
缓存列表
+ +
+
+ + + + + + + + + + + + + {% for c in caches.items %} + + + + + + + + + {% else %} + + {% endfor %} + +
文件哈希页数大小命中次数创建时间操作
{{ c.file_hash[:16] }}...{{ c.page_count }}{{ (c.file_size / 1024)|round(1) }}KB{{ c.hit_count }}{{ c.created_at.strftime('%m-%d %H:%M') }} + +
暂无缓存
+
+ +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..3733643 --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,294 @@ + + + + + + 后台管理 - PDF翻译助手 + + + + + + + + + +
+ +
+
+
+
+
+
+
总用户数
+
{{ total_users }}
+
今日 +{{ new_users_today }}
+
+
+
+
+
+
+
+
+
+
总翻译次数
+
{{ total_translations }}
+
今日 +{{ today_translations }}
+
+
+
+
+
+
+
+
+
+
VIP用户
+
{{ vip_users }}
+
+
+
+
+
+
+
+
+
+
缓存数量
+
{{ total_cache }}
+
命中 {{ total_cache_hits }} 次
+
+
+
+
+
+ +
+ +
+
+
+
每日翻译趋势(最近7天)
+
+
+ +
+
+
+ + + +
+ +
+ +
+
+
+
最近翻译
+ 查看全部 +
+
+ + + + + + + + + + {% for t in recent_translations %} + + + + + + {% endfor %} + +
文件名状态时间
{{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %} + + {{ t.status }} + + {{ t.created_at.strftime('%H:%M') }}
+
+
+
+ + +
+
+
+
最近注册
+ 查看全部 +
+
+ + + + + + + + + + {% for u in recent_users %} + + + + + + {% endfor %} + +
用户名类型注册时间
{{ u.username }} + + {{ u.user_type }} + + {{ u.created_at.strftime('%m-%d %H:%M') }}
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/templates/admin/dashboard.html.bak b/templates/admin/dashboard.html.bak new file mode 100644 index 0000000..3733643 --- /dev/null +++ b/templates/admin/dashboard.html.bak @@ -0,0 +1,294 @@ + + + + + + 后台管理 - PDF翻译助手 + + + + + + + + + +
+ +
+
+
+
+
+
+
总用户数
+
{{ total_users }}
+
今日 +{{ new_users_today }}
+
+
+
+
+
+
+
+
+
+
总翻译次数
+
{{ total_translations }}
+
今日 +{{ today_translations }}
+
+
+
+
+
+
+
+
+
+
VIP用户
+
{{ vip_users }}
+
+
+
+
+
+
+
+
+
+
缓存数量
+
{{ total_cache }}
+
命中 {{ total_cache_hits }} 次
+
+
+
+
+
+ +
+ +
+
+
+
每日翻译趋势(最近7天)
+
+
+ +
+
+
+ + + +
+ +
+ +
+
+
+
最近翻译
+ 查看全部 +
+
+ + + + + + + + + + {% for t in recent_translations %} + + + + + + {% endfor %} + +
文件名状态时间
{{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %} + + {{ t.status }} + + {{ t.created_at.strftime('%H:%M') }}
+
+
+
+ + +
+
+
+
最近注册
+ 查看全部 +
+
+ + + + + + + + + + {% for u in recent_users %} + + + + + + {% endfor %} + +
用户名类型注册时间
{{ u.username }} + + {{ u.user_type }} + + {{ u.created_at.strftime('%m-%d %H:%M') }}
+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/templates/admin/logs.html b/templates/admin/logs.html new file mode 100644 index 0000000..d7cbaef --- /dev/null +++ b/templates/admin/logs.html @@ -0,0 +1,103 @@ + + + + + 操作日志 - 后台管理 + + + + + + + +
+
+
+
+
操作日志
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + {% for log in logs.items %} + + + + + + + + + {% else %} + + {% endfor %} + +
ID操作者操作类型操作对象IP地址时间
{{ log.id }}{{ log.username or '系统' }} + + {{ log.action }} + + {{ log.target or '-' }}{{ log.ip_address or '-' }}{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
暂无日志
+
+ +
+
+ + \ No newline at end of file diff --git a/templates/admin/logs.html.bak b/templates/admin/logs.html.bak new file mode 100644 index 0000000..d7cbaef --- /dev/null +++ b/templates/admin/logs.html.bak @@ -0,0 +1,103 @@ + + + + + 操作日志 - 后台管理 + + + + + + + +
+
+
+
+
操作日志
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + {% for log in logs.items %} + + + + + + + + + {% else %} + + {% endfor %} + +
ID操作者操作类型操作对象IP地址时间
{{ log.id }}{{ log.username or '系统' }} + + {{ log.action }} + + {{ log.target or '-' }}{{ log.ip_address or '-' }}{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
暂无日志
+
+ +
+
+ + \ No newline at end of file diff --git a/templates/admin/membership.html b/templates/admin/membership.html new file mode 100644 index 0000000..2671fc4 --- /dev/null +++ b/templates/admin/membership.html @@ -0,0 +1,147 @@ + + + + + 会员套餐配置 - 后台管理 + + + + + + + +
+
+

会员套餐配置

+ +
+ +
+
+ {% for plan_key, plan in plans_config.items() %} +
+
+
+
+ + +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + +
+ +
+ 默认: {{ default_plans.get(plan_key, {}).get('name', '') }} - + ¥{{ default_plans.get(plan_key, {}).get('price', 0) }}/{{ default_plans.get(plan_key, {}).get('period', 'month') }} +
+
+
+
+ {% endfor %} +
+ +
+
+ + + 管理数据包套餐 + +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/package_form.html b/templates/admin/package_form.html new file mode 100644 index 0000000..e57fcf1 --- /dev/null +++ b/templates/admin/package_form.html @@ -0,0 +1,158 @@ + + + + + {% if package %}编辑套餐{% else %}添加套餐{% endif %} - 后台管理 + + + + + + + +
+
+
+
+ + {% if package %}编辑套餐{% else %}添加套餐{% endif %} +
+
+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+
+ + + 输入 0 表示无限次数 +
+
+ + + 输入 0 表示永久有效 +
+
+ + + 数字越小越靠前 +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ + 取消 +
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/packages.html b/templates/admin/packages.html new file mode 100644 index 0000000..b0b347a --- /dev/null +++ b/templates/admin/packages.html @@ -0,0 +1,124 @@ + + + + + 数据包套餐 - 后台管理 + + + + + + + +
+
+

数据包套餐管理

+ + 添加套餐 + +
+ +
+ {% for pkg in packages %} +
+ +
+ {% else %} +
+
+ 暂无数据包套餐,点击右上角添加 +
+
+ {% endfor %} +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..a0ab377 --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,114 @@ + + + + + 系统配置 - 后台管理 + + + + + + + +
+
+
+
+
用户权限配置
+
+ + + + + + + + + + {% for type, limits in user_limits.items() %} + + + + + + {% endfor %} + +
用户类型每日次数最大页数
{{ type }}{{ limits.daily_translations if limits.daily_translations > 0 else '无限' }}{{ limits.max_pages if limits.max_pages > 0 else '无限' }}
+ 权限配置需修改 config.py 文件 +
+
+
+ +
+
+
会员套餐配置
+
+ + + + + + + + + + {% for key, plan in membership_plans.items() %} + + + + + + {% endfor %} + +
套餐价格周期
{{ plan.name }}¥{{ plan.price }}{{ plan.period }}
+ 套餐配置需修改 config.py 文件 +
+
+
+
+ +
+
系统信息
+
+
+
+

应用名称: PDF翻译助手

+

版本: 1.0.0

+

框架: Flask + SQLAlchemy

+
+
+

LLM模型: qwen/qwen3.5-35b-a3b

+

API地址: http://192.168.2.5:1234/v1

+
+
+

缓存有效期: 30天

+

最大文件: 50MB

+
+
+
+
+
+ + \ No newline at end of file diff --git a/templates/admin/stats.html b/templates/admin/stats.html new file mode 100644 index 0000000..7df0e63 --- /dev/null +++ b/templates/admin/stats.html @@ -0,0 +1,160 @@ + + + + + 统计报表 - 后台管理 + + + + + + + + +
+
+ +
+
+
用户增长趋势(30天)
+
+ +
+
+
+ +
+
+
翻译量趋势(30天)
+
+ +
+
+
+
+ +
+ +
+
+
用户类型分布
+
+ +
+
+
+ +
+
+
翻译状态分布
+
+ +
+
+
+ +
+
+
活跃用户Top10
+
+ + + + + + {% for name, count in top_users %} + + + + + + {% else %} + + {% endfor %} + +
排名用户翻译数
{{ loop.index }}{{ name }}{{ count }}
暂无数据
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/stats.html.bak b/templates/admin/stats.html.bak new file mode 100644 index 0000000..7df0e63 --- /dev/null +++ b/templates/admin/stats.html.bak @@ -0,0 +1,160 @@ + + + + + 统计报表 - 后台管理 + + + + + + + + +
+
+ +
+
+
用户增长趋势(30天)
+
+ +
+
+
+ +
+
+
翻译量趋势(30天)
+
+ +
+
+
+
+ +
+ +
+
+
用户类型分布
+
+ +
+
+
+ +
+
+
翻译状态分布
+
+ +
+
+
+ +
+
+
活跃用户Top10
+
+ + + + + + {% for name, count in top_users %} + + + + + + {% else %} + + {% endfor %} + +
排名用户翻译数
{{ loop.index }}{{ name }}{{ count }}
暂无数据
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/translation_detail.html b/templates/admin/translation_detail.html new file mode 100644 index 0000000..993d51c --- /dev/null +++ b/templates/admin/translation_detail.html @@ -0,0 +1,113 @@ + + + + + 翻译详情 - 后台管理 + + + + + + + +
+
+
+
翻译详情
+ +
+
+
+
+

文件名: {{ translation.original_filename }}

+

页数: {{ translation.page_count }}

+

大小: {{ (translation.file_size / 1024)|round(1) }} KB

+
+
+

状态: + + {{ translation.status }} + + {% if translation.from_cache %} + 来自缓存 + {% endif %} +

+

用户: {% if user %}{{ user.username }}{% else %}访客{% endif %}

+

时间: {{ translation.created_at.strftime('%Y-%m-%d %H:%M:%S') }}

+
+
+ + {% if translation.status == 'completed' %} +
+
翻译结果
+
+ 加载中... +
+ {% elif translation.status == 'failed' %} +
+ 错误信息: {{ translation.error_message or '未知错误' }} +
+ {% endif %} + + {% if translation.translate_params %} +
+
翻译参数
+
{{ translation.translate_params }}
+ {% endif %} +
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/translation_detail.html.bak b/templates/admin/translation_detail.html.bak new file mode 100644 index 0000000..993d51c --- /dev/null +++ b/templates/admin/translation_detail.html.bak @@ -0,0 +1,113 @@ + + + + + 翻译详情 - 后台管理 + + + + + + + +
+
+
+
翻译详情
+ +
+
+
+
+

文件名: {{ translation.original_filename }}

+

页数: {{ translation.page_count }}

+

大小: {{ (translation.file_size / 1024)|round(1) }} KB

+
+
+

状态: + + {{ translation.status }} + + {% if translation.from_cache %} + 来自缓存 + {% endif %} +

+

用户: {% if user %}{{ user.username }}{% else %}访客{% endif %}

+

时间: {{ translation.created_at.strftime('%Y-%m-%d %H:%M:%S') }}

+
+
+ + {% if translation.status == 'completed' %} +
+
翻译结果
+
+ 加载中... +
+ {% elif translation.status == 'failed' %} +
+ 错误信息: {{ translation.error_message or '未知错误' }} +
+ {% endif %} + + {% if translation.translate_params %} +
+
翻译参数
+
{{ translation.translate_params }}
+ {% endif %} +
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/translations.html b/templates/admin/translations.html new file mode 100644 index 0000000..fa8213f --- /dev/null +++ b/templates/admin/translations.html @@ -0,0 +1,122 @@ + + + + + 翻译记录 - 后台管理 + + + + + + + +
+
+
+
+
翻译记录
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + + + + {% for t in translations.items %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID文件名用户页数大小状态缓存时间操作
{{ t.id }}{{ t.original_filename[:25] }}{% if t.original_filename|length > 25 %}...{% endif %}{% if t.user_id %}ID:{{ t.user_id }}{% else %}访客{% endif %}{{ t.page_count }}{{ (t.file_size / 1024)|round(1) }}KB + + {{ t.status }} + + {% if t.from_cache %}{% else %}-{% endif %}{{ t.created_at.strftime('%m-%d %H:%M') }} + + +
暂无数据
+
+ +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/translations.html.bak b/templates/admin/translations.html.bak new file mode 100644 index 0000000..fa8213f --- /dev/null +++ b/templates/admin/translations.html.bak @@ -0,0 +1,122 @@ + + + + + 翻译记录 - 后台管理 + + + + + + + +
+
+
+
+
翻译记录
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + + + + {% for t in translations.items %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID文件名用户页数大小状态缓存时间操作
{{ t.id }}{{ t.original_filename[:25] }}{% if t.original_filename|length > 25 %}...{% endif %}{% if t.user_id %}ID:{{ t.user_id }}{% else %}访客{% endif %}{{ t.page_count }}{{ (t.file_size / 1024)|round(1) }}KB + + {{ t.status }} + + {% if t.from_cache %}{% else %}-{% endif %}{{ t.created_at.strftime('%m-%d %H:%M') }} + + +
暂无数据
+
+ +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/user_detail.html b/templates/admin/user_detail.html new file mode 100644 index 0000000..8f0973a --- /dev/null +++ b/templates/admin/user_detail.html @@ -0,0 +1,180 @@ + + + + + + 用户详情 - 后台管理 + + + + + + + +
+
+ +
+
+
+
用户信息
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
统计信息
+
+
+

今日翻译: {{ user.daily_count }}

+

总翻译: {{ user.total_count }}

+

注册时间: {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}

+

最后翻译: {% if user.last_translate_date %}{{ user.last_translate_date }}{% else %}无{% endif %}

+
+
+
+ + +
+
+
+
翻译记录
+
+
+ + + + + + + + + + + + + {% for t in translations %} + + + + + + + + + {% else %} + + {% endfor %} + +
文件名页数状态缓存时间操作
{{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %}{{ t.page_count }} + + {{ t.status }} + + {% if t.from_cache %}{% else %}-{% endif %}{{ t.created_at.strftime('%m-%d %H:%M') }} + + + +
暂无翻译记录
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/user_detail.html.bak b/templates/admin/user_detail.html.bak new file mode 100644 index 0000000..8f0973a --- /dev/null +++ b/templates/admin/user_detail.html.bak @@ -0,0 +1,180 @@ + + + + + + 用户详情 - 后台管理 + + + + + + + +
+
+ +
+
+
+
用户信息
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
+
统计信息
+
+
+

今日翻译: {{ user.daily_count }}

+

总翻译: {{ user.total_count }}

+

注册时间: {{ user.created_at.strftime('%Y-%m-%d %H:%M') }}

+

最后翻译: {% if user.last_translate_date %}{{ user.last_translate_date }}{% else %}无{% endif %}

+
+
+
+ + +
+
+
+
翻译记录
+
+
+ + + + + + + + + + + + + {% for t in translations %} + + + + + + + + + {% else %} + + {% endfor %} + +
文件名页数状态缓存时间操作
{{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %}{{ t.page_count }} + + {{ t.status }} + + {% if t.from_cache %}{% else %}-{% endif %}{{ t.created_at.strftime('%m-%d %H:%M') }} + + + +
暂无翻译记录
+
+
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/user_limits.html b/templates/admin/user_limits.html new file mode 100644 index 0000000..d7fa8b5 --- /dev/null +++ b/templates/admin/user_limits.html @@ -0,0 +1,154 @@ + + + + + 用户权限配置 - 后台管理 + + + + + + + +
+
+

用户权限配置

+ +
+ +
+
+ {% for user_type, limits in limits_config.items() %} +
+
+
+
+ {% if user_type == 'guest' %}👤 访客 + {% elif user_type == 'free' %}🆓 免费用户 + {% elif user_type == 'vip_basic' %}⭐ 基础会员 + {% elif user_type == 'vip_pro' %}⭐⭐ 专业会员 + {% elif user_type == 'vip_enterprise' %}👑 企业会员 + {% endif %} +
+
+
+
+ + + 输入 0 表示无限制 +
+
+ + + 输入 0 表示无限制 +
+
+ + +
+ +
+ 默认值:
+ 次数: {{ default_limits.get(user_type, {}).get('daily_translations', 'N/A') }}
+ 页数: {{ default_limits.get(user_type, {}).get('max_pages', 'N/A') }} +
+
+
+
+ {% endfor %} +
+ +
+
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..fc1e59f --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,148 @@ + + + + + + 用户管理 - 后台管理 + + + + + + + + + +
+
+
+
+
+
用户管理
+
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + + + {% for user in users.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID用户名邮箱类型翻译次数状态注册时间操作
{{ user.id }} + {{ user.username }} + {% if user.is_admin %}管理员{% endif %} + {{ user.email }} + + {{ user.user_type }} + + + {{ user.daily_count }} / + {{ user.total_count }} + + {% if user.is_active %} + 正常 + {% else %} + 禁用 + {% endif %} + {{ user.created_at.strftime('%Y-%m-%d %H:%M') }} + + + + +
暂无数据
+
+ +
+
+ + + + \ No newline at end of file diff --git a/templates/admin/users.html.bak b/templates/admin/users.html.bak new file mode 100644 index 0000000..fc1e59f --- /dev/null +++ b/templates/admin/users.html.bak @@ -0,0 +1,148 @@ + + + + + + 用户管理 - 后台管理 + + + + + + + + + +
+
+
+
+
+
用户管理
+
+
+
+ + + +
+
+
+
+
+ + + + + + + + + + + + + + + {% for user in users.items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID用户名邮箱类型翻译次数状态注册时间操作
{{ user.id }} + {{ user.username }} + {% if user.is_admin %}管理员{% endif %} + {{ user.email }} + + {{ user.user_type }} + + + {{ user.daily_count }} / + {{ user.total_count }} + + {% if user.is_active %} + 正常 + {% else %} + 禁用 + {% endif %} + {{ user.created_at.strftime('%Y-%m-%d %H:%M') }} + + + + +
暂无数据
+
+ +
+
+ + + + \ 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