修改task_id生成逻辑
This commit is contained in:
@@ -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,6 +1017,7 @@ 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}")
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user