From cb4f941e435f85593b005e30ed24b4b9ba3101bf Mon Sep 17 00:00:00 2001 From: Weilong Liao <37870767+Soulter@users.noreply.github.com> Date: Mon, 4 May 2026 20:15:21 +0800 Subject: [PATCH] feat: enhance plugin page internationalization (#7998) * feat: enhance plugin page internationalization - Updated PluginRoute to read initial context from JWT and set it in the bridge SDK. - Added methods to retrieve locale and plugin metadata for better i18n support. - Enhanced pluginI18n utility to resolve page-specific translations and added new functions for page titles and descriptions. - Modified PluginPagePage and PluginDetailPage to utilize new i18n features for dynamic content rendering. - Improved documentation for plugin page i18n structure and usage. - Added tests to verify the correct integration of i18n in plugin pages and context handling. * fix test --- astrbot/dashboard/plugin_page_bridge.js | 98 +++++++- astrbot/dashboard/routes/plugin.py | 87 ++++++- dashboard/src/utils/pluginI18n.js | 119 ++++++--- dashboard/src/views/PluginPagePage.vue | 84 +++++-- .../src/views/extension/PluginDetailPage.vue | 34 ++- docs/en/dev/star/guides/plugin-i18n.md | 47 ++++ docs/en/dev/star/guides/plugin-pages.md | 237 +++++++++++++++++- docs/zh/dev/star/guides/plugin-i18n.md | 47 ++++ docs/zh/dev/star/guides/plugin-pages.md | 237 +++++++++++++++++- tests/fixtures/helpers.py | 6 +- tests/test_dashboard.py | 24 ++ tests/test_plugin_manager.py | 4 +- 12 files changed, 952 insertions(+), 72 deletions(-) diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js index c14f7357e..a656378e3 100644 --- a/astrbot/dashboard/plugin_page_bridge.js +++ b/astrbot/dashboard/plugin_page_bridge.js @@ -3,6 +3,7 @@ const SELF_ORIGIN = window.location.origin; const pendingRequests = new Map(); const sseHandlers = new Map(); + const contextHandlers = new Set(); let requestCounter = 0; let subscriptionCounter = 0; let context = null; @@ -13,7 +14,11 @@ }); function getTargetOrigin() { - if (typeof parentOrigin === "string" && parentOrigin && parentOrigin !== "null") { + if ( + typeof parentOrigin === "string" && + parentOrigin && + parentOrigin !== "null" + ) { return parentOrigin; } if (SELF_ORIGIN !== "null") { @@ -70,6 +75,63 @@ } } + function getByPath(source, key) { + if (!source || typeof source !== "object" || !key) { + return undefined; + } + + return String(key) + .split(".") + .reduce((current, part) => { + if (!current || typeof current !== "object" || !(part in current)) { + return undefined; + } + return current[part]; + }, source); + } + + function translate(key, fallback) { + const locale = context?.locale; + const messages = context?.i18n; + const locales = [locale, "zh-CN", "en-US"].filter(Boolean); + let value; + for (const candidateLocale of locales) { + value = getByPath(messages?.[candidateLocale], key); + if (value !== undefined && value !== null) { + break; + } + } + if (value === undefined || value === null) { + return fallback || ""; + } + return typeof value === "string" ? value : String(value); + } + + function notifyContextHandlers() { + contextHandlers.forEach((handler) => { + try { + handler(context); + } catch (error) { + console.error("AstrBotPluginPage context handler failed:", error); + } + }); + } + + function applyContext(nextContext) { + if (!nextContext || typeof nextContext !== "object") { + return; + } + context = { + ...(context || {}), + ...nextContext, + }; + if (resolveReady) { + resolveReady(context); + resolveReady = null; + } + notifyContextHandlers(); + } + window.addEventListener("message", (event) => { if (event.source !== window.parent) { return; @@ -87,11 +149,7 @@ } if (message.kind === "context") { - context = message.context || null; - if (resolveReady) { - resolveReady(context); - resolveReady = null; - } + applyContext(message.context); return; } @@ -104,7 +162,9 @@ if (message.ok) { pending.resolve(message.data); } else { - pending.reject(new Error(message.error || "Plugin bridge request failed.")); + pending.reject( + new Error(message.error || "Plugin bridge request failed."), + ); } return; } @@ -139,6 +199,30 @@ getContext() { return context; }, + getLocale() { + return context?.locale || "zh-CN"; + }, + getI18n() { + return context?.i18n || {}; + }, + t(key, fallback) { + return translate(key, fallback); + }, + onContext(handler) { + if (typeof handler !== "function") { + return () => {}; + } + contextHandlers.add(handler); + if (context) { + handler(context); + } + return () => { + contextHandlers.delete(handler); + }; + }, + __setInitialContext(nextContext) { + applyContext(nextContext); + }, apiGet(endpoint, params) { return makeRequest("api:get", { endpoint, params }); }, diff --git a/astrbot/dashboard/routes/plugin.py b/astrbot/dashboard/routes/plugin.py index 69453bd3f..10d87eabe 100644 --- a/astrbot/dashboard/routes/plugin.py +++ b/astrbot/dashboard/routes/plugin.py @@ -189,7 +189,13 @@ class PluginRoute(Route): return await self._plugin_page_error_response( 404, "Plugin Page bridge SDK not found" ) - bridge_js = await self._read_plugin_page_binary(_PLUGIN_PAGE_BRIDGE_FILE) + bridge_js = await self._read_plugin_page_text(_PLUGIN_PAGE_BRIDGE_FILE) + initial_context = self._get_plugin_page_initial_context() + if initial_context: + context_json = json.dumps(initial_context, ensure_ascii=False) + bridge_js += ( + f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n" + ) response = cast( QuartResponse, await make_response( @@ -204,6 +210,82 @@ class PluginRoute(Route): return plugin return None + @staticmethod + def _get_by_path(source: dict | None, key: str): + if not isinstance(source, dict) or not key: + return None + current = source + for part in key.split("."): + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + @staticmethod + def _get_request_locale(default: str = "zh-CN") -> str: + raw_locale = request.headers.get("Accept-Language", "").strip() + locale = raw_locale.split(",", 1)[0].split(";", 1)[0].strip() + if not locale or len(locale) > 32: + return default + return locale + + def _get_plugin_page_initial_context(self) -> dict | None: + asset_token = request.args.get("asset_token", "").strip() + if not asset_token: + return None + jwt_secret = self.config.get("dashboard", {}).get("jwt_secret") + if not isinstance(jwt_secret, str) or not jwt_secret.strip(): + return None + + try: + payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"]) + except jwt.InvalidTokenError: + return None + if payload.get("token_type") != _PLUGIN_PAGE_ASSET_TOKEN_TYPE: + return None + + plugin_name = payload.get("plugin_name") + page_name = payload.get("page_name") + if not isinstance(plugin_name, str) or not isinstance(page_name, str): + return None + + plugin = self._get_plugin_metadata_by_name(plugin_name) + if not plugin: + return None + + locale = ( + payload.get("locale") + if isinstance(payload.get("locale"), str) + else self._get_request_locale() + ) + plugin_i18n = plugin.i18n or {} + try: + plugin_root = self._get_plugin_root_dir(plugin) + fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root)) + if fresh_i18n: + plugin_i18n = fresh_i18n + except (OSError, ValueError): + pass + + locale_data = plugin_i18n.get(locale) + display_name = ( + self._get_by_path(locale_data, "metadata.display_name") + or plugin.display_name + or plugin.name + ) + page_title = ( + self._get_by_path(locale_data, f"pages.{page_name}.title") or page_name + ) + + return { + "pluginName": plugin.name, + "displayName": display_name, + "pageName": page_name, + "pageTitle": page_title, + "locale": locale, + "i18n": plugin_i18n, + } + @staticmethod def _normalize_plugin_page_path( raw_path: str, @@ -634,6 +716,7 @@ class PluginRoute(Route): page_data = { "name": page.name, "title": page.title, + "i18n_key": f"pages.{page.name}", } if include_content_path: asset_token = ( @@ -675,6 +758,7 @@ class PluginRoute(Route): "token_type": _PLUGIN_PAGE_ASSET_TOKEN_TYPE, "plugin_name": plugin_name, "page_name": page_name, + "locale": self._get_request_locale(), "iat": now, "exp": now + timedelta(seconds=_PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS), } @@ -1285,6 +1369,7 @@ class PluginRoute(Route): "name": page["title"], "title": page["title"], "page_name": page["name"], + "i18n_key": page["i18n_key"], "description": "Plugin Page entry", "plugin_name": plugin.name, } diff --git a/dashboard/src/utils/pluginI18n.js b/dashboard/src/utils/pluginI18n.js index 5715ccafb..3a8b50333 100644 --- a/dashboard/src/utils/pluginI18n.js +++ b/dashboard/src/utils/pluginI18n.js @@ -1,62 +1,106 @@ -import { useI18n } from '@/i18n/composables' +import { useI18n } from "@/i18n/composables"; function getLocaleData(i18n, locale) { - if (!i18n || typeof i18n !== 'object' || !locale) return null - return i18n[locale] || null + if (!i18n || typeof i18n !== "object" || !locale) return null; + return i18n[locale] || null; } function getByPath(source, key) { - if (!source || typeof source !== 'object' || !key) return undefined + if (!source || typeof source !== "object" || !key) return undefined; - const parts = key.split('.') - let current = source + const parts = key.split("."); + let current = source; for (const part of parts) { - if (!current || typeof current !== 'object' || !(part in current)) { - return undefined + if (!current || typeof current !== "object" || !(part in current)) { + return undefined; } - current = current[part] + current = current[part]; } - return current + return current; } -export function resolvePluginI18n(i18n, locale, key, fallback = '') { - const localeData = getLocaleData(i18n, locale) - const value = getByPath(localeData, key) - return value === undefined || value === null ? fallback : value +export function resolvePluginI18n(i18n, locale, key, fallback = "") { + const localeData = getLocaleData(i18n, locale); + const value = getByPath(localeData, key); + return value === undefined || value === null ? fallback : value; +} + +function getPluginPageI18nBase(page) { + if (page && typeof page === "object") { + if (typeof page.i18n_key === "string" && page.i18n_key.trim()) { + return page.i18n_key.trim(); + } + const pageName = page.page_name || page.name; + return pageName ? `pages.${pageName}` : ""; + } + + return page ? `pages.${page}` : ""; } export function usePluginI18n() { - const { locale } = useI18n() + const { locale } = useI18n(); - const resolve = (i18n, key, fallback = '') => { - return resolvePluginI18n(i18n, locale.value, key, fallback) - } + const resolve = (i18n, key, fallback = "") => { + return resolvePluginI18n(i18n, locale.value, key, fallback); + }; const pluginName = (plugin) => { - const fallback = plugin?.display_name?.length ? plugin.display_name : plugin?.name - return resolve(plugin?.i18n, 'metadata.display_name', fallback || '') - } + const fallback = plugin?.display_name?.length + ? plugin.display_name + : plugin?.name; + return resolve(plugin?.i18n, "metadata.display_name", fallback || ""); + }; - const pluginDesc = (plugin, fallback = '') => { + const pluginDesc = (plugin, fallback = "") => { return resolve( plugin?.i18n, - 'metadata.desc', - fallback || plugin?.desc || plugin?.description || '', - ) - } + "metadata.desc", + fallback || plugin?.desc || plugin?.description || "", + ); + }; - const pluginShortDesc = (plugin, fallback = '') => { + const pluginShortDesc = (plugin, fallback = "") => { return resolve( plugin?.i18n, - 'metadata.short_desc', - fallback || plugin?.short_desc || plugin?.desc || plugin?.description || '', - ) - } + "metadata.short_desc", + fallback || + plugin?.short_desc || + plugin?.desc || + plugin?.description || + "", + ); + }; - const configText = (i18n, path, attr, fallback = '') => { - const key = path ? `config.${path}.${attr}` : `config.${attr}` - return resolve(i18n, key, fallback) - } + const pluginPageText = (plugin, page, attr, fallback = "") => { + const base = getPluginPageI18nBase(page); + if (!base || !attr) { + return fallback; + } + return resolve(plugin?.i18n, `${base}.${attr}`, fallback); + }; + + const pluginPageTitle = (plugin, page, fallback = "") => { + const pageFallback = + fallback || + (page && typeof page === "object" + ? page.title || page.name || page.page_name + : page) || + ""; + return pluginPageText(plugin, page, "title", pageFallback); + }; + + const pluginPageDescription = (plugin, page, fallback = "") => { + const pageFallback = + fallback || + (page && typeof page === "object" ? page.description || page.desc : "") || + ""; + return pluginPageText(plugin, page, "description", pageFallback); + }; + + const configText = (i18n, path, attr, fallback = "") => { + const key = path ? `config.${path}.${attr}` : `config.${attr}`; + return resolve(i18n, key, fallback); + }; return { locale, @@ -64,6 +108,9 @@ export function usePluginI18n() { pluginName, pluginDesc, pluginShortDesc, + pluginPageText, + pluginPageTitle, + pluginPageDescription, configText, - } + }; } diff --git a/dashboard/src/views/PluginPagePage.vue b/dashboard/src/views/PluginPagePage.vue index 369909c71..4f9ec3904 100644 --- a/dashboard/src/views/PluginPagePage.vue +++ b/dashboard/src/views/PluginPagePage.vue @@ -1,14 +1,20 @@