diff --git a/README.md b/README.md index 7d2a54f..3758204 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ - **回收站**:删除是软删除,30 天内可随时恢复;也可永久删除或清空 - 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期) - **标签管理**:全局重命名、合并、删除标签,不必逐个任务改 +- **标签颜色**:8 色色板,设过颜色的标签在卡片与筛选下拉里带一个圆点;重命名 / 合并时颜色自动跟随 - **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计 - 键盘快捷键;任务卡片可用方向键换列与调序 - **深色模式**:跟随系统 / 浅色 / 深色三态 @@ -63,6 +64,14 @@ npm run dev 操作后会提示影响了多少个任务。回收站里的任务不参与这些改写。 +### 标签颜色 + +标签默认**不带颜色** —— 没设过就不画圆点,避免每个标签都顶着一个灰点。 + +在标签管理面板里给标签点一个色块即可(8 色,另有虚线圆点表示「清除颜色」)。颜色会同步出现在看板卡片和标签筛选下拉里。重命名或合并标签时颜色自动跟着走,不会出现「改名之后突然掉色」。 + +颜色是**本机显示偏好**,存在 `kanban.tagColors`,不进入任务数据:导出 JSON 与快照恢复都不会带上它,换设备后需要重新设置。这样「怎么显示」不会污染「有哪些任务」,导入别人的数据时也不会把颜色映射一起覆盖掉。 + ## 脚本 | 命令 | 说明 | @@ -354,4 +363,4 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx - 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。 - 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。 - 导入冲突策略固定为「`updatedAt` 新者胜」,不可配置。 -- 标签还没有颜色,重命名 / 合并会同步落盘但不会保留旧名映射。 +- 标签颜色是本机显示偏好,不随导出 JSON 或快照恢复携带,换设备后需要重新设置。 diff --git a/docs/adr.md b/docs/adr.md index 1b815de..67f21ea 100644 --- a/docs/adr.md +++ b/docs/adr.md @@ -338,3 +338,47 @@ ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」 `resetHistory()`,避免「打开页面就能撤销一次数据清理」。 - ADR-007 中「_批量改属性不做撤销_」一条作废;其余结论(软删除、`activeTasks` 单一剔除点、 标签增删语义)不变。 + +--- + +## ADR-011 · 标签颜色独立存储,不进入任务数据 + +**背景** + +「已知限制」里长期挂着一条「标签还没有颜色」。标签是纯字符串,在卡片上与优先级、截止日期挤在同一行, +标签一多就难以扫读,上色是最直接的缓解手段,成本也远低于子任务、多设备同步这类结构性改动。 + +真正的决策点不是「怎么上色」,而是**颜色存在哪里**:放进 `PersistedState`(跟随导出、快照、导入), +还是单独一个 localStorage 键。 + +**结论** + +1. 颜色存独立的 `kanban.tagColors`,结构为 `{ [tag: string]: 色板id }`,**不进 `PersistedState`**, + 因此 `schemaVersion` 保持 3,不需要任何迁移。 +2. 存的是**色板 id**(`rose` / `sky` / …)而不是 class 字符串。以后调整视觉样式只改代码,不用动用户数据。 +3. 色板固定 8 色,每个色板在源码里写**完整类名字符串**。Tailwind v4 构建时扫描源码, + 动态拼接的 `bg-${color}-500` 不会被生成,样式会静默失效 —— 这是这个功能唯一的硬坑。 +4. **未设置颜色的标签不画圆点**,也不在映射里占位。默认给每个标签一个灰点只是把噪音放大,等于没上色。 +5. 标签治理(重命名 / 合并 / 删除)在 `stores/tasks.ts` 的三个方法里顺带调用颜色 store, + 把颜色的键名一起搬走。放在这里而不是面板组件里,是为了以后新增一种治理操作时不会漏掉颜色迁移。 +6. 目标标签已经有颜色时**保留目标的**:那是用户为它单独设过的,来源方更像「顺手被并过来」的一方。 +7. 读写失败静默降级(隐私模式 / 配额满)。颜色丢了不影响任何任务,不值得弹告警打断使用。 + +**被否决的方案** + +- _放进 `PersistedState`,跟随导出与快照_:好处是换设备能带走。代价是导入语义变浑 —— + 覆盖导入会变成「连别人的审美一起覆盖」,合并导入又该不该合并颜色?为了让导出多带几个键, + 引入一套「导入时忽略某字段」的特例,不划算。而且颜色是**可重建的**,任务不是。 +- _允许任意取色器_:会带来对比度失控(深色模式尤甚)、需要对存储值额外校验,以及一堆无法被 + Tailwind 静态扫描的动态类名。8 个手挑的色板足够区分,每档都验证过明暗两套主题。 +- _卡片上用整块 chip 背景着色_:标签行与优先级、截止日期共处一行,加背景色会把行撑高, + 也会与「红色 = 逾期」的语义打架。改成小圆点 + 文字保持中性,既区分了标签,又不干扰紧急度判断。 + +**影响** + +- 新增 `src/utils/tagColors.ts`(纯逻辑)与 `src/stores/tagColors.ts`(持久化),共 41 个单测。 +- `TaskCard` / `TagFilter` / `TagManagerPanel` 三处消费颜色;前两者改为依赖 store, + 因此对应组件测试需要 `setActivePinia`。 +- 标签圆点带 `data-role="tag-dot"`:卡片上本来就有别的圆点元素(状态点、勾选框), + 用 `.rounded-full` 这类样式选择器会误伤,单测与 e2e 一律按 `data-role` 定位。 +- 导出 JSON 不含颜色,README 的已知限制已写明。 diff --git a/e2e/board.spec.ts b/e2e/board.spec.ts index 55afd6e..93169e6 100644 --- a/e2e/board.spec.ts +++ b/e2e/board.spec.ts @@ -176,7 +176,9 @@ test('搜索筛选后列计数同步变化', async ({ page }) => { // 关键词有 200ms 防抖 await expect(page.locator('article')).toHaveCount(1) await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible() - await expect(page.locator('section').first().locator('[data-role="column-count"]')).toHaveText('1') + await expect(page.locator('section').first().locator('[data-role="column-count"]')).toHaveText( + '1', + ) }) test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => { @@ -280,3 +282,32 @@ test('输入框内 Ctrl+Z 不劫持任务撤销', async ({ page }) => { await expect(page.locator('article')).toHaveCount(1) }) + +test('标签可设颜色,卡片显示圆点且刷新后保持', async ({ page }) => { + // 建一个带标签的任务 + await addTask(page, '带标签的任务') + await page.locator('article').filter({ hasText: '带标签的任务' }).click() + const tagInput = page.getByLabel('添加标签') + await tagInput.fill('设计') + await tagInput.press('Enter') + await page.getByRole('button', { name: '保存' }).click() + + // 未设置颜色时不画圆点 + const card = page.locator('article').filter({ hasText: '带标签的任务' }) + await expect(card.locator('[data-role="tag-dot"]')).toHaveCount(0) + + // 打开标签管理,设为红色 + await page.getByRole('button', { name: /^标签/ }).click() + await page.getByRole('button', { name: /管理标签/ }).click() + await page.getByRole('button', { name: '把标签「设计」标为红色' }).click() + + await page.keyboard.press('Escape') + + await expect(card.locator('[data-role="tag-dot"]')).toHaveClass(/bg-rose-500/) + + // 颜色存在独立的 storage key,刷新后仍然生效 + await page.reload() + await expect( + page.locator('article').filter({ hasText: '带标签的任务' }).locator('[data-role="tag-dot"]'), + ).toHaveClass(/bg-rose-500/) +}) diff --git a/src/App.vue b/src/App.vue index 48e7e9d..32efd2c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -11,6 +11,7 @@ import TagManagerPanel from '@/components/TagManagerPanel.vue' import ToastHost from '@/components/ToastHost.vue' import TrashPanel from '@/components/TrashPanel.vue' import { useKeyboardShortcuts, useUndoShortcuts } from '@/composables/useKeyboardShortcuts' +import { useTagColorStore } from '@/stores/tagColors' import { useTaskStore } from '@/stores/tasks' import { useThemeStore } from '@/stores/theme' import { useToastStore } from '@/stores/toast' @@ -37,10 +38,12 @@ const store = useTaskStore() const ui = useUiStore() const theme = useThemeStore() const toast = useToastStore() +const tagColors = useTagColorStore() const { notice } = storeToRefs(store) onMounted(() => { store.init() + tagColors.init() theme.init() }) diff --git a/src/components/TagFilter.spec.ts b/src/components/TagFilter.spec.ts index b9fe889..4e1c093 100644 --- a/src/components/TagFilter.spec.ts +++ b/src/components/TagFilter.spec.ts @@ -1,11 +1,19 @@ import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' import { nextTick } from 'vue' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { useTagColorStore } from '@/stores/tagColors' import TagFilter from './TagFilter.vue' const mounted: Array<{ unmount: () => void }> = [] +/** 下拉要读标签颜色,每个用例给一份干净的 Pinia */ +beforeEach(() => { + setActivePinia(createPinia()) +}) + function mountFilter(tags: string[] = ['设计', '前端'], selected: string[] = []) { const wrapper = mount(TagFilter, { props: { tags, selected }, @@ -160,8 +168,7 @@ describe('TagFilter 面板定位', () => { it('左侧空间充足时不偏移', async () => { const wrapper = mountFilter() const root = wrapper.element as HTMLElement - root.getBoundingClientRect = () => - ({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect + root.getBoundingClientRect = () => ({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect await triggerButton(wrapper).trigger('click') await nextTick() @@ -170,3 +177,30 @@ describe('TagFilter 面板定位', () => { expect(Number.parseFloat(panel.style.marginLeft || '0')).toBe(0) }) }) + +describe('标签颜色', () => { + it('未设置颜色的标签不画圆点', async () => { + const wrapper = mountFilter(['前端', '设计']) + await triggerButton(wrapper).trigger('click') + + expect(wrapper.findAll('[data-role="tag-dot"]')).toHaveLength(0) + }) + + it('设置了颜色的标签在下拉里显示圆点', async () => { + useTagColorStore().setColor('前端', 'teal') + const wrapper = mountFilter(['前端', '设计']) + await triggerButton(wrapper).trigger('click') + + expect(wrapper.findAll('[data-role="tag-dot"]')).toHaveLength(1) + expect(wrapper.find('.bg-teal-500').exists()).toBe(true) + }) + + it('圆点不影响勾选行为,emit 的仍是标签名', async () => { + useTagColorStore().setColor('前端', 'teal') + const wrapper = mountFilter(['前端', '设计']) + await triggerButton(wrapper).trigger('click') + + await wrapper.findAll('input[type="checkbox"]')[0]?.trigger('change') + expect(lastEmitted(wrapper, 'toggle')).toBe('前端') + }) +}) diff --git a/src/components/TagFilter.vue b/src/components/TagFilter.vue index 5e53901..2ae9a2a 100644 --- a/src/components/TagFilter.vue +++ b/src/components/TagFilter.vue @@ -6,12 +6,21 @@ * 改成下拉面板:平时只占一个按钮,选中的数量用角标提示。 */ -import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' -defineProps<{ tags: readonly string[]; selected: readonly string[] }>() +import { useTagColorStore } from '@/stores/tagColors' + +const props = defineProps<{ tags: readonly string[]; selected: readonly string[] }>() const emit = defineEmits<{ toggle: [tag: string]; clear: []; manage: [] }>() +const tagColors = useTagColorStore() + +/** 标签 + 它的圆点类名;未设置颜色时 dot 为 null,不画圆点 */ +const items = computed(() => + props.tags.map((tag) => ({ name: tag, dot: tagColors.dotClassOf(tag) })), +) + const open = ref(false) const rootRef = ref(null) @@ -119,22 +128,32 @@ onBeforeUnmount(() => { class="absolute top-full left-0 z-20 mt-1.5 w-56 animate-pop-in rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop shadow-pop-dark motion-reduce:animate-none dark:border-zinc-700 dark:bg-zinc-900" :style="{ marginLeft: `${shiftX}px` }" > -

+

还没有任何标签

diff --git a/src/components/TagManagerPanel.spec.ts b/src/components/TagManagerPanel.spec.ts index cfbb96d..c1221a2 100644 --- a/src/components/TagManagerPanel.spec.ts +++ b/src/components/TagManagerPanel.spec.ts @@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it } from 'vitest' import { createMemoryTaskRepository } from '@/data/taskRepository' +import { useTagColorStore } from '@/stores/tagColors' import { useTaskStore } from '@/stores/tasks' import TagManagerPanel from './TagManagerPanel.vue' @@ -118,3 +119,52 @@ describe('TagManagerPanel 合并与删除', () => { expect(wrapper.text()).not.toContain('确认删除') }) }) + +describe('标签颜色', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('点色板给标签设颜色,并在标签名旁显示圆点', async () => { + const { wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + + const rose = wrapper + .findAll('button') + .find((node) => node.attributes('aria-label') === '把标签「前端」标为红色') + await rose?.trigger('click') + + expect(useTagColorStore().colorOf('前端')).toBe('rose') + expect(wrapper.find('[data-role="tag-dot"].bg-rose-500').exists()).toBe(true) + }) + + it('点「清除颜色」恢复为未设置', async () => { + const { wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + useTagColorStore().setColor('前端', 'rose') + await wrapper.vm.$nextTick() + expect(wrapper.find('[data-role="tag-dot"].bg-rose-500').exists()).toBe(true) + + await wrapper.find('button[aria-label="清除颜色"]').trigger('click') + + expect(useTagColorStore().colorOf('前端')).toBeNull() + expect(wrapper.find('[data-role="tag-dot"]').exists()).toBe(false) + }) + + it('重命名标签后颜色跟到新名字,不会掉色', async () => { + const { wrapper } = setup((store) => { + store.createTask({ title: 'A', tags: ['前端'] }) + }) + useTagColorStore().setColor('前端', 'sky') + + await wrapper.find('input[aria-label="重命名标签 前端"]').setValue('Frontend') + await wrapper + .findAll('button') + .find((node) => node.text() === '保存') + ?.trigger('click') + + expect(useTagColorStore().colorOf('前端')).toBeNull() + expect(useTagColorStore().colorOf('Frontend')).toBe('sky') + expect(wrapper.find('[data-role="tag-dot"].bg-sky-500').exists()).toBe(true) + }) +}) diff --git a/src/components/TagManagerPanel.vue b/src/components/TagManagerPanel.vue index 76afaa5..0babde6 100644 --- a/src/components/TagManagerPanel.vue +++ b/src/components/TagManagerPanel.vue @@ -8,8 +8,10 @@ import { computed, ref } from 'vue' +import { useTagColorStore } from '@/stores/tagColors' import { useTaskStore } from '@/stores/tasks' import { useToastStore } from '@/stores/toast' +import { TAG_COLOR_OPTIONS, type TagColorId } from '@/utils/tagColors' import { collectTagStats } from '@/utils/tags' import SideDrawer from './SideDrawer.vue' @@ -20,6 +22,20 @@ const emit = defineEmits<{ close: [] }>() const store = useTaskStore() const toast = useToastStore() +const tagColors = useTagColorStore() + +/** 当前标签的颜色;未设置为 null,色板里对应「无」那一格 */ +function colorOf(tag: string): TagColorId | null { + return tagColors.colorOf(tag) +} + +function dotClass(tag: string): string | null { + return tagColors.dotClassOf(tag) +} + +function setColor(tag: string, color: TagColorId | null): void { + tagColors.setColor(tag, color) +} /** 每行的重命名草稿;没有输入时以原标签为准 */ const drafts = ref>({}) @@ -104,13 +120,53 @@ function remove(tag: string): void { :key="item.tag" class="flex flex-col gap-2 rounded-md border border-slate-200 px-3 py-2.5 dark:border-zinc-700" > -
- +
+ + {{ item.count }} 个任务
+
+ 颜色 +
+
{ + setActivePinia(createPinia()) +}) + function makeTask(overrides: Partial = {}): Task { return { id: 'task-1', @@ -58,9 +68,18 @@ describe('TaskCard 基础渲染', () => { describe('优先级视觉区分', () => { it('三档优先级使用不同色系', () => { - const high = priorityBadge(mountCard({ priority: 3 }))?.classes().join(' ') ?? '' - const medium = priorityBadge(mountCard({ priority: 2 }))?.classes().join(' ') ?? '' - const low = priorityBadge(mountCard({ priority: 1 }))?.classes().join(' ') ?? '' + const high = + priorityBadge(mountCard({ priority: 3 })) + ?.classes() + .join(' ') ?? '' + const medium = + priorityBadge(mountCard({ priority: 2 })) + ?.classes() + .join(' ') ?? '' + const low = + priorityBadge(mountCard({ priority: 1 })) + ?.classes() + .join(' ') ?? '' expect(high).toContain('rose') expect(medium).toContain('amber') @@ -189,6 +208,34 @@ describe('标签展示', () => { it('无标签时不渲染标签', () => { expect(mountCard({ tags: [] }).text()).not.toContain('#') }) + + it('未设置颜色的标签不画圆点', () => { + const wrapper = mountCard({ tags: ['前端'] }) + expect(wrapper.find('.bg-sky-500').exists()).toBe(false) + expect(wrapper.text()).toContain('#前端') + }) + + it('设置了颜色的标签显示对应圆点', () => { + useTagColorStore().setColor('前端', 'sky') + const wrapper = mountCard({ tags: ['前端', '设计'] }) + // 只有设过颜色的那个标签带圆点 + expect(wrapper.findAll('[data-role="tag-dot"]').length).toBe(1) + expect(wrapper.find('.bg-sky-500').exists()).toBe(true) + }) + + it('颜色与标签文字一一对应,不会串到相邻标签上', () => { + const store = useTagColorStore() + store.setColor('前端', 'sky') + store.setColor('设计', 'rose') + + const wrapper = mountCard({ tags: ['前端', '设计'] }) + const labels = wrapper + .findAll('.inline-flex.items-center') + .filter((node) => node.text().startsWith('#')) + + expect(labels[0]?.find('.bg-sky-500').exists()).toBe(true) + expect(labels[1]?.find('.bg-rose-500').exists()).toBe(true) + }) }) describe('交互', () => { diff --git a/src/components/TaskCard.vue b/src/components/TaskCard.vue index d5c433f..cb70089 100644 --- a/src/components/TaskCard.vue +++ b/src/components/TaskCard.vue @@ -1,6 +1,7 @@