mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-10-31 20:55:35 +08:00
fix(webui): 内置插件的名称、描述与配置字段支持英文
内置插件的清单与 config_model 只有中文,英文界面里直接显示中文。前端在 builtin_plugins.<id> 下为 4 个内置插件配齐中英文案,按插件 id 查找,查不到 (第三方插件)时仍显示后端文字。插件的停用原因改为代码(not_enabled、 unsigned_blocked),由前端翻译;“未启用”不再重复显示为提示框。
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 位。渲染失败或结果含路径分隔符的文件保留原名,并发送通知。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
52
webui/src/utils/plugin-text.ts
Normal file
52
webui/src/utils/plugin-text.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user