From 88ea09e94dc5f318d8e16f0f09dc4a3ab5e43b4e Mon Sep 17 00:00:00 2001 From: chenshoulin Date: Sun, 18 Oct 2026 20:26:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(tags):=20=E7=BC=96=E8=BE=91=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=97=B6=E5=8F=AF=E4=BB=8E=E5=B7=B2=E6=9C=89=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E9=87=8C=E9=80=89=EF=BC=8C=E4=B8=8D=E5=BF=85=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E6=89=8B=E6=89=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TagInput 增加候选下拉:聚焦即展开(输入框为空列全部、有草稿按子串过滤), 上下键移动高亮、回车选中高亮项、点击也能选;候选项排除已选中的,选完自动 从列表移除。列表没展开或没有匹配时,回车仍把草稿建成新标签,原行为不变。 候选点击用 mousedown.prevent 保住输入框焦点,否则 blur 会先把草稿提交掉。 把「全部标签」从 BoardToolbar 的局部 computed 提到 store 的 allTags, 筛选下拉和标签候选共用同一份,避免两处各算一份导致结果不一致。 测试:TagInput 新增 13 条(候选过滤、键盘选择、失焦/Escape 收起、aria-selected), store 新增 2 条 allTags,E2E 新增 1 条打通 store → 抽屉 → 组件这条接线。 --- e2e/board.spec.ts | 30 +++++ src/components/BoardToolbar.vue | 11 +- src/components/TagInput.spec.ts | 127 +++++++++++++++++++ src/components/TagInput.vue | 190 +++++++++++++++++++++++----- src/components/TaskDetailDrawer.vue | 2 +- src/stores/tasks.spec.ts | 11 ++ src/stores/tasks.ts | 13 ++ 7 files changed, 341 insertions(+), 43 deletions(-) diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index cca4dde..0cc4291 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -62,6 +62,36 @@ test('详情抽屉可编辑并持久化', async ({ page }) => { await expect(card).toContainText('高') }) +/** + * 标签候选要打通 store → 抽屉 → TagInput 三层,单测覆盖不到接线, + * 所以这里从「另一个任务上已有的标签」出发验证整条链路。 + */ +test('编辑任务时可以直接选已有标签', async ({ page }) => { + // 先在一个任务上建出标签 + await addTask(page, '任务甲') + await page.locator('article').filter({ hasText: '任务甲' }).click() + const firstInput = page.getByLabel('添加标签') + await firstInput.fill('设计') + await firstInput.press('Enter') + await page.getByRole('button', { name: '保存' }).click() + + // 另一个任务:聚焦标签输入框应列出「设计」 + await addTask(page, '任务乙') + await page.locator('article').filter({ hasText: '任务乙' }).click() + + const tagInput = page.getByLabel('添加标签') + await tagInput.click() + + const option = page.locator('[role="option"]').filter({ hasText: '#设计' }) + await expect(option).toBeVisible() + await option.click() + + await page.getByRole('button', { name: '保存' }).click() + + await page.reload() + await expect(page.locator('article').filter({ hasText: '任务乙' })).toContainText('#设计') +}) + test('删除后可撤销', async ({ page }) => { await addTask(page, '待删除任务') await page.locator('article').filter({ hasText: '待删除任务' }).click() diff --git a/src/components/BoardToolbar.vue b/src/components/BoardToolbar.vue index bcf8b7e..948a8eb 100644 --- a/src/components/BoardToolbar.vue +++ b/src/components/BoardToolbar.vue @@ -31,15 +31,6 @@ const overdueCount = computed( () => store.tasks.filter((task) => isOverdue(task.dueDate, task.status)).length, ) -/** 从现有任务里收集标签,作为筛选候选项 */ -const allTags = computed(() => { - const collected = new Set() - for (const task of store.tasks) { - for (const tag of task.tags) collected.add(tag) - } - return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) -}) - /** 当前可归档(已完成且超过保留天数)的任务数量 */ const archivableCount = computed(() => findArchivableTasks(store.tasks).length) @@ -96,7 +87,7 @@ defineExpose({ focusSearch }) { await wrapper.find('input').trigger('keydown', { key: 'Enter' }) @@ -137,3 +141,126 @@ describe('TagInput 删除', () => { expect(wrapper.emitted('update:modelValue')).toBeUndefined() }) }) + +/** + * 已有标签候选:编辑任务时应当能直接挑,而不是每次手打, + * 否则同一个标签很容易被写成「前端」「前端 」这种近似重复。 + */ +describe('TagInput 候选列表', () => { + const options = (wrapper: VueWrapper) => + wrapper.findAll('[role="option"]').map((item) => item.text()) + + it('聚焦后列出已有标签', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端']) + await wrapper.find('input').trigger('focus') + expect(options(wrapper)).toEqual(['#设计', '#前端']) + }) + + it('候选里不含已选中的标签', async () => { + const wrapper = mountWithSuggestions(['设计'], ['设计', '前端']) + await wrapper.find('input').trigger('focus') + expect(options(wrapper)).toEqual(['#前端']) + }) + + it('按草稿做子串过滤', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端', '前端性能']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.setValue('前端') + expect(options(wrapper)).toEqual(['#前端', '#前端性能']) + }) + + it('未聚焦时不展开', () => { + expect(mountWithSuggestions([], ['设计']).find('[role="listbox"]').exists()).toBe(false) + }) + + it('没有候选数据时不展开', async () => { + const wrapper = mountTags([]) + await wrapper.find('input').trigger('focus') + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('候选全部已选中时不展开', async () => { + const wrapper = mountWithSuggestions(['设计'], ['设计']) + await wrapper.find('input').trigger('focus') + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('点击候选项添加该标签并清空草稿', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.setValue('设') + + await wrapper.find('[role="option"] button').trigger('click') + + expect(lastEmitted(wrapper)).toEqual(['设计']) + expect((input.element as HTMLInputElement).value).toBe('') + }) + + it('回车选中高亮的候选,而不是把半截草稿建成新标签', async () => { + const wrapper = mountWithSuggestions([], ['前端']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.setValue('前') + await pressEnter(wrapper) + expect(lastEmitted(wrapper)).toEqual(['前端']) + }) + + it('下键移动高亮,回车选中第二项', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.trigger('keydown', { key: 'ArrowDown' }) + await pressEnter(wrapper) + expect(lastEmitted(wrapper)).toEqual(['前端']) + }) + + it('高亮到底后回到第一项', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.trigger('keydown', { key: 'ArrowDown' }) + await input.trigger('keydown', { key: 'ArrowDown' }) + await pressEnter(wrapper) + expect(lastEmitted(wrapper)).toEqual(['设计']) + }) + + it('没有匹配候选时回车仍把草稿建成新标签', async () => { + const wrapper = mountWithSuggestions([], ['设计']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.setValue('全新标签') + await pressEnter(wrapper) + expect(lastEmitted(wrapper)).toEqual(['全新标签']) + }) + + it('失焦后收起候选列表', async () => { + const wrapper = mountWithSuggestions([], ['设计']) + const input = wrapper.find('input') + await input.trigger('focus') + expect(wrapper.find('[role="listbox"]').exists()).toBe(true) + + await input.trigger('blur') + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('Escape 收起候选列表', async () => { + const wrapper = mountWithSuggestions([], ['设计']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.trigger('keydown', { key: 'Escape' }) + expect(wrapper.find('[role="listbox"]').exists()).toBe(false) + }) + + it('候选项标记了 aria-selected 以便读屏', async () => { + const wrapper = mountWithSuggestions([], ['设计', '前端']) + const input = wrapper.find('input') + await input.trigger('focus') + await input.trigger('keydown', { key: 'ArrowDown' }) + + const items = wrapper.findAll('[role="option"]') + expect(items[0]?.attributes('aria-selected')).toBe('false') + expect(items[1]?.attributes('aria-selected')).toBe('true') + }) +}) diff --git a/src/components/TagInput.vue b/src/components/TagInput.vue index e68ec7a..e2fa554 100644 --- a/src/components/TagInput.vue +++ b/src/components/TagInput.vue @@ -1,7 +1,25 @@ diff --git a/src/components/TaskDetailDrawer.vue b/src/components/TaskDetailDrawer.vue index 7a40b5e..d6a7cdf 100644 --- a/src/components/TaskDetailDrawer.vue +++ b/src/components/TaskDetailDrawer.vue @@ -131,7 +131,7 @@ function remove(): void {
标签 - +
diff --git a/src/stores/tasks.spec.ts b/src/stores/tasks.spec.ts index 7727bb9..b80de8b 100644 --- a/src/stores/tasks.spec.ts +++ b/src/stores/tasks.spec.ts @@ -59,6 +59,17 @@ describe('初始化', () => { store.init({ crossTab: false }) expect(store.tasks).toHaveLength(1) }) + + it('allTags 汇总全部任务的标签并去重排序', () => { + const store = freshStore() + store.createTask({ title: 'A', tags: ['前端', '设计'] }) + store.createTask({ title: 'B', status: 'doing', tags: ['设计', 'Bug'] }) + expect(store.allTags).toEqual(['Bug', '前端', '设计'].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN'))) + }) + + it('没有任务时 allTags 为空数组', () => { + expect(freshStore().allTags).toEqual([]) + }) }) describe('快速新增', () => { diff --git a/src/stores/tasks.ts b/src/stores/tasks.ts index 293db62..57812d2 100644 --- a/src/stores/tasks.ts +++ b/src/stores/tasks.ts @@ -89,6 +89,18 @@ export const useTaskStore = defineStore('tasks', () => { return result }) + /** + * 现有任务里出现过的全部标签,按中文排序。 + * 筛选下拉和编辑任务时的标签候选都用它,避免两处各算一份、结果不一致。 + */ + const allTags = computed(() => { + const collected = new Set() + for (const task of tasks.value) { + for (const tag of task.tags) collected.add(tag) + } + return [...collected].sort((a, b) => a.localeCompare(b, 'zh-Hans-CN')) + }) + function persist(): void { if (!repository) return try { @@ -391,6 +403,7 @@ export const useTaskStore = defineStore('tasks', () => { snapshots, byColumn, counts, + allTags, init, createTask, updateTask,