diff --git a/backend/src/module/api/notification.py b/backend/src/module/api/notification.py
index 13fc5116..3db3dc3d 100644
--- a/backend/src/module/api/notification.py
+++ b/backend/src/module/api/notification.py
@@ -63,15 +63,7 @@ async def test_provider(request: TestProviderRequest):
in the current configuration.
"""
try:
- manager = NotificationManager()
- if request.provider_index >= len(manager):
- return TestResponse(
- success=False,
- message=f"Invalid provider index: {request.provider_index}",
- message_zh=f"无效的提供者索引: {request.provider_index}",
- message_en=f"Invalid provider index: {request.provider_index}",
- )
-
+ manager = NotificationManager(load_providers=False)
success, message = await manager.test_provider(request.provider_index)
return TestResponse(
success=success,
diff --git a/backend/src/module/notification/manager.py b/backend/src/module/notification/manager.py
index 862d7450..d24cd9ad 100644
--- a/backend/src/module/notification/manager.py
+++ b/backend/src/module/notification/manager.py
@@ -148,23 +148,16 @@ class NotificationManager:
await self._broadcast("system event", send_one)
async def test_provider(self, index: int) -> tuple[bool, str]:
- """Test a specific provider by index.
+ """按下标测试已保存的渠道。
- Args:
- index: The index of the provider in the providers list.
-
- Returns:
- A tuple of (success, message).
+ 下标对应 ``settings.notification.providers`` 的完整列表(含停用项),
+ 与前端列表一致;``self.providers`` 只含已启用且构造成功的渠道,不能
+ 按它取下标。使用保存的配置,密钥是真实值而非前端拿到的掩码。
"""
- if index < 0 or index >= len(self.providers):
+ providers = settings.notification.providers
+ if index < 0 or index >= len(providers):
return False, f"Invalid provider index: {index}"
-
- provider = self.providers[index]
- try:
- async with provider:
- return await provider.test()
- except Exception as e:
- return False, f"Test failed: {e}"
+ return await self.test_provider_config(providers[index])
@staticmethod
async def test_provider_config(config: "ProviderConfig") -> tuple[bool, str]:
diff --git a/backend/src/test/test_notification.py b/backend/src/test/test_notification.py
index 53bd40ca..d946faca 100644
--- a/backend/src/test/test_notification.py
+++ b/backend/src/test/test_notification.py
@@ -165,23 +165,24 @@ class TestNotificationManager:
for provider in manager.providers:
provider.send.assert_called_once_with(notify) # type: ignore[attr-defined]
- async def test_test_provider(self, mock_settings):
- """Manager can test a specific provider."""
- config = ProviderConfig(
+ async def test_test_provider_index_counts_disabled_rows(self, mock_settings):
+ """下标对应已保存的完整渠道列表(含停用项),用保存的配置测试。"""
+ disabled = ProviderConfig(type="bark", enabled=False, device_key="k")
+ target = ProviderConfig(
type="telegram", enabled=True, token="test", chat_id="123"
)
- mock_settings.notification.providers = [config]
+ mock_settings.notification.providers = [disabled, target]
manager = NotificationManager()
-
- # Mock the provider's test method
- manager.providers[0].test = AsyncMock(return_value=(True, "Test successful"))
- manager.providers[0].__aenter__ = AsyncMock(return_value=manager.providers[0])
- manager.providers[0].__aexit__ = AsyncMock(return_value=None)
-
- success, message = await manager.test_provider(0)
+ with patch.object(
+ NotificationManager,
+ "test_provider_config",
+ AsyncMock(return_value=(True, "Test successful")),
+ ) as tested:
+ success, message = await manager.test_provider(1)
assert success is True
assert message == "Test successful"
+ tested.assert_awaited_once_with(target)
async def test_test_provider_invalid_index(self, mock_settings):
"""Manager handles invalid provider index."""
diff --git a/webui/src/components/__tests__/plugin-slot.test.ts b/webui/src/components/__tests__/plugin-slot.test.ts
index a6a085cc..bd5cf627 100644
--- a/webui/src/components/__tests__/plugin-slot.test.ts
+++ b/webui/src/components/__tests__/plugin-slot.test.ts
@@ -166,8 +166,12 @@ describe('plugin-slot', () => {
expect(wrapper.findComponent(AbSkeleton).exists()).toBe(false);
});
- it('should show the reason and a next step, and retry on demand', async () => {
+ // 浏览器缓存失败的模块,原地重新导入无法恢复,只能刷新页面
+ it('should show the reason and a next step, and reload the page on demand', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
+ const reload = vi
+ .spyOn(window.location, 'reload')
+ .mockImplementation(() => {});
loadPluginElement.mockRejectedValueOnce(new Error('bundle 404'));
const wrapper = mountSlot(makeUi('broken'));
await flushPromises();
@@ -179,7 +183,7 @@ describe('plugin-slot', () => {
await alert.findComponent(AbButton).trigger('click');
await flushPromises();
- expect(wrapper.findComponent(AbAlert).exists()).toBe(false);
- expect(shadowText(wrapper)).toContain('plugin content');
+ expect(reload).toHaveBeenCalledTimes(1);
+ expect(loadPluginElement).toHaveBeenCalledTimes(1);
});
});
diff --git a/webui/src/components/basic/__tests__/ab-tags-input.test.ts b/webui/src/components/basic/__tests__/ab-tags-input.test.ts
index 14189c6c..6cf635b5 100644
--- a/webui/src/components/basic/__tests__/ab-tags-input.test.ts
+++ b/webui/src/components/basic/__tests__/ab-tags-input.test.ts
@@ -27,4 +27,30 @@ describe('ab-tags-input', () => {
await wrapper.find('.ab-tag-close').trigger('click');
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['b']]);
});
+
+ // blur 不冒泡,组件根节点上的监听收不到;focusout 才会从 input 冒泡上来
+ it('should add the typed tag when the input loses focus', async () => {
+ const wrapper = mount(AbTagsInput, {
+ props: { modelValue: [] },
+ global: { mocks: { $t: (k: string) => k } },
+ });
+ const input = wrapper.find('input');
+ await input.setValue('c');
+ await input.trigger('focusout');
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([['c']]);
+ });
+
+ it.each([{ isComposing: true }, { keyCode: 229 }])(
+ 'should ignore Enter during IME composition (%j)',
+ async (init) => {
+ const wrapper = mount(AbTagsInput, {
+ props: { modelValue: [] },
+ global: { mocks: { $t: (k: string) => k } },
+ });
+ const input = wrapper.find('input');
+ await input.setValue('ni');
+ await input.trigger('keydown', { key: 'Enter', ...init });
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined();
+ }
+ );
});
diff --git a/webui/src/components/basic/ab-tags-input.vue b/webui/src/components/basic/ab-tags-input.vue
index c1ac9130..31d9cf2c 100644
--- a/webui/src/components/basic/ab-tags-input.vue
+++ b/webui/src/components/basic/ab-tags-input.vue
@@ -13,6 +13,12 @@ function add() {
model.value = [...model.value, value];
draft.value = '';
}
+
+// 输入法组字时的回车用于确认候选词,不添加标签
+function onEnter(event: KeyboardEvent) {
+ if (event.isComposing || event.keyCode === 229) return;
+ add();
+}
@@ -30,8 +36,8 @@ function add() {
diff --git a/webui/src/components/layout/ab-mobile-nav.test.ts b/webui/src/components/layout/ab-mobile-nav.test.ts
index edc73224..858a0f7d 100644
--- a/webui/src/components/layout/ab-mobile-nav.test.ts
+++ b/webui/src/components/layout/ab-mobile-nav.test.ts
@@ -15,7 +15,7 @@ vi.mock('@/hooks/useDarkMode', async () => {
vi.mock('vue-inline-svg', () => ({ default: { template: '' } }));
vi.mock('@/hooks/usePluginUi', () => ({
slotTitle: (ui: { title: Record }) => ui.title['en-US'],
- useUiSlots: () =>
+ usePluginPages: () =>
ref([
{
plugin_id: 'demo',
diff --git a/webui/src/components/layout/ab-mobile-nav.vue b/webui/src/components/layout/ab-mobile-nav.vue
index 1214d0c2..67376cf3 100644
--- a/webui/src/components/layout/ab-mobile-nav.vue
+++ b/webui/src/components/layout/ab-mobile-nav.vue
@@ -10,7 +10,7 @@ import {
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
-import { slotTitle, useUiSlots } from '@/hooks/usePluginUi';
+import { slotTitle, usePluginPages } from '@/hooks/usePluginUi';
const { t, lang } = useMyI18n();
const route = useRoute();
@@ -56,7 +56,7 @@ const navItems = [
];
// 插件经 page 挂载点提供的页面,排在设置之前;导航栏可横向滚动
-const pageSlots = useUiSlots('page');
+const pageSlots = usePluginPages();
const visibleItems = computed(() => {
const pluginItems = pageSlots.value.map((ui, index) => ({
id: 100 + index,
@@ -121,13 +121,14 @@ const visibleItems = computed(() => {
}
&__item {
- flex: 1;
+ // 不收缩:项多时导航栏横向滚动,保持 44px 以上的触控区域
+ flex: 1 0 auto;
+ min-width: 56px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
- min-width: 0;
height: 56px;
padding: 6px 4px;
cursor: pointer;
@@ -172,7 +173,7 @@ const visibleItems = computed(() => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
- max-width: 100%;
+ max-width: 6em;
line-height: 1.2;
}
}
diff --git a/webui/src/components/layout/ab-sidebar.vue b/webui/src/components/layout/ab-sidebar.vue
index 16f6d27a..2598e452 100644
--- a/webui/src/components/layout/ab-sidebar.vue
+++ b/webui/src/components/layout/ab-sidebar.vue
@@ -13,7 +13,7 @@ import {
Sun,
} from '@icon-park/vue-next';
import InlineSvg from 'vue-inline-svg';
-import { slotTitle, useUiSlots } from '@/hooks/usePluginUi';
+import { slotTitle, usePluginPages } from '@/hooks/usePluginUi';
const props = withDefaults(
defineProps<{
@@ -94,7 +94,7 @@ const items = [
];
// 插件经 page 挂载点提供的页面,排在设置之前
-const pageSlots = useUiSlots('page');
+const pageSlots = usePluginPages();
const navItems = computed(() => {
const pluginItems = pageSlots.value.map((ui, index) => ({
id: 100 + index,
diff --git a/webui/src/components/plugin-slot.vue b/webui/src/components/plugin-slot.vue
index da3fd1e7..fde2a078 100644
--- a/webui/src/components/plugin-slot.vue
+++ b/webui/src/components/plugin-slot.vue
@@ -69,6 +69,12 @@ async function mount() {
}
}
+// 浏览器会缓存求值失败的模块(插件加载器只丢弃失败的 Promise),
+// 原地重新导入无法恢复,只能刷新页面
+function reload() {
+ location.reload();
+}
+
function unmount() {
generation += 1;
teardown?.();
@@ -95,7 +101,9 @@ watch(
{{ reason }}
{{ $t('plugin.load_failed_hint') }}
- {{ $t('plugin.retry') }}
+ {{
+ $t('plugin.reload')
+ }}
diff --git a/webui/src/components/setting/__tests__/config-download.test.ts b/webui/src/components/setting/__tests__/config-download.test.ts
index 230b35ad..82365707 100644
--- a/webui/src/components/setting/__tests__/config-download.test.ts
+++ b/webui/src/components/setting/__tests__/config-download.test.ts
@@ -38,9 +38,11 @@ function instance(id: string) {
}
const state = ref({} as Plugins);
+// 服务端最近一次加载/保存的快照
+const saved = { plugins: {} as Plugins };
vi.mock('@/store/config', () => ({
- useConfigStore: () => ({ getSettingGroup: () => state }),
+ useConfigStore: () => ({ getSettingGroup: () => state, savedConfig: saved }),
}));
const AbSettingStub = defineComponent({
@@ -100,6 +102,7 @@ describe('config-download', () => {
slots: { downloader: 'default' },
instances: [instance('default'), instance('nas')],
} as unknown as Plugins;
+ saved.plugins = JSON.parse(JSON.stringify(state.value));
});
it('should list every downloader instance and mark the default', () => {
@@ -203,6 +206,28 @@ describe('config-download', () => {
expect(state.value.instances[0].options.host).toBe('');
});
+ // 切走再切回已保存的类型时恢复保存的配置(含掩码密码,保存时由后端还原)
+ it('should restore the saved options when switching back to the saved provider', async () => {
+ saved.plugins.instances[0].options.password = '********';
+ state.value.instances[0].options.password = '********';
+ const wrapper = mountIt();
+ const type = wrapper
+ .findAllComponents(AbSettingStub)
+ .find(
+ (s) =>
+ (s.props('label') as () => string)() === 'config.downloader_set.type'
+ );
+ await type?.vm.$emit('update:data', 'aria2');
+ await type?.vm.$emit('update:data', 'qbittorrent');
+ expect(state.value.instances[0].provider).toBe('qbittorrent');
+ expect(state.value.instances[0].options).toEqual(
+ saved.plugins.instances[0].options
+ );
+ expect(state.value.instances[0].options).not.toBe(
+ saved.plugins.instances[0].options
+ );
+ });
+
it('should hide the username field for aria2', async () => {
state.value.instances[0].provider = 'aria2';
const wrapper = mountIt();
diff --git a/webui/src/components/setting/__tests__/config-notification.test.ts b/webui/src/components/setting/__tests__/config-notification.test.ts
index 31e82a39..30f33edb 100644
--- a/webui/src/components/setting/__tests__/config-notification.test.ts
+++ b/webui/src/components/setting/__tests__/config-notification.test.ts
@@ -20,10 +20,14 @@ vi.mock('@/hooks/usePluginProviders', () => ({
}));
const group = ref({
enable: true,
- providers: [{ type: 'telegram', enabled: true, token: 'tk' }],
+ providers: [
+ { type: 'telegram', enabled: true, token: '********' },
+ { type: 'ntfy', enabled: true, topic: '********' },
+ ],
});
+const saved = { notification: { providers: [] as unknown[] } };
vi.mock('@/store/config', () => ({
- useConfigStore: () => ({ getSettingGroup: () => group }),
+ useConfigStore: () => ({ getSettingGroup: () => group, savedConfig: saved }),
}));
const api = vi.mocked(apiNotification);
@@ -61,9 +65,33 @@ describe('config-notification', () => {
beforeEach(() => {
vi.clearAllMocks();
api.testProviderConfig.mockResolvedValue(ok as any);
+ api.testProvider.mockResolvedValue(ok as any);
+ saved.notification.providers = JSON.parse(
+ JSON.stringify(group.value.providers)
+ );
});
- it('should test the in-memory config and name the provider when testing from the list', async () => {
+ // /config/get 返回的密钥是掩码,列表测试须按下标走后端保存的配置
+ it.each([
+ [0, 'Telegram: ok'],
+ [1, 'config.notification_set.plugin_label:ntfy: ok'],
+ ])(
+ 'should test saved row %i by index when testing from the list',
+ async (index, text) => {
+ const w = mountPage();
+ await w
+ .findAll('[aria-label="config.notification_set.test"]')
+ [index].trigger('click');
+ await flushPromises();
+ expect(api.testProviderConfig).not.toHaveBeenCalled();
+ expect(api.testProvider).toHaveBeenCalledWith({ provider_index: index });
+ expect(w.find('.test-result').text()).toBe(text);
+ }
+ );
+
+ // 未保存的行(新增或删除后下标错位)在后端没有对应项,按当前配置测试
+ it('should test an unsaved row with its in-memory config', async () => {
+ saved.notification.providers = [];
const w = mountPage();
await w
.find('[aria-label="config.notification_set.test"]')
@@ -71,9 +99,8 @@ describe('config-notification', () => {
await flushPromises();
expect(api.testProvider).not.toHaveBeenCalled();
expect(api.testProviderConfig).toHaveBeenCalledWith(
- expect.objectContaining({ type: 'telegram', token: 'tk' })
+ expect.objectContaining({ type: 'telegram' })
);
- expect(w.find('.test-result').text()).toBe('Telegram: ok');
});
it('should keep dialog test results out of the list and clear them on close', async () => {
diff --git a/webui/src/components/setting/__tests__/plugin-schema-form.test.ts b/webui/src/components/setting/__tests__/plugin-schema-form.test.ts
index 7de98776..60c99658 100644
--- a/webui/src/components/setting/__tests__/plugin-schema-form.test.ts
+++ b/webui/src/components/setting/__tests__/plugin-schema-form.test.ts
@@ -41,17 +41,29 @@ describe('plugin-schema-form', () => {
});
describe('plugin-schema-form number clear', () => {
+ // 清空后保持空白以便重新输入,不立刻填回默认值;Optional 字段清空为 null
it.each([
- [{ type: 'integer' as const, default: 5 }, 5],
- [{ type: 'integer' as const }, undefined],
- ])('should reset %j to its default when cleared', async (prop, expected) => {
+ [{ type: 'integer' as const, default: 5 }, false, undefined],
+ [{ type: 'integer' as const }, false, undefined],
+ [
+ {
+ anyOf: [{ type: 'integer' as const }, { type: 'null' as const }],
+ default: 5,
+ },
+ true,
+ null,
+ ],
+ ])('should keep %j empty when cleared', async (prop, hasKey, expected) => {
const fields = schemaFields({ properties: { n: prop } });
const model: Record = { n: 9 };
const wrapper = mount(PluginSchemaForm, {
props: { fields, modelValue: model },
});
- await wrapper.find('input').setValue('');
+ const input = wrapper.find('input');
+ await input.setValue('');
+ expect('n' in model).toBe(hasKey);
expect(model.n).toBe(expected);
+ expect(input.element.value).toBe('');
});
});
diff --git a/webui/src/components/setting/config-download.vue b/webui/src/components/setting/config-download.vue
index 3c0d1036..1128f701 100644
--- a/webui/src/components/setting/config-download.vue
+++ b/webui/src/components/setting/config-download.vue
@@ -7,7 +7,8 @@ import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t } = useMyI18n();
const { confirm } = useConfirm();
-const { getSettingGroup } = useConfigStore();
+const configStore = useConfigStore();
+const { getSettingGroup } = configStore;
const plugins = getSettingGroup('plugins');
const downloaders = computed(() =>
@@ -34,7 +35,7 @@ const idError = computed(() => {
return idTaken.value ? t('config.downloader_set.id_taken') : '';
});
-// 空白 options;切换下载器类型时重置,避免旧类型的凭据带入新类型
+// 空白 options;切换到新类型时重置,避免旧类型的凭据带入新类型
function defaultOptions() {
return {
host: '',
@@ -45,10 +46,19 @@ function defaultOptions() {
} satisfies DownloaderOptions;
}
+// 切回已保存的类型时恢复保存的 options(掩码密码保存时由后端还原),
+// 否则切走再切回会把已保存的凭据清空
function setProvider(provider: string) {
- if (!downloader.value || downloader.value.provider === provider) return;
- downloader.value.provider = provider;
- downloader.value.options = defaultOptions();
+ const current = downloader.value;
+ if (!current || current.provider === provider) return;
+ const saved = configStore.savedConfig.plugins.instances.find(
+ (i) => i.id === current.id
+ );
+ current.provider = provider;
+ current.options =
+ saved?.provider === provider
+ ? JSON.parse(JSON.stringify(saved.options))
+ : defaultOptions();
}
function addInstance() {
diff --git a/webui/src/components/setting/config-notification.vue b/webui/src/components/setting/config-notification.vue
index 053cd197..f898d56d 100644
--- a/webui/src/components/setting/config-notification.vue
+++ b/webui/src/components/setting/config-notification.vue
@@ -8,7 +8,8 @@ import { usePluginProviders } from '@/hooks/usePluginProviders';
const { t, returnUserLangText } = useMyI18n();
const { confirm } = useConfirm();
-const { getSettingGroup } = useConfigStore();
+const configStore = useConfigStore();
+const { getSettingGroup } = configStore;
const notificationRef = getSettingGroup('notification');
@@ -214,10 +215,10 @@ function toggleProvider(index: number) {
}
async function runTest(
- config: NotificationProviderConfig
+ request: () => ReturnType
): Promise {
try {
- const response = await apiNotification.testProviderConfig(config as any);
+ const response = await request();
return {
success: response.data.success,
message: returnUserLangText({
@@ -233,12 +234,20 @@ async function runTest(
}
}
-// 列表测试发送当前(含未保存)的配置,而不是后端已保存的第 index 项
+// 已保存且未改动的行按下标使用后端保存的配置:前端拿到的密钥是掩码,
+// 发回去必然失败;插件渠道的自带字段也只有后端的完整配置里才有。
+// 其余行(新增、删除后下标错位、已编辑)在后端没有对应项,按当前配置测试
async function testProvider(index: number) {
testingIndex.value = index;
listResult.value = null;
const provider = providers.value[index];
- const result = await runTest(provider);
+ const saved = configStore.savedConfig.notification.providers?.[index];
+ const unchanged = JSON.stringify(saved) === JSON.stringify(provider);
+ const result = await runTest(() =>
+ unchanged
+ ? apiNotification.testProvider({ provider_index: index })
+ : apiNotification.testProviderConfig(provider as any)
+ );
listResult.value = {
...result,
message: `${getProviderLabel(provider.type)}: ${result.message}`,
@@ -249,7 +258,9 @@ async function testProvider(index: number) {
async function testNewProvider() {
testingIndex.value = -999; // Special index for new provider
dialogResult.value = null;
- dialogResult.value = await runTest(newProvider.value);
+ dialogResult.value = await runTest(() =>
+ apiNotification.testProviderConfig(newProvider.value as any)
+ );
testingIndex.value = -1;
}
diff --git a/webui/src/components/setting/plugin-schema-form.vue b/webui/src/components/setting/plugin-schema-form.vue
index a1424e5a..540d3890 100644
--- a/webui/src/components/setting/plugin-schema-form.vue
+++ b/webui/src/components/setting/plugin-schema-form.vue
@@ -76,10 +76,10 @@ async function removeRow(field: SchemaField, index: number) {
function setNumber(field: SchemaField, value: string | number) {
const parsed = Number(value);
if (value === '' || Number.isNaN(parsed)) {
- // 清空时回到 schema 默认值,没有默认值才删除该键
- if (field.default === undefined || field.default === null)
- delete model.value[field.key];
- else model.value[field.key] = field.default;
+ // 清空后保持空白便于重新输入:Optional 字段存 null,其余删除该键
+ // (保存时由后端取默认值)
+ if (field.nullable) model.value[field.key] = null;
+ else delete model.value[field.key];
return;
}
model.value[field.key] = field.integer ? Math.trunc(parsed) : parsed;
diff --git a/webui/src/hooks/__tests__/usePluginUi.test.ts b/webui/src/hooks/__tests__/usePluginUi.test.ts
index 44833e73..02f69546 100644
--- a/webui/src/hooks/__tests__/usePluginUi.test.ts
+++ b/webui/src/hooks/__tests__/usePluginUi.test.ts
@@ -5,7 +5,11 @@ import { flushPromises } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { apiPlugins } from '@/api/plugins';
-import { refreshPluginUi, usePluginUi } from '@/hooks/usePluginUi';
+import {
+ refreshPluginUi,
+ usePluginPages,
+ usePluginUi,
+} from '@/hooks/usePluginUi';
vi.mock('@/api/plugins', () => ({
apiPlugins: { ui: vi.fn() },
@@ -25,4 +29,25 @@ describe('usePluginUi', () => {
expect(loadFailed.value).toBe(false);
expect(loaded.value).toBe(true);
});
+
+ // 同一插件的多个页面共用 /plugins/ 路由,导航里只出现一项(取第一个页面)
+ it('should list one page entry per plugin', async () => {
+ const page = (plugin_id: string, element: string) => ({
+ plugin_id,
+ slot: 'page',
+ element,
+ entry: 'web/index.js',
+ title: { 'en-US': element },
+ });
+ vi.mocked(apiPlugins.ui).mockResolvedValueOnce([
+ page('a', 'a-one'),
+ page('a', 'a-two'),
+ page('b', 'b-one'),
+ ] as never);
+ await refreshPluginUi();
+ expect(usePluginPages().value.map((ui) => ui.element)).toEqual([
+ 'a-one',
+ 'b-one',
+ ]);
+ });
});
diff --git a/webui/src/hooks/usePluginUi.ts b/webui/src/hooks/usePluginUi.ts
index 9c76b4bc..185f09ac 100644
--- a/webui/src/hooks/usePluginUi.ts
+++ b/webui/src/hooks/usePluginUi.ts
@@ -35,6 +35,19 @@ export function useUiSlots(slot: UiSlot) {
return computed(() => all.value.filter((s) => s.slot === slot));
}
+/**
+ * 导航用的插件页面:每个插件一项(取它的第一个 page 挂载点)。同一插件的
+ * 多个页面共用 /plugins/ 路由,逐个列出会出现重复入口。
+ */
+export function usePluginPages() {
+ const pages = useUiSlots('page');
+ return computed(() =>
+ pages.value.filter(
+ (ui, i, all) => all.findIndex((s) => s.plugin_id === ui.plugin_id) === i
+ )
+ );
+}
+
/** 按当前语言取标题:`zh-CN` → `en-US` → 第一个。 */
export function slotTitle(ui: PluginUiSlot, locale: string): string {
return ui.title[locale] ?? ui.title['en-US'] ?? Object.values(ui.title)[0];
diff --git a/webui/src/i18n/en.json b/webui/src/i18n/en.json
index c4792458..669672d4 100644
--- a/webui/src/i18n/en.json
+++ b/webui/src/i18n/en.json
@@ -627,8 +627,9 @@
"load_failed": "Plugin component failed to load.",
"page_missing": "This plugin has no page",
"page_missing_hint": "The plugin is disabled, or it does not declare a page.",
- "load_failed_hint": "Retry, or reload the page. If it keeps failing, disable the plugin in Settings > Plugins.",
+ "load_failed_hint": "Reload the page to try again. If it keeps failing, disable the plugin in Settings > Plugins.",
"retry": "Retry",
+ "reload": "Reload page",
"load_failed_page": "Could not load plugin pages"
},
"sidebar": {
diff --git a/webui/src/i18n/zh-CN.json b/webui/src/i18n/zh-CN.json
index 4d998e19..c4f5bf61 100644
--- a/webui/src/i18n/zh-CN.json
+++ b/webui/src/i18n/zh-CN.json
@@ -627,8 +627,9 @@
"load_failed": "插件组件加载失败。",
"page_missing": "该插件没有页面",
"page_missing_hint": "插件未启用,或没有声明页面。",
- "load_failed_hint": "请重试或刷新页面。仍然失败时,请在 设置 > 插件 中停用该插件。",
+ "load_failed_hint": "请刷新页面后重试。仍然失败时,请在 设置 > 插件 中停用该插件。",
"retry": "重试",
+ "reload": "刷新页面",
"load_failed_page": "无法加载插件页面"
},
"sidebar": {
diff --git a/webui/src/pages/index.test.ts b/webui/src/pages/index.test.ts
index f8fee1bb..118daf36 100644
--- a/webui/src/pages/index.test.ts
+++ b/webui/src/pages/index.test.ts
@@ -15,9 +15,11 @@ vi.mock('@/hooks/useMyI18n', async () => {
});
const slots = ref([]);
+const loaded = ref(false);
+const loadFailed = ref(false);
vi.mock('@/hooks/usePluginUi', () => ({
slotTitle: (ui: { title: Record }) => ui.title['en-US'],
- usePluginUi: () => ({ slots }),
+ usePluginUi: () => ({ slots, loaded, loadFailed }),
}));
vi.mock('@/store/bangumi', () => ({
useBangumiStore: () => ({ editRule: ref({}) }),
@@ -37,6 +39,8 @@ function mountIndex() {
describe('index page title', () => {
beforeEach(() => {
setActivePinia(createPinia());
+ loaded.value = false;
+ loadFailed.value = false;
});
it('should leave the title empty on a plugin route until the manifest arrives', async () => {
@@ -50,4 +54,19 @@ describe('index page title', () => {
await w.vm.$nextTick();
expect(w.find('h1').text()).toBe('Demo page');
});
+
+ // 插件已停用/不存在或清单加载失败时没有匹配的页面,标题回退为插件 id
+ it.each([
+ [true, false],
+ [false, true],
+ ])(
+ 'should fall back to the plugin id when loaded=%s failed=%s and no page matches',
+ async (isLoaded, failed) => {
+ slots.value = [];
+ loaded.value = isLoaded;
+ loadFailed.value = failed;
+ const w = mountIndex();
+ expect(w.find('h1').text()).toBe('demo');
+ }
+ );
});
diff --git a/webui/src/pages/index.vue b/webui/src/pages/index.vue
index c3455ae0..3521a650 100644
--- a/webui/src/pages/index.vue
+++ b/webui/src/pages/index.vue
@@ -9,19 +9,18 @@ definePage({
const { editRule } = storeToRefs(useBangumiStore());
-// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin);其它页面沿用路由名
+// 插件页面的标题取自清单(清单到达前留空,避免闪出路由名 Plugin;清单已到达
+// 或加载失败仍无匹配页面时回退为插件 id);其它页面沿用路由名
const route = useRoute();
const { lang } = useMyI18n();
-const { slots } = usePluginUi();
+const { slots, loaded, loadFailed } = usePluginUi();
const pageTitle = computed(() => {
const name = String(route.name ?? '');
if (name !== 'Plugin') return name;
- const ui = slots.value.find(
- (s) =>
- s.slot === 'page' &&
- s.plugin_id === (route.params as Record).id
- );
- return ui ? slotTitle(ui, lang.value === 'zh-CN' ? 'zh-CN' : 'en-US') : '';
+ const id = (route.params as Record).id;
+ const ui = slots.value.find((s) => s.slot === 'page' && s.plugin_id === id);
+ if (ui) return slotTitle(ui, lang.value === 'zh-CN' ? 'zh-CN' : 'en-US');
+ return loaded.value || loadFailed.value ? id : '';
});
const { updateRule, enableRule, archiveRule, unarchiveRule, ruleManage } =
useBangumiStore();
diff --git a/webui/src/store/config.ts b/webui/src/store/config.ts
index 01283262..5e39b849 100644
--- a/webui/src/store/config.ts
+++ b/webui/src/store/config.ts
@@ -71,6 +71,7 @@ export const useConfigStore = defineStore('config', () => {
return {
config,
+ savedConfig,
lastSaveError,
dirtyGroups,
isDirty,
diff --git a/webui/src/utils/__tests__/plugin-schema.test.ts b/webui/src/utils/__tests__/plugin-schema.test.ts
index b0773d86..982fd4fa 100644
--- a/webui/src/utils/__tests__/plugin-schema.test.ts
+++ b/webui/src/utils/__tests__/plugin-schema.test.ts
@@ -132,3 +132,22 @@ describe('fillSchemaDefaults', () => {
expect(schema.properties!.tags.default).toEqual([]);
});
});
+
+describe('fillSchemaDefaults deep copy', () => {
+ // 草稿与已保存的 options 共用嵌套行时,编辑行会同时改到"已保存",脏值检测失效
+ it('should not share nested rows with the saved options', () => {
+ const saved = { rows: [{ from: 'a' }] };
+ const draft = fillSchemaDefaults([], saved);
+ (draft.rows as { from: string }[])[0].from = 'b';
+ expect(saved.rows[0].from).toBe('a');
+ });
+});
+
+describe('schemaFields nullable', () => {
+ it('should mark Optional (anyOf with null) fields as nullable', () => {
+ const fields = schemaFields(schema);
+ const nullable = Object.fromEntries(fields.map((f) => [f.key, f.nullable]));
+ expect(nullable.note).toBe(true);
+ expect(nullable.limit).toBe(false);
+ });
+});
diff --git a/webui/src/utils/plugin-schema.ts b/webui/src/utils/plugin-schema.ts
index f6d6ef82..db4d45cd 100644
--- a/webui/src/utils/plugin-schema.ts
+++ b/webui/src/utils/plugin-schema.ts
@@ -24,6 +24,8 @@ export interface SchemaField {
default: unknown;
/** 出现在 schema 的 required 列表中(pydantic 中没有默认值的字段) */
required: boolean;
+ /** Optional 字段(anyOf 含 null),清空时存为 null */
+ nullable: boolean;
/** number 字段的取值范围 */
minimum?: number;
maximum?: number;
@@ -88,6 +90,7 @@ function buildFields(
: [],
default: prop.default,
required: required.includes(key),
+ nullable: Boolean(raw.anyOf?.some((p) => p.type === 'null')),
minimum: prop.minimum,
maximum: prop.maximum,
};
@@ -100,12 +103,15 @@ export function schemaFields(schema: JsonSchema | null): SchemaField[] {
return buildFields(schema.properties, schema.$defs ?? {}, schema.required);
}
-/** 未保存过的字段用 schema 默认值填充,供表单初始展示 */
+/**
+ * 未保存过的字段用 schema 默认值填充,供表单初始展示。深拷贝 options:
+ * 表单会原地修改嵌套行,与已保存的 options 共用对象会让脏值检测失效。
+ */
export function fillSchemaDefaults(
fields: SchemaField[],
options: Record
): Record {
- const result: Record = { ...options };
+ const result: Record = JSON.parse(JSON.stringify(options));
for (const field of fields) {
if (!(field.key in result) && field.default !== undefined) {
result[field.key] = JSON.parse(JSON.stringify(field.default));