mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
feat: add backend i18n response support
This commit is contained in:
@@ -34,6 +34,7 @@ from app.db.models.agentchat import AgentChat
|
||||
from app.db.user_oper import UserOper, get_current_active_user
|
||||
from app.helper.agent import attach_web_agent_edit_queue, detach_web_agent_edit_queue
|
||||
from app.helper.interaction import agent_interaction_manager, media_interaction_manager
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, MessageChannel
|
||||
|
||||
@@ -326,15 +327,25 @@ def _save_web_agent_display_snapshot(
|
||||
logger.debug(f"保存WebAgent展示历史失败: {e}")
|
||||
|
||||
|
||||
def _build_web_agent_sse(event_type: str, data: Optional[dict] = None) -> str:
|
||||
def _build_web_agent_sse(
|
||||
event_type: str,
|
||||
data: Optional[dict] = None,
|
||||
locale: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
构建 Web Agent SSE 消息。
|
||||
|
||||
:param event_type: 前端事件类型
|
||||
:param data: 事件数据
|
||||
:param locale: 当前请求语言
|
||||
:return: 符合 SSE 格式的字符串
|
||||
"""
|
||||
payload = {"type": event_type, **(data or {})}
|
||||
message = payload.get("message")
|
||||
if event_type == "error" and isinstance(message, str):
|
||||
payload["message_i18n"] = LocaleHelper.translate_text(
|
||||
message, locale=locale
|
||||
)
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
@@ -1597,6 +1608,7 @@ async def web_agent_stream(
|
||||
:return: SSE 流式响应
|
||||
"""
|
||||
prompt = payload.text.strip()
|
||||
locale = LocaleHelper.get_locale_from_request(request)
|
||||
display_prompt = (payload.display_text or payload.text).strip()
|
||||
is_traditional_message = (
|
||||
_is_web_agent_traditional_message(prompt)
|
||||
@@ -1610,6 +1622,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": denied_message},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1621,6 +1634,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": unknown_command_message},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1649,7 +1663,11 @@ async def web_agent_stream(
|
||||
"""
|
||||
生成传统消息链路的 WebAgent SSE 事件。
|
||||
"""
|
||||
yield _build_web_agent_sse("start", {"session_id": session_id})
|
||||
yield _build_web_agent_sse(
|
||||
"start",
|
||||
{"session_id": session_id},
|
||||
locale=locale,
|
||||
)
|
||||
events = await _collect_web_agent_traditional_events(
|
||||
text=prompt,
|
||||
current_user=current_user,
|
||||
@@ -1660,7 +1678,11 @@ async def web_agent_stream(
|
||||
display_messages.append(assistant_message)
|
||||
for event in events:
|
||||
event_payload = copy.deepcopy(event)
|
||||
yield _build_web_agent_sse(event_payload.pop("type"), event_payload)
|
||||
yield _build_web_agent_sse(
|
||||
event_payload.pop("type"),
|
||||
event_payload,
|
||||
locale=locale,
|
||||
)
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
await run_in_threadpool(
|
||||
@@ -1670,7 +1692,7 @@ async def web_agent_stream(
|
||||
messages=display_messages,
|
||||
client_session_id=payload.session_id or session_id,
|
||||
)
|
||||
yield _build_web_agent_sse("done", {})
|
||||
yield _build_web_agent_sse("done", {}, locale=locale)
|
||||
|
||||
return StreamingResponse(
|
||||
traditional_event_generator(),
|
||||
@@ -1688,6 +1710,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "智能助手未启用,请先在系统设置中开启。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1703,6 +1726,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "语音识别失败,请稍后重试。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1713,6 +1737,7 @@ async def web_agent_stream(
|
||||
_build_web_agent_sse(
|
||||
"error",
|
||||
{"message": "请输入要发送给智能助手的内容或选择附件。"},
|
||||
locale=locale,
|
||||
)
|
||||
]),
|
||||
media_type="text/event-stream",
|
||||
@@ -1825,6 +1850,7 @@ async def web_agent_stream(
|
||||
yield _build_web_agent_sse(
|
||||
"start",
|
||||
{"session_id": session_id},
|
||||
locale=locale,
|
||||
)
|
||||
disconnected = False
|
||||
while not global_vars.is_system_stopped:
|
||||
@@ -1832,7 +1858,11 @@ async def web_agent_stream(
|
||||
disconnected = True
|
||||
break
|
||||
event = await event_queue.get()
|
||||
yield _build_web_agent_sse(event.pop("type"), event)
|
||||
yield _build_web_agent_sse(
|
||||
event.pop("type"),
|
||||
event,
|
||||
locale=locale,
|
||||
)
|
||||
if task.done() and event_queue.empty():
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_resource_token, verify_token
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaRecognizeConvertEventData
|
||||
from app.schemas.types import MediaType, ChainEventType
|
||||
@@ -39,11 +40,22 @@ def _parse_media_type(mtype: Optional[str]) -> Optional[MediaType]:
|
||||
return MediaType.from_agent(mtype) or MediaType(mtype)
|
||||
|
||||
|
||||
def _sse_event(data: dict) -> str:
|
||||
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
|
||||
"""
|
||||
转换为SSE事件
|
||||
"""
|
||||
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||
payload = data
|
||||
message = payload.get("message")
|
||||
text = payload.get("text")
|
||||
if isinstance(message, str) or isinstance(text, str):
|
||||
payload = data.copy()
|
||||
if isinstance(message, str):
|
||||
payload["message_i18n"] = LocaleHelper.translate_text(
|
||||
message, locale=locale
|
||||
)
|
||||
if isinstance(text, str):
|
||||
payload["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
|
||||
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
|
||||
|
||||
|
||||
def _serialize_signed_subtitle_result(subtitle: Any) -> dict:
|
||||
@@ -167,6 +179,7 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
|
||||
"""
|
||||
输出搜索SSE事件
|
||||
"""
|
||||
locale = LocaleHelper.get_locale_from_request(request)
|
||||
try:
|
||||
has_sent_final_replace = False
|
||||
async for event in _iter_batched_search_events(event_source):
|
||||
@@ -182,10 +195,13 @@ async def _stream_search_events(request: Request, event_source: AsyncIterator[di
|
||||
and event.get("items")
|
||||
):
|
||||
event = {key: value for key, value in event.items() if key != "items"}
|
||||
yield _sse_event(event)
|
||||
yield _sse_event(event, locale=locale)
|
||||
except Exception as err:
|
||||
logger.error(f"渐进式搜索出错:{err}", exc_info=True)
|
||||
yield _sse_event({"type": "error", "success": False, "message": str(err)})
|
||||
yield _sse_event(
|
||||
{"type": "error", "success": False, "message": str(err)},
|
||||
locale=locale,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/last", summary="查询搜索结果", response_model=List[schemas.Context])
|
||||
|
||||
@@ -35,10 +35,11 @@ from app.db.user_oper import (
|
||||
get_current_active_user_async,
|
||||
)
|
||||
from app.helper.image import ImageHelper
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.helper.message import MessageHelper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.helper.rule import RuleHelper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.system import SystemHelper
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
@@ -797,13 +798,14 @@ async def get_progress(
|
||||
实时获取处理进度,返回格式为SSE
|
||||
"""
|
||||
progress = ProgressHelper(process_type)
|
||||
locale = LocaleHelper.get_current_locale()
|
||||
|
||||
async def event_generator():
|
||||
try:
|
||||
while not global_vars.is_system_stopped:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
detail = progress.get()
|
||||
detail = progress.get(locale=locale)
|
||||
yield f"data: {json.dumps(detail)}\n\n"
|
||||
await asyncio.sleep(0.5)
|
||||
except asyncio.CancelledError:
|
||||
@@ -1271,13 +1273,20 @@ def modulelist(_: schemas.TokenPayload = Depends(verify_token)):
|
||||
"""
|
||||
查询已加载的模块ID列表
|
||||
"""
|
||||
modules = [
|
||||
{
|
||||
"id": k,
|
||||
"name": v.get_name(),
|
||||
}
|
||||
for k, v in ModuleManager().get_modules().items()
|
||||
]
|
||||
modules = []
|
||||
for module_id, module in ModuleManager().get_modules().items():
|
||||
name = module.get_name()
|
||||
modules.append(
|
||||
{
|
||||
"id": module_id,
|
||||
"name": name,
|
||||
"name_i18n": LocaleHelper.translate(
|
||||
f"system.modules.{module_id}.name",
|
||||
default=name,
|
||||
),
|
||||
"name_key": f"system.modules.{module_id}.name",
|
||||
}
|
||||
)
|
||||
return schemas.Response(success=True, data={"modules": modules})
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
from fastapi import FastAPI
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.startup.lifecycle import lifespan
|
||||
|
||||
|
||||
async def localized_http_exception_handler(
|
||||
_request: Request,
|
||||
exc: HTTPException,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
为 HTTPException 响应补充多语言错误详情。
|
||||
|
||||
:param _request: 当前 HTTP 请求
|
||||
:param exc: FastAPI HTTP 异常
|
||||
:return: 带 detail_i18n 的 JSON 错误响应
|
||||
"""
|
||||
content = {"detail": exc.detail}
|
||||
if isinstance(exc.detail, str):
|
||||
content["detail_i18n"] = LocaleHelper.translate_text(exc.detail)
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content=content,
|
||||
headers=exc.headers,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
创建并配置 FastAPI 应用实例。
|
||||
@@ -15,6 +40,8 @@ def create_app() -> FastAPI:
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
_app.add_exception_handler(HTTPException, localized_http_exception_handler)
|
||||
|
||||
# 配置 CORS 中间件
|
||||
_app.add_middleware(
|
||||
CORSMiddleware, # noqa
|
||||
@@ -24,6 +51,22 @@ def create_app() -> FastAPI:
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@_app.middleware("http")
|
||||
async def locale_context_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""
|
||||
为每个请求设置后端多语言上下文。
|
||||
"""
|
||||
token = LocaleHelper.set_current_locale(
|
||||
LocaleHelper.get_locale_from_request(request)
|
||||
)
|
||||
try:
|
||||
return await call_next(request)
|
||||
finally:
|
||||
LocaleHelper.reset_current_locale(token)
|
||||
|
||||
return _app
|
||||
|
||||
|
||||
|
||||
280
app/helper/locale.py
Normal file
280
app/helper/locale.py
Normal file
@@ -0,0 +1,280 @@
|
||||
import json
|
||||
import re
|
||||
from contextvars import ContextVar, Token
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class LocaleHelper:
|
||||
"""
|
||||
后端多语言文本辅助器。
|
||||
|
||||
该类只为需要返回给前端展示的文本生成并行多语言字段,旧有中文字段仍由调用方保留。
|
||||
"""
|
||||
|
||||
DEFAULT_LOCALE = "zh-CN"
|
||||
SUPPORTED_LOCALES = ("zh-CN", "zh-TW", "en-US")
|
||||
HEADER_NAMES = ("x-moviepilot-locale", "x-locale")
|
||||
_PATTERN_FIELD = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_CURRENT_LOCALE: ContextVar[str] = ContextVar("moviepilot_locale", default=DEFAULT_LOCALE)
|
||||
_LOCALES_DIR = Path(__file__).resolve().parents[1] / "locales"
|
||||
_LOCALE_ALIASES = {
|
||||
"zh": "zh-CN",
|
||||
"zh-cn": "zh-CN",
|
||||
"zh-hans": "zh-CN",
|
||||
"zh-hans-cn": "zh-CN",
|
||||
"zh-tw": "zh-TW",
|
||||
"zh-hant": "zh-TW",
|
||||
"zh-hant-tw": "zh-TW",
|
||||
"en": "en-US",
|
||||
"en-us": "en-US",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def normalize_locale(cls, locale: Optional[str]) -> str:
|
||||
"""
|
||||
规范化语言标识,无法识别时返回默认简体中文。
|
||||
|
||||
:param locale: 原始语言标识,如 zh-CN、zh_CN、en-US
|
||||
:return: 项目支持的语言标识
|
||||
"""
|
||||
return cls._match_locale(locale) or cls.DEFAULT_LOCALE
|
||||
|
||||
@classmethod
|
||||
def get_locale_from_request(cls, request: Any) -> str:
|
||||
"""
|
||||
从请求参数或请求头解析前端期望语言。
|
||||
|
||||
:param request: FastAPI Request 或带 headers 属性的兼容对象
|
||||
:return: 项目支持的语言标识
|
||||
"""
|
||||
query_params = getattr(request, "query_params", {}) or {}
|
||||
query_locale = query_params.get("locale") if hasattr(query_params, "get") else None
|
||||
if query_locale:
|
||||
return cls.normalize_locale(query_locale)
|
||||
|
||||
headers = getattr(request, "headers", {}) or {}
|
||||
for header_name in cls.HEADER_NAMES:
|
||||
value = headers.get(header_name)
|
||||
if value:
|
||||
return cls.normalize_locale(value)
|
||||
|
||||
accept_language = headers.get("accept-language")
|
||||
if not accept_language:
|
||||
return cls.DEFAULT_LOCALE
|
||||
|
||||
choices = []
|
||||
for index, item in enumerate(accept_language.split(",")):
|
||||
parts = [part.strip() for part in item.split(";") if part.strip()]
|
||||
if not parts:
|
||||
continue
|
||||
quality = 1.0
|
||||
for part in parts[1:]:
|
||||
if part.startswith("q="):
|
||||
try:
|
||||
quality = float(part[2:])
|
||||
except ValueError:
|
||||
quality = 0.0
|
||||
choices.append((-quality, index, parts[0]))
|
||||
|
||||
for _, _, candidate in sorted(choices):
|
||||
locale = cls._match_locale(candidate)
|
||||
if locale:
|
||||
return locale
|
||||
return cls.DEFAULT_LOCALE
|
||||
|
||||
@classmethod
|
||||
def get_current_locale(cls) -> str:
|
||||
"""
|
||||
获取当前请求上下文中的语言标识。
|
||||
|
||||
:return: 项目支持的语言标识
|
||||
"""
|
||||
return cls._CURRENT_LOCALE.get()
|
||||
|
||||
@classmethod
|
||||
def set_current_locale(cls, locale: Optional[str]) -> Token[str]:
|
||||
"""
|
||||
设置当前请求上下文中的语言标识。
|
||||
|
||||
:param locale: 原始语言标识
|
||||
:return: 用于恢复上下文的令牌
|
||||
"""
|
||||
return cls._CURRENT_LOCALE.set(cls.normalize_locale(locale))
|
||||
|
||||
@classmethod
|
||||
def reset_current_locale(cls, token: Token[str]) -> None:
|
||||
"""
|
||||
恢复当前请求上下文中的语言标识。
|
||||
|
||||
:param token: set_current_locale 返回的上下文令牌
|
||||
"""
|
||||
cls._CURRENT_LOCALE.reset(token)
|
||||
|
||||
@classmethod
|
||||
def translate(
|
||||
cls,
|
||||
key: str,
|
||||
locale: Optional[str] = None,
|
||||
default: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""
|
||||
根据翻译键获取多语言文本。
|
||||
|
||||
:param key: 点分隔翻译键
|
||||
:param locale: 目标语言,未传入或无法识别时使用默认语言
|
||||
:param default: 翻译缺失时返回的默认文本
|
||||
:param kwargs: 字符串格式化参数
|
||||
:return: 翻译后的文本
|
||||
"""
|
||||
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
|
||||
template = cls._lookup(cls._load_catalog(normalized_locale), key)
|
||||
if template is None and normalized_locale != cls.DEFAULT_LOCALE:
|
||||
template = cls._lookup(cls._load_catalog(cls.DEFAULT_LOCALE), key)
|
||||
if template is None:
|
||||
template = default or key
|
||||
return cls._format(template, kwargs)
|
||||
|
||||
@classmethod
|
||||
def translate_text(cls, text: Optional[str], locale: Optional[str] = None) -> str:
|
||||
"""
|
||||
翻译存量接口返回的中文文本。
|
||||
|
||||
:param text: 原始中文文本
|
||||
:param locale: 目标语言,未传入或无法识别时使用默认语言
|
||||
:return: 翻译后的文本,缺失翻译时返回原文
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
normalized_locale = cls.normalize_locale(locale) if locale else cls.get_current_locale()
|
||||
translated = cls._lookup_message(cls._load_catalog(normalized_locale), text)
|
||||
if translated is None and cls._contains_chinese(text):
|
||||
translated = cls._lookup_pattern(normalized_locale, text)
|
||||
if translated is None and normalized_locale != cls.DEFAULT_LOCALE:
|
||||
translated = cls._lookup_message(cls._load_catalog(cls.DEFAULT_LOCALE), text)
|
||||
if (
|
||||
translated is None
|
||||
and normalized_locale != cls.DEFAULT_LOCALE
|
||||
and cls._contains_chinese(text)
|
||||
):
|
||||
translated = cls._lookup_pattern(cls.DEFAULT_LOCALE, text)
|
||||
return translated or text
|
||||
|
||||
@classmethod
|
||||
def _match_locale(cls, locale: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
将原始语言标识匹配为项目支持的语言。
|
||||
"""
|
||||
if not locale:
|
||||
return None
|
||||
normalized = locale.strip().replace("_", "-").lower()
|
||||
if not normalized:
|
||||
return None
|
||||
return cls._LOCALE_ALIASES.get(normalized)
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=16)
|
||||
def _load_catalog(locale: str) -> dict[str, Any]:
|
||||
"""
|
||||
加载指定语言的翻译表。
|
||||
"""
|
||||
catalog_path = LocaleHelper._LOCALES_DIR / f"{locale}.json"
|
||||
try:
|
||||
with catalog_path.open("r", encoding="utf-8") as file:
|
||||
return json.load(file)
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _lookup(catalog: dict[str, Any], key: str) -> Optional[str]:
|
||||
"""
|
||||
按点分隔键从结构化翻译表中查找文本。
|
||||
"""
|
||||
current: Any = catalog
|
||||
for part in key.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return None
|
||||
current = current[part]
|
||||
return current if isinstance(current, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _lookup_message(catalog: dict[str, Any], text: str) -> Optional[str]:
|
||||
"""
|
||||
从精确消息表中查找存量中文文本。
|
||||
"""
|
||||
messages = catalog.get("messages")
|
||||
if not isinstance(messages, dict):
|
||||
return None
|
||||
translated = messages.get(text)
|
||||
return translated if isinstance(translated, str) else None
|
||||
|
||||
@classmethod
|
||||
def _lookup_pattern(cls, locale: str, text: str) -> Optional[str]:
|
||||
"""
|
||||
使用动态模板匹配存量中文文本。
|
||||
"""
|
||||
for pattern, target in cls._load_pattern_matchers(locale):
|
||||
matched = pattern.fullmatch(text)
|
||||
if matched:
|
||||
return cls._format(target, matched.groupdict())
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=16)
|
||||
def _load_pattern_matchers(locale: str) -> list[tuple[re.Pattern[str], str]]:
|
||||
"""
|
||||
加载并缓存指定语言的动态文本匹配器。
|
||||
"""
|
||||
catalog = LocaleHelper._load_catalog(locale)
|
||||
patterns = catalog.get("message_patterns")
|
||||
if not isinstance(patterns, list):
|
||||
return []
|
||||
matchers = []
|
||||
for item in patterns:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
source = item.get("source")
|
||||
target = item.get("target")
|
||||
if not isinstance(source, str) or not isinstance(target, str):
|
||||
continue
|
||||
pattern = LocaleHelper._compile_pattern(source)
|
||||
if pattern is None:
|
||||
continue
|
||||
matchers.append((pattern, target))
|
||||
return matchers
|
||||
|
||||
@classmethod
|
||||
def _compile_pattern(cls, source: str) -> Optional[re.Pattern[str]]:
|
||||
"""
|
||||
将带命名占位符的中文模板编译为正则。
|
||||
"""
|
||||
field_names = cls._PATTERN_FIELD.findall(source)
|
||||
if not field_names:
|
||||
return None
|
||||
|
||||
pattern = cls._PATTERN_FIELD.sub(
|
||||
lambda match: f"(?P<{match.group(1)}>.+?)",
|
||||
re.escape(source).replace(r"\{", "{").replace(r"\}", "}"),
|
||||
)
|
||||
return re.compile(pattern)
|
||||
|
||||
@staticmethod
|
||||
def _contains_chinese(text: str) -> bool:
|
||||
"""
|
||||
判断文本是否包含中文字符。
|
||||
"""
|
||||
return any("\u4e00" <= char <= "\u9fff" for char in text)
|
||||
|
||||
@staticmethod
|
||||
def _format(template: str, kwargs: dict[str, Any]) -> str:
|
||||
"""
|
||||
格式化翻译模板,参数缺失时保留模板原文。
|
||||
"""
|
||||
if not kwargs:
|
||||
return template
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except (KeyError, AttributeError, IndexError):
|
||||
return template
|
||||
@@ -1,7 +1,8 @@
|
||||
from enum import Enum
|
||||
from typing import Union, Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from app.core.cache import TTLCache
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.schemas.types import ProgressKey
|
||||
|
||||
|
||||
@@ -82,8 +83,34 @@ class ProgressHelper:
|
||||
current['data'].update(data)
|
||||
self._progress[self._key] = current
|
||||
|
||||
def get(self) -> Optional[dict]:
|
||||
def get(self, locale: Optional[str] = None) -> Optional[dict]:
|
||||
"""
|
||||
获取当前进度
|
||||
获取当前进度,并按语言补充前端展示字段。
|
||||
|
||||
:param locale: 目标语言,未传入时使用当前请求上下文语言
|
||||
:return: 当前进度字典
|
||||
"""
|
||||
return self._progress.get(self._key)
|
||||
current = self._progress.get(self._key)
|
||||
if not current:
|
||||
return current
|
||||
|
||||
detail = current.copy()
|
||||
text = detail.get("text")
|
||||
if isinstance(text, str):
|
||||
detail["text_i18n"] = LocaleHelper.translate_text(text, locale=locale)
|
||||
|
||||
data = detail.get("data")
|
||||
if isinstance(data, dict):
|
||||
localized_data = data.copy()
|
||||
error = localized_data.get("error")
|
||||
message = localized_data.get("message")
|
||||
if isinstance(error, str):
|
||||
localized_data["error_i18n"] = LocaleHelper.translate_text(
|
||||
error, locale=locale
|
||||
)
|
||||
if isinstance(message, str):
|
||||
localized_data["message_i18n"] = LocaleHelper.translate_text(
|
||||
message, locale=locale
|
||||
)
|
||||
detail["data"] = localized_data
|
||||
return detail
|
||||
|
||||
1303
app/locales/en-US.json
Normal file
1303
app/locales/en-US.json
Normal file
File diff suppressed because it is too large
Load Diff
186
app/locales/zh-CN.json
Normal file
186
app/locales/zh-CN.json
Normal file
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"system": {
|
||||
"modules": {
|
||||
"BangumiModule": {
|
||||
"name": "Bangumi"
|
||||
},
|
||||
"DiscordModule": {
|
||||
"name": "Discord"
|
||||
},
|
||||
"DoubanModule": {
|
||||
"name": "豆瓣"
|
||||
},
|
||||
"EmbyModule": {
|
||||
"name": "Emby"
|
||||
},
|
||||
"FanartModule": {
|
||||
"name": "Fanart"
|
||||
},
|
||||
"FeishuModule": {
|
||||
"name": "飞书"
|
||||
},
|
||||
"FileManagerModule": {
|
||||
"name": "文件整理"
|
||||
},
|
||||
"FilterModule": {
|
||||
"name": "过滤器"
|
||||
},
|
||||
"IndexerModule": {
|
||||
"name": "站点索引"
|
||||
},
|
||||
"JellyfinModule": {
|
||||
"name": "Jellyfin"
|
||||
},
|
||||
"PlexModule": {
|
||||
"name": "Plex"
|
||||
},
|
||||
"PostgreSQLModule": {
|
||||
"name": "PostgreSQL"
|
||||
},
|
||||
"QbittorrentModule": {
|
||||
"name": "Qbittorrent"
|
||||
},
|
||||
"QQBotModule": {
|
||||
"name": "QQ"
|
||||
},
|
||||
"RedisModule": {
|
||||
"name": "Redis缓存"
|
||||
},
|
||||
"RtorrentModule": {
|
||||
"name": "Rtorrent"
|
||||
},
|
||||
"SlackModule": {
|
||||
"name": "Slack"
|
||||
},
|
||||
"SubtitleModule": {
|
||||
"name": "站点字幕"
|
||||
},
|
||||
"SynologyChatModule": {
|
||||
"name": "Synology Chat"
|
||||
},
|
||||
"TelegramModule": {
|
||||
"name": "Telegram"
|
||||
},
|
||||
"TheMovieDbModule": {
|
||||
"name": "TheMovieDb"
|
||||
},
|
||||
"TheTvDbModule": {
|
||||
"name": "TheTvDb"
|
||||
},
|
||||
"TransmissionModule": {
|
||||
"name": "Transmission"
|
||||
},
|
||||
"TrimeMediaModule": {
|
||||
"name": "飞牛影视"
|
||||
},
|
||||
"UgreenModule": {
|
||||
"name": "绿联影视"
|
||||
},
|
||||
"VoceChatModule": {
|
||||
"name": "VoceChat"
|
||||
},
|
||||
"WebPushModule": {
|
||||
"name": "WebPush"
|
||||
},
|
||||
"WechatModule": {
|
||||
"name": "企业微信"
|
||||
},
|
||||
"WechatClawBotModule": {
|
||||
"name": "微信 ClawBot"
|
||||
},
|
||||
"ZSpaceModule": {
|
||||
"name": "极影视"
|
||||
}
|
||||
},
|
||||
"module_test": {
|
||||
"unsupported": "模块不支持测试"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"模块不支持测试": "模块不支持测试",
|
||||
"网络请求失败": "网络请求失败",
|
||||
"豆瓣网络连接失败": "豆瓣网络连接失败",
|
||||
"Bangumi网络连接失败": "Bangumi网络连接失败",
|
||||
"fanart网络连接失败": "fanart网络连接失败",
|
||||
"未配置站点或未通过用户认证": "未配置站点或未通过用户认证",
|
||||
"Redis连接失败,请检查配置": "Redis连接失败,请检查配置"
|
||||
},
|
||||
"message_patterns": [
|
||||
{
|
||||
"source": "无法连接Qbittorrent下载器:{name}",
|
||||
"target": "无法连接Qbittorrent下载器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接Transmission下载器:{name}",
|
||||
"target": "无法连接Transmission下载器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接rTorrent下载器:{name}",
|
||||
"target": "无法连接rTorrent下载器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接Emby服务器:{name}",
|
||||
"target": "无法连接Emby服务器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接Jellyfin服务器:{name}",
|
||||
"target": "无法连接Jellyfin服务器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接Plex服务器:{name}",
|
||||
"target": "无法连接Plex服务器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "飞牛影视配置不完整:{name}",
|
||||
"target": "飞牛影视配置不完整:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接飞牛影视:{name}",
|
||||
"target": "无法连接飞牛影视:{name}"
|
||||
},
|
||||
{
|
||||
"source": "绿联影视配置不完整:{name}",
|
||||
"target": "绿联影视配置不完整:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接绿联影视:{name}",
|
||||
"target": "无法连接绿联影视:{name}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接极影视服务器:{name}",
|
||||
"target": "无法连接极影视服务器:{name}"
|
||||
},
|
||||
{
|
||||
"source": "Telegram {name} 未就绪",
|
||||
"target": "Telegram {name} 未就绪"
|
||||
},
|
||||
{
|
||||
"source": "飞书 {name} 未就绪",
|
||||
"target": "飞书 {name} 未就绪"
|
||||
},
|
||||
{
|
||||
"source": "Discord {name} Bot 未就绪",
|
||||
"target": "Discord {name} Bot 未就绪"
|
||||
},
|
||||
{
|
||||
"source": "Slack {name} 未就绪",
|
||||
"target": "Slack {name} 未就绪"
|
||||
},
|
||||
{
|
||||
"source": "无法连接Bangumi,错误码:{code}",
|
||||
"target": "无法连接Bangumi,错误码:{code}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接fanart,错误码:{code}",
|
||||
"target": "无法连接fanart,错误码:{code}"
|
||||
},
|
||||
{
|
||||
"source": "无法连接 {domain},错误码:{code}",
|
||||
"target": "无法连接 {domain},错误码:{code}"
|
||||
},
|
||||
{
|
||||
"source": "{domain} 网络连接失败",
|
||||
"target": "{domain} 网络连接失败"
|
||||
}
|
||||
]
|
||||
}
|
||||
1303
app/locales/zh-TW.json
Normal file
1303
app/locales/zh-TW.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.helper.locale import LocaleHelper
|
||||
|
||||
|
||||
class Statistic(BaseModel):
|
||||
@@ -87,14 +89,20 @@ class ScheduleProgress(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 多语言名称
|
||||
name_i18n: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 多语言提供者
|
||||
provider_i18n: Optional[str] = None
|
||||
# 是否正在执行
|
||||
enable: Optional[bool] = False
|
||||
# 当前完成百分比
|
||||
value: Optional[float] = 0.0
|
||||
# 当前进度文本
|
||||
text: Optional[str] = None
|
||||
# 多语言进度文本
|
||||
text_i18n: Optional[str] = None
|
||||
# 执行状态 waiting/running/success/failed
|
||||
status: Optional[str] = None
|
||||
# 最近一次执行是否成功
|
||||
@@ -105,9 +113,27 @@ class ScheduleProgress(BaseModel):
|
||||
finished_at: Optional[str] = None
|
||||
# 最近一次错误信息
|
||||
error: Optional[str] = None
|
||||
# 多语言错误信息
|
||||
error_i18n: Optional[str] = None
|
||||
# 扩展数据
|
||||
data: Optional[dict] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_i18n_fields(self) -> "ScheduleProgress":
|
||||
"""
|
||||
自动补充后台服务进度的多语言展示字段。
|
||||
"""
|
||||
locale = LocaleHelper.get_current_locale()
|
||||
if self.name and self.name_i18n is None:
|
||||
self.name_i18n = LocaleHelper.translate_text(self.name, locale=locale)
|
||||
if self.provider and self.provider_i18n is None:
|
||||
self.provider_i18n = LocaleHelper.translate_text(self.provider, locale=locale)
|
||||
if self.text and self.text_i18n is None:
|
||||
self.text_i18n = LocaleHelper.translate_text(self.text, locale=locale)
|
||||
if self.error and self.error_i18n is None:
|
||||
self.error_i18n = LocaleHelper.translate_text(self.error, locale=locale)
|
||||
return self
|
||||
|
||||
|
||||
class ScheduleInfo(BaseModel):
|
||||
"""仪表板后台服务信息。"""
|
||||
@@ -116,21 +142,49 @@ class ScheduleInfo(BaseModel):
|
||||
id: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
# 多语言名称
|
||||
name_i18n: Optional[str] = None
|
||||
# 提供者
|
||||
provider: Optional[str] = None
|
||||
# 多语言提供者
|
||||
provider_i18n: Optional[str] = None
|
||||
# 状态
|
||||
status: Optional[str] = None
|
||||
# 多语言状态
|
||||
status_i18n: Optional[str] = None
|
||||
# 下次执行时间
|
||||
next_run: Optional[str] = None
|
||||
# 多语言下次执行时间
|
||||
next_run_i18n: Optional[str] = None
|
||||
# 当前完成百分比
|
||||
progress: Optional[float] = 0.0
|
||||
# 进度文本
|
||||
progress_text: Optional[str] = None
|
||||
# 多语言进度文本
|
||||
progress_text_i18n: Optional[str] = None
|
||||
# 是否正在更新进度
|
||||
progress_enable: Optional[bool] = False
|
||||
# 进度详情
|
||||
progress_detail: Optional[ScheduleProgress] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_i18n_fields(self) -> "ScheduleInfo":
|
||||
"""
|
||||
自动补充后台服务列表的多语言展示字段。
|
||||
"""
|
||||
locale = LocaleHelper.get_current_locale()
|
||||
if self.name and self.name_i18n is None:
|
||||
self.name_i18n = LocaleHelper.translate_text(self.name, locale=locale)
|
||||
if self.provider and self.provider_i18n is None:
|
||||
self.provider_i18n = LocaleHelper.translate_text(self.provider, locale=locale)
|
||||
if self.status and self.status_i18n is None:
|
||||
self.status_i18n = LocaleHelper.translate_text(self.status, locale=locale)
|
||||
if self.next_run and self.next_run_i18n is None:
|
||||
self.next_run_i18n = LocaleHelper.translate_text(self.next_run, locale=locale)
|
||||
if self.progress_text and self.progress_text_i18n is None:
|
||||
self.progress_text_i18n = LocaleHelper.translate_text(self.progress_text, locale=locale)
|
||||
return self
|
||||
|
||||
|
||||
class DashboardSystemInfo(BaseModel):
|
||||
"""仪表板系统摘要信息。"""
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
from typing import Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from app.helper.locale import LocaleHelper
|
||||
|
||||
|
||||
class Response(BaseModel):
|
||||
"""通用接口响应结构"""
|
||||
|
||||
# 状态
|
||||
success: bool
|
||||
# 消息文本
|
||||
message: Optional[str] = None
|
||||
# 多语言消息文本
|
||||
message_i18n: Optional[str] = None
|
||||
# 数据
|
||||
data: Optional[Union[dict, list]] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def fill_message_i18n(self) -> "Response":
|
||||
"""
|
||||
自动补充响应消息的多语言文本。
|
||||
"""
|
||||
if self.message and self.message_i18n is None:
|
||||
self.message_i18n = LocaleHelper.translate_text(
|
||||
self.message, locale=LocaleHelper.get_current_locale()
|
||||
)
|
||||
return self
|
||||
|
||||
Reference in New Issue
Block a user