修改task_id生成逻辑
This commit is contained in:
@@ -6,6 +6,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from contextlib import asynccontextmanager, closing
|
from contextlib import asynccontextmanager, closing
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Dict, Any, Optional, Literal, Union
|
from typing import List, Dict, Any, Optional, Literal, Union
|
||||||
@@ -281,11 +282,11 @@ DocuTranslate 后端服务 API,提供文档翻译、状态查询、结果下
|
|||||||
**注意**: 所有任务状态都保存在服务进程的内存中,服务重启将导致所有任务信息丢失。
|
**注意**: 所有任务状态都保存在服务进程的内存中,服务重启将导致所有任务信息丢失。
|
||||||
|
|
||||||
### 主要工作流程:
|
### 主要工作流程:
|
||||||
1. **`POST /service/translate`**: 提交文件和翻译参数,启动一个后台任务,并获取 `task_id`。
|
1. **`POST /service/translate`**: 提交文件和翻译参数,启动一个后台任务。服务会自动生成并返回一个唯一的 `task_id`。
|
||||||
2. **`GET /service/status/{{task_id}}`**: 使用 `task_id` 轮询此端点,获取任务的实时状态。
|
2. **`GET /service/status/{{task_id}}`**: 使用获取到的 `task_id` 轮询此端点,获取任务的实时状态。
|
||||||
3. **`GET /service/logs/{{task_id}}`**: (可选) 获取实时的翻译日志。
|
3. **`GET /service/logs/{{task_id}}`**: (可选) 获取实时的翻译日志。
|
||||||
4. **`GET /service/download/{{task_id}}/{{file_type}}`**: 任务完成后 (当 `download_ready` 为 `true` 时),通过此端点下载结果文件。
|
4. **`GET /service/download/{{task_id}}/{{file_type}}`**: 任务完成后 (当 `download_ready` 为 `true` 时),通过此端点下载结果文件。
|
||||||
5. **`GET /service/download_content/{{task_id}}/{{file_type}}`**: 任务完成后,以JSON格式获取文件内容。
|
5. **`GET /service/content/{{task_id}}/{{file_type}}`**: 任务完成后(当 `download_ready` 为 `true` 时),以JSON格式获取文件内容。
|
||||||
6. **`POST /service/cancel/{{task_id}}`**: (可选) 取消一个正在进行的任务。
|
6. **`POST /service/cancel/{{task_id}}`**: (可选) 取消一个正在进行的任务。
|
||||||
7. **`POST /service/release/{{task_id}}`**: (可选) 当任务不再需要时,释放其在服务器上占用的所有资源。
|
7. **`POST /service/release/{{task_id}}`**: (可选) 当任务不再需要时,释放其在服务器上占用的所有资源。
|
||||||
|
|
||||||
@@ -302,14 +303,9 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
|||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# --- Pydantic Models for Service API ---
|
# --- Pydantic Models for Service API (MODIFIED) ---
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
class TranslateServiceRequest(BaseModel):
|
class TranslateServiceRequest(BaseModel):
|
||||||
task_id: str = Field(
|
|
||||||
default="0",
|
|
||||||
description="任务的唯一标识符。用于后续跟踪任务状态和结果。",
|
|
||||||
examples=["task-b2865b93"]
|
|
||||||
)
|
|
||||||
base_url: str = Field(
|
base_url: str = Field(
|
||||||
...,
|
...,
|
||||||
description="LLM API的基础URL,例如 OpenAI, deepseek, 或任何兼容OpenAI的接口。",
|
description="LLM API的基础URL,例如 OpenAI, deepseek, 或任何兼容OpenAI的接口。",
|
||||||
@@ -386,7 +382,6 @@ class TranslateServiceRequest(BaseModel):
|
|||||||
class Config:
|
class Config:
|
||||||
json_schema_extra = {
|
json_schema_extra = {
|
||||||
"example": {
|
"example": {
|
||||||
"task_id": "task-b2865b93-85d7-40a8-b118-a61048698585",
|
|
||||||
"base_url": "https://api.openai.com/v1",
|
"base_url": "https://api.openai.com/v1",
|
||||||
"apikey": "sk-your-api-key-here",
|
"apikey": "sk-your-api-key-here",
|
||||||
"model_id": "gpt-4o",
|
"model_id": "gpt-4o",
|
||||||
@@ -407,7 +402,7 @@ class TranslateServiceRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
# --- Service Endpoints (/service) ---
|
# --- Service Endpoints (/service) (MODIFIED) ---
|
||||||
# ===================================================================
|
# ===================================================================
|
||||||
|
|
||||||
@service_router.post(
|
@service_router.post(
|
||||||
@@ -417,20 +412,20 @@ class TranslateServiceRequest(BaseModel):
|
|||||||
接收一个包含文件内容(Base64编码)和翻译参数的JSON请求,启动一个后台翻译任务。
|
接收一个包含文件内容(Base64编码)和翻译参数的JSON请求,启动一个后台翻译任务。
|
||||||
|
|
||||||
- **异步处理**: 此端点会立即返回,不会等待翻译完成。
|
- **异步处理**: 此端点会立即返回,不会等待翻译完成。
|
||||||
- **任务ID**: 成功启动后,会返回任务ID (`task_id`)。
|
- **任务ID**: 成功启动后,服务会自动生成并返回任务ID (`task_id`)。
|
||||||
- **后续步骤**: 客户端应使用返回的 `task_id` 轮询 `/service/status/{task_id}` 接口来获取任务进度和结果。
|
- **后续步骤**: 客户端应使用返回的 `task_id` 轮询 `/service/status/{task_id}` 接口来获取任务进度和结果。
|
||||||
""",
|
""",
|
||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
"description": "翻译任务成功启动。",
|
"description": "翻译任务成功启动。",
|
||||||
"content": {"application/json": {"example": {"task_started": True, "task_id": "task-b2865b93",
|
"content": {"application/json": {"example": {"task_started": True, "task_id": "b2865b93",
|
||||||
"message": "翻译任务已成功启动,请稍候..."}}}
|
"message": "翻译任务已成功启动,请稍候..."}}}
|
||||||
},
|
},
|
||||||
400: {"description": "请求体中的Base64文件内容无效。",
|
400: {"description": "请求体中的Base64文件内容无效。",
|
||||||
"content": {"application/json": {"example": {"detail": "无效的Base64文件内容: Incorrect padding"}}}},
|
"content": {"application/json": {"example": {"detail": "无效的Base64文件内容: Incorrect padding"}}}},
|
||||||
429: {"description": "同一任务ID已在进行中,无法重复提交。", "content": {
|
429: {"description": "服务器内部任务冲突,请重试。", "content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"example": {"task_started": False, "message": "任务ID 'task-b2865b93' 正在进行中,请稍后再试。"}}}},
|
"example": {"task_started": False, "message": "任务ID 'b2865b93' 正在进行中,请稍后再试。"}}}},
|
||||||
500: {"description": "服务器内部错误,导致任务启动失败。",
|
500: {"description": "服务器内部错误,导致任务启动失败。",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
@@ -440,25 +435,27 @@ class TranslateServiceRequest(BaseModel):
|
|||||||
async def service_translate(request: TranslateServiceRequest = Body(..., description="翻译任务的详细参数和文件内容。")):
|
async def service_translate(request: TranslateServiceRequest = Body(..., description="翻译任务的详细参数和文件内容。")):
|
||||||
"""
|
"""
|
||||||
提交一个文件进行翻译,并启动一个后台任务。
|
提交一个文件进行翻译,并启动一个后台任务。
|
||||||
文件内容需以Base64编码。
|
文件内容需以Base64编码,任务ID将由后端自动生成并返回。
|
||||||
返回任务ID,后续可凭此ID查询状态和下载结果。
|
后续可凭此ID查询状态和下载结果。
|
||||||
"""
|
"""
|
||||||
|
task_id = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
file_contents = base64.b64decode(request.file_content)
|
file_contents = base64.b64decode(request.file_content)
|
||||||
except (binascii.Error, TypeError) as e:
|
except (binascii.Error, TypeError) as e:
|
||||||
raise HTTPException(status_code=400, detail=f"无效的Base64文件内容: {e}")
|
raise HTTPException(status_code=400, detail=f"无效的Base64文件内容: {e}")
|
||||||
|
|
||||||
params = request.model_dump(exclude={'file_name', 'file_content', 'task_id'})
|
params = request.model_dump(exclude={'file_name', 'file_content'})
|
||||||
try:
|
try:
|
||||||
response_data = await _start_translation_task(
|
response_data = await _start_translation_task(
|
||||||
task_id=request.task_id,
|
task_id=task_id,
|
||||||
params=params,
|
params=params,
|
||||||
file_contents=file_contents,
|
file_contents=file_contents,
|
||||||
original_filename=request.file_name
|
original_filename=request.file_name
|
||||||
)
|
)
|
||||||
return JSONResponse(content=response_data)
|
return JSONResponse(content=response_data)
|
||||||
except HTTPException as e:
|
except HTTPException as e:
|
||||||
# Re-raise as JSONResponse to fit the documented response model
|
# 重新包装为JSONResponse以匹配文档中的响应模型
|
||||||
if e.status_code == 429:
|
if e.status_code == 429:
|
||||||
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
|
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
|
||||||
if e.status_code == 500:
|
if e.status_code == 500:
|
||||||
@@ -488,7 +485,7 @@ async def service_translate(request: TranslateServiceRequest = Body(..., descrip
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_cancel_translate(
|
async def service_cancel_translate(
|
||||||
task_id: str = FastApiPath(..., description="要取消的任务的ID", example="task-b2865b93")):
|
task_id: str = FastApiPath(..., description="要取消的任务的ID", example="b2865b93")):
|
||||||
"""根据任务ID取消一个正在进行的翻译任务。"""
|
"""根据任务ID取消一个正在进行的翻译任务。"""
|
||||||
try:
|
try:
|
||||||
response_data = _cancel_translation_logic(task_id)
|
response_data = _cancel_translation_logic(task_id)
|
||||||
@@ -511,7 +508,7 @@ async def service_cancel_translate(
|
|||||||
200: {
|
200: {
|
||||||
"description": "任务资源已成功释放。",
|
"description": "任务资源已成功释放。",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {"example": {"released": True, "message": "任务 'task-b2865b93' 的资源已释放。"}}
|
"application/json": {"example": {"released": True, "message": "任务 'b2865b93' 的资源已释放。"}}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
404: {
|
404: {
|
||||||
@@ -522,7 +519,7 @@ async def service_cancel_translate(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_release_task(
|
async def service_release_task(
|
||||||
task_id: str = FastApiPath(..., description="要释放资源的任务的ID", example="task-b2865b93")
|
task_id: str = FastApiPath(..., description="要释放资源的任务的ID", example="b2865b93")
|
||||||
):
|
):
|
||||||
"""根据任务ID释放其占用的所有服务器资源。"""
|
"""根据任务ID释放其占用的所有服务器资源。"""
|
||||||
if task_id not in tasks_state:
|
if task_id not in tasks_state:
|
||||||
@@ -575,7 +572,7 @@ async def service_release_task(
|
|||||||
"processing": {
|
"processing": {
|
||||||
"summary": "处理中",
|
"summary": "处理中",
|
||||||
"value": {
|
"value": {
|
||||||
"task_id": "task-b2865b93",
|
"task_id": "b2865b93",
|
||||||
"is_processing": True,
|
"is_processing": True,
|
||||||
"status_message": "正在翻译: 15/50 块",
|
"status_message": "正在翻译: 15/50 块",
|
||||||
"error_flag": False,
|
"error_flag": False,
|
||||||
@@ -594,7 +591,7 @@ async def service_release_task(
|
|||||||
"completed": {
|
"completed": {
|
||||||
"summary": "已完成",
|
"summary": "已完成",
|
||||||
"value": {
|
"value": {
|
||||||
"task_id": "task-b2865b93",
|
"task_id": "b2865b93",
|
||||||
"is_processing": False,
|
"is_processing": False,
|
||||||
"status_message": "翻译成功!用时 123.45 秒。",
|
"status_message": "翻译成功!用时 123.45 秒。",
|
||||||
"error_flag": False,
|
"error_flag": False,
|
||||||
@@ -604,16 +601,16 @@ async def service_release_task(
|
|||||||
"task_start_time": 1678886400.123,
|
"task_start_time": 1678886400.123,
|
||||||
"task_end_time": 1678886523.573,
|
"task_end_time": 1678886523.573,
|
||||||
"downloads": {
|
"downloads": {
|
||||||
"markdown": "/service/download/task-b2865b93/markdown",
|
"markdown": "/service/download/b2865b93/markdown",
|
||||||
"markdown_zip": "/service/download/task-b2865b93/markdown_zip",
|
"markdown_zip": "/service/download/b2865b93/markdown_zip",
|
||||||
"html": "/service/download/task-b2865b93/html"
|
"html": "/service/download/b2865b93/html"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"summary": "出错",
|
"summary": "出错",
|
||||||
"value": {
|
"value": {
|
||||||
"task_id": "task-b2865b93",
|
"task_id": "b2865b93",
|
||||||
"is_processing": False,
|
"is_processing": False,
|
||||||
"status_message": "翻译过程中发生错误 (用时 45.67 秒): APIConnectionError(...)",
|
"status_message": "翻译过程中发生错误 (用时 45.67 秒): APIConnectionError(...)",
|
||||||
"error_flag": True,
|
"error_flag": True,
|
||||||
@@ -640,7 +637,7 @@ async def service_release_task(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_get_status(
|
async def service_get_status(
|
||||||
task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="task-b2865b93")):
|
task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="b2865b93")):
|
||||||
"""根据任务ID获取任务的当前状态和结果下载链接。"""
|
"""根据任务ID获取任务的当前状态和结果下载链接。"""
|
||||||
task_state = tasks_state.get(task_id)
|
task_state = tasks_state.get(task_id)
|
||||||
if not task_state:
|
if not task_state:
|
||||||
@@ -692,7 +689,7 @@ async def service_get_status(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_get_logs(
|
async def service_get_logs(
|
||||||
task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="task-b2865b93")):
|
task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="b2865b93")):
|
||||||
"""获取指定任务ID自上次查询以来的新日志。"""
|
"""获取指定任务ID自上次查询以来的新日志。"""
|
||||||
if task_id not in tasks_log_queues:
|
if task_id not in tasks_log_queues:
|
||||||
raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}' 的日志队列。")
|
raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}' 的日志队列。")
|
||||||
@@ -730,7 +727,7 @@ FileType = Literal["markdown", "markdown_zip", "html"]
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_download_file(
|
async def service_download_file(
|
||||||
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-b2865b93"),
|
task_id: str = FastApiPath(..., description="已完成任务的ID", example="b2865b93"),
|
||||||
file_type: FileType = FastApiPath(..., description="要下载的文件类型。", example="html")
|
file_type: FileType = FastApiPath(..., description="要下载的文件类型。", example="html")
|
||||||
):
|
):
|
||||||
"""根据任务ID和文件类型下载翻译结果。"""
|
"""根据任务ID和文件类型下载翻译结果。"""
|
||||||
@@ -756,7 +753,7 @@ async def service_download_file(
|
|||||||
|
|
||||||
|
|
||||||
@service_router.get(
|
@service_router.get(
|
||||||
"/download_content/{task_id}/{file_type}",
|
"/content/{task_id}/{file_type}",
|
||||||
summary="下载翻译结果内容 (JSON)",
|
summary="下载翻译结果内容 (JSON)",
|
||||||
description="""
|
description="""
|
||||||
根据任务ID和文件类型,以JSON格式返回翻译结果的内容。该接口总是返回一个JSON对象。
|
根据任务ID和文件类型,以JSON格式返回翻译结果的内容。该接口总是返回一个JSON对象。
|
||||||
@@ -778,7 +775,7 @@ async def service_download_file(
|
|||||||
"summary": "Markdown 内容",
|
"summary": "Markdown 内容",
|
||||||
"value": {
|
"value": {
|
||||||
"file_type": "markdown",
|
"file_type": "markdown",
|
||||||
"filename": "my_doc_translated.md",
|
"original_filename": "my_doc.pdf",
|
||||||
"content": "# 标题\n\n这是翻译后的Markdown内容..."
|
"content": "# 标题\n\n这是翻译后的Markdown内容..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -786,7 +783,7 @@ async def service_download_file(
|
|||||||
"summary": "HTML 内容",
|
"summary": "HTML 内容",
|
||||||
"value": {
|
"value": {
|
||||||
"file_type": "html",
|
"file_type": "html",
|
||||||
"filename": "my_doc_translated.html",
|
"original_filename": "my_doc.pdf",
|
||||||
"content": "<h1>标题</h1><p>这是翻译后的HTML内容...</p>"
|
"content": "<h1>标题</h1><p>这是翻译后的HTML内容...</p>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -794,7 +791,7 @@ async def service_download_file(
|
|||||||
"summary": "ZIP 内容 (Base64)",
|
"summary": "ZIP 内容 (Base64)",
|
||||||
"value": {
|
"value": {
|
||||||
"file_type": "markdown_zip",
|
"file_type": "markdown_zip",
|
||||||
"filename": "my_doc_translated.zip",
|
"filename": "my_doc.pdf",
|
||||||
"content": "UEsDBBQAAAAIA... (base64-encoded string)"
|
"content": "UEsDBBQAAAAIA... (base64-encoded string)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -808,8 +805,8 @@ async def service_download_file(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
async def service_download_content(
|
async def service_content(
|
||||||
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-b2865b93"),
|
task_id: str = FastApiPath(..., description="已完成任务的ID", example="b2865b93"),
|
||||||
file_type: FileType = FastApiPath(..., description="要获取内容的文件类型。", example="html")
|
file_type: FileType = FastApiPath(..., description="要获取内容的文件类型。", example="html")
|
||||||
):
|
):
|
||||||
"""根据任务ID和文件类型,以JSON格式返回内容。zip文件会进行Base64编码。"""
|
"""根据任务ID和文件类型,以JSON格式返回内容。zip文件会进行Base64编码。"""
|
||||||
@@ -821,10 +818,9 @@ async def service_download_content(
|
|||||||
raise HTTPException(status_code=404, detail="内容尚未准备好。")
|
raise HTTPException(status_code=404, detail="内容尚未准备好。")
|
||||||
|
|
||||||
content_map = {
|
content_map = {
|
||||||
"markdown": (task_state.get("markdown_content"), f"{task_state['original_filename_stem']}_translated.md"),
|
"markdown": (task_state.get("markdown_content"), task_state['original_filename']),
|
||||||
"markdown_zip": (task_state.get("markdown_zip_content"),
|
"markdown_zip": (task_state.get("markdown_zip_content"),task_state['original_filename']),
|
||||||
f"{task_state['original_filename_stem']}_translated.zip"),
|
"html": (task_state.get("html_content"), task_state['original_filename']),
|
||||||
"html": (task_state.get("html_content"), f"{task_state['original_filename_stem']}_translated.html"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
raw_content, filename = content_map.get(file_type, (None, None))
|
raw_content, filename = content_map.get(file_type, (None, None))
|
||||||
@@ -837,7 +833,7 @@ async def service_download_content(
|
|||||||
|
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"file_type": file_type,
|
"file_type": file_type,
|
||||||
"filename": filename,
|
"original_filename": filename,
|
||||||
"content": final_content
|
"content": final_content
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -871,7 +867,7 @@ async def service_get_engin_list():
|
|||||||
responses={
|
responses={
|
||||||
200: {
|
200: {
|
||||||
"description": "成功返回任务ID列表。",
|
"description": "成功返回任务ID列表。",
|
||||||
"content": {"application/json": {"example": ["task-b2865b93", "task-another-one", "0"]}}
|
"content": {"application/json": {"example": ["b2865b93", "f4e2a1c8"]}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1021,6 +1017,7 @@ def run_app(port: int | None = None):
|
|||||||
if port_to_use != initial_port: print(f"端口 {initial_port} 被占用,将使用端口 {port_to_use} 代替")
|
if port_to_use != initial_port: print(f"端口 {initial_port} 被占用,将使用端口 {port_to_use} 代替")
|
||||||
print(f"正在启动 DocuTranslate WebUI 版本号:{__version__}")
|
print(f"正在启动 DocuTranslate WebUI 版本号:{__version__}")
|
||||||
print(f"请用浏览器访问 http://127.0.0.1:{port_to_use}")
|
print(f"请用浏览器访问 http://127.0.0.1:{port_to_use}")
|
||||||
|
print(f"服务接口文档: http://127.0.0.1:{port_to_use}/docs")
|
||||||
uvicorn.run(app, host=None, port=port_to_use, workers=1)
|
uvicorn.run(app, host=None, port=port_to_use, workers=1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"启动失败: {e}")
|
print(f"启动失败: {e}")
|
||||||
|
|||||||
@@ -39,6 +39,12 @@
|
|||||||
transition: all 0.3s ease-in-out;
|
transition: all 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* === 修改点: 为等待ID的状态添加样式 === */
|
||||||
|
.task-id-placeholder {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.log-area {
|
.log-area {
|
||||||
height: 150px;
|
height: 150px;
|
||||||
background-color: var(--bs-tertiary-bg);
|
background-color: var(--bs-tertiary-bg);
|
||||||
@@ -67,7 +73,6 @@
|
|||||||
background-color: var(--bs-secondary-bg);
|
background-color: var(--bs-secondary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- 修改点: 添加了文件选中后的样式 --- */
|
|
||||||
.file-drop-area.file-selected {
|
.file-drop-area.file-selected {
|
||||||
border-style: solid;
|
border-style: solid;
|
||||||
border-color: var(--bs-success);
|
border-color: var(--bs-success);
|
||||||
@@ -88,7 +93,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- MODIFIED: Styles for the new Offcanvas Preview --- */
|
|
||||||
#previewOffcanvas {
|
#previewOffcanvas {
|
||||||
--bs-offcanvas-width: 95vw;
|
--bs-offcanvas-width: 95vw;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
@@ -118,7 +122,6 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* split.js gutter style */
|
|
||||||
.gutter {
|
.gutter {
|
||||||
background-color: var(--bs-tertiary-bg);
|
background-color: var(--bs-tertiary-bg);
|
||||||
border-left: 1px solid var(--bs-border-color);
|
border-left: 1px solid var(--bs-border-color);
|
||||||
@@ -127,7 +130,6 @@
|
|||||||
.gutter.gutter-horizontal {
|
.gutter.gutter-horizontal {
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
}
|
}
|
||||||
/* --- END OF MODIFICATION --- */
|
|
||||||
|
|
||||||
|
|
||||||
.preview-pane iframe, .preview-pane pre {
|
.preview-pane iframe, .preview-pane pre {
|
||||||
@@ -386,14 +388,14 @@
|
|||||||
<template id="taskCardTemplate">
|
<template id="taskCardTemplate">
|
||||||
<div class="card mb-3 task-card">
|
<div class="card mb-3 task-card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<span class="fw-bold">任务 ID: <code class="task-id-display"></code></span>
|
<!-- === 修改点: 初始显示占位符,而不是ID === -->
|
||||||
|
<span class="fw-bold">任务 ID: <code class="task-id-display"><span class="task-id-placeholder">等待提交...</span></code></span>
|
||||||
<button type="button" class="btn-close remove-task-btn" aria-label="Close"></button>
|
<button type="button" class="btn-close remove-task-btn" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<input type="file" class="d-none file-input">
|
<input type="file" class="d-none file-input">
|
||||||
<!-- --- 修改点: 更新拖放区域的内部结构以支持两种状态 --- -->
|
|
||||||
<div class="file-drop-area">
|
<div class="file-drop-area">
|
||||||
<div class="file-drop-default">
|
<div class="file-drop-default">
|
||||||
<i class="bi bi-cloud-arrow-up fs-1"></i>
|
<i class="bi bi-cloud-arrow-up fs-1"></i>
|
||||||
@@ -517,6 +519,7 @@
|
|||||||
<script src="https://unpkg.com/split.js/dist/split.min.js"></script>
|
<script src="https://unpkg.com/split.js/dist/split.min.js"></script>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
// === 以下是修改后的 JavaScript 代码 ===
|
||||||
|
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||||
@@ -553,7 +556,6 @@
|
|||||||
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
|
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
|
||||||
const taskCardTemplate = document.getElementById('taskCardTemplate');
|
const taskCardTemplate = document.getElementById('taskCardTemplate');
|
||||||
|
|
||||||
// MODIFIED: Offcanvas and preview elements
|
|
||||||
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
|
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
|
||||||
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
|
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
|
||||||
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
|
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
|
||||||
@@ -568,9 +570,10 @@
|
|||||||
|
|
||||||
// --- Global State ---
|
// --- Global State ---
|
||||||
let defaultParams = {};
|
let defaultParams = {};
|
||||||
const tasks = {}; // { taskId: { elements: {...}, state: {...}, intervals: {...} } }
|
// === 修改点: tasks对象的键现在是前端生成的cardId, 用于管理UI。后端返回的taskId将存在state中。
|
||||||
let isAdminMode = false; // Flag for admin view
|
const tasks = {}; // { cardId: { elements: {...}, state: {...}, intervals: {...} } }
|
||||||
let previewSplitInstance = null; // To hold the Split.js instance
|
let isAdminMode = false;
|
||||||
|
let previewSplitInstance = null;
|
||||||
|
|
||||||
const apiHrefMap = {
|
const apiHrefMap = {
|
||||||
"https://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
|
"https://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
|
||||||
@@ -584,7 +587,8 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- Utility Functions ---
|
// --- Utility Functions ---
|
||||||
const generateTaskId = () => Math.random().toString(36).substring(2, 10);
|
// === 修改点: 此函数现在生成的是临时的UI卡片ID (cardId)
|
||||||
|
const generateCardId = () => `card_${Math.random().toString(36).substring(2, 10)}`;
|
||||||
const saveToStorage = (key, value) => { try { localStorage.setItem(key, value); } catch (e) { console.warn("Save to storage failed:", e); } };
|
const saveToStorage = (key, value) => { try { localStorage.setItem(key, value); } catch (e) { console.warn("Save to storage failed:", e); } };
|
||||||
const getFromStorage = (key, defaultValue = '') => { try { return localStorage.getItem(key) || defaultValue; } catch (e) { console.warn("Read from storage failed:", e); return defaultValue; } };
|
const getFromStorage = (key, defaultValue = '') => { try { return localStorage.getItem(key) || defaultValue; } catch (e) { console.warn("Read from storage failed:", e); return defaultValue; } };
|
||||||
|
|
||||||
@@ -593,7 +597,6 @@
|
|||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
reader.onload = () => {
|
reader.onload = () => {
|
||||||
// Result is "data:...,base64,XYZ...", we only want "XYZ..."
|
|
||||||
const base64String = reader.result.split(',')[1];
|
const base64String = reader.result.split(',')[1];
|
||||||
resolve(base64String);
|
resolve(base64String);
|
||||||
};
|
};
|
||||||
@@ -660,25 +663,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveTaskIds() {
|
function saveTaskIds() {
|
||||||
if (isAdminMode) return; // In admin mode, do not save task list to localStorage
|
if (isAdminMode) return;
|
||||||
// === 修改点: 只保存已提交到后端的任务ID ===
|
// === 修改点: 保存的是已经从后端获取到ID的任务
|
||||||
// 这是解决问题的核心。通过只持久化那些已经成功提交给后端处理的任务,
|
const submittedTaskIds = Object.values(tasks)
|
||||||
// 我们可以防止未提交的任务(这些任务在后端不存在)在页面刷新后被恢复。
|
.map(task => task.state.backendTaskId)
|
||||||
// 如果一个未提交的任务是页面上唯一的任务,刷新后 `localStorage` 中的任务列表会是空的,
|
.filter(id => id); // 过滤掉null或undefined
|
||||||
// `init`函数在初始化时会发现没有任务,从而新建一个空白任务,符合预期。
|
|
||||||
const submittedTaskIds = Object.keys(tasks).filter(taskId => tasks[taskId].state.isSubmitted);
|
|
||||||
saveToStorage('active_task_ids', JSON.stringify(submittedTaskIds));
|
saveToStorage('active_task_ids', JSON.stringify(submittedTaskIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Task Card Management ---
|
// --- Task Card Management ---
|
||||||
function createTaskCard(taskId = null, restoreState = false) {
|
// === 修改点: taskId现在是后端ID, cardId是前端ID。恢复时taskId就是cardId。
|
||||||
if (!taskId) {
|
function createTaskCard(backendTaskId = null, restoreState = false) {
|
||||||
taskId = generateTaskId();
|
// cardId是用于在前端tasks对象中唯一标识一个卡片的ID
|
||||||
}
|
const cardId = backendTaskId || generateCardId();
|
||||||
|
|
||||||
const cardFragment = taskCardTemplate.content.cloneNode(true);
|
const cardFragment = taskCardTemplate.content.cloneNode(true);
|
||||||
const cardElement = cardFragment.querySelector('.task-card');
|
const cardElement = cardFragment.querySelector('.task-card');
|
||||||
cardElement.dataset.taskId = taskId;
|
cardElement.dataset.cardId = cardId; // 使用cardId作为标识
|
||||||
|
|
||||||
const elements = {
|
const elements = {
|
||||||
card: cardElement,
|
card: cardElement,
|
||||||
@@ -702,17 +703,19 @@
|
|||||||
startBtn: cardElement.querySelector('.start-translate-btn'),
|
startBtn: cardElement.querySelector('.start-translate-btn'),
|
||||||
};
|
};
|
||||||
|
|
||||||
elements.taskIdDisplay.textContent = taskId;
|
// === 修改点: 如果是恢复任务,直接显示ID,否则显示占位符
|
||||||
|
if (restoreState && backendTaskId) {
|
||||||
|
elements.taskIdDisplay.textContent = backendTaskId;
|
||||||
|
}
|
||||||
|
|
||||||
tasks[taskId] = {
|
tasks[cardId] = {
|
||||||
elements,
|
elements,
|
||||||
state: {
|
state: {
|
||||||
|
backendTaskId: backendTaskId, // 存储从后端获取的真实ID
|
||||||
isTranslating: false,
|
isTranslating: false,
|
||||||
file: null,
|
file: null,
|
||||||
htmlUrl: null,
|
htmlUrl: null,
|
||||||
fileNameStem: null,
|
fileNameStem: null,
|
||||||
// 如果任务是从之前的会话中恢复的(restoreState为true),
|
|
||||||
// 那么它一定是一个已经提交过的任务。
|
|
||||||
isSubmitted: restoreState
|
isSubmitted: restoreState
|
||||||
},
|
},
|
||||||
intervals: {
|
intervals: {
|
||||||
@@ -721,41 +724,42 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
addEventListenersToCard(taskId);
|
addEventListenersToCard(cardId);
|
||||||
|
|
||||||
taskContainer.prepend(cardElement);
|
taskContainer.prepend(cardElement);
|
||||||
updateTaskPlaceholderVisibility();
|
updateTaskPlaceholderVisibility();
|
||||||
|
|
||||||
if (!restoreState) {
|
if (restoreState && backendTaskId) {
|
||||||
saveTaskIds();
|
// 如果是恢复任务,立即用backendTaskId检查状态
|
||||||
} else {
|
pollStatus(backendTaskId, true);
|
||||||
// If restoring, immediately check status
|
|
||||||
pollStatus(taskId, true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeTask(taskId) {
|
async function removeTask(cardId) {
|
||||||
stopPolling(taskId);
|
const task = tasks[cardId];
|
||||||
tasks[taskId].elements.card.remove();
|
if (!task) return;
|
||||||
delete tasks[taskId];
|
|
||||||
|
const backendTaskId = task.state.backendTaskId;
|
||||||
|
if (backendTaskId) {
|
||||||
|
stopPolling(backendTaskId);
|
||||||
|
await fetch(`/service/release/${backendTaskId}`,{method: 'POST'});
|
||||||
|
}
|
||||||
|
|
||||||
|
task.elements.card.remove();
|
||||||
|
delete tasks[cardId];
|
||||||
saveTaskIds();
|
saveTaskIds();
|
||||||
updateTaskPlaceholderVisibility();
|
updateTaskPlaceholderVisibility();
|
||||||
await fetch(`service/release/${taskId}`,{method: 'POST'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEventListenersToCard(taskId) {
|
function addEventListenersToCard(cardId) {
|
||||||
const { elements, state } = tasks[taskId];
|
const { elements } = tasks[cardId];
|
||||||
|
|
||||||
elements.removeBtn.addEventListener('click', () => removeTask(taskId));
|
elements.removeBtn.addEventListener('click', () => removeTask(cardId));
|
||||||
|
|
||||||
// File Drop/Select Logic
|
|
||||||
elements.fileDropArea.addEventListener('click', () => elements.fileInput.click());
|
elements.fileDropArea.addEventListener('click', () => elements.fileInput.click());
|
||||||
elements.fileInput.addEventListener('change', () => handleFileSelect(taskId));
|
elements.fileInput.addEventListener('change', () => handleFileSelect(cardId));
|
||||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
elements.fileDropArea.addEventListener(eventName, e => {
|
elements.fileDropArea.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false);
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
}, false);
|
|
||||||
});
|
});
|
||||||
['dragenter', 'dragover'].forEach(eventName => {
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
|
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
|
||||||
@@ -766,46 +770,38 @@
|
|||||||
elements.fileDropArea.addEventListener('drop', e => {
|
elements.fileDropArea.addEventListener('drop', e => {
|
||||||
if (e.dataTransfer.files.length > 0) {
|
if (e.dataTransfer.files.length > 0) {
|
||||||
elements.fileInput.files = e.dataTransfer.files;
|
elements.fileInput.files = e.dataTransfer.files;
|
||||||
handleFileSelect(taskId);
|
handleFileSelect(cardId);
|
||||||
}
|
}
|
||||||
}, false);
|
}, false);
|
||||||
|
|
||||||
// Main Action Button
|
|
||||||
elements.startBtn.addEventListener('click', () => {
|
elements.startBtn.addEventListener('click', () => {
|
||||||
if (state.isTranslating) {
|
if (tasks[cardId].state.isTranslating) {
|
||||||
cancelTranslation(taskId);
|
cancelTranslation(cardId);
|
||||||
} else {
|
} else {
|
||||||
startTranslation(taskId);
|
startTranslation(cardId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelect(taskId) {
|
function handleFileSelect(cardId) {
|
||||||
const { elements, state } = tasks[taskId];
|
const { elements, state } = tasks[cardId];
|
||||||
const file = elements.fileInput.files[0];
|
const file = elements.fileInput.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
state.file = file;
|
state.file = file;
|
||||||
|
|
||||||
// 更新外部文件名显示
|
|
||||||
elements.fileNameDisplay.textContent = file.name;
|
elements.fileNameDisplay.textContent = file.name;
|
||||||
elements.fileNameDisplayWrapper.style.display = 'block';
|
elements.fileNameDisplayWrapper.style.display = 'block';
|
||||||
|
|
||||||
// 更新拖放区外观为“已选择”状态
|
|
||||||
elements.fileDropArea.classList.add('file-selected');
|
elements.fileDropArea.classList.add('file-selected');
|
||||||
elements.fileDropDefault.style.display = 'none';
|
elements.fileDropDefault.style.display = 'none';
|
||||||
elements.fileDropSelected.style.display = 'block';
|
elements.fileDropSelected.style.display = 'block';
|
||||||
|
|
||||||
// 清除任何之前的错误状态
|
|
||||||
elements.fileDropArea.classList.remove('input-error');
|
elements.fileDropArea.classList.remove('input-error');
|
||||||
elements.fileNameDisplay.classList.remove('input-error-text');
|
elements.fileNameDisplay.classList.remove('input-error-text');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Core Translation Logic ---
|
// --- Core Translation Logic ---
|
||||||
async function startTranslation(taskId) {
|
async function startTranslation(cardId) {
|
||||||
const { elements, state } = tasks[taskId];
|
const { elements, state } = tasks[cardId];
|
||||||
|
|
||||||
// --- Validation ---
|
|
||||||
if (!state.file) {
|
if (!state.file) {
|
||||||
elements.statusMessage.textContent = '请先选择一个文件。';
|
elements.statusMessage.textContent = '请先选择一个文件。';
|
||||||
elements.statusMessage.className = 'status-message small text-danger';
|
elements.statusMessage.className = 'status-message small text-danger';
|
||||||
@@ -832,7 +828,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UI Update for Starting ---
|
|
||||||
state.isTranslating = true;
|
state.isTranslating = true;
|
||||||
elements.startBtn.disabled = true;
|
elements.startBtn.disabled = true;
|
||||||
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
|
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
|
||||||
@@ -845,10 +840,8 @@
|
|||||||
try {
|
try {
|
||||||
const fileContentBase64 = await fileToBase64(state.file);
|
const fileContentBase64 = await fileToBase64(state.file);
|
||||||
|
|
||||||
state.isSubmitted = true;
|
// === 修改点: payload不再包含task_id ===
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
task_id: taskId,
|
|
||||||
base_url: baseUrlInput.value,
|
base_url: baseUrlInput.value,
|
||||||
apikey: apikeyInput.value,
|
apikey: apikeyInput.value,
|
||||||
model_id: modelInput.value,
|
model_id: modelInput.value,
|
||||||
@@ -874,22 +867,29 @@
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.task_started) {
|
if (response.ok && result.task_started) {
|
||||||
// === 修改点: 任务成功提交后,保存其ID ===
|
// === 修改点: 从后端响应中获取 task_id ===
|
||||||
// 由于 saveTaskIds 只保存 isSubmitted 为 true 的任务,
|
const backendTaskId = result.task_id;
|
||||||
// 在这里调用它可以确保这个新提交的任务ID被持久化。
|
state.backendTaskId = backendTaskId;
|
||||||
saveTaskIds();
|
state.isSubmitted = true;
|
||||||
|
|
||||||
|
// === 修改点: 更新UI显示 task_id ===
|
||||||
|
elements.taskIdDisplay.textContent = backendTaskId;
|
||||||
|
elements.taskIdDisplay.classList.remove('task-id-placeholder');
|
||||||
|
|
||||||
|
saveTaskIds(); // 保存已提交的任务ID
|
||||||
|
|
||||||
elements.statusMessage.textContent = result.message || '任务已开始,正在处理...';
|
elements.statusMessage.textContent = result.message || '任务已开始,正在处理...';
|
||||||
elements.statusMessage.className = 'status-message small text-info';
|
elements.statusMessage.className = 'status-message small text-info';
|
||||||
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i>取消翻译`;
|
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i>取消翻译`;
|
||||||
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
|
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
|
||||||
elements.startBtn.disabled = false;
|
elements.startBtn.disabled = false;
|
||||||
startPolling(taskId);
|
|
||||||
|
// === 修改点: 使用从后端获取的ID开始轮询 ===
|
||||||
|
startPolling(backendTaskId);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || `请求失败 (${response.status})`);
|
throw new Error(result.message || `请求失败 (${response.status})`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// === 修改点: 如果提交失败或过程中出现任何异常,将任务状态重置为未提交 ===
|
|
||||||
// 这样可以防止一个失败的任务被错误地标记为“已提交”,从而避免了刷新后出现问题。
|
|
||||||
state.isSubmitted = false;
|
state.isSubmitted = false;
|
||||||
console.error('请求失败:', error);
|
console.error('请求失败:', error);
|
||||||
elements.statusMessage.textContent = `启动失败: ${error.message}`;
|
elements.statusMessage.textContent = `启动失败: ${error.message}`;
|
||||||
@@ -902,13 +902,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelTranslation(taskId) {
|
async function cancelTranslation(cardId) {
|
||||||
const { elements } = tasks[taskId];
|
const task = tasks[cardId];
|
||||||
|
if (!task || !task.state.backendTaskId) return;
|
||||||
|
|
||||||
|
const { elements } = task;
|
||||||
|
const backendTaskId = task.state.backendTaskId;
|
||||||
|
|
||||||
elements.startBtn.disabled = true;
|
elements.startBtn.disabled = true;
|
||||||
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
|
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/service/cancel/${taskId}`, { method: 'POST' });
|
const response = await fetch(`/service/cancel/${backendTaskId}`, { method: 'POST' });
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok && result.cancelled) {
|
if (response.ok && result.cancelled) {
|
||||||
@@ -926,27 +931,37 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Polling ---
|
// --- Polling ---
|
||||||
function startPolling(taskId) {
|
// === 修改点: taskId参数现在总是后端的ID
|
||||||
stopPolling(taskId);
|
function startPolling(backendTaskId) {
|
||||||
const { intervals } = tasks[taskId];
|
stopPolling(backendTaskId);
|
||||||
intervals.log = setInterval(() => pollLogs(taskId), 2000);
|
// 找到对应的卡片
|
||||||
intervals.status = setInterval(() => pollStatus(taskId), 1500);
|
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
|
||||||
pollLogs(taskId);
|
if (!card) return;
|
||||||
pollStatus(taskId);
|
|
||||||
|
card.intervals.log = setInterval(() => pollLogs(backendTaskId), 2000);
|
||||||
|
card.intervals.status = setInterval(() => pollStatus(backendTaskId), 1500);
|
||||||
|
pollLogs(backendTaskId);
|
||||||
|
pollStatus(backendTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling(taskId) {
|
function stopPolling(backendTaskId) {
|
||||||
const { intervals } = tasks[taskId];
|
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const { intervals } = card;
|
||||||
if (intervals.log) clearInterval(intervals.log);
|
if (intervals.log) clearInterval(intervals.log);
|
||||||
if (intervals.status) clearInterval(intervals.status);
|
if (intervals.status) clearInterval(intervals.status);
|
||||||
intervals.log = null;
|
intervals.log = null;
|
||||||
intervals.status = null;
|
intervals.status = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollLogs(taskId) {
|
async function pollLogs(backendTaskId) {
|
||||||
const { elements } = tasks[taskId];
|
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
|
||||||
|
if (!card) return;
|
||||||
|
const { elements } = card;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/service/logs/${taskId}`);
|
const response = await fetch(`/service/logs/${backendTaskId}`);
|
||||||
if (!response.ok) return;
|
if (!response.ok) return;
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.logs && data.logs.length > 0) {
|
if (data.logs && data.logs.length > 0) {
|
||||||
@@ -954,23 +969,35 @@
|
|||||||
elements.logArea.scrollTop = elements.logArea.scrollHeight;
|
elements.logArea.scrollTop = elements.logArea.scrollHeight;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[${taskId}] Error polling logs:`, error);
|
console.warn(`[${backendTaskId}] Error polling logs:`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pollStatus(taskId, isRestore = false) {
|
async function pollStatus(backendTaskId, isRestore = false) {
|
||||||
const { elements, state } = tasks[taskId];
|
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
|
||||||
|
if (!card) {
|
||||||
|
// 如果是恢复任务时找不到卡片,可能是在其他地方被删除了
|
||||||
|
if (isRestore) {
|
||||||
|
console.warn(`Restored task ${backendTaskId} not found in UI, removing from storage.`);
|
||||||
|
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
|
||||||
|
const newIds = savedTaskIds.filter(id => id !== backendTaskId);
|
||||||
|
saveToStorage('active_task_ids', JSON.stringify(newIds));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { elements, state } = card;
|
||||||
|
const cardId = card.elements.card.dataset.cardId;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/service/status/${taskId}`);
|
const response = await fetch(`/service/status/${backendTaskId}`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if (response.status === 404 && isRestore && state.isSubmitted) {
|
if (response.status === 404 && isRestore) {
|
||||||
await removeTask(taskId);
|
await removeTask(cardId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const status = await response.json();
|
const status = await response.json();
|
||||||
|
|
||||||
// 页面刷新时,从后端状态恢复文件名显示
|
|
||||||
if (status.original_filename && (!state.file || isRestore)) {
|
if (status.original_filename && (!state.file || isRestore)) {
|
||||||
elements.fileNameDisplay.textContent = status.original_filename;
|
elements.fileNameDisplay.textContent = status.original_filename;
|
||||||
elements.fileNameDisplayWrapper.style.display = 'block';
|
elements.fileNameDisplayWrapper.style.display = 'block';
|
||||||
@@ -980,7 +1007,7 @@
|
|||||||
elements.statusMessage.className = `status-message small ${status.error_flag ? 'text-danger' : 'text-info'}`;
|
elements.statusMessage.className = `status-message small ${status.error_flag ? 'text-danger' : 'text-info'}`;
|
||||||
|
|
||||||
if (!status.is_processing) {
|
if (!status.is_processing) {
|
||||||
stopPolling(taskId);
|
stopPolling(backendTaskId);
|
||||||
state.isTranslating = false;
|
state.isTranslating = false;
|
||||||
elements.startBtn.disabled = false;
|
elements.startBtn.disabled = false;
|
||||||
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i>重新翻译`;
|
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i>重新翻译`;
|
||||||
@@ -989,8 +1016,6 @@
|
|||||||
|
|
||||||
if (status.download_ready && !status.error_flag) {
|
if (status.download_ready && !status.error_flag) {
|
||||||
elements.statusMessage.className = 'status-message small text-success';
|
elements.statusMessage.className = 'status-message small text-success';
|
||||||
|
|
||||||
// Use download URLs directly from the service response
|
|
||||||
state.htmlUrl = status.downloads.html;
|
state.htmlUrl = status.downloads.html;
|
||||||
state.fileNameStem = status.original_filename_stem;
|
state.fileNameStem = status.original_filename_stem;
|
||||||
|
|
||||||
@@ -998,8 +1023,8 @@
|
|||||||
elements.mdLink.href = status.downloads.markdown;
|
elements.mdLink.href = status.downloads.markdown;
|
||||||
elements.mdZipLink.href = status.downloads.markdown_zip;
|
elements.mdZipLink.href = status.downloads.markdown_zip;
|
||||||
|
|
||||||
elements.previewBtn.onclick = () => setupPreview(taskId);
|
elements.previewBtn.onclick = () => setupPreview(cardId);
|
||||||
elements.pdfBtn.onclick = () => downloadPdf(taskId);
|
elements.pdfBtn.onclick = () => downloadPdf(cardId);
|
||||||
|
|
||||||
elements.downloadButtons.style.display = 'flex';
|
elements.downloadButtons.style.display = 'flex';
|
||||||
} else {
|
} else {
|
||||||
@@ -1013,29 +1038,27 @@
|
|||||||
elements.progress.style.display = 'block';
|
elements.progress.style.display = 'block';
|
||||||
elements.downloadButtons.style.display = 'none';
|
elements.downloadButtons.style.display = 'none';
|
||||||
|
|
||||||
if (isRestore && !tasks[taskId].intervals.status) {
|
if (isRestore && !card.intervals.status) {
|
||||||
startPolling(taskId);
|
startPolling(backendTaskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[${taskId}] Error polling status:`, error);
|
console.error(`[${backendTaskId}] Error polling status:`, error);
|
||||||
elements.statusMessage.textContent = '状态更新出错。';
|
elements.statusMessage.textContent = '状态更新出错。';
|
||||||
elements.statusMessage.className = 'status-message small text-danger';
|
elements.statusMessage.className = 'status-message small text-danger';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MODIFIED: Download and Preview ---
|
// --- Download and Preview (No changes needed here, they use cardId to get state) ---
|
||||||
function setupPreview(taskId) {
|
function setupPreview(cardId) {
|
||||||
const { state } = tasks[taskId];
|
const { state } = tasks[cardId];
|
||||||
if (!state.htmlUrl) return;
|
if (!state.htmlUrl) return;
|
||||||
|
|
||||||
// Clear previous content
|
|
||||||
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
|
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
|
||||||
if (existingOriginalContent) existingOriginalContent.remove();
|
if (existingOriginalContent) existingOriginalContent.remove();
|
||||||
translatedPreviewFrame.src = 'about:blank';
|
translatedPreviewFrame.src = 'about:blank';
|
||||||
|
|
||||||
// Render original file preview
|
|
||||||
if (state.file) {
|
if (state.file) {
|
||||||
const fileType = state.file.type;
|
const fileType = state.file.type;
|
||||||
const fileExtension = state.file.name.split('.').pop().toLowerCase();
|
const fileExtension = state.file.name.split('.').pop().toLowerCase();
|
||||||
@@ -1062,7 +1085,6 @@
|
|||||||
originalPreviewPane.appendChild(p);
|
originalPreviewPane.appendChild(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and render translated HTML
|
|
||||||
fetch(state.htmlUrl)
|
fetch(state.htmlUrl)
|
||||||
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
|
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
|
||||||
.then(html => {
|
.then(html => {
|
||||||
@@ -1085,8 +1107,8 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadPdf(taskId) {
|
function downloadPdf(cardId) {
|
||||||
const { elements, state } = tasks[taskId];
|
const { elements, state } = tasks[cardId];
|
||||||
if (!state.htmlUrl) return;
|
if (!state.htmlUrl) return;
|
||||||
|
|
||||||
elements.pdfBtn.disabled = true;
|
elements.pdfBtn.disabled = true;
|
||||||
@@ -1119,73 +1141,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setPreviewDisplayMode(mode) {
|
function setPreviewDisplayMode(mode) {
|
||||||
// Always destroy the previous split instance to avoid errors
|
|
||||||
if (previewSplitInstance) {
|
if (previewSplitInstance) {
|
||||||
previewSplitInstance.destroy();
|
previewSplitInstance.destroy();
|
||||||
previewSplitInstance = null;
|
previewSplitInstance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure containers are visible and reset styles before applying new ones
|
|
||||||
originalPreviewContainer.style.display = 'flex';
|
originalPreviewContainer.style.display = 'flex';
|
||||||
originalPreviewContainer.style.width = '';
|
originalPreviewContainer.style.width = '';
|
||||||
translatedPreviewContainer.style.width = '';
|
translatedPreviewContainer.style.width = '';
|
||||||
|
|
||||||
if (mode === 'bilingual') {
|
if (mode === 'bilingual') {
|
||||||
previewOffcanvasLabel.textContent = '双语预览';
|
previewOffcanvasLabel.textContent = '双语预览';
|
||||||
|
|
||||||
// Re-initialize Split.js for resizable panes
|
|
||||||
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
|
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
|
||||||
sizes: [50, 50],
|
sizes: [50, 50], minSize: 200, gutterSize: 10, cursor: 'col-resize',
|
||||||
minSize: 200, // Minimum pane size in pixels
|
|
||||||
gutterSize: 10,
|
|
||||||
cursor: 'col-resize',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update button states
|
|
||||||
setBilingualViewBtn.classList.add('btn-primary');
|
setBilingualViewBtn.classList.add('btn-primary');
|
||||||
setBilingualViewBtn.classList.remove('btn-outline-primary');
|
setBilingualViewBtn.classList.remove('btn-outline-primary');
|
||||||
setTranslatedOnlyViewBtn.classList.remove('btn-primary');
|
setTranslatedOnlyViewBtn.classList.remove('btn-primary');
|
||||||
setTranslatedOnlyViewBtn.classList.add('btn-outline-primary');
|
setTranslatedOnlyViewBtn.classList.add('btn-outline-primary');
|
||||||
|
} else {
|
||||||
} else { // translationOnly
|
|
||||||
previewOffcanvasLabel.textContent = '译文预览';
|
previewOffcanvasLabel.textContent = '译文预览';
|
||||||
|
|
||||||
// Hide original, make translated pane full-width
|
|
||||||
originalPreviewContainer.style.display = 'none';
|
originalPreviewContainer.style.display = 'none';
|
||||||
translatedPreviewContainer.style.width = '100%';
|
translatedPreviewContainer.style.width = '100%';
|
||||||
|
|
||||||
// Update button states
|
|
||||||
setTranslatedOnlyViewBtn.classList.add('btn-primary');
|
setTranslatedOnlyViewBtn.classList.add('btn-primary');
|
||||||
setTranslatedOnlyViewBtn.classList.remove('btn-outline-primary');
|
setTranslatedOnlyViewBtn.classList.remove('btn-outline-primary');
|
||||||
setBilingualViewBtn.classList.remove('btn-primary');
|
setBilingualViewBtn.classList.remove('btn-primary');
|
||||||
setBilingualViewBtn.classList.add('btn-outline-primary');
|
setBilingualViewBtn.classList.add('btn-outline-primary');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// --- End of MODIFICATION ---
|
|
||||||
|
|
||||||
// --- Initialization ---
|
// --- Initialization ---
|
||||||
async function init() {
|
async function init() {
|
||||||
// Determine if in admin mode
|
|
||||||
isAdminMode = window.location.pathname === '/admin';
|
isAdminMode = window.location.pathname === '/admin';
|
||||||
|
|
||||||
// Fetch metadata
|
|
||||||
try {
|
try {
|
||||||
const [metaRes, enginRes, paramsRes] = await Promise.all([
|
const [metaRes, enginRes, paramsRes] = await Promise.all([
|
||||||
fetch("/service/meta"),
|
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params")
|
||||||
fetch('/service/engin-list'),
|
|
||||||
fetch("/service/default-params")
|
|
||||||
]);
|
]);
|
||||||
const meta = await metaRes.json();
|
const meta = await metaRes.json();
|
||||||
versionDisplay.textContent = `v${meta.version}`;
|
versionDisplay.textContent = `v${meta.version}`;
|
||||||
|
|
||||||
const enginList = await enginRes.json();
|
const enginList = await enginRes.json();
|
||||||
Array.from(convertEnginSelect.options).forEach(option => {
|
Array.from(convertEnginSelect.options).forEach(option => {
|
||||||
if (!enginList.includes(option.value)) {
|
if (!enginList.includes(option.value)) {
|
||||||
option.disabled = true;
|
option.disabled = true; option.textContent += " (不可用)";
|
||||||
option.textContent += " (不可用)";
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
defaultParams = await paramsRes.json();
|
defaultParams = await paramsRes.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Initialization failed:", error);
|
console.error("Initialization failed:", error);
|
||||||
@@ -1193,7 +1192,6 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load settings
|
|
||||||
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
|
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
|
||||||
updatePlatformUI();
|
updatePlatformUI();
|
||||||
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
|
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
|
||||||
@@ -1204,12 +1202,10 @@
|
|||||||
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
|
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
|
||||||
customPromptTranslateArea.value = getFromStorage("custom_prompt_translate");
|
customPromptTranslateArea.value = getFromStorage("custom_prompt_translate");
|
||||||
|
|
||||||
// Setup sliders
|
|
||||||
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
|
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
|
||||||
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
|
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
|
||||||
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
|
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
|
||||||
|
|
||||||
// Restore tasks based on mode
|
|
||||||
if (isAdminMode) {
|
if (isAdminMode) {
|
||||||
document.title = "DocuTranslate - Admin Panel";
|
document.title = "DocuTranslate - Admin Panel";
|
||||||
try {
|
try {
|
||||||
@@ -1225,16 +1221,14 @@
|
|||||||
}
|
}
|
||||||
updateTaskPlaceholderVisibility();
|
updateTaskPlaceholderVisibility();
|
||||||
} else {
|
} else {
|
||||||
// Normal mode: Restore from localStorage
|
|
||||||
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
|
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
|
||||||
if (savedTaskIds.length > 0) {
|
if (savedTaskIds.length > 0) {
|
||||||
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
|
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
|
||||||
} else {
|
} else {
|
||||||
createTaskCard(); // Create one new task by default
|
createTaskCard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add event listeners for settings
|
|
||||||
platformSelect.addEventListener('change', updatePlatformUI);
|
platformSelect.addEventListener('change', updatePlatformUI);
|
||||||
apikeyInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value));
|
apikeyInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_apikey`, e.target.value));
|
||||||
modelInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value));
|
modelInput.addEventListener('input', e => saveToStorage(`translator_platform_${platformSelect.value}_model_id`, e.target.value));
|
||||||
@@ -1253,53 +1247,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Theme switcher logic ---
|
// --- Theme switcher logic ---
|
||||||
const getPreferredTheme = () => {
|
const getPreferredTheme = () => { const storedTheme = localStorage.getItem('theme'); if (storedTheme) { return storedTheme; } return 'auto'; };
|
||||||
const storedTheme = localStorage.getItem('theme');
|
const setTheme = theme => { if (theme === 'auto') { document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); } else { document.documentElement.setAttribute('data-bs-theme', theme); } };
|
||||||
if (storedTheme) {
|
const showActiveTheme = (theme) => { document.querySelectorAll('[data-bs-theme-value]').forEach(element => { element.classList.remove('active'); }); const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`); if (activeButton) { activeButton.classList.add('active'); } };
|
||||||
return storedTheme;
|
|
||||||
}
|
|
||||||
return 'auto';
|
|
||||||
};
|
|
||||||
|
|
||||||
const setTheme = theme => {
|
|
||||||
if (theme === 'auto') {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
||||||
} else {
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const showActiveTheme = (theme) => {
|
|
||||||
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
|
|
||||||
element.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`);
|
|
||||||
if (activeButton) {
|
|
||||||
activeButton.classList.add('active');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const preferredTheme = getPreferredTheme();
|
const preferredTheme = getPreferredTheme();
|
||||||
setTheme(preferredTheme);
|
setTheme(preferredTheme);
|
||||||
showActiveTheme(preferredTheme);
|
showActiveTheme(preferredTheme);
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { const storedTheme = localStorage.getItem('theme'); if (storedTheme === 'auto' || !storedTheme) { setTheme('auto'); } });
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => { toggle.addEventListener('click', () => { const theme = toggle.getAttribute('data-bs-theme-value'); localStorage.setItem('theme', theme); setTheme(theme); showActiveTheme(theme); }); });
|
||||||
const storedTheme = localStorage.getItem('theme');
|
|
||||||
if (storedTheme === 'auto' || !storedTheme) {
|
|
||||||
setTheme('auto');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.querySelectorAll('[data-bs-theme-value]').forEach(toggle => {
|
|
||||||
toggle.addEventListener('click', () => {
|
|
||||||
const theme = toggle.getAttribute('data-bs-theme-value');
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
setTheme(theme);
|
|
||||||
showActiveTheme(theme);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// --- Start the application ---
|
// --- Start the application ---
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|||||||
Reference in New Issue
Block a user