feat: add i18n supports for custom platform adapters (#5045)

* Feat: 为插件提供的适配器的元数据&i18n提供数据通路

* chore: update docstrings with pull request references

Added references to pull request 5045 in docstrings.

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
This commit is contained in:
stevessr
2026-02-12 21:49:12 +08:00
committed by GitHub
parent cd5312ba77
commit 473e01aadd
9 changed files with 171 additions and 5 deletions
@@ -24,3 +24,14 @@ class PlatformMetadata:
module_path: str | None = None
"""注册该适配器的模块路径,用于插件热重载时清理"""
i18n_resources: dict[str, dict] | None = None
"""国际化资源数据,如 {"zh-CN": {...}, "en-US": {...}}
参考 https://github.com/AstrBotDevs/AstrBot/pull/5045
"""
config_metadata: dict | None = None
"""配置项元数据,用于 WebUI 生成表单。对应 config_metadata.json 的内容
参考 https://github.com/AstrBotDevs/AstrBot/pull/5045
"""
+5
View File
@@ -15,11 +15,14 @@ def register_platform_adapter(
adapter_display_name: str | None = None,
logo_path: str | None = None,
support_streaming_message: bool = True,
i18n_resources: dict[str, dict] | None = None,
config_metadata: dict | None = None,
):
"""用于注册平台适配器的带参装饰器。
default_config_tmpl 指定了平台适配器的默认配置模板。用户填写好后将会作为 platform_config 传入你的 Platform 类的实现类。
logo_path 指定了平台适配器的 logo 文件路径,是相对于插件目录的路径。
config_metadata 指定了配置项的元数据,用于 WebUI 生成表单。如果不指定,WebUI 将会把配置项渲染为原始的键值对编辑框。
"""
def decorator(cls):
@@ -49,6 +52,8 @@ def register_platform_adapter(
logo_path=logo_path,
support_streaming_message=support_streaming_message,
module_path=module_path,
i18n_resources=i18n_resources,
config_metadata=config_metadata,
)
platform_registry.append(pm)
platform_cls_map[adapter_name] = cls
+42 -2
View File
@@ -1290,6 +1290,30 @@ class ConfigRoute(Route):
f"Unexpected error registering logo for platform {platform.name}: {e}",
)
def _inject_platform_metadata_with_i18n(
self, platform, metadata, platform_i18n_translations: dict
):
"""将配置元数据注入到 metadata 中并处理国际化键转换。"""
metadata["platform_group"]["metadata"]["platform"].setdefault("items", {})
platform_items_to_inject = copy.deepcopy(platform.config_metadata)
if platform.i18n_resources:
i18n_prefix = f"platform_group.platform.{platform.name}"
for lang, lang_data in platform.i18n_resources.items():
platform_i18n_translations.setdefault(lang, {}).setdefault(
"platform_group", {}
).setdefault("platform", {})[platform.name] = lang_data
for field_key, field_value in platform_items_to_inject.items():
for key in ("description", "hint", "labels"):
if key in field_value:
field_value[key] = f"{i18n_prefix}.{field_key}.{key}"
metadata["platform_group"]["metadata"]["platform"]["items"].update(
platform_items_to_inject
)
async def _get_astrbot_config(self):
config = self.config
metadata = copy.deepcopy(CONFIG_METADATA_2)
@@ -1311,11 +1335,23 @@ class ConfigRoute(Route):
"config_template"
]
# 收集平台的 i18n 翻译数据
platform_i18n_translations = {}
# 收集需要注册logo的平台
logo_registration_tasks = []
for platform in platform_registry:
if platform.default_config_tmpl:
platform_default_tmpl[platform.name] = platform.default_config_tmpl
platform_default_tmpl[platform.name] = copy.deepcopy(
platform.default_config_tmpl
)
# 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键)
if platform.config_metadata:
self._inject_platform_metadata_with_i18n(
platform, metadata, platform_i18n_translations
)
# 收集logo注册任务
if platform.logo_path:
logo_registration_tasks.append(
@@ -1334,7 +1370,11 @@ class ConfigRoute(Route):
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
return {"metadata": metadata, "config": config}
return {
"metadata": metadata,
"config": config,
"platform_i18n_translations": platform_i18n_translations,
}
async def _get_plugin_config(self, plugin_name: str):
ret: dict = {"metadata": None, "config": None}
@@ -522,7 +522,14 @@ export default {
}
},
methods: {
getPlatformIcon,
getPlatformIcon(platformType) {
// Check for plugin-provided logo_token first
const template = this.platformTemplates?.[platformType];
if (template && template.logo_token) {
return `/api/file/${template.logo_token}`;
}
return getPlatformIcon(platformType);
},
getPlatformDescription,
resetForm() {
this.selectedPlatformType = null;
+45
View File
@@ -89,6 +89,13 @@ export function useI18n() {
// 保存到localStorage
localStorage.setItem('astrbot-locale', newLocale);
// 触发自定义事件,通知相关页面重新加载配置数据
// 这是因为插件适配器的 i18n 数据是通过后端 API 注入的,
// 需要根据 Accept-Language 头重新获取
window.dispatchEvent(new CustomEvent('astrbot-locale-changed', {
detail: { locale: newLocale }
}));
}
};
@@ -171,6 +178,44 @@ export function useLanguageSwitcher() {
};
}
/**
* 将动态翻译数据(如插件提供的 i18n)合并到当前翻译中。
* @param modulePath 模块路径,如 'features.config-metadata'
* @param allLocaleData 所有语言的翻译数据,如 { "zh-CN": {...}, "en-US": {...} }
*/
export function mergeDynamicTranslations(modulePath: string, allLocaleData: Record<string, any>) {
const locale = currentLocale.value;
const localeData = allLocaleData[locale];
if (!localeData || typeof localeData !== 'object') return;
const pathParts = modulePath.split('.');
let target: any = translations.value;
for (const part of pathParts) {
if (!(part in target) || typeof target[part] !== 'object') {
target[part] = {};
}
target = target[part];
}
deepMerge(target, localeData);
// 触发响应式更新
translations.value = { ...translations.value };
}
function deepMerge(target: Record<string, any>, source: Record<string, any>) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!(key in target) || typeof target[key] !== 'object') {
target[key] = {};
}
deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
}
// 初始化函数(在应用启动时调用)
export async function setupI18n() {
// 从localStorage获取保存的语言设置
+8
View File
@@ -84,6 +84,10 @@ axios.interceptors.request.use((config) => {
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
const locale = localStorage.getItem('astrbot-locale');
if (locale) {
config.headers['Accept-Language'] = locale;
}
return config;
});
@@ -98,6 +102,10 @@ window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
if (!headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
const locale = localStorage.getItem('astrbot-locale');
if (locale && !headers.has('Accept-Language')) {
headers.set('Accept-Language', locale);
}
return _origFetch(input, { ...init, headers });
};
+18
View File
@@ -303,8 +303,26 @@ export default {
this.getConfigInfoList(targetConfigId);
// 初始化配置类型状态
this.configType = this.isSystemConfig ? 'system' : 'normal';
// 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
beforeUnmount() {
// 移除语言切换事件监听器
window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
// 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
handleLocaleChange() {
// 重新加载当前配置
if (this.selectedConfigID) {
this.getConfig(this.selectedConfigID);
} else if (this.isSystemConfig) {
this.getConfig();
}
},
getConfigInfoList(abconf_id) {
// 获取配置列表
axios.get('/api/config/abconfs').then((res) => {
+17 -1
View File
@@ -14,7 +14,7 @@ import { useCommonStore } from "@/stores/common";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import defaultPluginIcon from "@/assets/images/plugin_icon.png";
import { ref, computed, onMounted, reactive, watch } from "vue";
import { ref, computed, onMounted, onUnmounted, reactive, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const commonStore = useCommonStore();
@@ -1054,6 +1054,22 @@ onMounted(async () => {
}
});
// 处理语言切换事件,重新加载插件配置以获取插件的 i18n 数据
const handleLocaleChange = () => {
// 如果配置对话框是打开的,重新加载当前插件的配置
if (configDialog.value && currentConfigPlugin.value) {
openExtensionConfig(currentConfigPlugin.value);
}
};
// 监听语言切换事件
window.addEventListener("astrbot-locale-changed", handleLocaleChange);
// 清理事件监听器
onUnmounted(() => {
window.removeEventListener("astrbot-locale-changed", handleLocaleChange);
});
// 搜索防抖处理
let searchDebounceTimer = null;
watch(marketSearch, (newVal) => {
+17 -1
View File
@@ -195,7 +195,7 @@ import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n, mergeDynamicTranslations } from '@/i18n/composables';
import { getPlatformIcon, getTutorialLink } from '@/utils/platformUtils';
import {
askForConfirmation as askForConfirmationDialog,
@@ -280,15 +280,25 @@ export default {
this.statsRefreshInterval = setInterval(() => {
this.getPlatformStats();
}, 10000);
// 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
beforeUnmount() {
if (this.statsRefreshInterval) {
clearInterval(this.statsRefreshInterval);
}
// 移除语言切换事件监听器
window.removeEventListener('astrbot-locale-changed', this.handleLocaleChange);
},
methods: {
// 处理语言切换事件,重新加载配置以获取插件的 i18n 数据
handleLocaleChange() {
this.getConfig();
},
// 从工具函数导入
getPlatformIcon(platform_id) {
// 首先检查是否有来自插件的 logo_token
@@ -305,6 +315,12 @@ export default {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
// 将插件平台适配器的 i18n 翻译注入到前端 i18n 系统中
const platformI18n = res.data.data.platform_i18n_translations;
if (platformI18n && typeof platformI18n === 'object') {
mergeDynamicTranslations('features.config-metadata', platformI18n);
}
}).catch((err) => {
this.showError(err);
});