fix(webui): 内置插件的名称、描述与配置字段支持英文

内置插件的清单与 config_model 只有中文,英文界面里直接显示中文。前端在
builtin_plugins.<id> 下为 4 个内置插件配齐中英文案,按插件 id 查找,查不到
(第三方插件)时仍显示后端文字。插件的停用原因改为代码(not_enabled、
unsigned_blocked),由前端翻译;“未启用”不再重复显示为提示框。
This commit is contained in:
Estrella Pan
2026-10-09 13:14:33 +02:00
parent 27c30478ff
commit 49a499d417
7 changed files with 246 additions and 17 deletions

View File

@@ -331,12 +331,12 @@ class PluginManager:
del self._failed[plugin_id]
def _blocked_reason(self, candidate: PluginCandidate) -> str | None:
"""插件不应运行的原因;None 表示应启用。"""
"""插件不应运行的原因代码(前端按代码翻译);None 表示应启用。"""
conf = self._settings.plugins
if not self._enabled(candidate):
return "未启用"
return "not_enabled"
if not candidate.signed and not conf.allow_unsigned:
return "未签名插件需要开启 plugins.allow_unsigned"
return "unsigned_blocked"
return None
def _enabled(self, candidate: PluginCandidate) -> bool:

View File

@@ -686,7 +686,7 @@ class TestManager:
await manager.start()
[status] = manager.statuses()
assert status.state == "disabled"
assert "allow_unsigned" in (status.error or "")
assert status.error == "unsigned_blocked"
settings_obj.plugins.allow_unsigned = True
await manager.apply_settings()
assert manager.statuses()[0].state == "active"
@@ -834,7 +834,7 @@ class TestManager:
settings_obj.plugins.enabled = {"demo": False}
await manager.apply_settings()
[status] = manager.statuses()
assert (status.state, status.error) == ("disabled", "未启用")
assert (status.state, status.error) == ("disabled", "not_enabled")
async def test_unknown_extension_point_fails_load(self, tmp_path):
recorder = Recorder()
@@ -984,7 +984,7 @@ class TestPluginsApi:
source="local",
signed=False,
state="disabled",
error="未签名插件需要开启 plugins.allow_unsigned",
error="unsigned_blocked",
)
ctx = SimpleNamespace(plugins=SimpleNamespace(statuses=lambda: [status]))
monkeypatch.setattr(settings, "plugins", Plugins())

View File

@@ -13,6 +13,12 @@ import { refreshPluginProviders } from '@/hooks/usePluginProviders';
import { refreshPluginUi } from '@/hooks/usePluginUi';
import { idLabel } from '@/utils/id-label';
import { fillSchemaDefaults, schemaFields } from '@/utils/plugin-schema';
import {
localizeFields,
pluginDescription,
pluginName,
pluginReason,
} from '@/utils/plugin-text';
// 插件卡片不参与全局保存:每次改动直接调 /plugins 接口落盘并应用,
// 随后刷新 config store 的 plugins 段,避免全局保存用旧值覆盖。
@@ -138,7 +144,7 @@ async function setEnabled(plugin: PluginInfo, enabled: boolean) {
impact &&
!(await confirm({
title: t('config.plugins_set.disable_confirm_title', {
name: plugin.name,
name: pluginName(t, plugin.id, plugin.name),
}),
body: impact,
confirmText: t('config.plugins_set.disable'),
@@ -187,7 +193,7 @@ async function install(entry: CatalogEntry) {
async function uninstall(plugin: PluginInfo) {
const confirmed = await confirm({
title: t('config.plugins_set.uninstall_confirm_title', {
name: plugin.name,
name: pluginName(t, plugin.id, plugin.name),
}),
body: [impactText(plugin), t('config.plugins_set.uninstall_confirm_body')]
.filter(Boolean)
@@ -246,7 +252,7 @@ onMounted(load);
>
<header class="plugin__header">
<div class="plugin__title">
<strong>{{ plugin.name }}</strong>
<strong>{{ pluginName(t, plugin.id, plugin.name) }}</strong>
<span class="plugin__meta"
>{{ plugin.id }} · v{{ plugin.version }}</span
>
@@ -255,7 +261,9 @@ onMounted(load);
:model-value="plugin.enabled"
:loading="busy.has(plugin.id)"
:aria-label="
$t('config.plugins_set.enabled_for', { name: plugin.name })
$t('config.plugins_set.enabled_for', {
name: pluginName(t, plugin.id, plugin.name),
})
"
@update:model-value="setEnabled(plugin, $event)"
/>
@@ -272,14 +280,14 @@ onMounted(load);
</div>
<p v-if="plugin.description" class="plugin__desc">
{{ plugin.description }}
{{ pluginDescription(t, plugin.id, plugin.description) }}
</p>
<AbAlert
v-if="plugin.error"
v-if="plugin.error && plugin.error !== 'not_enabled'"
:type="plugin.state === 'error' ? 'danger' : 'info'"
:title="$t(`config.plugins_set.state_${plugin.state}`)"
>
{{ plugin.error }}
{{ pluginReason(t, plugin.error) }}
</AbAlert>
<p
v-if="plugin.permissions.length"
@@ -302,7 +310,9 @@ onMounted(load);
<template v-if="plugin.config_schema">
<PluginSchemaForm
v-model="drafts[plugin.id]"
:fields="schemaFields(plugin.config_schema)"
:fields="
localizeFields(t, plugin.id, schemaFields(plugin.config_schema))
"
/>
<div class="plugin__save">
<AbButton

View File

@@ -270,7 +270,11 @@
"remove_row_confirm_body": "The values in this row are deleted when you save the options.",
"field_required": "Required",
"field_min": "Must be {n} or more",
"field_max": "Must be {n} or less"
"field_max": "Must be {n} or less",
"reason": {
"not_enabled": "Not enabled",
"unsigned_blocked": "Unsigned plugins only load when “Allow unsigned plugins” is on."
}
}
},
"downloader": {
@@ -854,5 +858,82 @@
"body": "{torrent_name} (rename method: {strategy}). Detail: {reason}. The files keep their original names. Fix the rename setting. AutoBangumi retries on the next run."
}
}
},
"builtin_plugins": {
"hardlink": {
"name": "Hardlink to media library",
"description": "After organizing, hardlink episodes and subtitles into the media library folder. The download folder stays as it is and keeps seeding.",
"fields": {
"path_map": {
"title": "Path mapping",
"description": "When the downloader and AutoBangumi see different file systems (for example, separate containers), replace the downloader's path prefix with a local path. The longest matching prefix wins. Paths that do not match are used as they are.",
"downloader": {
"title": "Downloader",
"description": "Downloader instance id"
},
"from": {
"title": "Downloader path",
"description": "Path prefix as the downloader sees it"
},
"to": {
"title": "Local path",
"description": "The same location as AutoBangumi sees it"
}
},
"source_root": {
"title": "Download root",
"description": "Local path. The media library keeps the same folder structure."
},
"library_root": {
"title": "Media library folder",
"description": "Links go here. Hardlinks need the same file system as the download root."
},
"cross_device": {
"title": "Across file systems",
"description": "copy copies the file, symlink creates a symbolic link, skip skips it and sends a notification"
}
}
},
"ingest_filters": {
"name": "Torrent filter",
"description": "On top of each rule's exclude filters, only download torrents that match include keywords. Has no effect when empty.",
"fields": {
"include": {
"title": "Include filter",
"description": "Only download torrents whose name matches any of these expressions (regex, case-insensitive), for example 1080p or 简体. Empty means no limit. Applies to all subscriptions."
}
}
},
"media_server_refresh": {
"name": "Media server refresh",
"description": "After torrents are organized, tell Jellyfin, Emby or Plex to refresh the library. Takes effect once the server URL and API key are set.",
"fields": {
"server": {
"title": "Server type"
},
"url": {
"title": "Server URL",
"description": "For example http://192.168.1.10:8096 (Plex uses port 32400 by default). Leave empty to skip refreshing."
},
"api_key": {
"title": "API key",
"description": "The Jellyfin or Emby API key, or the Plex X-Plex-Token"
},
"delay": {
"title": "Delay (seconds)",
"description": "How long to wait after an organize event before refreshing. Events in that window are merged into one refresh."
}
}
},
"rename": {
"name": "Rename",
"description": "Provides the rename methods pn and advance, and a custom template. When disabled, all bangumi use none and keep their original file names.",
"fields": {
"template": {
"title": "File name template",
"description": "The Jinja2 template used when the rename method is template. It renders the file name without the extension (subtitles get the language appended). Variables: title, bangumi_name, season, episode, episode_type (episode / movie / special), group, kind (media / subtitle), language. The filter pad(n) zero-pads a number to n digits. A file whose template fails or whose result contains a path separator keeps its original name, and you get a notification."
}
}
}
}
}

View File

@@ -270,7 +270,11 @@
"remove_row_confirm_body": "保存插件选项后,该行的内容会被删除。",
"field_required": "必填",
"field_min": "不能小于 {n}",
"field_max": "不能大于 {n}"
"field_max": "不能大于 {n}",
"reason": {
"not_enabled": "未启用",
"unsigned_blocked": "未签名插件需要开启「允许未签名插件」后才会加载。"
}
}
},
"downloader": {
@@ -854,5 +858,82 @@
"body": "{torrent_name}(重命名方式:{strategy})。详情:{reason}。文件保留原名。请修正重命名设置,下次运行时会自动重试。"
}
}
},
"builtin_plugins": {
"hardlink": {
"name": "硬链接到媒体库",
"description": "整理完成后把正片与字幕硬链接到媒体库目录,下载目录保持原样继续做种",
"fields": {
"path_map": {
"title": "路径映射",
"description": "下载器与 AutoBangumi 不在同一个文件系统视图(如分别运行在不同容器)时,把下载器路径前缀换成本地路径;按最长前缀匹配,未匹配的路径原样使用",
"downloader": {
"title": "下载器",
"description": "下载器实例 id"
},
"from": {
"title": "下载器路径",
"description": "下载器看到的路径前缀"
},
"to": {
"title": "本地路径",
"description": "同一位置在 AutoBangumi 中的路径"
}
},
"source_root": {
"title": "下载根目录",
"description": "本地路径,媒体库保持与它相同的目录结构"
},
"library_root": {
"title": "媒体库目录",
"description": "链接放在这里;硬链接要求与下载根目录在同一个文件系统"
},
"cross_device": {
"title": "跨文件系统时",
"description": "copy 复制文件,symlink 创建软链接,skip 跳过并通知"
}
}
},
"ingest_filters": {
"name": "种子过滤",
"description": "在规则的排除过滤之外,按包含关键词进一步筛选要下载的种子(留空不生效)",
"fields": {
"include": {
"title": "包含过滤",
"description": "只下载名称匹配任一表达式的种子(正则,忽略大小写),例如 1080p、简体。留空表示不限制。对所有订阅生效。"
}
}
},
"media_server_refresh": {
"name": "媒体库刷新",
"description": "种子整理完成后通知 Jellyfin / Emby / Plex 刷新媒体库;填写服务器地址与 API Key 后生效",
"fields": {
"server": {
"title": "服务器类型"
},
"url": {
"title": "服务器地址",
"description": "如 http://192.168.1.10:8096(Plex 默认端口 32400);留空则不刷新"
},
"api_key": {
"title": "API Key",
"description": "Jellyfin / Emby 的 API Key,或 Plex 的 X-Plex-Token"
},
"delay": {
"title": "延迟(秒)",
"description": "收到整理完成事件后等待多久再刷新,期间的事件合并为一次刷新"
}
}
},
"rename": {
"name": "重命名",
"description": "提供重命名方式 pn、advance 与自定义模板 template。停用后所有番剧按 none 处理(保留原文件名)",
"fields": {
"template": {
"title": "文件名模板",
"description": "重命名方式选择 template 时使用的 Jinja2 模板,渲染文件名主体(不含扩展名,字幕会自动追加语言)。可用变量:title、bangumi_name、season、episode、episode_type(episode / movie / special)、group、kind(media / subtitle)、language;过滤器 pad(n) 把数字补零到 n 位。渲染失败或结果含路径分隔符的文件保留原名,并发送通知。"
}
}
}
}
}

View File

@@ -11,11 +11,16 @@ export function idLabel(
id: string
): string {
const key = `${group}.${id.replaceAll('.', '_')}`;
return hasKey(key) ? t(key) : id;
}
/** en.json 中是否有这条文案(en 与 zh-CN 的 key 集合由测试保证一致) */
export function hasKey(key: string): boolean {
const found = key
.split('.')
.reduce<unknown>(
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
en
);
return typeof found === 'string' ? t(key) : id;
return typeof found === 'string';
}

View File

@@ -0,0 +1,52 @@
import { hasKey } from './id-label';
import type { SchemaField } from './plugin-schema';
type Translate = (key: string) => string;
/**
* 内置插件的名称、描述与配置字段文案:清单和 config_model 里只有中文,前端在
* `builtin_plugins.<id>` 下为它们各配一份翻译。没有收录的插件(第三方)原样显示
* 后端给的文字。
*/
function root(pluginId: string): string {
return `builtin_plugins.${pluginId.replaceAll('-', '_')}`;
}
function pick(t: Translate, key: string, fallback: string): string {
return hasKey(key) ? t(key) : fallback;
}
export function pluginName(t: Translate, id: string, fallback: string): string {
return pick(t, `${root(id)}.name`, fallback);
}
export function pluginDescription(
t: Translate,
id: string,
fallback: string
): string {
return pick(t, `${root(id)}.description`, fallback);
}
/** 字段文案:`fields.<key>.title / description`,对象数组的子字段嵌套在父字段下 */
export function localizeFields(
t: Translate,
id: string,
fields: SchemaField[],
parent = `${root(id)}.fields`
): SchemaField[] {
return fields.map((field) => {
const base = `${parent}.${field.key}`;
return {
...field,
label: pick(t, `${base}.title`, field.label),
description: pick(t, `${base}.description`, field.description),
itemFields: localizeFields(t, id, field.itemFields, base),
};
});
}
/** 后端 blocked 原因代码(如 not_enabled)的文案;其它错误原样显示 */
export function pluginReason(t: Translate, error: string): string {
return pick(t, `config.plugins_set.reason.${error}`, error);
}