修复亮/暗模式切换bug

This commit is contained in:
xunbu
2025-07-14 17:41:40 +08:00
parent 47249c6dab
commit 215717b078
2 changed files with 270 additions and 211 deletions

View File

@@ -7,21 +7,21 @@ import socket
import time
from contextlib import asynccontextmanager, closing
from pathlib import Path
from typing import List, Dict, Any, Optional
from typing import List, Dict, Any, Optional, Literal
from urllib.parse import quote
import httpx
import uvicorn
from fastapi import FastAPI, File, Form, UploadFile, Request, HTTPException, Query, APIRouter, Body
from fastapi import FastAPI, HTTPException, APIRouter, Body, Path as FastApiPath
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field
from docutranslate import FileTranslater, __version__
from docutranslate.global_values import available_packages
from docutranslate.logger import translater_logger
from docutranslate.translater import default_params
from docutranslate.utils.resource_utils import resource_path
from docutranslate.global_values import available_packages
# --- 全局配置 ---
tasks_state: Dict[str, Dict[str, Any]] = {}
@@ -226,9 +226,40 @@ def _cancel_translation_logic(task_id: str):
# --- FastAPI 应用和路由设置 ---
app = FastAPI(lifespan=lifespan)
backend_router = APIRouter(prefix="/backend")
service_router = APIRouter(prefix="/service")
tags_metadata = [
{
"name": "Service API",
"description": "核心的服务API用于提交、管理和下载翻译任务。",
},
{
"name": "Application",
"description": "应用本身的相关端点,如元信息和默认参数。",
},
{
"name": "Temp",
"description": "测试用接口。",
},
]
app = FastAPI(
lifespan=lifespan,
title="DocuTranslate API",
description=f"""
DocuTranslate 后端服务 API提供文档翻译、状态查询、结果下载等功能。
### 主要工作流程:
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` 时),通过此端点下载结果文件。
**版本**: {__version__}
""",
version=__version__,
openapi_tags=tags_metadata,
)
service_router = APIRouter(prefix="/service", tags=["Service API"])
STATIC_DIR = resource_path("static")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@@ -238,30 +269,66 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
# --- Pydantic Models for Service API ---
# ===================================================================
class TranslateServiceRequest(BaseModel):
task_id: str = Field("0", description="任务ID用于跟踪默认为'0'")
base_url: str
apikey: str
model_id: str
to_lang: str = "中文"
formula_ocr: bool = False
code_ocr: bool = False
refine_markdown: bool = False
convert_engin: str
mineru_token: Optional[str] = None
chunk_size: int
concurrent: int
temperature: float
custom_prompt_translate: Optional[str] = None
file_name: str = Field(..., description="上传的原始文件名")
file_content: str = Field(..., description="Base64编码的文件内容")
task_id: str = Field(
"0",
description="任务的唯一标识符。用于后续跟踪任务状态和结果。默认为 '0'表示单个任务模式。建议为每个任务提供唯一的ID例如UUID。",
examples=["task-12345"]
)
base_url: str = Field(..., description="LLM API的基础URL。", examples=["https://api.openai.com/v1"])
apikey: str = Field(..., description="LLM API的密钥。注意请勿在不安全的环境中暴露此密钥。", examples=["sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxx"])
model_id: str = Field(..., description="使用的模型ID。", examples=["gpt-4-turbo"])
to_lang: str = Field("中文", description="目标翻译语言。", examples=["中文","英文","English"])
formula_ocr: bool = Field(False, description="是否对公式进行OCR识别。")
code_ocr: bool = Field(False, description="是否对代码块进行OCR识别。")
refine_markdown: bool = Field(False, description="是否使用ai对解析后的文档进行一遍优化现不推荐使用")
convert_engin: str = Field(..., description="文档解析和转换引擎,可选 'mineru''docling'", examples=["mineru"])
mineru_token: Optional[str] = Field(None, description="当使用 'mineru' 是必填。", examples=["token-abcdefg"])
chunk_size: int = Field(..., description="文本分块的大小。", examples=[2048])
concurrent: int = Field(..., description="并发请求的数量。", examples=[5])
temperature: float = Field(..., description="LLM的温度参数控制生成文本的随机性。", examples=[0.7])
custom_prompt_translate: Optional[str] = Field(None, description="用户自定义的翻译Prompt。", examples=["人名保持原文不翻译。"])
file_name: str = Field(..., description="上传的原始文件名,包含扩展名。", examples=["my_document.pdf"])
file_content: str = Field(..., description="Base64编码的文件内容。", examples=["JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PAovVHlwZS..."])
class Config:
json_schema_extra = {
"example": {
"task_id": "task-abc-123",
"base_url": "https://api.openai.com/v1",
"apikey": "sk-your-api-key",
"model_id": "gpt-4o",
"to_lang": "简体中文",
"formula_ocr": True,
"code_ocr": True,
"refine_markdown": False,
"convert_engin": "mineru",
"mineru_token": "your-mineru-token",
"chunk_size": 2048,
"concurrent": 5,
"temperature": 0.1,
"custom_prompt_translate": "Translate the following technical document into professional Chinese.",
"file_name": "example.pdf",
"file_content": "JVBERi0xLjQKJ..."
}
}
# ===================================================================
# --- Service Endpoints (/service) ---
# ===================================================================
@service_router.post("/translate", summary="提交翻译任务 (JSON/Base64)")
async def service_translate(request: TranslateServiceRequest = Body(...)):
@service_router.post(
"/translate",
summary="提交翻译任务 (Base64)",
description="""
接收一个包含文件内容Base64编码和翻译参数的JSON请求启动一个后台翻译任务。
- **异步处理**: 此端点会立即返回,不会等待翻译完成。
- **任务ID**: 成功启动后会返回任务ID (`task_id`)。
- **后续步骤**: 客户端应使用返回的 `task_id` 轮询 `/service/status/{task_id}` 接口来获取任务进度和结果。
"""
)
async def service_translate(request: TranslateServiceRequest = Body(..., description="翻译任务的详细参数和文件内容。")):
"""
提交一个文件进行翻译,并启动一个后台任务。
文件内容需以Base64编码。
@@ -272,7 +339,7 @@ async def service_translate(request: TranslateServiceRequest = Body(...)):
except (base64.binascii.Error, TypeError) as e:
raise HTTPException(status_code=400, detail=f"无效的Base64文件内容: {e}")
params = request.dict(exclude={'file_name', 'file_content', 'task_id'})
params = request.model_dump(exclude={'file_name', 'file_content', 'task_id'})
try:
response_data = await _start_translation_task(
task_id=request.task_id,
@@ -285,8 +352,12 @@ async def service_translate(request: TranslateServiceRequest = Body(...)):
return JSONResponse(status_code=e.status_code, content={"task_started": False, "message": e.detail})
@service_router.post("/cancel/{task_id}", summary="取消翻译任务")
async def service_cancel_translate(task_id: str):
@service_router.post(
"/cancel/{task_id}",
summary="取消翻译任务",
description="根据任务ID取消一个正在进行的翻译任务。这是一个异步操作发送取消请求后任务不会立即停止需要通过状态接口确认最终状态。"
)
async def service_cancel_translate(task_id: str = FastApiPath(..., description="要取消的任务的ID", example="task-12345")):
"""根据任务ID取消一个正在进行的翻译任务。"""
try:
response_data = _cancel_translation_logic(task_id)
@@ -295,8 +366,17 @@ async def service_cancel_translate(task_id: str):
return JSONResponse(status_code=e.status_code, content={"cancelled": False, "message": e.detail})
@service_router.get("/status/{task_id}", summary="获取任务状态")
async def service_get_status(task_id: str):
@service_router.get(
"/status/{task_id}",
summary="获取任务状态",
description="""
根据任务ID获取任务的当前状态。
- **轮询**: 此端点设计用于被客户端轮询,以监控后台任务进度。
- **结果下载**: 当 `download_ready` 字段为 `true` 时,`downloads` 对象中会包含可用的下载链接。
"""
)
async def service_get_status(task_id: str = FastApiPath(..., description="要查询状态的任务的ID", example="task-12345")):
"""根据任务ID获取任务的当前状态和结果下载链接。"""
task_state = tasks_state.get(task_id)
if not task_state:
@@ -322,8 +402,12 @@ async def service_get_status(task_id: str):
})
@service_router.get("/logs/{task_id}", summary="获取任务日志")
async def service_get_logs(task_id: str):
@service_router.get(
"/logs/{task_id}",
summary="获取任务增量日志",
description="获取指定任务ID自上次查询以来的新日志。这是一个非阻塞的轮询接口用于实时显示后台任务的日志输出。"
)
async def service_get_logs(task_id: str = FastApiPath(..., description="要获取日志的任务的ID", example="task-12345")):
"""获取指定任务ID自上次查询以来的新日志。"""
if task_id not in tasks_log_queues:
raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}' 的日志队列。")
@@ -338,8 +422,16 @@ async def service_get_logs(task_id: str):
return JSONResponse(content={"logs": new_logs})
@service_router.get("/download/{task_id}/{file_type}", summary="下载结果文件")
async def service_download_file(task_id: str, file_type: str):
FileType = Literal["markdown", "markdown_zip", "html"]
@service_router.get(
"/download/{task_id}/{file_type}",
summary="下载翻译结果文件",
description="根据任务ID和文件类型下载翻译结果。下载前请先通过状态接口确认 `download_ready` 为 `true`。"
)
async def service_download_file(
task_id: str = FastApiPath(..., description="已完成任务的ID", example="task-12345"),
file_type: FileType = FastApiPath(..., description="要下载的文件类型。", example="html")
):
"""根据任务ID和文件类型下载翻译结果。"""
task_state = tasks_state.get(task_id)
if not task_state: raise HTTPException(status_code=404, detail=f"找不到任务ID '{task_id}'")
@@ -362,139 +454,32 @@ async def service_download_file(task_id: str, file_type: str):
return StreamingResponse(io.BytesIO(content), media_type=media_type, headers=headers)
@service_router.get("/engin-list", summary="获取可用引擎列表")
@service_router.get("/engin-list", summary="获取可用解析引擎", tags=["Application"],
description="返回当前后端环境支持的文档解析引擎列表。前端可以根据此列表动态展示选项。")
async def service_get_engin_list():
engin_list = ["mineru"]
if available_packages.get("docling"): engin_list.append("docling")
return JSONResponse(content=engin_list)
@service_router.get("/task-list", summary="获取所有task-id")
@service_router.get("/task-list", summary="获取所有任务ID列表", tags=["Application"],
description="返回当前服务实例中存在的所有任务ID的列表。可用于管理或概览所有已创建的任务。")
async def service_get_task_list():
return JSONResponse(content=list(tasks_state.keys()))
@service_router.get("/default-params", summary="获取默认翻译参数")
@service_router.get("/default-params", summary="获取默认翻译参数", tags=["Application"],
description="返回一套默认的翻译参数,可用于填充前端表单的初始值。")
def service_get_default_params():
return JSONResponse(content=default_params)
@service_router.get("/meta", summary="获取应用版本信息")
@service_router.get("/meta", summary="获取应用信息", tags=["Application"],
description="返回应用程序的元数据,例如当前版本号。")
async def service_get_app_version():
return JSONResponse(content={"version": __version__})
# ===================================================================
# --- Backend Endpoints for Frontend (/backend) ---
# --- (Calls /service endpoints) ---
# ===================================================================
async def _proxy_request(request: Request, method: str, service_path: str, **kwargs):
"""Helper to proxy requests to the service layer."""
service_url = f"{str(request.base_url).rstrip('/')}{service_path}"
try:
response = await httpx_client.request(method, service_url, timeout=30.0, **kwargs)
response.raise_for_status()
if "application/json" in response.headers.get("content-type", ""):
return JSONResponse(content=response.json(), status_code=response.status_code)
# For streaming downloads
return StreamingResponse(response.aiter_bytes(), status_code=response.status_code, headers=response.headers)
except httpx.HTTPStatusError as e:
content = e.response.json() if "application/json" in e.response.headers.get("content-type", "") else {
"detail": e.response.text}
return JSONResponse(content=content, status_code=e.response.status_code)
except httpx.RequestError as e:
return JSONResponse(status_code=503, content={"detail": f"服务调用失败: {e}"})
@backend_router.post("/translate")
async def handle_translate_for_frontend(
request: Request, task_id: str = Form("0"), base_url: str = Form(...), apikey: str = Form(...),
model_id: str = Form(...), to_lang: str = Form("中文"), formula_ocr: bool = Form(False),
code_ocr: bool = Form(False), refine_markdown: bool = Form(False), convert_engin: str = Form(...),
mineru_token: Optional[str] = Form(None), chunk_size: int = Form(...), concurrent: int = Form(...),
temperature: float = Form(...), custom_prompt_translate: Optional[str] = Form(None),
file: UploadFile = File(...)
):
file_contents = await file.read()
await file.close()
file_base64 = base64.b64encode(file_contents).decode('utf-8')
payload = {
"task_id": task_id, "base_url": base_url, "apikey": apikey, "model_id": model_id,
"to_lang": to_lang, "formula_ocr": formula_ocr, "code_ocr": code_ocr,
"refine_markdown": refine_markdown, "convert_engin": convert_engin, "mineru_token": mineru_token,
"chunk_size": chunk_size, "concurrent": concurrent, "temperature": temperature,
"custom_prompt_translate": custom_prompt_translate, "file_name": file.filename, "file_content": file_base64,
}
return await _proxy_request(request, "POST", "/service/translate", json=payload)
@backend_router.post("/cancel-translate")
async def cancel_translate_for_frontend(request: Request, task_id: str = Form("0")):
return await _proxy_request(request, "POST", f"/service/cancel/{task_id}")
@backend_router.get("/get-status")
async def get_status_for_frontend(request: Request, task_id: str = Query("0")):
service_url = f"{str(request.base_url).rstrip('/')}/service/status/{task_id}"
try:
response = await httpx_client.get(service_url)
if response.status_code == 404:
return JSONResponse(content=_create_default_task_state()) # Return default state for UI
response.raise_for_status()
service_data = response.json()
except httpx.RequestError as e:
return JSONResponse(status_code=503, content={"detail": f"服务调用失败: {e}"})
# Adapt service response for the frontend (generate frontend-specific URLs)
filename_stem = service_data.get("original_filename_stem")
def generate_frontend_url(path_prefix, ext):
if service_data["download_ready"] and filename_stem:
return f"/backend/download/{path_prefix}/{filename_stem}_translated.{ext}?task_id={task_id}"
return None
return JSONResponse(content={
"is_processing": service_data["is_processing"], "status_message": service_data["status_message"],
"error_flag": service_data["error_flag"], "download_ready": service_data["download_ready"],
"original_filename_stem": filename_stem,
"markdown_url": generate_frontend_url("markdown", "md"),
"markdown_zip_url": generate_frontend_url("markdown_zip", "zip"),
"html_url": generate_frontend_url("html", "html"),
"task_start_time": service_data.get("task_start_time", 0),
"task_end_time": service_data.get("task_end_time", 0),
})
@backend_router.get("/download/{file_type}/{filename_with_ext}")
async def download_file_for_frontend(request: Request, file_type: str, filename_with_ext: str,
task_id: str = Query(...)):
# filename_with_ext is for vanity, real identifiers are task_id and file_type
return await _proxy_request(request, "GET", f"/service/download/{task_id}/{file_type}")
@backend_router.get("/get-logs")
async def get_logs_from_queue_for_frontend(request: Request, task_id: str = Query("0")):
return await _proxy_request(request, "GET", f"/service/logs/{task_id}")
@backend_router.get("/get-engin-list")
async def get_engin_list_for_frontend(request: Request):
return await _proxy_request(request, "GET", "/service/engin-list")
@backend_router.get("/get-task-list")
async def get_task_list_for_frontend(request: Request):
return await _proxy_request(request, "GET", "/service/task-list")
@backend_router.get("/translate/default_param")
async def get_default_param_for_frontend(request: Request):
return await _proxy_request(request, "GET", "/service/default-params")
@backend_router.get("/meta")
async def get_app_version_for_frontend(request: Request):
return await _proxy_request(request, "GET", "/service/meta")
# ===================================================================
# --- 应用主路由和启动 ---
# ===================================================================
@@ -506,6 +491,8 @@ async def main_page():
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
"Expires": "0"}
return FileResponse(index_path, headers=no_cache_headers)
@app.get("/admin", response_class=HTMLResponse, include_in_schema=False)
async def main_page_admin():
index_path = Path(STATIC_DIR) / "index.html"
@@ -513,23 +500,28 @@ async def main_page_admin():
no_cache_headers = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0", "Pragma": "no-cache",
"Expires": "0"}
return FileResponse(index_path, headers=no_cache_headers)
@app.post("/temp/translate")
async def temp_translate(base_url: str = Body(...),
api_key: str = Body(...),
model_id: str = Body(...),
mineru_token: str = Body(...),
file_name: str = Body(...),
file_content: str = Body(...),
to_lang: str = Body("中文")
@app.post("/temp/translate",
summary="[内部] 临时同步翻译接口",
description="一个简单的、同步的翻译接口,用于快速测试。不涉及后台任务、状态管理或多格式输出。**不建议在生产环境中使用。**",
tags=["Internal / UI"])
async def temp_translate(base_url: str = Body(..., description="LLM API的基础URL。", example="https://api.openai.com/v1"),
api_key: str = Body(..., description="LLM API的密钥。", example="sk-xxxxxxxxxx"),
model_id: str = Body(..., description="使用的模型ID。", example="gpt-4-turbo"),
mineru_token: str = Body(..., description="Mineru引擎的Token。"),
file_name: str = Body(..., description="原始文件名。", example="test.txt"),
file_content: str = Body(..., description="文件内容可以是纯文本或Base64编码的字符串。"),
to_lang: str = Body("中文", description="目标语言。")
):
def is_base64(s):
# 尝试解码验证
try:
base64.b64decode(s)
return True
except Exception:
return False
ft=FileTranslater(base_url=base_url,
ft = FileTranslater(base_url=base_url,
key=api_key,
model_id=model_id,
mineru_token=mineru_token,
@@ -537,17 +529,16 @@ async def temp_translate(base_url: str = Body(...),
try:
if is_base64(file_content):
await ft.translate_bytes_async(name=file_name,file=base64.b64decode(file_content),to_lang=to_lang,save=False)
await ft.translate_bytes_async(name=file_name, file=base64.b64decode(file_content), to_lang=to_lang,
save=False)
else:
await ft.translate_bytes_async(name=file_name,file=file_content.encode(),to_lang=to_lang,save=False)
return {"success":True,"content":ft.export_to_markdown()}
await ft.translate_bytes_async(name=file_name, file=file_content.encode(), to_lang=to_lang, save=False)
return {"success": True, "content": ft.export_to_markdown()}
except Exception as e:
print(f"翻译出现错误:{e.__repr__()}")
return {"success":False,"reason":{e.__repr__()}}
return {"success": False, "reason": {e.__repr__()}}
# 包含两个路由组
app.include_router(backend_router)
app.include_router(service_router)

View File

@@ -135,17 +135,17 @@
<!-- Parsing Engine Settings -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingOne">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="true" aria-controls="collapseOne">
<strong><i class="bi bi-file-earmark-binary me-2"></i>解析引擎</strong>
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseOne" aria-expanded="false" aria-controls="collapseOne">
<strong><i class="bi bi-file-earmark-binary me-2"></i>解析配置</strong>
</button>
</h2>
<div id="collapseOne" class="accordion-collapse collapse show" aria-labelledby="headingOne">
<div id="collapseOne" class="accordion-collapse collapse" aria-labelledby="headingOne">
<div class="accordion-body">
<div class="mb-3">
<label for="convert_engin" class="form-label">解析引擎</label>
<select class="form-select" id="convert_engin" name="convert_engin">
<option value="mineru">minerU (推荐)</option>
<option value="docling">Docling (本地翻译)</option>
<option value="docling">Docling (本地解析)</option>
</select>
</div>
<div class="mb-3" id="mineruTokenGroup">
@@ -162,11 +162,11 @@
<!-- AI Settings -->
<div class="accordion-item">
<h2 class="accordion-header" id="headingTwo">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="true" aria-controls="collapseTwo">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseTwo" aria-expanded="false" aria-controls="collapseTwo">
<strong><i class="bi bi-robot me-2"></i>翻译模型</strong>
</button>
</h2>
<div id="collapseTwo" class="accordion-collapse collapse show" aria-labelledby="headingTwo">
<div id="collapseTwo" class="accordion-collapse collapse" aria-labelledby="headingTwo">
<div class="accordion-body">
<div class="mb-3">
<label for="platform_select" class="form-label">选择平台</label>
@@ -472,6 +472,19 @@
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; } };
function fileToBase64(file) {
return new Promise((resolve, reject) => {
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);
};
reader.onerror = error => reject(error);
});
}
// --- UI Update Functions ---
function updatePlatformUI() {
const selectedPlatformValue = platformSelect.value;
@@ -690,17 +703,38 @@
elements.startBtn.disabled = true;
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 初始化...`;
elements.logArea.innerHTML = '';
elements.statusMessage.textContent = '正在提交任务...';
elements.statusMessage.textContent = '正在编码文件并提交任务...';
elements.statusMessage.className = 'status-message small text-muted';
elements.downloadButtons.style.display = 'none';
elements.progress.style.display = 'block';
const formData = new FormData(settingsForm);
formData.append('file', state.file);
formData.append('task_id', taskId);
try {
const response = await fetch('backend/translate', { method: 'POST', body: formData });
const fileContentBase64 = await fileToBase64(state.file);
const payload = {
task_id: taskId,
base_url: baseUrlInput.value,
apikey: apikeyInput.value,
model_id: modelInput.value,
to_lang: toLangSelect.value,
formula_ocr: formulaCheckbox.checked,
code_ocr: codeCheckbox.checked,
refine_markdown: refineCheckbox.checked,
convert_engin: convertEnginSelect.value,
mineru_token: mineruTokenInput.value || null,
chunk_size: parseInt(chunkSizeSlider.value, 10),
concurrent: parseInt(concurrentSlider.value, 10),
temperature: parseFloat(temperatureSlider.value),
custom_prompt_translate: customPromptTranslateArea.value || null,
file_name: state.file.name,
file_content: fileContentBase64
};
const response = await fetch('/service/translate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await response.json();
if (response.ok && result.task_started) {
@@ -731,9 +765,7 @@
elements.startBtn.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 正在取消...`;
try {
const formData = new FormData();
formData.append('task_id', taskId);
const response = await fetch('backend/cancel-translate', { method: 'POST', body: formData });
const response = await fetch(`/service/cancel/${taskId}`, { method: 'POST' });
const result = await response.json();
if (response.ok && result.cancelled) {
@@ -771,7 +803,7 @@
async function pollLogs(taskId) {
const { elements } = tasks[taskId];
try {
const response = await fetch(`backend/get-logs?task_id=${taskId}`);
const response = await fetch(`/service/logs/${taskId}`);
if (!response.ok) return;
const data = await response.json();
if (data.logs && data.logs.length > 0) {
@@ -786,7 +818,7 @@
async function pollStatus(taskId, isRestore = false) {
const { elements, state } = tasks[taskId];
try {
const response = await fetch(`backend/get-status?task_id=${taskId}`);
const response = await fetch(`/service/status/${taskId}`);
if (!response.ok) {
// If 404 on restore, it means the task is old and gone from server. Remove it.
if (response.status === 404 && isRestore) {
@@ -810,12 +842,13 @@
if (status.download_ready && !status.error_flag) {
elements.statusMessage.className = 'status-message small text-success';
state.htmlUrl = status.html_url;
// Use download URLs directly from the service response
state.htmlUrl = status.downloads.html;
state.fileNameStem = status.original_filename_stem;
elements.htmlLink.href = status.html_url;
elements.mdLink.href = status.markdown_url;
elements.mdZipLink.href = status.markdown_zip_url;
elements.htmlLink.href = status.downloads.html;
elements.mdLink.href = status.downloads.markdown;
elements.mdZipLink.href = status.downloads.markdown_zip;
elements.previewBtn.onclick = () => setupPreview(taskId);
elements.pdfBtn.onclick = () => downloadPdf(taskId);
@@ -963,9 +996,9 @@
// Fetch metadata
try {
const [metaRes, enginRes, paramsRes] = await Promise.all([
fetch("backend/meta"),
fetch('backend/get-engin-list'),
fetch("backend/translate/default_param")
fetch("/service/meta"),
fetch('/service/engin-list'),
fetch("/service/default-params")
]);
const meta = await metaRes.json();
versionDisplay.textContent = `v${meta.version}`;
@@ -1005,7 +1038,7 @@
if (isAdminMode) {
document.title = "DocuTranslate - Admin Panel";
try {
const response = await fetch('/backend/get-task-list');
const response = await fetch('/service/task-list');
if (!response.ok) throw new Error(`Failed to fetch task list: ${response.statusText}`);
const allTaskIds = await response.json();
if (allTaskIds && Array.isArray(allTaskIds) && allTaskIds.length > 0) {
@@ -1044,28 +1077,63 @@
setTranslatedOnlyViewBtn.addEventListener('click', () => setPreviewDisplayMode('translationOnly'));
}
// Theme switcher logic
// --- MODIFIED: Theme switcher logic ---
const getPreferredTheme = () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme) return storedTheme;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
if (storedTheme) {
return storedTheme;
}
// Default to 'auto' if no preference is stored
return 'auto';
};
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark');
if (theme === 'auto') {
// Set theme based on system preference
document.documentElement.setAttribute('data-bs-theme', window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
} else {
// Set theme based on user's choice (light/dark)
document.documentElement.setAttribute('data-bs-theme', theme);
}
};
setTheme(getPreferredTheme());
const showActiveTheme = (theme) => {
// Remove active class from all buttons
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active');
});
// Add active class to the button corresponding to the current theme
const activeButton = document.querySelector(`[data-bs-theme-value="${theme}"]`);
if (activeButton) {
activeButton.classList.add('active');
}
};
// On page load, set theme and update UI
const preferredTheme = getPreferredTheme();
setTheme(preferredTheme);
showActiveTheme(preferredTheme);
// When system theme changes, update if in 'auto' mode
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = localStorage.getItem('theme');
if (storedTheme === 'auto' || !storedTheme) {
setTheme('auto');
}
});
// Add click listeners to theme switch buttons
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);
</script>