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 %}
+
+ {{ 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') }} |
+
+
+ |
+
+ {% else %}
+ | 暂无缓存 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 %}
+
+ {{ 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') }} |
+
+
+ |
+
+ {% else %}
+ | 暂无缓存 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 }} 次
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 文件名 |
+ 状态 |
+ 时间 |
+
+
+
+ {% for t in recent_translations %}
+
+ | {{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %} |
+
+
+ {{ t.status }}
+
+ |
+ {{ t.created_at.strftime('%H:%M') }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 用户名 |
+ 类型 |
+ 注册时间 |
+
+
+
+ {% for u in recent_users %}
+
+ | {{ u.username }} |
+
+
+ {{ u.user_type }}
+
+ |
+ {{ u.created_at.strftime('%m-%d %H:%M') }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 }} 次
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 文件名 |
+ 状态 |
+ 时间 |
+
+
+
+ {% for t in recent_translations %}
+
+ | {{ t.original_filename[:30] }}{% if t.original_filename|length > 30 %}...{% endif %} |
+
+
+ {{ t.status }}
+
+ |
+ {{ t.created_at.strftime('%H:%M') }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 用户名 |
+ 类型 |
+ 注册时间 |
+
+
+
+ {% for u in recent_users %}
+
+ | {{ u.username }} |
+
+
+ {{ u.user_type }}
+
+ |
+ {{ u.created_at.strftime('%m-%d %H:%M') }} |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 操作日志 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 操作者 |
+ 操作类型 |
+ 操作对象 |
+ IP地址 |
+ 时间 |
+
+
+
+ {% for log in logs.items %}
+
+ | {{ log.id }} |
+ {{ log.username or '系统' }} |
+
+
+ {{ log.action }}
+
+ |
+ {{ log.target or '-' }} |
+ {{ log.ip_address or '-' }} |
+ {{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
+
+ {% else %}
+ | 暂无日志 |
+ {% endfor %}
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 操作日志 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 操作者 |
+ 操作类型 |
+ 操作对象 |
+ IP地址 |
+ 时间 |
+
+
+
+ {% for log in logs.items %}
+
+ | {{ log.id }} |
+ {{ log.username or '系统' }} |
+
+
+ {{ log.action }}
+
+ |
+ {{ log.target or '-' }} |
+ {{ log.ip_address or '-' }} |
+ {{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') }} |
+
+ {% else %}
+ | 暂无日志 |
+ {% endfor %}
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 会员套餐配置 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 %} - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 %}
+
+
+ {% if pkg.is_recommended %}
+
+ {% endif %}
+
+
{{ pkg.name }}
+
{{ pkg.description or '暂无描述' }}
+
+
+ ¥{{ pkg.price }}
+ {% if pkg.original_price %}
+ ¥{{ pkg.original_price }}
+ {% endif %}
+
+
+
+ - 翻译次数:
+ {% if pkg.translation_count > 0 %}{{ pkg.translation_count }}次{% else %}无限{% endif %}
+
+ - 有效期:
+ {% if pkg.valid_days > 0 %}{{ pkg.valid_days }}天{% else %}永久{% endif %}
+
+
+
+
+
+ 编辑
+
+
+
+
+
+
+
+
+ {% 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() %}
+
+ | {{ type }} |
+ {{ limits.daily_translations if limits.daily_translations > 0 else '无限' }} |
+ {{ limits.max_pages if limits.max_pages > 0 else '无限' }} |
+
+ {% endfor %}
+
+
+
权限配置需修改 config.py 文件
+
+
+
+
+
+
+
+
+
+
+
+ | 套餐 |
+ 价格 |
+ 周期 |
+
+
+
+ {% for key, plan in membership_plans.items() %}
+
+ | {{ plan.name }} |
+ ¥{{ plan.price }} |
+ {{ plan.period }} |
+
+ {% endfor %}
+
+
+
套餐配置需修改 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 @@
+
+
+
+
+ 统计报表 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 排名 | 用户 | 翻译数 |
+
+
+ {% for name, count in top_users %}
+
+ | {{ loop.index }} |
+ {{ name }} |
+ {{ count }} |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 统计报表 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 排名 | 用户 | 翻译数 |
+
+
+ {% for name, count in top_users %}
+
+ | {{ loop.index }} |
+ {{ name }} |
+ {{ count }} |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 翻译记录 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 文件名 |
+ 用户 |
+ 页数 |
+ 大小 |
+ 状态 |
+ 缓存 |
+ 时间 |
+ 操作 |
+
+
+
+ {% for t in translations.items %}
+
+ | {{ 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') }} |
+
+
+
+ |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 翻译记录 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 文件名 |
+ 用户 |
+ 页数 |
+ 大小 |
+ 状态 |
+ 缓存 |
+ 时间 |
+ 操作 |
+
+
+
+ {% for t in translations.items %}
+
+ | {{ 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') }} |
+
+
+
+ |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 %}
+
+ | {{ 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') }} |
+
+
+
+
+ |
+
+ {% else %}
+ | 暂无翻译记录 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
\ 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 %}
+
+ | {{ 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') }} |
+
+
+
+
+ |
+
+ {% else %}
+ | 暂无翻译记录 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+ 用户权限配置 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ 用户管理 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 用户名 |
+ 邮箱 |
+ 类型 |
+ 翻译次数 |
+ 状态 |
+ 注册时间 |
+ 操作 |
+
+
+
+ {% for user in users.items %}
+
+ | {{ 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') }} |
+
+
+
+
+
+ |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 @@
+
+
+
+
+
+ 用户管理 - 后台管理
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ 用户名 |
+ 邮箱 |
+ 类型 |
+ 翻译次数 |
+ 状态 |
+ 注册时间 |
+ 操作 |
+
+
+
+ {% for user in users.items %}
+
+ | {{ 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') }} |
+
+
+
+
+
+ |
+
+ {% else %}
+ | 暂无数据 |
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
\ 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 %}
+
+
+
+
+
+
+
✨ 功能特点
+
+ - ✅ 自动翻译缓存,相同文件秒出结果
+ - ✅ 支持自定义翻译要求
+ - ✅ 原文译文对比查看
+ - ✅ 翻译历史记录
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
等待开始...
+
+ ✅ 使用缓存结果,无需重新翻译
+
+
+
+
+
+
+
+
+
+
+ {% if user %}
+
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
\ 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翻译助手
+
+
+
+
+
+
+
+
+
+
+
\ 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翻译助手
+
+
+
+
+
+
+
+
+
+
+
\ 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翻译助手
+
+ {% if user %}
+
👋 {{ user.username }}
+
退出
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+ 页数: {{ 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