diff --git a/backend/src/module/models/bangumi.py b/backend/src/module/models/bangumi.py index c5a9da3a..18299c4d 100644 --- a/backend/src/module/models/bangumi.py +++ b/backend/src/module/models/bangumi.py @@ -10,32 +10,30 @@ class Bangumi(SQLModel, table=True): official_title: str = Field( default="official_title", alias="official_title", title="番剧中文名" ) - year: Optional[str] = Field(default=None, alias="year", title="番剧年份") + year: Optional[str] = Field(default=..., alias="year", title="番剧年份") title_raw: str = Field( default="title_raw", alias="title_raw", title="番剧原名", index=True ) season: int = Field(default=1, alias="season", title="番剧季度") season_raw: Optional[str] = Field( - default=None, alias="season_raw", title="番剧季度原名" + default=..., alias="season_raw", title="番剧季度原名" ) - group_name: Optional[str] = Field(default=None, alias="group_name", title="字幕组") - dpi: Optional[str] = Field(default=None, alias="dpi", title="分辨率") - source: Optional[str] = Field(default=None, alias="source", title="来源") - subtitle: Optional[str] = Field(default=None, alias="subtitle", title="字幕") + group_name: Optional[str] = Field(default=..., alias="group_name", title="字幕组") + dpi: Optional[str] = Field(default=..., alias="dpi", title="分辨率") + source: Optional[str] = Field(default=..., alias="source", title="来源") + subtitle: Optional[str] = Field(default=..., alias="subtitle", title="字幕") eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") episode_offset: int = Field(default=0, alias="episode_offset", title="集数偏移量") season_offset: int = Field(default=0, alias="season_offset", title="季度偏移量") filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") poster_link: Optional[str] = Field( - default=None, alias="poster_link", title="番剧海报链接" + default=..., alias="poster_link", title="番剧海报链接" ) added: bool = Field(default=False, alias="added", title="是否已添加") - rule_name: Optional[str] = Field( - default=None, alias="rule_name", title="番剧规则名" - ) + rule_name: Optional[str] = Field(default=..., alias="rule_name", title="番剧规则名") save_path: Optional[str] = Field( - default=None, alias="save_path", title="番剧保存路径" + default=..., alias="save_path", title="番剧保存路径" ) deleted: bool = Field( default=False, alias="deleted", title="是否已删除", index=True @@ -81,30 +79,28 @@ class BangumiUpdate(SQLModel): official_title: str = Field( default="official_title", alias="official_title", title="番剧中文名" ) - year: Optional[str] = Field(default=None, alias="year", title="番剧年份") + year: Optional[str] = Field(default=..., alias="year", title="番剧年份") title_raw: str = Field(default="title_raw", alias="title_raw", title="番剧原名") season: int = Field(default=1, alias="season", title="番剧季度") season_raw: Optional[str] = Field( - default=None, alias="season_raw", title="番剧季度原名" + default=..., alias="season_raw", title="番剧季度原名" ) - group_name: Optional[str] = Field(default=None, alias="group_name", title="字幕组") - dpi: Optional[str] = Field(default=None, alias="dpi", title="分辨率") - source: Optional[str] = Field(default=None, alias="source", title="来源") - subtitle: Optional[str] = Field(default=None, alias="subtitle", title="字幕") + group_name: Optional[str] = Field(default=..., alias="group_name", title="字幕组") + dpi: Optional[str] = Field(default=..., alias="dpi", title="分辨率") + source: Optional[str] = Field(default=..., alias="source", title="来源") + subtitle: Optional[str] = Field(default=..., alias="subtitle", title="字幕") eps_collect: bool = Field(default=False, alias="eps_collect", title="是否已收集") episode_offset: int = Field(default=0, alias="episode_offset", title="集数偏移量") season_offset: int = Field(default=0, alias="season_offset", title="季度偏移量") filter: str = Field(default="720,\\d+-\\d+", alias="filter", title="番剧过滤器") rss_link: str = Field(default="", alias="rss_link", title="番剧RSS链接") poster_link: Optional[str] = Field( - default=None, alias="poster_link", title="番剧海报链接" + default=..., alias="poster_link", title="番剧海报链接" ) added: bool = Field(default=False, alias="added", title="是否已添加") - rule_name: Optional[str] = Field( - default=None, alias="rule_name", title="番剧规则名" - ) + rule_name: Optional[str] = Field(default=..., alias="rule_name", title="番剧规则名") save_path: Optional[str] = Field( - default=None, alias="save_path", title="番剧保存路径" + default=..., alias="save_path", title="番剧保存路径" ) downloader_id: Optional[str] = Field( default=None, alias="downloader_id", title="下载器实例" diff --git a/backend/src/test/test_api_bangumi.py b/backend/src/test/test_api_bangumi.py index aee926c2..c5822ff0 100644 --- a/backend/src/test/test_api_bangumi.py +++ b/backend/src/test/test_api_bangumi.py @@ -152,6 +152,18 @@ class TestUpdateBangumi: assert response.status_code == 200 + def test_update_partial_body_rejected(self, authed_client): + """只传部分字段时返回 422,不能用占位值改写规则与保存路径。""" + with patch("module.api.bangumi.TorrentManager") as MockManager: + mock_mgr = MockManager.return_value + mock_mgr.update_rule = AsyncMock() + response = authed_client.patch( + "/api/v1/bangumi/update/1", json={"year": "2025"} + ) + + assert response.status_code == 422 + mock_mgr.update_rule.assert_not_called() + # --------------------------------------------------------------------------- # DELETE endpoints diff --git a/webui/src/components/basic/__tests__/ab-menu.test.ts b/webui/src/components/basic/__tests__/ab-menu.test.ts index 7c09cdbc..944c0da5 100644 --- a/webui/src/components/basic/__tests__/ab-menu.test.ts +++ b/webui/src/components/basic/__tests__/ab-menu.test.ts @@ -60,6 +60,28 @@ describe('ab-menu', () => { expect(trigger.attributes('aria-expanded')).toBe('true'); }); + it('should move focus into the menu and announce the highlighted item when opened', async () => { + const wrapper = mount(AbMenu, { + props: { items: items() }, + slots: { trigger: '' }, + attachTo: document.body, + }); + const trigger = wrapper.find('button'); + (trigger.element as HTMLElement).focus(); + await trigger.trigger('click'); + await flushPromises(); + const menu = document.querySelector('[role="menu"]')!; + expect(document.activeElement).toBe(menu); + + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await flushPromises(); + expect(menu.getAttribute('aria-activedescendant')).toBe(menuItems()[0].id); + + menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await flushPromises(); + expect(document.activeElement).toBe(trigger.element); + }); + it('should mark danger items with the danger class', async () => { const wrapper = mount(AbMenu, { props: { items: items() }, diff --git a/webui/src/components/basic/ab-menu.vue b/webui/src/components/basic/ab-menu.vue index 1cbd82e0..a5f69fc2 100644 --- a/webui/src/components/basic/ab-menu.vue +++ b/webui/src/components/basic/ab-menu.vue @@ -14,8 +14,9 @@ export interface AbMenuItem { } // 下拉菜单:naive-ui NDropdown 负责定位与键盘导航(方向键/回车/Esc); -// 触发器放在 #trigger 插槽。NDropdown 不输出 aria,menuitem 角色与 -// 触发器的 aria-haspopup/aria-expanded 在这里补上。 +// 触发器放在 #trigger 插槽。NDropdown 不输出 aria,也不移动焦点:这里补上 +// menu/menuitem 角色、触发器的 aria-haspopup/aria-expanded,打开时把焦点 +// 移到菜单容器,并用 aria-activedescendant 指向高亮项,读屏才能读出当前项。 const { items, align = 'left' } = defineProps<{ items: AbMenuItem[]; /** 菜单相对触发器的对齐边 */ @@ -27,6 +28,8 @@ const emit = defineEmits<{ select: [item: AbMenuItem] }>(); const slots = defineSlots<{ trigger: () => VNode[] }>(); const open = ref(false); +const listId = useId(); +const activeId = ref(); const options = computed(() => items.map((item, index) => ({ @@ -35,7 +38,9 @@ const options = computed(() => disabled: item.disabled, icon: item.icon && (() => h(item.icon!, { size: 15 })), props: { + id: `${listId}-${item.key ?? index}`, role: 'menuitem', + 'aria-disabled': item.disabled || undefined, class: item.danger ? 'ab-menu-item--danger' : undefined, }, })) @@ -48,6 +53,38 @@ function Trigger() { }); } +// NDropdown 不暴露高亮项,只能从它的 --pending 类读出 +let observer: MutationObserver | undefined; +let opener: HTMLElement | null = null; + +watch(open, async (isOpen) => { + observer?.disconnect(); + activeId.value = undefined; + const list = document.getElementById(listId); + if (!isOpen) { + // 焦点留在正在关闭的菜单里或已丢到 body 时,还给触发器 + const focused = document.activeElement; + if (focused === document.body || list?.contains(focused)) opener?.focus(); + return; + } + opener = document.activeElement as HTMLElement | null; + await nextTick(); + const menu = document.getElementById(listId); + if (!menu) return; + menu.focus({ preventScroll: true }); + observer = new MutationObserver(() => { + activeId.value = + menu.querySelector('.n-dropdown-option-body--pending')?.id || undefined; + }); + observer.observe(menu, { + subtree: true, + attributes: true, + attributeFilter: ['class'], + }); +}); + +onBeforeUnmount(() => observer?.disconnect()); + function onSelect(key: string | number) { const item = items.find((entry, index) => (entry.key ?? index) === key); if (!item || item.disabled) return; @@ -62,7 +99,15 @@ function onSelect(key: string | number) { :show="open" :options="options" :placement="align === 'right' ? 'bottom-end' : 'bottom-start'" - :menu-props="() => ({ class: 'ab-menu-list' })" + :menu-props=" + () => ({ + 'id': listId, + 'class': 'ab-menu-list', + 'role': 'menu', + 'tabindex': -1, + 'aria-activedescendant': activeId, + }) + " @update:show="open = $event" @select="onSelect" > @@ -82,6 +127,11 @@ function onSelect(key: string | number) { .ab-menu-list { min-width: 180px; + // 焦点在容器上只为 aria-activedescendant,高亮项已经标出当前项 + &:focus { + outline: none; + } + // 触屏放大命中区域,与旧菜单一致 @include forTouch { .n-dropdown-option-body {