11 Commits

Author SHA1 Message Date
5157561e83 feat: PDF对比用PDF.js渲染+滚动同步 2026-04-17 10:05:47 +08:00
30527a5ce6 feat: PDF对比用iframe显示原PDF文件(支持扫描版PDF) 2026-04-17 09:53:41 +08:00
2e428b2500 feat: 添加修复upload_path脚本 2026-04-17 09:43:18 +08:00
9569898f33 fix: PDF对比功能无法显示原文 - uploads和outputs使用同一UUID,添加upload_path字段 2026-04-17 09:32:21 +08:00
3479cbd04c fix: 实现对比查看功能
- 读取翻译结果文件内容
- 尝试从原PDF提取原文(OCR识别的文字)
- 按页面分块显示对比
- 前端支持分页对比展示
2026-04-16 23:45:57 +08:00
e5c9ea322a fix: 上传成功后按钮状态改为翻译中
上传PDF成功后立即更新按钮文字为翻译中...而不是保持上传中...
2026-04-16 23:33:40 +08:00
17a442b144 feat: 扫描版PDF支持视觉模型OCR识别
- 添加 pdf_to_images 将PDF页面转为图像
- 添加 extract_text_from_image 使用视觉模型OCR识别图像文字
- 检测扫描版PDF自动切换OCR模式
- glm-4.6v 等视觉模型可识别图像中的文字
- 进度提示显示OCR识别过程
2026-04-16 23:02:59 +08:00
e524938276 fix: 添加PDF文本提取检测,提示扫描版PDF无法翻译
- 检查PDF是否能提取文本内容
- 如果是扫描版PDF(图像形式),抛出明确错误提示
- 需要OCR处理的PDF会提示用户使用文本版PDF
2026-04-16 22:06:03 +08:00
ed3d8e095e feat: 新增 pdf_translate 命令行工具
- 支持命令: translate/list/status/download/config
- 支持用户认证: --user --password 参数
- 翻译进度实时显示,结果可在网页查看
- 新增 /api/config 和 /api/translations 接口
- 修复异步翻译任务的配置获取逻辑
2026-04-16 21:50:24 +08:00
44077796f8 feat: 翻译记录添加不共享开关功能
- Translation 模型新增 no_share 字段
- 管理后台翻译记录页面添加共享状态列和切换按钮
- 不共享的翻译不会被其他用户使用缓存
- 缓存匹配时检查是否有 no_share 标记
2026-04-16 19:06:43 +08:00
504fed6c3e fix: 修复网站配置保存问题
- 保存配置时 key 不再加 site_ 前缀,与读取时一致
- 修正:site_site_name -> site_name
- 修正:site_site_footer -> site_footer
2026-04-16 18:49:03 +08:00
12 changed files with 1092 additions and 41 deletions

View File

@@ -255,6 +255,28 @@ def delete_translation(trans_id):
return jsonify({'success': True}) return jsonify({'success': True})
@admin_bp.route('/translation/<int:trans_id>/toggle-share', methods=['POST'])
@admin_required
def toggle_translation_share(trans_id):
"""切换翻译共享状态"""
translation = Translation.query.get_or_404(trans_id)
translation.no_share = not translation.no_share
db.session.commit()
# 记录日志
log = OperationLog(
user_id=session.get('user_id'),
username='admin',
action='toggle_translation_share',
target=f'翻译#{trans_id}',
detail=f'设置共享状态为: {not translation.no_share}'
)
db.session.add(log)
db.session.commit()
return jsonify({'success': True, 'no_share': translation.no_share})
# ==================== 缓存管理 ==================== # ==================== 缓存管理 ====================
@admin_bp.route('/cache') @admin_bp.route('/cache')
@admin_required @admin_required
@@ -341,14 +363,14 @@ def save_site_settings():
"""保存网站基础配置""" """保存网站基础配置"""
data = request.json data = request.json
# 保存每个配置项 # 保存每个配置项key 直接使用,不带 site_ 前缀)
for key, value in data.items(): for key, value in data.items():
if key in ['max_file_size', 'cache_expire_days']: if key in ['max_file_size', 'cache_expire_days']:
DynamicConfig.set(f'site_{key}', int(value), category='site', value_type='int', user_id=session.get('user_id')) DynamicConfig.set(key, int(value), category='site', value_type='int', user_id=session.get('user_id'))
elif key in ['enable_email_notify', 'enable_cache', 'enable_guest']: elif key in ['enable_email_notify', 'enable_cache', 'enable_guest']:
DynamicConfig.set(f'site_{key}', bool(value), category='site', value_type='bool', user_id=session.get('user_id')) DynamicConfig.set(key, bool(value), category='site', value_type='bool', user_id=session.get('user_id'))
else: else:
DynamicConfig.set(f'site_{key}', value, category='site', user_id=session.get('user_id')) DynamicConfig.set(key, value, category='site', user_id=session.get('user_id'))
# 记录日志 # 记录日志
log = OperationLog( log = OperationLog(

139
app.py
View File

@@ -365,22 +365,26 @@ def upload_pdf():
cache_path = cache_service.get_cache(file_hash) cache_path = cache_service.get_cache(file_hash)
from_cache = False from_cache = False
if cache_path and ENABLE_CACHE and not instruction: # 检查是否有用户设置了不共享此文件
# 有缓存且无特殊翻译要求,直接使用缓存 no_share_check = Translation.query.filter_by(file_hash=file_hash, no_share=True).first()
if cache_path and ENABLE_CACHE and not instruction and not no_share_check:
# 有缓存且无特殊翻译要求且无不共享标记,直接使用缓存
from_cache = True from_cache = True
output_path = cache_path output_path = cache_path
else: else:
# 需要翻译 # 需要翻译
# 保存上传文件 # 保存上传文件 - 使用同一个UUID确保uploads和outputs目录关联
upload_dir = os.path.join(UPLOAD_DIR, str(uuid.uuid4())) session_uuid = str(uuid.uuid4())
upload_dir = os.path.join(UPLOAD_DIR, session_uuid)
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)
upload_path = os.path.join(upload_dir, filename) upload_path = os.path.join(upload_dir, filename)
with open(upload_path, 'wb') as f: with open(upload_path, 'wb') as f:
f.write(file_content) f.write(file_content)
# 创建输出路径 # 创建输出路径 - 使用相同的UUID
output_dir = os.path.join(OUTPUT_DIR, str(uuid.uuid4())) output_dir = os.path.join(OUTPUT_DIR, session_uuid)
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"{filename}_translated.md") output_path = os.path.join(output_dir, f"{filename}_translated.md")
@@ -397,6 +401,7 @@ def upload_pdf():
translate_params=json.dumps({'instruction': instruction}) if instruction else None, translate_params=json.dumps({'instruction': instruction}) if instruction else None,
status='processing' if not from_cache else 'completed', status='processing' if not from_cache else 'completed',
progress=0 if not from_cache else 100, progress=0 if not from_cache else 100,
upload_path=upload_path if not from_cache else None, # 保存上传路径
output_path=output_path, output_path=output_path,
from_cache=from_cache from_cache=from_cache
) )
@@ -442,6 +447,36 @@ def upload_pdf():
}) })
@app.route('/api/config')
def api_config():
"""获取系统配置"""
from admin import get_llm_config, get_site_config
return jsonify({
'site_name': get_site_config().get('site_name'),
'max_file_size': get_site_config().get('max_file_size'),
'cache_expire_days': get_site_config().get('cache_expire_days'),
'llm_config': get_llm_config()
})
@app.route('/api/translations')
def api_translations_list():
"""获取翻译记录列表"""
user = get_current_user()
if user:
translations = Translation.query.filter_by(user_id=user.id)\
.order_by(Translation.created_at.desc()).limit(20).all()
else:
# 访客返回空列表
translations = []
return jsonify({
'translations': [t.to_dict() for t in translations]
})
@app.route('/api/status/<int:translation_id>') @app.route('/api/status/<int:translation_id>')
def translation_status(translation_id): def translation_status(translation_id):
"""获取翻译状态""" """获取翻译状态"""
@@ -459,8 +494,12 @@ def translation_status(translation_id):
'id': translation.id, 'id': translation.id,
'status': translation.status, 'status': translation.status,
'progress': translation.progress, 'progress': translation.progress,
'filename': translation.original_filename,
'pages': translation.page_count,
'from_cache': translation.from_cache, 'from_cache': translation.from_cache,
'error': translation.error_message 'error': translation.error_message,
'created_at': translation.created_at.isoformat() if translation.created_at else None,
'completed_at': translation.completed_at.isoformat() if translation.completed_at else None,
}) })
@@ -572,19 +611,95 @@ def compare_view(translation_id):
return jsonify({'error': '请登录后使用此功能'}), 401 return jsonify({'error': '请登录后使用此功能'}), 401
translation = Translation.query.get(translation_id) translation = Translation.query.get(translation_id)
if not translation or translation.user_id != user.id: if not translation or (translation.user_id != user.id and user.user_type != 'admin'):
return jsonify({'error': '无权访问'}), 403 return jsonify({'error': '无权访问'}), 403
# 生成对比文件 # 读取翻译结果文件
# TODO: 实现对比功能 translated_content = ''
if translation.output_path and os.path.exists(translation.output_path):
try:
with open(translation.output_path, 'r', encoding='utf-8') as f:
translated_content = f.read()
except Exception as e:
translated_content = f'读取失败: {str(e)}'
# 从翻译结果中提取各页内容
# 翻译结果格式是Markdown包含"## 第 X 页"分隔
original_pages = []
translated_pages = []
if translated_content:
# 解析翻译结果的页面结构
import re
page_pattern = r'## 第 (\d+) 页\n\n(.*?)\n\n---'
matches = re.findall(page_pattern, translated_content, re.DOTALL)
for page_num, content in matches:
translated_pages.append({
'page': int(page_num),
'content': content.strip()
})
# 如果有原文内容存储,获取原文
original_content = ''
# 优先从数据库存储的upload_path获取原PDF
possible_paths = []
if translation.upload_path:
possible_paths.append(translation.upload_path)
# 备用方案:尝试从路径推断(兼容旧数据)
upload_dir = os.path.dirname(translation.output_path.replace('outputs', 'uploads').replace('_translated.md', '')) if translation.output_path else ''
if upload_dir:
possible_paths.append(
translation.output_path.replace('outputs', 'uploads').replace('_translated.md', '') if translation.output_path else ''
)
possible_paths.append(os.path.join(upload_dir, translation.original_filename))
for pdf_path in possible_paths:
if pdf_path and os.path.exists(pdf_path) and pdf_path.endswith('.pdf'):
try:
from pypdf import PdfReader
reader = PdfReader(pdf_path)
for page in reader.pages:
text = page.extract_text()
if text:
original_content += text + '\n\n'
except:
pass
break
return jsonify({ return jsonify({
'id': translation.id, 'id': translation.id,
'original': '原文内容', 'filename': translation.original_filename,
'translated': '译文内容' 'original': original_content or '',
'original_pdf_url': f'/api/original-pdf/{translation.id}' if translation.upload_path else None,
'translated': translated_content,
'pages': translated_pages
}) })
@app.route('/api/original-pdf/<int:translation_id>')
def get_original_pdf(translation_id):
"""获取原始PDF文件"""
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 and user.user_type != 'admin'):
return jsonify({'error': '无权访问'}), 403
if not translation.upload_path or not os.path.exists(translation.upload_path):
return jsonify({'error': '原PDF文件不存在'}), 404
return send_file(translation.upload_path,
mimetype='application/pdf',
as_attachment=False,
download_name=translation.original_filename)
# ==================== 路由: 用户系统 ==================== # ==================== 路由: 用户系统 ====================
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():

187
logs/app.log Normal file
View File

@@ -0,0 +1,187 @@
* Serving Flask app 'app'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on all addresses (0.0.0.0)
* Running on http://127.0.0.1:19000
* Running on http://192.168.2.17:19000
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/pdf_translate.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
127.0.0.1 - - [16/Apr/2026 21:50:38] "GET /api/config HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:08] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:08] "GET /static/img/favicon.svg HTTP/1.1" 304 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:53: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
return User.query.get(user_id)
192.168.2.10 - - [16/Apr/2026 21:58:13] "GET / HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:13] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:13] "GET /static/js/main.js HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:13] "GET /api/user/info HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:13] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:16] "GET /history HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:16] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:18] "GET / HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:18] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:18] "GET /static/js/main.js HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 21:58:18] "GET /api/user/info HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:26] "POST /api/upload HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:26] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:481: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
translation = Translation.query.get(translation_id)
192.168.2.10 - - [16/Apr/2026 21:58:26] "GET /api/status/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:28] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:28] "GET /api/status/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:30] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:30] "GET /api/status/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:32] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:32] "GET /api/status/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:34] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:34] "GET /api/status/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:37] "GET /api/task/17baff33-894b-4e56-8975-cc2e6359cd66 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 21:58:37] "GET /api/status/4 HTTP/1.1" 200 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:519: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
translation = Translation.query.get(translation_id)
192.168.2.10 - - [16/Apr/2026 21:58:37] "GET /api/result/4 HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 22:00:01] "GET /api/health HTTP/1.1" 404 -
127.0.0.1 - - [16/Apr/2026 22:00:33] "POST /api/upload HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 22:00:38] "GET /api/status/5 HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 22:01:15] "GET /api/status/5 HTTP/1.1" 200 -
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
[翻译任务] 开始翻译,使用配置: https://open.bigmodel.cn/api/paas/v4
[翻译任务] 开始翻译,使用配置: https://open.bigmodel.cn/api/paas/v4
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
127.0.0.1 - - [16/Apr/2026 22:20:01] "GET /api/health HTTP/1.1" 404 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:53: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
return User.query.get(user_id)
192.168.2.10 - - [16/Apr/2026 22:24:27] "GET / HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:27] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:27] "GET /static/js/main.js HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:27] "GET /api/user/info HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:27] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:31] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:31] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:33] "POST /admin/translation/5/toggle-share HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:33] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:33] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:34] "POST /admin/translation/4/toggle-share HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:34] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:34] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:42] "GET /admin/translation/5 HTTP/1.1" 200 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:519: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
translation = Translation.query.get(translation_id)
192.168.2.10 - - [16/Apr/2026 22:24:42] "GET /api/result/5 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:45] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:46] "GET /admin/translation/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:47] "GET /api/result/4 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:48] "GET /admin/translations HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:52] "GET / HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:52] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:52] "GET /static/js/main.js HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:52] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:52] "GET /api/user/info HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:54] "GET / HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:54] "GET /static/css/style.css HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:54] "GET /static/js/main.js HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:24:54] "GET /api/user/info HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:24:54] "GET /static/img/favicon.svg HTTP/1.1" 304 -
192.168.2.10 - - [16/Apr/2026 22:25:00] "POST /api/upload HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:00] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/app.py:481: LegacyAPIWarning: The Query.get() method is considered legacy as of the 1.x series of SQLAlchemy and becomes a legacy construct in 2.0. The method is now available as Session.get() (deprecated since: 2.0) (Background on SQLAlchemy 2.0 at: https://sqlalche.me/e/b8d9)
translation = Translation.query.get(translation_id)
192.168.2.10 - - [16/Apr/2026 22:25:00] "GET /api/status/6 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:02] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:02] "GET /api/status/6 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:05] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:05] "GET /api/status/6 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:07] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:07] "GET /api/status/6 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:09] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:09] "GET /api/status/6 HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:11] "GET /api/task/ec82bf8c-70b3-474a-b94f-64598c81f7fa HTTP/1.1" 200 -
192.168.2.10 - - [16/Apr/2026 22:25:11] "GET /api/status/6 HTTP/1.1" 200 -
127.0.0.1 - - [16/Apr/2026 22:40:01] "GET /api/health HTTP/1.1" 404 -
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
[翻译任务] 开始翻译,使用配置: https://open.bigmodel.cn/api/paas/v4
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
* Detected change in '/home/xian/.openclaw/workspace-coder/works/pdf-translate-web-v2/services.py', reloading
* Restarting with stat
* Debugger is active!
* Debugger PIN: 154-698-244
127.0.0.1 - - [16/Apr/2026 23:00:01] "GET /api/health HTTP/1.1" 404 -
127.0.0.1 - - [16/Apr/2026 23:03:32] "GET / HTTP/1.1" 200 -

View File

@@ -169,6 +169,7 @@ class Translation(db.Model):
error_message = db.Column(db.Text, nullable=True) error_message = db.Column(db.Text, nullable=True)
# 输出 # 输出
upload_path = db.Column(db.String(255), nullable=True) # 原始PDF文件路径
output_path = db.Column(db.String(255), nullable=True) # 翻译结果文件路径 output_path = db.Column(db.String(255), nullable=True) # 翻译结果文件路径
# 时间戳 # 时间戳
@@ -178,6 +179,9 @@ class Translation(db.Model):
# 是否来自缓存 # 是否来自缓存
from_cache = db.Column(db.Boolean, default=False) from_cache = db.Column(db.Boolean, default=False)
# 不共享缓存
no_share = db.Column(db.Boolean, default=False) # 不共享此翻译给其他用户
# 重译信息 # 重译信息
retranslate_request = db.Column(db.Text, nullable=True) # 重译要求 retranslate_request = db.Column(db.Text, nullable=True) # 重译要求
parent_id = db.Column(db.Integer, db.ForeignKey('translations.id'), nullable=True) # 原翻译ID parent_id = db.Column(db.Integer, db.ForeignKey('translations.id'), nullable=True) # 原翻译ID
@@ -190,6 +194,7 @@ class Translation(db.Model):
'status': self.status, 'status': self.status,
'progress': self.progress, 'progress': self.progress,
'from_cache': self.from_cache, 'from_cache': self.from_cache,
'no_share': self.no_share,
'file_size': self.file_size, 'file_size': self.file_size,
'created_at': self.created_at.isoformat() if self.created_at else None, 'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None, 'completed_at': self.completed_at.isoformat() if self.completed_at else None,

307
pdf_translate.py Executable file
View File

@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
PDF翻译命令行工具
使用方法:
pdf_translate translate <pdf_file> [--instruction "翻译要求"] [--user <username>] [--password <password>]
pdf_translate list [--user <username>] [--password <password>]
pdf_translate status <translation_id> [--user <username>] [--password <password>]
pdf_translate download <translation_id> <output_file> [--user <username>] [--password <password>]
pdf_translate config
"""
import argparse
import sys
import os
import json
import requests
from pathlib import Path
# API 基础地址
API_BASE = "http://localhost:19000"
def login(username, password, api_base=API_BASE):
"""登录获取session"""
url = f"{api_base}/api/login"
data = {"username": username, "password": password}
try:
response = requests.post(url, json=data, timeout=10)
result = response.json()
if result.get('success'):
print(f"✅ 登录成功: {username}")
return response.cookies
else:
print(f"❌ 登录失败: {result.get('error', '未知错误')}")
return None
except Exception as e:
print(f"❌ 登录请求失败: {e}")
return None
def get_user_info(cookies, api_base=API_BASE):
"""获取用户信息"""
url = f"{api_base}/api/user/info"
try:
response = requests.get(url, cookies=cookies, timeout=10)
result = response.json()
return result.get('user')
except:
return None
def translate_pdf(pdf_file, instruction=None, cookies=None, api_base=API_BASE):
"""上传并翻译PDF"""
if not os.path.exists(pdf_file):
print(f"❌ 文件不存在: {pdf_file}")
return None
url = f"{api_base}/api/upload"
print(f"📤 上传文件: {pdf_file}")
try:
with open(pdf_file, 'rb') as f:
files = {'file': f}
data = {}
if instruction:
data['instruction'] = instruction
response = requests.post(url, files=files, data=data, cookies=cookies, timeout=60)
result = response.json()
if response.status_code != 200:
print(f"❌ 上传失败: {result.get('error', '未知错误')}")
return None
translation_id = result.get('translation_id')
task_id = result.get('task_id')
from_cache = result.get('from_cache', False)
print(f"✅ 上传成功!")
print(f" 翻译ID: {translation_id}")
print(f" 任务ID: {task_id}")
print(f" 使用缓存: {'' if from_cache else ''}")
# 等待翻译完成
if not from_cache:
print(f"\n🔄 等待翻译完成...")
poll_translation_status(translation_id, cookies, api_base)
return translation_id
except Exception as e:
print(f"❌ 上传请求失败: {e}")
return None
def poll_translation_status(translation_id, cookies=None, api_base=API_BASE):
"""轮询翻译状态"""
url = f"{api_base}/api/status/{translation_id}"
import time
max_wait = 300 # 最大等待5分钟
waited = 0
while waited < max_wait:
try:
response = requests.get(url, cookies=cookies, timeout=10)
result = response.json()
status = result.get('status', 'unknown')
progress = result.get('progress', 0)
print(f" 状态: {status}, 进度: {progress}%")
if status == 'completed':
print(f"✅ 翻译完成!")
return True
elif status == 'failed':
print(f"❌ 翻译失败: {result.get('error', '未知错误')}")
return False
time.sleep(5)
waited += 5
except Exception as e:
print(f"⚠️ 状态查询失败: {e}")
time.sleep(5)
waited += 5
print(f"⚠️ 等待超时,请在网页查看结果")
return False
def list_translations(cookies=None, api_base=API_BASE):
"""列出翻译记录"""
url = f"{api_base}/api/translations"
try:
response = requests.get(url, cookies=cookies, timeout=10)
result = response.json()
translations = result.get('translations', [])
if not translations:
print("暂无翻译记录")
return
print(f"\n📋 翻译记录 (共{len(translations)}条):\n")
print("ID | 文件名 | 状态 | 进度 | 时间")
print("-" * 60)
for t in translations:
id_str = str(t.get('id', '?'))[:4]
filename = t.get('filename', '?')[:15]
status = t.get('status', '?')
progress = t.get('progress', 0)
created = t.get('created_at', '?')
if created and created != '?':
created = created[5:16] # 月-日 时:分
print(f"{id_str:4} | {filename:15} | {status:8} | {progress:3}% | {created}")
except Exception as e:
print(f"❌ 获取列表失败: {e}")
def get_translation_status(translation_id, cookies=None, api_base=API_BASE):
"""获取翻译状态"""
url = f"{api_base}/api/status/{translation_id}"
try:
response = requests.get(url, cookies=cookies, timeout=10)
result = response.json()
print(f"\n📊 翻译状态 (ID: {translation_id}):\n")
print(f" 状态: {result.get('status', '未知')}")
print(f" 进度: {result.get('progress', 0)}%")
print(f" 文件: {result.get('filename', '未知')}")
print(f" 页数: {result.get('pages', '?')}")
print(f" 缓存: {'' if result.get('from_cache') else ''}")
if result.get('error'):
print(f" 错误: {result.get('error')}")
except Exception as e:
print(f"❌ 获取状态失败: {e}")
def download_translation(translation_id, output_file, cookies=None, api_base=API_BASE):
"""下载翻译结果"""
url = f"{api_base}/api/download/{translation_id}"
try:
response = requests.get(url, cookies=cookies, timeout=30)
if response.status_code != 200:
print(f"❌ 下载失败: {response.status_code}")
return False
with open(output_file, 'wb') as f:
f.write(response.content)
print(f"✅ 下载成功: {output_file}")
return True
except Exception as e:
print(f"❌ 下载失败: {e}")
return False
def show_config(api_base=API_BASE):
"""显示当前大模型配置"""
url = f"{api_base}/api/config"
try:
response = requests.get(url, timeout=10)
result = response.json()
print(f"\n⚙️ 当前配置:\n")
print(f" 网站名称: {result.get('site_name', '未知')}")
print(f" 最大文件: {result.get('max_file_size', '?')}MB")
print(f" 缓存天数: {result.get('cache_expire_days', '?')}")
llm = result.get('llm_config', {})
print(f" 大模型API: {llm.get('api_base', '未设置')}")
print(f" 模型: {llm.get('model', '未设置')}")
except Exception as e:
print(f"❌ 获取配置失败: {e}")
def main():
parser = argparse.ArgumentParser(
description="PDF翻译命令行工具",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
pdf_translate translate test.pdf --user admin --password admin123
pdf_translate translate test.pdf --instruction "保持专业术语原文"
pdf_translate list --user admin --password admin123
pdf_translate status 1
pdf_translate download 1 result.md
pdf_translate config
"""
)
parser.add_argument('--user', '-u', help='用户名')
parser.add_argument('--password', '-p', help='密码')
parser.add_argument('--api', default=API_BASE, help=f'API地址 (默认: {API_BASE})')
subparsers = parser.add_subparsers(dest='command', help='命令')
# translate 命令
trans_parser = subparsers.add_parser('translate', help='翻译PDF文件')
trans_parser.add_argument('file', help='PDF文件路径')
trans_parser.add_argument('--instruction', '-i', help='翻译要求')
# list 命令
list_parser = subparsers.add_parser('list', help='列出翻译记录')
# status 命令
status_parser = subparsers.add_parser('status', help='查看翻译状态')
status_parser.add_argument('id', type=int, help='翻译ID')
# download 命令
download_parser = subparsers.add_parser('download', help='下载翻译结果')
download_parser.add_argument('id', type=int, help='翻译ID')
download_parser.add_argument('output', help='输出文件路径')
# config 命令
config_parser = subparsers.add_parser('config', help='显示当前配置')
args = parser.parse_args()
# 更新 API 地址(如果用户指定了)
api_base_arg = args.api
if not args.command:
parser.print_help()
return
# 登录(如果提供了用户名密码)
cookies = None
if args.user and args.password:
cookies = login(args.user, args.password, api_base_arg)
if not cookies:
print("请检查用户名和密码")
return
user = get_user_info(cookies, api_base_arg)
if user:
print(f"👤 用户: {user.get('username')} ({user.get('user_type')})")
# 执行命令
if args.command == 'translate':
translation_id = translate_pdf(args.file, args.instruction, cookies, api_base_arg)
if translation_id:
print(f"\n💡 在网页查看结果: {api_base_arg}/history")
elif args.command == 'list':
list_translations(cookies, api_base_arg)
elif args.command == 'status':
get_translation_status(args.id, cookies, api_base_arg)
elif args.command == 'download':
download_translation(args.id, args.output, cookies, api_base_arg)
elif args.command == 'config':
show_config(api_base_arg)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python3
"""
修复旧数据的 upload_path 字段
通过 file_hash 匹配 uploads 目录中的 PDF 文件
"""
import sqlite3
import os
import hashlib
DB_PATH = 'instance/pdf_translate.db'
UPLOADS_DIR = 'uploads'
def compute_file_hash(filepath):
"""计算文件MD5"""
hasher = hashlib.md5()
with open(filepath, 'rb') as f:
hasher.update(f.read())
return hasher.hexdigest()
def main():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# 获取所有 upload_path 为空的翻译记录
cursor.execute('SELECT id, file_hash, original_filename, output_path FROM translations WHERE upload_path IS NULL')
records = cursor.fetchall()
print(f"找到 {len(records)} 条需要修复的记录")
if not records:
print("无需修复")
conn.close()
return
# 找所有上传的PDF文件
pdf_files = []
for root, dirs, files in os.walk(UPLOADS_DIR):
for f in files:
if f.endswith('.pdf'):
pdf_files.append(os.path.join(root, f))
print(f"找到 {len(pdf_files)} 个PDF文件")
# 按hash匹配
fixed_count = 0
for record in records:
id, file_hash, filename, output_path = record
# 找匹配hash的文件
for pdf_path in pdf_files:
try:
pdf_hash = compute_file_hash(pdf_path)
if pdf_hash == file_hash:
print(f"ID {id}: 找到匹配 {pdf_path}")
cursor.execute('UPDATE translations SET upload_path = ? WHERE id = ?', (pdf_path, id))
conn.commit()
fixed_count += 1
break
except Exception as e:
print(f"处理 {pdf_path} 失败: {e}")
conn.close()
print(f"修复完成,共修复 {fixed_count} 条记录")
if __name__ == '__main__':
main()

View File

@@ -7,10 +7,20 @@ import json
import time import time
import hashlib import hashlib
import threading import threading
import base64
import io
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pypdf import PdfReader from pypdf import PdfReader
from openai import OpenAI from openai import OpenAI
from flask import current_app from flask import current_app
from PIL import Image
# pdf2image 用于将PDF转为图像
try:
from pdf2image import convert_from_path
PDF_TO_IMAGE_AVAILABLE = True
except ImportError:
PDF_TO_IMAGE_AVAILABLE = False
# ==================== LLM客户端 ==================== # ==================== LLM客户端 ====================
class TranslationService: class TranslationService:
@@ -99,6 +109,115 @@ class TranslationService:
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
return text.strip() return text.strip()
def is_vision_model(self):
"""检查是否是视觉模型"""
model = self.llm_config.get('model', '')
# 常见视觉模型名称
vision_models = ['vision', 'vlm', 'glm-4v', 'glm-4.6v', 'gpt-4-vision', 'gpt-4o', 'qwen-vl', 'claude-3']
return any(v in model.lower() for v in vision_models)
def pdf_to_images(self, pdf_path, max_pages=None):
"""将PDF页面转换为图像"""
if not PDF_TO_IMAGE_AVAILABLE:
return None, "pdf2image未安装无法处理扫描版PDF。请安装: pip install pdf2image"
try:
# 获取PDF页数
reader = PdfReader(pdf_path)
total_pages = len(reader.pages)
if max_pages:
pages_to_convert = min(max_pages, total_pages)
else:
pages_to_convert = total_pages
# 转换PDF为图像
images = convert_from_path(
pdf_path,
first_page=1,
last_page=pages_to_convert,
dpi=200, # 适当的DPI
fmt='jpeg'
)
return images, None
except Exception as e:
return None, f"PDF转图像失败: {str(e)}"
def extract_text_from_image(self, image):
"""使用视觉模型从图像中提取文字"""
if not self.is_vision_model():
return None, "当前模型不是视觉模型,无法识别图像文字"
try:
# 将图像转为base64
buffered = io.BytesIO()
image.save(buffered, format="JPEG")
img_base64 = base64.b64encode(buffered.getvalue()).decode('utf-8')
# 构建多模态请求
response = self.client.chat.completions.create(
model=self.llm_config['model'],
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "请识别并提取这张图片中的所有文字内容。只输出提取的文字,不要添加任何解释或说明。保持原有的段落和格式。"
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{img_base64}"
}
}
]
}
],
max_tokens=self.llm_config['max_tokens'],
temperature=0.1,
timeout=self.llm_config['timeout'],
)
content = response.choices[0].message.content
return content.strip() if content else '', None
except Exception as e:
return '', f"视觉模型识别失败: {str(e)}"
def extract_text_from_scanned_pdf(self, pdf_path, progress_callback=None):
"""从扫描版PDF提取文字使用视觉模型OCR"""
images, error = self.pdf_to_images(pdf_path)
if error:
return [], error
pages_text = []
total = len(images)
for i, image in enumerate(images):
if progress_callback:
progress_callback(int((i+1)/total*50), total, f"OCR识别第{i+1}页...")
text, err = self.extract_text_from_image(image)
if err:
pages_text.append({
'page': i + 1,
'text': '',
'error': err
})
else:
pages_text.append({
'page': i + 1,
'text': text or '',
'error': None
})
return pages_text, None
def chunk_text(self, text, max_size=2000): def chunk_text(self, text, max_size=2000):
"""分块""" """分块"""
paragraphs = text.split('\n\n') paragraphs = text.split('\n\n')
@@ -131,11 +250,38 @@ class TranslationService:
Returns: Returns:
翻译统计信息 翻译统计信息
""" """
# 先尝试常规提取
pages = self.extract_pdf_text(pdf_path) pages = self.extract_pdf_text(pdf_path)
total_pages = len(pages) total_pages = len(pages)
total_text = sum(len(p['text']) for p in pages)
# 如果无法提取文本尝试使用视觉模型OCR
if total_pages == 0 or total_text < 10:
if self.is_vision_model() and PDF_TO_IMAGE_AVAILABLE:
if progress_callback:
progress_callback(0, 0, "检测到扫描版PDF使用视觉模型OCR...")
pages, error = self.extract_text_from_scanned_pdf(pdf_path, progress_callback)
if error:
raise ValueError(error)
total_pages = len(pages)
total_text = sum(len(p['text']) for p in pages)
if total_text < 10:
raise ValueError("视觉模型OCR未能提取到有效文字内容")
if progress_callback: if progress_callback:
progress_callback(0, total_pages, "开始翻译...") progress_callback(50, total_pages, "OCR完成开始翻译...")
else:
error_msg = "PDF无法提取文本内容。可能原因\n1. PDF是扫描版图像形式\n2. 当前大模型不是视觉模型,无法识别图像文字\n\n如需处理扫描版PDF请配置视觉大模型如 glm-4.6v、gpt-4-vision"
if progress_callback:
progress_callback(0, 0, error_msg)
raise ValueError(error_msg)
if progress_callback:
progress_callback(50, total_pages, "开始翻译...")
translated_pages = [] translated_pages = []
total_chunks = 0 total_chunks = 0
@@ -150,8 +296,10 @@ class TranslationService:
translated_chunks.append(translated) translated_chunks.append(translated)
if progress_callback: if progress_callback:
progress = int((i + 1) / len(chunks) * 100 / total_pages) # OCR占50%翻译占50%
progress_callback(progress, total_pages, f"翻译第{page_data['page']}") page_progress = (i + 1) / len(chunks)
overall_progress = 50 + int(page_progress * 50 / total_pages)
progress_callback(overall_progress, total_pages, f"翻译第{page_data['page']}")
translated_pages.append({ translated_pages.append({
'page': page_data['page'], 'page': page_data['page'],
@@ -279,12 +427,14 @@ class TranslationTask:
with app.app_context(): with app.app_context():
from admin import get_llm_config from admin import get_llm_config
llm_config = get_llm_config() llm_config = get_llm_config()
config['LLM_CONFIG'] = llm_config config = {'LLM_CONFIG': llm_config}
service = TranslationService(config) service = TranslationService(config)
task['status'] = 'processing' task['status'] = 'processing'
task['started_at'] = datetime.now().isoformat() task['started_at'] = datetime.now().isoformat()
print(f"[翻译任务] 开始翻译,使用配置: {config.get('LLM_CONFIG', {}).get('api_base', '未知')}")
# 更新数据库状态为 processing # 更新数据库状态为 processing
if app and translation_id: if app and translation_id:
with app.app_context(): with app.app_context():

View File

@@ -75,12 +75,21 @@ body {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 20px; gap: 20px;
height: 70vh;
} }
.compare-panel { .compare-panel {
padding: 15px; padding: 15px;
border-radius: 5px; border-radius: 5px;
background-color: #f8f9fa; background-color: #f8f9fa;
overflow-y: auto;
height: 100%;
}
.compare-panel h5 {
margin-bottom: 10px;
padding-bottom: 5px;
border-bottom: 1px solid #ddd;
} }
.compare-panel.original { .compare-panel.original {
@@ -91,6 +100,27 @@ body {
border-left: 4px solid var(--success-color); border-left: 4px solid var(--success-color);
} }
.compare-panel iframe {
width: 100%;
height: calc(100% - 40px);
border: none;
}
#pdfContainer {
height: calc(100% - 40px);
overflow-y: auto;
}
#pdfPages {
padding: 10px;
}
.pdf-page-canvas {
border: 1px solid #ddd;
border-radius: 3px;
background: white;
}
/* 会员卡片 */ /* 会员卡片 */
.pricing-card { .pricing-card {
transition: transform 0.3s ease; transition: transform 0.3s ease;

View File

@@ -70,6 +70,9 @@ document.getElementById('uploadForm').addEventListener('submit', async function(
currentTranslationId = result.translation_id; currentTranslationId = result.translation_id;
currentTaskId = result.task_id; currentTaskId = result.task_id;
// 更新按钮状态为翻译中
btnText.textContent = '翻译中...';
// 如果使用缓存,直接显示结果 // 如果使用缓存,直接显示结果
if (result.from_cache) { if (result.from_cache) {
document.getElementById('cacheNotice').style.display = 'block'; document.getElementById('cacheNotice').style.display = 'block';
@@ -201,18 +204,55 @@ document.getElementById('viewCompare')?.addEventListener('click', async function
function showCompareView(data) { function showCompareView(data) {
const resultContent = document.getElementById('resultContent'); const resultContent = document.getElementById('resultContent');
resultContent.innerHTML = ` // 如果有分页数据,按页显示
<div class="compare-container"> if (data.pages && data.pages.length > 0) {
<div class="compare-panel original"> let html = '<div class="compare-container">';
<h5>原文</h5>
<div class="content">${escapeHtml(data.original)}</div> for (const page of data.pages) {
html += `
<div class="compare-section mb-4">
<h5 class="text-center mb-3">第 ${page.page} 页</h5>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">原文OCR识别</div>
<div class="card-body"><pre style="white-space: pre-wrap;">${escapeHtml(data.original || '原文内容')}</pre></div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">译文</div>
<div class="card-body">${renderMarkdown(page.content)}</div>
</div>
</div> </div>
<div class="compare-panel translated">
<h5>译文</h5>
<div class="content">${renderMarkdown(data.translated)}</div>
</div> </div>
</div> </div>
`; `;
}
html += '</div>';
resultContent.innerHTML = html;
} else {
// 单一对比视图
resultContent.innerHTML = `
<div class="compare-container">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">原文</div>
<div class="card-body"><pre style="white-space: pre-wrap;">${escapeHtml(data.original || '无原文内容')}</pre></div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">译文</div>
<div class="card-body">${renderMarkdown(data.translated || '无译文内容')}</div>
</div>
</div>
</div>
</div>
`;
}
} }
// 重新翻译 // 重新翻译

View File

@@ -69,13 +69,14 @@
<th>大小</th> <th>大小</th>
<th>状态</th> <th>状态</th>
<th>缓存</th> <th>缓存</th>
<th>共享</th>
<th>时间</th> <th>时间</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for t in translations.items %} {% for t in translations.items %}
<tr> <tr id="trans-row-{{ t.id }}">
<td>{{ t.id }}</td> <td>{{ t.id }}</td>
<td>{{ t.original_filename[:25] }}{% if t.original_filename|length > 25 %}...{% endif %}</td> <td>{{ t.original_filename[:25] }}{% if t.original_filename|length > 25 %}...{% endif %}</td>
<td>{% if t.user_id %}ID:{{ t.user_id }}{% else %}访客{% endif %}</td> <td>{% if t.user_id %}ID:{{ t.user_id }}{% else %}访客{% endif %}</td>
@@ -87,14 +88,26 @@
</span> </span>
</td> </td>
<td>{% if t.from_cache %}<i class="bi bi-check-circle text-success"></i>{% else %}-{% endif %}</td> <td>{% if t.from_cache %}<i class="bi bi-check-circle text-success"></i>{% else %}-{% endif %}</td>
<td>
{% if t.no_share %}
<span class="badge bg-secondary" id="share-badge-{{ t.id }}">不共享</span>
{% else %}
<span class="badge bg-success" id="share-badge-{{ t.id }}">共享</span>
{% endif %}
</td>
<td>{{ t.created_at.strftime('%m-%d %H:%M') }}</td> <td>{{ t.created_at.strftime('%m-%d %H:%M') }}</td>
<td> <td>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-{% if t.no_share %}success{% else %}warning{% endif %}" onclick="toggleShare({{ t.id }})" title="切换共享状态">
<i class="bi bi-share{% if t.no_share %}-fill{% endif %}"></i>
</button>
<a href="{{ url_for('admin.translation_detail', trans_id=t.id) }}" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a> <a href="{{ url_for('admin.translation_detail', trans_id=t.id) }}" class="btn btn-sm btn-outline-primary"><i class="bi bi-eye"></i></a>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTrans({{ t.id }})"><i class="bi bi-trash"></i></button> <button class="btn btn-sm btn-outline-danger" onclick="deleteTrans({{ t.id }})"><i class="bi bi-trash"></i></button>
</div>
</td> </td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="9" class="text-center text-muted py-4">暂无数据</td></tr> <tr><td colspan="10" class="text-center text-muted py-4">暂无数据</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -122,6 +135,26 @@
.then(r => r.json()) .then(r => r.json())
.then(d => { if (d.success) location.reload(); }); .then(d => { if (d.success) location.reload(); });
} }
function toggleShare(id) {
fetch(`/admin/translation/${id}/toggle-share`, { method: 'POST' })
.then(r => r.json())
.then(d => {
if (d.success) {
// 更新显示状态
const badge = document.getElementById(`share-badge-${id}`);
if (d.no_share) {
badge.className = 'badge bg-secondary';
badge.textContent = '不共享';
} else {
badge.className = 'badge bg-success';
badge.textContent = '共享';
}
// 更新按钮样式
location.reload(); // 简化处理,刷新页面
}
});
}
</script> </script>
</body> </body>
</html> </html>

View File

@@ -7,6 +7,7 @@
<link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/static/img/favicon.svg" type="image/svg+xml">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/css/style.css" rel="stylesheet"> <link href="/static/css/style.css" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
@@ -76,6 +77,9 @@
} }
// 切换对比视图 // 切换对比视图
let syncScrollEnabled = true;
let pdfDoc = null;
document.getElementById('toggleCompare').addEventListener('click', async function() { document.getElementById('toggleCompare').addEventListener('click', async function() {
showCompare = !showCompare; showCompare = !showCompare;
@@ -84,27 +88,117 @@
const response = await fetch(`/api/compare/${translationId}`); const response = await fetch(`/api/compare/${translationId}`);
const result = await response.json(); const result = await response.json();
// 原文面板如果有PDF URL用PDF.js渲染否则显示提取的文本
let originalHtml = '';
if (result.original_pdf_url) {
originalHtml = '<canvas id="pdfCanvas"></canvas><div id="pdfPages"></div>';
} else if (result.original && result.original.length > 0) {
originalHtml = `<div class="original-text" style="white-space:pre-wrap;font-family:monospace;">${escapeHtml(result.original)}</div>`;
} else {
originalHtml = '<div class="text-muted">原文内容未找到可能PDF已被删除</div>';
}
document.getElementById('resultContent').innerHTML = ` document.getElementById('resultContent').innerHTML = `
<div class="compare-container"> <div class="compare-container">
<div class="compare-panel original"> <div class="compare-panel original" id="originalPanel">
<h5>原文</h5> <h5>原文 PDF</h5>
<div>${escapeHtml(result.original)}</div> <div id="pdfContainer">${originalHtml}</div>
</div> </div>
<div class="compare-panel translated"> <div class="compare-panel translated" id="translatedPanel">
<h5>译文</h5> <h5>译文</h5>
<div>${renderMarkdown(result.translated)}</div> <div class="translated-content">${renderMarkdown(result.translated)}</div>
</div> </div>
</div> </div>
<div class="text-center mt-2">
<small class="text-muted">💡 左右滚动同步,方便逐页对比</small>
</div>
`; `;
// 如果有PDF用PDF.js渲染
if (result.original_pdf_url) {
renderPDF(result.original_pdf_url);
}
// 启用滚动同步
setTimeout(enableSyncScroll, 300);
} catch (error) { } catch (error) {
alert('加载对比失败'); alert('加载对比失败: ' + error.message);
} }
} else { } else {
loadResult(); loadResult();
} }
}); });
// PDF.js渲染PDF
async function renderPDF(url) {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
try {
const pdf = await pdfjsLib.getDocument(url).promise;
pdfDoc = pdf;
const container = document.getElementById('pdfPages');
container.innerHTML = '';
// 渲染所有页面
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.style.width = '100%';
canvas.style.display = 'block';
canvas.style.marginBottom = '20px';
const context = canvas.getContext('2d');
const viewport = page.getViewport({ scale: 1.5 });
canvas.height = viewport.height;
canvas.width = viewport.width;
await page.render({
canvasContext: context,
viewport: viewport
}).promise;
container.appendChild(canvas);
// 添加页码标识
const pageNum = document.createElement('div');
pageNum.className = 'text-center text-muted mb-3';
pageNum.textContent = `— 第 ${i} 页 —`;
container.appendChild(pageNum);
}
} catch (error) {
document.getElementById('pdfContainer').innerHTML =
'<div class="text-danger">PDF加载失败: ' + error.message + '</div>';
}
}
// 滚动同步
function enableSyncScroll() {
const originalPanel = document.getElementById('originalPanel');
const translatedPanel = document.getElementById('translatedPanel');
if (!originalPanel || !translatedPanel) return;
originalPanel.addEventListener('scroll', function() {
if (!syncScrollEnabled) return;
syncScrollEnabled = false;
const ratio = this.scrollTop / (this.scrollHeight - this.clientHeight);
translatedPanel.scrollTop = ratio * (translatedPanel.scrollHeight - translatedPanel.clientHeight);
setTimeout(() => syncScrollEnabled = true, 50);
});
translatedPanel.addEventListener('scroll', function() {
if (!syncScrollEnabled) return;
syncScrollEnabled = false;
const ratio = this.scrollTop / (this.scrollHeight - this.clientHeight);
originalPanel.scrollTop = ratio * (originalPanel.scrollHeight - originalPanel.clientHeight);
setTimeout(() => syncScrollEnabled = true, 50);
});
}
// 重新翻译 // 重新翻译
document.getElementById('retranslateBtn')?.addEventListener('click', async function() { document.getElementById('retranslateBtn')?.addEventListener('click', async function() {
const instruction = document.getElementById('retranslateInstruction').value; const instruction = document.getElementById('retranslateInstruction').value;

1
test_simple.txt Normal file
View File

@@ -0,0 +1 @@
This is a test PDF file. Hello World. This is a simple test document.