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(); +} 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') }}
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));