feat: add backend i18n response support

This commit is contained in:
jxxghp
2026-07-06 19:13:25 +08:00
parent 76803ae7a3
commit b16c50b03a
14 changed files with 3776 additions and 25 deletions

View File

@@ -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:

View File

@@ -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])

View File

@@ -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})

View File

@@ -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
View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

186
app/locales/zh-CN.json Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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):
"""仪表板系统摘要信息。"""

View File

@@ -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