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 {