修改task_id生成逻辑

This commit is contained in:
xunbu
2025-07-15 13:55:03 +08:00
parent 8745329d2c
commit 93004e9838
2 changed files with 187 additions and 235 deletions

View File

@@ -6,6 +6,7 @@ import logging
import os
import socket
import time
import uuid
from contextlib import asynccontextmanager, closing
from pathlib import Path
from typing import List, Dict, Any, Optional, Literal, Union
@@ -281,11 +282,11 @@ DocuTranslate 后端服务 API提供文档翻译、状态查询、结果下
**注意**: 所有任务状态都保存在服务进程的内存中,服务重启将导致所有任务信息丢失。
### 主要工作流程:
1. **`POST /service/translate`**: 提交文件和翻译参数,启动一个后台任务,并获取 `task_id`。
2. **`GET /service/status/{{task_id}}`**: 使用 `task_id` 轮询此端点,获取任务的实时状态。
1. **`POST /service/translate`**: 提交文件和翻译参数,启动一个后台任务。服务会自动生成并返回一个唯一的 `task_id`。
2. **`GET /service/status/{{task_id}}`**: 使用获取到的 `task_id` 轮询此端点,获取任务的实时状态。
3. **`GET /service/logs/{{task_id}}`**: (可选) 获取实时的翻译日志。
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}}`**: (可选) 取消一个正在进行的任务。
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):
task_id: str = Field(
default="0",
description="任务的唯一标识符。用于后续跟踪任务状态和结果。",
examples=["task-b2865b93"]
)
base_url: str = Field(
...,
description="LLM API的基础URL例如 OpenAI, deepseek, 或任何兼容OpenAI的接口。",
@@ -386,7 +382,6 @@ class TranslateServiceRequest(BaseModel):
class Config:
json_schema_extra = {
"example": {
"task_id": "task-b2865b93-85d7-40a8-b118-a61048698585",
"base_url": "https://api.openai.com/v1",
"apikey": "sk-your-api-key-here",
"model_id": "gpt-4o",
@@ -407,7 +402,7 @@ class TranslateServiceRequest(BaseModel):
# ===================================================================
# --- Service Endpoints (/service) ---
# --- Service Endpoints (/service) (MODIFIED) ---
# ===================================================================
@service_router.post(
@@ -417,20 +412,20 @@ class TranslateServiceRequest(BaseModel):
接收一个包含文件内容Base64编码和翻译参数的JSON请求启动一个后台翻译任务。
- **异步处理**: 此端点会立即返回,不会等待翻译完成。
- **任务ID**: 成功启动后,返回任务ID (`task_id`)。
- **任务ID**: 成功启动后,服务会自动生成并返回任务ID (`task_id`)。
- **后续步骤**: 客户端应使用返回的 `task_id` 轮询 `/service/status/{task_id}` 接口来获取任务进度和结果。
""",
responses={
200: {
"description": "翻译任务成功启动。",
"content": {"application/json": {"example": {"task_started": True, "task_id": "task-b2865b93",
"content": {"application/json": {"example": {"task_started": True, "task_id": "b2865b93",
"message": "翻译任务已成功启动,请稍候..."}}}
},
400: {"description": "请求体中的Base64文件内容无效。",
"content": {"application/json": {"example": {"detail": "无效的Base64文件内容: Incorrect padding"}}}},
429: {"description": "同一任务ID已在进行中无法重复提交", "content": {
429: {"description": "服务器内部任务冲突,请重试", "content": {
"application/json": {
"example": {"task_started": False, "message": "任务ID 'task-b2865b93' 正在进行中,请稍后再试。"}}}},
"example": {"task_started": False, "message": "任务ID 'b2865b93' 正在进行中,请稍后再试。"}}}},
500: {"description": "服务器内部错误,导致任务启动失败。",
"content": {
"application/json": {
@@ -440,25 +435,27 @@ class TranslateServiceRequest(BaseModel):
async def service_translate(request: TranslateServiceRequest = Body(..., description="翻译任务的详细参数和文件内容。")):
"""
提交一个文件进行翻译,并启动一个后台任务。
文件内容需以Base64编码。
返回任务ID后续可凭此ID查询状态和下载结果。
文件内容需以Base64编码任务ID将由后端自动生成并返回
后续可凭此ID查询状态和下载结果。
"""
task_id = uuid.uuid4().hex[:8]
try:
file_contents = base64.b64decode(request.file_content)
except (binascii.Error, TypeError) as 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:
response_data = await _start_translation_task(
task_id=request.task_id,
task_id=task_id,
params=params,
file_contents=file_contents,
original_filename=request.file_name
)
return JSONResponse(content=response_data)
except HTTPException as e:
# Re-raise as JSONResponse to fit the documented response model
# 重新包装为JSONResponse以匹配文档中的响应模型
if e.status_code == 429:
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
if e.status_code == 500:
@@ -488,7 +485,7 @@ async def service_translate(request: TranslateServiceRequest = Body(..., descrip
}
)
async def service_cancel_translate(
task_id: str = FastApiPath(..., description="要取消的任务的ID", example="task-b2865b93")):
task_id: str = FastApiPath(..., description="要取消的任务的ID", example="b2865b93")):
"""根据任务ID取消一个正在进行的翻译任务。"""
try:
response_data = _cancel_translation_logic(task_id)
@@ -511,7 +508,7 @@ async def service_cancel_translate(
200: {
"description": "任务资源已成功释放。",
"content": {
"application/json": {"example": {"released": True, "message": "任务 'task-b2865b93' 的资源已释放。"}}
"application/json": {"example": {"released": True, "message": "任务 'b2865b93' 的资源已释放。"}}
}
},
404: {
@@ -522,7 +519,7 @@ async def service_cancel_translate(
}
)
async def service_release_task(
task_id: str = FastApiPath(..., description="要释放资源的任务的ID", example="task-b2865b93")
task_id: str = FastApiPath(..., description="要释放资源的任务的ID", example="b2865b93")
):
"""根据任务ID释放其占用的所有服务器资源。"""
if task_id not in tasks_state:
@@ -575,7 +572,7 @@ async def service_release_task(
"processing": {
"summary": "处理中",
"value": {
"task_id": "task-b2865b93",
"task_id": "b2865b93",
"is_processing": True,
"status_message": "正在翻译: 15/50 块",
"error_flag": False,
@@ -594,7 +591,7 @@ async def service_release_task(
"completed": {
"summary": "已完成",
"value": {
"task_id": "task-b2865b93",
"task_id": "b2865b93",
"is_processing": False,
"status_message": "翻译成功!用时 123.45 秒。",
"error_flag": False,
@@ -604,16 +601,16 @@ async def service_release_task(
"task_start_time": 1678886400.123,
"task_end_time": 1678886523.573,
"downloads": {
"markdown": "/service/download/task-b2865b93/markdown",
"markdown_zip": "/service/download/task-b2865b93/markdown_zip",
"html": "/service/download/task-b2865b93/html"
"markdown": "/service/download/b2865b93/markdown",
"markdown_zip": "/service/download/b2865b93/markdown_zip",
"html": "/service/download/b2865b93/html"
}
}
},
"error": {
"summary": "出错",
"value": {
"task_id": "task-b2865b93",
"task_id": "b2865b93",
"is_processing": False,
"status_message": "翻译过程中发生错误 (用时 45.67 秒): APIConnectionError(...)",
"error_flag": True,
@@ -640,7 +637,7 @@ async def service_release_task(
}
)
async def service_get_status(
task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="task-b2865b93")):
task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="b2865b93")):
"""根据任务ID获取任务的当前状态和结果下载链接。"""
task_state = tasks_state.get(task_id)
if not task_state:
@@ -692,7 +689,7 @@ async def service_get_status(
}
)
async def service_get_logs(
task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="task-b2865b93")):
task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="b2865b93")):
"""获取指定任务ID自上次查询以来的新日志。"""
if task_id not in tasks_log_queues:
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(
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-b2865b93"),
task_id: str = FastApiPath(..., description="已完成任务的ID", example="b2865b93"),
file_type: FileType = FastApiPath(..., description="要下载的文件类型。", example="html")
):
"""根据任务ID和文件类型下载翻译结果。"""
@@ -756,7 +753,7 @@ async def service_download_file(
@service_router.get(
"/download_content/{task_id}/{file_type}",
"/content/{task_id}/{file_type}",
summary="下载翻译结果内容 (JSON)",
description="""
根据任务ID和文件类型以JSON格式返回翻译结果的内容。该接口总是返回一个JSON对象。
@@ -778,7 +775,7 @@ async def service_download_file(
"summary": "Markdown 内容",
"value": {
"file_type": "markdown",
"filename": "my_doc_translated.md",
"original_filename": "my_doc.pdf",
"content": "# 标题\n\n这是翻译后的Markdown内容..."
}
},
@@ -786,7 +783,7 @@ async def service_download_file(
"summary": "HTML 内容",
"value": {
"file_type": "html",
"filename": "my_doc_translated.html",
"original_filename": "my_doc.pdf",
"content": "<h1>标题</h1><p>这是翻译后的HTML内容...</p>"
}
},
@@ -794,7 +791,7 @@ async def service_download_file(
"summary": "ZIP 内容 (Base64)",
"value": {
"file_type": "markdown_zip",
"filename": "my_doc_translated.zip",
"filename": "my_doc.pdf",
"content": "UEsDBBQAAAAIA... (base64-encoded string)"
}
}
@@ -808,8 +805,8 @@ async def service_download_file(
},
}
)
async def service_download_content(
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-b2865b93"),
async def service_content(
task_id: str = FastApiPath(..., description="已完成任务的ID", example="b2865b93"),
file_type: FileType = FastApiPath(..., description="要获取内容的文件类型。", example="html")
):
"""根据任务ID和文件类型以JSON格式返回内容。zip文件会进行Base64编码。"""
@@ -821,10 +818,9 @@ async def service_download_content(
raise HTTPException(status_code=404, detail="内容尚未准备好。")
content_map = {
"markdown": (task_state.get("markdown_content"), f"{task_state['original_filename_stem']}_translated.md"),
"markdown_zip": (task_state.get("markdown_zip_content"),
f"{task_state['original_filename_stem']}_translated.zip"),
"html": (task_state.get("html_content"), f"{task_state['original_filename_stem']}_translated.html"),
"markdown": (task_state.get("markdown_content"), task_state['original_filename']),
"markdown_zip": (task_state.get("markdown_zip_content"),task_state['original_filename']),
"html": (task_state.get("html_content"), task_state['original_filename']),
}
raw_content, filename = content_map.get(file_type, (None, None))
@@ -837,7 +833,7 @@ async def service_download_content(
return JSONResponse(content={
"file_type": file_type,
"filename": filename,
"original_filename": filename,
"content": final_content
})
@@ -871,7 +867,7 @@ async def service_get_engin_list():
responses={
200: {
"description": "成功返回任务ID列表。",
"content": {"application/json": {"example": ["task-b2865b93", "task-another-one", "0"]}}
"content": {"application/json": {"example": ["b2865b93", "f4e2a1c8"]}}
}
}
)
@@ -1021,10 +1017,11 @@ def run_app(port: int | None = None):
if port_to_use != initial_port: print(f"端口 {initial_port} 被占用,将使用端口 {port_to_use} 代替")
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}/docs")
uvicorn.run(app, host=None, port=port_to_use, workers=1)
except Exception as e:
print(f"启动失败: {e}")
if __name__ == "__main__":
run_app()
run_app()

View File

@@ -39,6 +39,12 @@
transition: all 0.3s ease-in-out;
}
/* === 修改点: 为等待ID的状态添加样式 === */
.task-id-placeholder {
color: var(--bs-secondary-color);
font-style: italic;
}
.log-area {
height: 150px;
background-color: var(--bs-tertiary-bg);
@@ -67,7 +73,6 @@
background-color: var(--bs-secondary-bg);
}
/* --- 修改点: 添加了文件选中后的样式 --- */
.file-drop-area.file-selected {
border-style: solid;
border-color: var(--bs-success);
@@ -88,7 +93,6 @@
width: 100%;
}
/* --- MODIFIED: Styles for the new Offcanvas Preview --- */
#previewOffcanvas {
--bs-offcanvas-width: 95vw;
max-width: 1600px;
@@ -118,7 +122,6 @@
overflow: auto;
}
/* split.js gutter style */
.gutter {
background-color: var(--bs-tertiary-bg);
border-left: 1px solid var(--bs-border-color);
@@ -127,7 +130,6 @@
.gutter.gutter-horizontal {
cursor: col-resize;
}
/* --- END OF MODIFICATION --- */
.preview-pane iframe, .preview-pane pre {
@@ -386,14 +388,14 @@
<template id="taskCardTemplate">
<div class="card mb-3 task-card">
<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>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-5">
<input type="file" class="d-none file-input">
<!-- --- 修改点: 更新拖放区域的内部结构以支持两种状态 --- -->
<div class="file-drop-area">
<div class="file-drop-default">
<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 type="module">
// === 以下是修改后的 JavaScript 代码 ===
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
@@ -553,7 +556,6 @@
const noTaskPlaceholder = document.getElementById('no-task-placeholder');
const taskCardTemplate = document.getElementById('taskCardTemplate');
// MODIFIED: Offcanvas and preview elements
const previewOffcanvasEl = document.getElementById('previewOffcanvas');
const previewOffcanvas = new bootstrap.Offcanvas(previewOffcanvasEl);
const previewOffcanvasLabel = document.getElementById('previewOffcanvasLabel');
@@ -568,9 +570,10 @@
// --- Global State ---
let defaultParams = {};
const tasks = {}; // { taskId: { elements: {...}, state: {...}, intervals: {...} } }
let isAdminMode = false; // Flag for admin view
let previewSplitInstance = null; // To hold the Split.js instance
// === 修改点: tasks对象的键现在是前端生成的cardId, 用于管理UI。后端返回的taskId将存在state中。
const tasks = {}; // { cardId: { elements: {...}, state: {...}, intervals: {...} } }
let isAdminMode = false;
let previewSplitInstance = null;
const apiHrefMap = {
"https://openrouter.ai/api/v1": "https://openrouter.ai/settings/keys",
@@ -584,7 +587,8 @@
};
// --- 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 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();
reader.readAsDataURL(file);
reader.onload = () => {
// Result is "data:...,base64,XYZ...", we only want "XYZ..."
const base64String = reader.result.split(',')[1];
resolve(base64String);
};
@@ -660,25 +663,23 @@
}
function saveTaskIds() {
if (isAdminMode) return; // In admin mode, do not save task list to localStorage
// === 修改点: 保存已提交到后端的任务ID ===
// 这是解决问题的核心。通过只持久化那些已经成功提交给后端处理的任务,
// 我们可以防止未提交的任务(这些任务在后端不存在)在页面刷新后被恢复。
// 如果一个未提交的任务是页面上唯一的任务,刷新后 `localStorage` 中的任务列表会是空的,
// `init`函数在初始化时会发现没有任务,从而新建一个空白任务,符合预期。
const submittedTaskIds = Object.keys(tasks).filter(taskId => tasks[taskId].state.isSubmitted);
if (isAdminMode) return;
// === 修改点: 保存的是已经从后端获取到ID的任务
const submittedTaskIds = Object.values(tasks)
.map(task => task.state.backendTaskId)
.filter(id => id); // 过滤掉null或undefined
saveToStorage('active_task_ids', JSON.stringify(submittedTaskIds));
}
// --- Task Card Management ---
function createTaskCard(taskId = null, restoreState = false) {
if (!taskId) {
taskId = generateTaskId();
}
// === 修改点: taskId现在是后端ID, cardId是前端ID。恢复时taskId就是cardId。
function createTaskCard(backendTaskId = null, restoreState = false) {
// cardId是用于在前端tasks对象中唯一标识一个卡片的ID
const cardId = backendTaskId || generateCardId();
const cardFragment = taskCardTemplate.content.cloneNode(true);
const cardElement = cardFragment.querySelector('.task-card');
cardElement.dataset.taskId = taskId;
cardElement.dataset.cardId = cardId; // 使用cardId作为标识
const elements = {
card: cardElement,
@@ -702,17 +703,19 @@
startBtn: cardElement.querySelector('.start-translate-btn'),
};
elements.taskIdDisplay.textContent = taskId;
// === 修改点: 如果是恢复任务直接显示ID否则显示占位符
if (restoreState && backendTaskId) {
elements.taskIdDisplay.textContent = backendTaskId;
}
tasks[taskId] = {
tasks[cardId] = {
elements,
state: {
backendTaskId: backendTaskId, // 存储从后端获取的真实ID
isTranslating: false,
file: null,
htmlUrl: null,
fileNameStem: null,
// 如果任务是从之前的会话中恢复的restoreState为true
// 那么它一定是一个已经提交过的任务。
isSubmitted: restoreState
},
intervals: {
@@ -721,41 +724,42 @@
}
};
addEventListenersToCard(taskId);
addEventListenersToCard(cardId);
taskContainer.prepend(cardElement);
updateTaskPlaceholderVisibility();
if (!restoreState) {
saveTaskIds();
} else {
// If restoring, immediately check status
pollStatus(taskId, true);
if (restoreState && backendTaskId) {
// 如果是恢复任务立即用backendTaskId检查状态
pollStatus(backendTaskId, true);
}
}
async function removeTask(taskId) {
stopPolling(taskId);
tasks[taskId].elements.card.remove();
delete tasks[taskId];
async function removeTask(cardId) {
const task = tasks[cardId];
if (!task) return;
const backendTaskId = task.state.backendTaskId;
if (backendTaskId) {
stopPolling(backendTaskId);
await fetch(`/service/release/${backendTaskId}`,{method: 'POST'});
}
task.elements.card.remove();
delete tasks[cardId];
saveTaskIds();
updateTaskPlaceholderVisibility();
await fetch(`service/release/${taskId}`,{method: 'POST'});
}
function addEventListenersToCard(taskId) {
const { elements, state } = tasks[taskId];
function addEventListenersToCard(cardId) {
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.fileInput.addEventListener('change', () => handleFileSelect(taskId));
elements.fileInput.addEventListener('change', () => handleFileSelect(cardId));
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, e => {
e.preventDefault();
e.stopPropagation();
}, false);
elements.fileDropArea.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false);
});
['dragenter', 'dragover'].forEach(eventName => {
elements.fileDropArea.addEventListener(eventName, () => elements.fileDropArea.classList.add('drag-over'), false);
@@ -766,46 +770,38 @@
elements.fileDropArea.addEventListener('drop', e => {
if (e.dataTransfer.files.length > 0) {
elements.fileInput.files = e.dataTransfer.files;
handleFileSelect(taskId);
handleFileSelect(cardId);
}
}, false);
// Main Action Button
elements.startBtn.addEventListener('click', () => {
if (state.isTranslating) {
cancelTranslation(taskId);
if (tasks[cardId].state.isTranslating) {
cancelTranslation(cardId);
} else {
startTranslation(taskId);
startTranslation(cardId);
}
});
}
function handleFileSelect(taskId) {
const { elements, state } = tasks[taskId];
function handleFileSelect(cardId) {
const { elements, state } = tasks[cardId];
const file = elements.fileInput.files[0];
if (file) {
state.file = file;
// 更新外部文件名显示
elements.fileNameDisplay.textContent = file.name;
elements.fileNameDisplayWrapper.style.display = 'block';
// 更新拖放区外观为“已选择”状态
elements.fileDropArea.classList.add('file-selected');
elements.fileDropDefault.style.display = 'none';
elements.fileDropSelected.style.display = 'block';
// 清除任何之前的错误状态
elements.fileDropArea.classList.remove('input-error');
elements.fileNameDisplay.classList.remove('input-error-text');
}
}
// --- Core Translation Logic ---
async function startTranslation(taskId) {
const { elements, state } = tasks[taskId];
async function startTranslation(cardId) {
const { elements, state } = tasks[cardId];
// --- Validation ---
if (!state.file) {
elements.statusMessage.textContent = '请先选择一个文件。';
elements.statusMessage.className = 'status-message small text-danger';
@@ -832,7 +828,6 @@
return;
}
// --- UI Update for Starting ---
state.isTranslating = true;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
@@ -845,10 +840,8 @@
try {
const fileContentBase64 = await fileToBase64(state.file);
state.isSubmitted = true;
// === 修改点: payload不再包含task_id ===
const payload = {
task_id: taskId,
base_url: baseUrlInput.value,
apikey: apikeyInput.value,
model_id: modelInput.value,
@@ -874,22 +867,29 @@
const result = await response.json();
if (response.ok && result.task_started) {
// === 修改点: 任务成功提交后保存其ID ===
// 由于 saveTaskIds 只保存 isSubmitted 为 true 的任务,
// 在这里调用它可以确保这个新提交的任务ID被持久化。
saveTaskIds();
// === 修改点: 从后端响应中获取 task_id ===
const backendTaskId = result.task_id;
state.backendTaskId = backendTaskId;
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.className = 'status-message small text-info';
elements.startBtn.innerHTML = `<i class="bi bi-stop-circle-fill me-1"></i>取消翻译`;
elements.startBtn.classList.replace('btn-primary', 'btn-danger');
elements.startBtn.disabled = false;
startPolling(taskId);
// === 修改点: 使用从后端获取的ID开始轮询 ===
startPolling(backendTaskId);
} else {
throw new Error(result.message || `请求失败 (${response.status})`);
}
} catch (error) {
// === 修改点: 如果提交失败或过程中出现任何异常,将任务状态重置为未提交 ===
// 这样可以防止一个失败的任务被错误地标记为“已提交”,从而避免了刷新后出现问题。
state.isSubmitted = false;
console.error('请求失败:', error);
elements.statusMessage.textContent = `启动失败: ${error.message}`;
@@ -902,13 +902,18 @@
}
}
async function cancelTranslation(taskId) {
const { elements } = tasks[taskId];
async function cancelTranslation(cardId) {
const task = tasks[cardId];
if (!task || !task.state.backendTaskId) return;
const { elements } = task;
const backendTaskId = task.state.backendTaskId;
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
try {
const response = await fetch(`/service/cancel/${taskId}`, { method: 'POST' });
const response = await fetch(`/service/cancel/${backendTaskId}`, { method: 'POST' });
const result = await response.json();
if (response.ok && result.cancelled) {
@@ -926,27 +931,37 @@
}
// --- Polling ---
function startPolling(taskId) {
stopPolling(taskId);
const { intervals } = tasks[taskId];
intervals.log = setInterval(() => pollLogs(taskId), 2000);
intervals.status = setInterval(() => pollStatus(taskId), 1500);
pollLogs(taskId);
pollStatus(taskId);
// === 修改点: taskId参数现在总是后端的ID
function startPolling(backendTaskId) {
stopPolling(backendTaskId);
// 找到对应的卡片
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
card.intervals.log = setInterval(() => pollLogs(backendTaskId), 2000);
card.intervals.status = setInterval(() => pollStatus(backendTaskId), 1500);
pollLogs(backendTaskId);
pollStatus(backendTaskId);
}
function stopPolling(taskId) {
const { intervals } = tasks[taskId];
function stopPolling(backendTaskId) {
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.status) clearInterval(intervals.status);
intervals.log = null;
intervals.status = null;
}
async function pollLogs(taskId) {
const { elements } = tasks[taskId];
async function pollLogs(backendTaskId) {
const card = Object.values(tasks).find(t => t.state.backendTaskId === backendTaskId);
if (!card) return;
const { elements } = card;
try {
const response = await fetch(`/service/logs/${taskId}`);
const response = await fetch(`/service/logs/${backendTaskId}`);
if (!response.ok) return;
const data = await response.json();
if (data.logs && data.logs.length > 0) {
@@ -954,23 +969,35 @@
elements.logArea.scrollTop = elements.logArea.scrollHeight;
}
} catch (error) {
console.warn(`[${taskId}] Error polling logs:`, error);
console.warn(`[${backendTaskId}] Error polling logs:`, error);
}
}
async function pollStatus(taskId, isRestore = false) {
const { elements, state } = tasks[taskId];
async function pollStatus(backendTaskId, isRestore = false) {
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 {
const response = await fetch(`/service/status/${taskId}`);
const response = await fetch(`/service/status/${backendTaskId}`);
if (!response.ok) {
if (response.status === 404 && isRestore && state.isSubmitted) {
await removeTask(taskId);
if (response.status === 404 && isRestore) {
await removeTask(cardId);
}
return;
}
const status = await response.json();
// 页面刷新时,从后端状态恢复文件名显示
if (status.original_filename && (!state.file || isRestore)) {
elements.fileNameDisplay.textContent = status.original_filename;
elements.fileNameDisplayWrapper.style.display = 'block';
@@ -980,7 +1007,7 @@
elements.statusMessage.className = `status-message small ${status.error_flag ? 'text-danger' : 'text-info'}`;
if (!status.is_processing) {
stopPolling(taskId);
stopPolling(backendTaskId);
state.isTranslating = false;
elements.startBtn.disabled = false;
elements.startBtn.innerHTML = `<i class="bi bi-play-fill me-1"></i>重新翻译`;
@@ -989,8 +1016,6 @@
if (status.download_ready && !status.error_flag) {
elements.statusMessage.className = 'status-message small text-success';
// Use download URLs directly from the service response
state.htmlUrl = status.downloads.html;
state.fileNameStem = status.original_filename_stem;
@@ -998,8 +1023,8 @@
elements.mdLink.href = status.downloads.markdown;
elements.mdZipLink.href = status.downloads.markdown_zip;
elements.previewBtn.onclick = () => setupPreview(taskId);
elements.pdfBtn.onclick = () => downloadPdf(taskId);
elements.previewBtn.onclick = () => setupPreview(cardId);
elements.pdfBtn.onclick = () => downloadPdf(cardId);
elements.downloadButtons.style.display = 'flex';
} else {
@@ -1013,29 +1038,27 @@
elements.progress.style.display = 'block';
elements.downloadButtons.style.display = 'none';
if (isRestore && !tasks[taskId].intervals.status) {
startPolling(taskId);
if (isRestore && !card.intervals.status) {
startPolling(backendTaskId);
}
}
} catch (error) {
console.error(`[${taskId}] Error polling status:`, error);
console.error(`[${backendTaskId}] Error polling status:`, error);
elements.statusMessage.textContent = '状态更新出错。';
elements.statusMessage.className = 'status-message small text-danger';
}
}
// --- MODIFIED: Download and Preview ---
function setupPreview(taskId) {
const { state } = tasks[taskId];
// --- Download and Preview (No changes needed here, they use cardId to get state) ---
function setupPreview(cardId) {
const { state } = tasks[cardId];
if (!state.htmlUrl) return;
// Clear previous content
const existingOriginalContent = originalPreviewPane.querySelector('iframe, pre, p');
if (existingOriginalContent) existingOriginalContent.remove();
translatedPreviewFrame.src = 'about:blank';
// Render original file preview
if (state.file) {
const fileType = state.file.type;
const fileExtension = state.file.name.split('.').pop().toLowerCase();
@@ -1062,7 +1085,6 @@
originalPreviewPane.appendChild(p);
}
// Fetch and render translated HTML
fetch(state.htmlUrl)
.then(resp => resp.ok ? resp.text() : Promise.reject(`HTTP error ${resp.status}`))
.then(html => {
@@ -1085,8 +1107,8 @@
};
}
function downloadPdf(taskId) {
const { elements, state } = tasks[taskId];
function downloadPdf(cardId) {
const { elements, state } = tasks[cardId];
if (!state.htmlUrl) return;
elements.pdfBtn.disabled = true;
@@ -1119,73 +1141,50 @@
}
function setPreviewDisplayMode(mode) {
// Always destroy the previous split instance to avoid errors
if (previewSplitInstance) {
previewSplitInstance.destroy();
previewSplitInstance = null;
}
// Ensure containers are visible and reset styles before applying new ones
originalPreviewContainer.style.display = 'flex';
originalPreviewContainer.style.width = '';
translatedPreviewContainer.style.width = '';
if (mode === 'bilingual') {
previewOffcanvasLabel.textContent = '双语预览';
// Re-initialize Split.js for resizable panes
previewSplitInstance = Split(['#originalPreviewContainer', '#translatedPreviewContainer'], {
sizes: [50, 50],
minSize: 200, // Minimum pane size in pixels
gutterSize: 10,
cursor: 'col-resize',
sizes: [50, 50], minSize: 200, gutterSize: 10, cursor: 'col-resize',
});
// Update button states
setBilingualViewBtn.classList.add('btn-primary');
setBilingualViewBtn.classList.remove('btn-outline-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-primary');
setTranslatedOnlyViewBtn.classList.add('btn-outline-primary');
} else { // translationOnly
} else {
previewOffcanvasLabel.textContent = '译文预览';
// Hide original, make translated pane full-width
originalPreviewContainer.style.display = 'none';
translatedPreviewContainer.style.width = '100%';
// Update button states
setTranslatedOnlyViewBtn.classList.add('btn-primary');
setTranslatedOnlyViewBtn.classList.remove('btn-outline-primary');
setBilingualViewBtn.classList.remove('btn-primary');
setBilingualViewBtn.classList.add('btn-outline-primary');
}
}
// --- End of MODIFICATION ---
// --- Initialization ---
async function init() {
// Determine if in admin mode
isAdminMode = window.location.pathname === '/admin';
// Fetch metadata
try {
const [metaRes, enginRes, paramsRes] = await Promise.all([
fetch("/service/meta"),
fetch('/service/engin-list'),
fetch("/service/default-params")
fetch("/service/meta"), fetch('/service/engin-list'), fetch("/service/default-params")
]);
const meta = await metaRes.json();
versionDisplay.textContent = `v${meta.version}`;
const enginList = await enginRes.json();
Array.from(convertEnginSelect.options).forEach(option => {
if (!enginList.includes(option.value)) {
option.disabled = true;
option.textContent += " (不可用)";
option.disabled = true; option.textContent += " (不可用)";
}
});
defaultParams = await paramsRes.json();
} catch (error) {
console.error("Initialization failed:", error);
@@ -1193,7 +1192,6 @@
return;
}
// Load settings
platformSelect.value = getFromStorage('translator_last_platform', 'https://api.openai.com/v1');
updatePlatformUI();
convertEnginSelect.value = getFromStorage('translator_convert_engin', 'mineru');
@@ -1204,12 +1202,10 @@
refineCheckbox.checked = getFromStorage('translator_refine_markdown') === 'true';
customPromptTranslateArea.value = getFromStorage("custom_prompt_translate");
// Setup sliders
setupSlider(chunkSizeSlider, chunkSizeDisplay, chunkSizeReset, 'chunk_size', defaultParams);
setupSlider(concurrentSlider, concurrentDisplay, concurrentReset, 'concurrent', defaultParams);
setupSlider(temperatureSlider, temperatureDisplay, temperatureReset, 'temperature', defaultParams);
// Restore tasks based on mode
if (isAdminMode) {
document.title = "DocuTranslate - Admin Panel";
try {
@@ -1225,16 +1221,14 @@
}
updateTaskPlaceholderVisibility();
} else {
// Normal mode: Restore from localStorage
const savedTaskIds = JSON.parse(getFromStorage('active_task_ids', '[]'));
if (savedTaskIds.length > 0) {
savedTaskIds.forEach(taskId => createTaskCard(taskId, true));
} else {
createTaskCard(); // Create one new task by default
createTaskCard();
}
}
// Add event listeners for settings
platformSelect.addEventListener('change', updatePlatformUI);
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));
@@ -1253,53 +1247,14 @@
}
// --- Theme switcher logic ---
const getPreferredTheme = () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) {
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 getPreferredTheme = () => { const storedTheme = localStorage.getItem('theme'); if (storedTheme) { 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();
setTheme(preferredTheme);
showActiveTheme(preferredTheme);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
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);
});
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { 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 ---
document.addEventListener('DOMContentLoaded', init);