feat: 添加标签颜色功能并更新相关组件和测试用例
CI / verify (push) Successful in 1m44s
CI / 部署到服务器 (push) Successful in 26s

This commit is contained in:
chenshoulin
2026-10-24 22:03:04 +08:00
parent 648b1ddca4
commit 61fa5515b8
16 changed files with 979 additions and 25 deletions
+10 -1
View File
@@ -11,6 +11,7 @@
- **回收站**:删除是软删除,30 天内可随时恢复;也可永久删除或清空 - **回收站**:删除是软删除,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 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。 - 回收站固定保留 30 天,没有搜索 / 筛选,只有「按删除时间」一种顺序。
- 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。 - 撤销栈只保留最近 50 步,且仅存在于当前会话与当前标签页:刷新或换标签页即清空,不跨设备同步;另一标签页改动数据后,本页的历史会作废。
- 导入冲突策略固定为「`updatedAt` 新者胜」,不可配置。 - 导入冲突策略固定为「`updatedAt` 新者胜」,不可配置。
- 标签还没有颜色,重命名 / 合并会同步落盘但不会保留旧名映射。 - 标签颜色是本机显示偏好,不随导出 JSON 或快照恢复携带,换设备后需要重新设置。
+44
View File
@@ -338,3 +338,47 @@ ADR-007 明确否决了「批量改属性做 `[{id, before}]` 快照式撤销」
`resetHistory()`,避免「打开页面就能撤销一次数据清理」。 `resetHistory()`,避免「打开页面就能撤销一次数据清理」。
- ADR-007 中「_批量改属性不做撤销_」一条作废;其余结论(软删除、`activeTasks` 单一剔除点、 - 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 的已知限制已写明。
+32 -1
View File
@@ -176,7 +176,9 @@ test('搜索筛选后列计数同步变化', async ({ page }) => {
// 关键词有 200ms 防抖 // 关键词有 200ms 防抖
await expect(page.locator('article')).toHaveCount(1) await expect(page.locator('article')).toHaveCount(1)
await expect(page.locator('article').filter({ hasText: '写方案' })).toBeVisible() 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 }) => { test('键盘快捷键:/ 聚焦搜索,N 聚焦新增,? 打开帮助', async ({ page }) => {
@@ -280,3 +282,32 @@ test('输入框内 Ctrl+Z 不劫持任务撤销', async ({ page }) => {
await expect(page.locator('article')).toHaveCount(1) 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/)
})
+3
View File
@@ -11,6 +11,7 @@ import TagManagerPanel from '@/components/TagManagerPanel.vue'
import ToastHost from '@/components/ToastHost.vue' import ToastHost from '@/components/ToastHost.vue'
import TrashPanel from '@/components/TrashPanel.vue' import TrashPanel from '@/components/TrashPanel.vue'
import { useKeyboardShortcuts, useUndoShortcuts } from '@/composables/useKeyboardShortcuts' import { useKeyboardShortcuts, useUndoShortcuts } from '@/composables/useKeyboardShortcuts'
import { useTagColorStore } from '@/stores/tagColors'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
@@ -37,10 +38,12 @@ const store = useTaskStore()
const ui = useUiStore() const ui = useUiStore()
const theme = useThemeStore() const theme = useThemeStore()
const toast = useToastStore() const toast = useToastStore()
const tagColors = useTagColorStore()
const { notice } = storeToRefs(store) const { notice } = storeToRefs(store)
onMounted(() => { onMounted(() => {
store.init() store.init()
tagColors.init()
theme.init() theme.init()
}) })
+37 -3
View File
@@ -1,11 +1,19 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue' 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' import TagFilter from './TagFilter.vue'
const mounted: Array<{ unmount: () => void }> = [] const mounted: Array<{ unmount: () => void }> = []
/** 下拉要读标签颜色,每个用例给一份干净的 Pinia */
beforeEach(() => {
setActivePinia(createPinia())
})
function mountFilter(tags: string[] = ['设计', '前端'], selected: string[] = []) { function mountFilter(tags: string[] = ['设计', '前端'], selected: string[] = []) {
const wrapper = mount(TagFilter, { const wrapper = mount(TagFilter, {
props: { tags, selected }, props: { tags, selected },
@@ -160,8 +168,7 @@ describe('TagFilter 面板定位', () => {
it('左侧空间充足时不偏移', async () => { it('左侧空间充足时不偏移', async () => {
const wrapper = mountFilter() const wrapper = mountFilter()
const root = wrapper.element as HTMLElement const root = wrapper.element as HTMLElement
root.getBoundingClientRect = () => root.getBoundingClientRect = () => ({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect
({ left: 100, top: 0, width: 60, height: 36 }) as DOMRect
await triggerButton(wrapper).trigger('click') await triggerButton(wrapper).trigger('click')
await nextTick() await nextTick()
@@ -170,3 +177,30 @@ describe('TagFilter 面板定位', () => {
expect(Number.parseFloat(panel.style.marginLeft || '0')).toBe(0) 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('前端')
})
})
+26 -7
View File
@@ -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 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 open = ref(false)
const rootRef = ref<HTMLElement | null>(null) const rootRef = ref<HTMLElement | null>(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" 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` }" :style="{ marginLeft: `${shiftX}px` }"
> >
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-500 dark:text-zinc-400"> <p
v-if="tags.length === 0"
class="px-2 py-3 text-center text-xs text-slate-500 dark:text-zinc-400"
>
还没有任何标签 还没有任何标签
</p> </p>
<ul v-else class="max-h-64 overflow-y-auto"> <ul v-else class="max-h-64 overflow-y-auto">
<li v-for="tag in tags" :key="tag"> <li v-for="tag in items" :key="tag.name">
<label <label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-slate-50 dark:text-zinc-300 dark:hover:bg-zinc-800" class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-slate-50 dark:text-zinc-300 dark:hover:bg-zinc-800"
> >
<input <input
type="checkbox" type="checkbox"
class="size-3.5 accent-sky-600 dark:accent-sky-500" class="size-3.5 accent-sky-600 dark:accent-sky-500"
:checked="selected.includes(tag)" :checked="selected.includes(tag.name)"
@change="emit('toggle', tag)" @change="emit('toggle', tag.name)"
/> />
<span class="truncate">#{{ tag }}</span> <span
v-if="tag.dot"
data-role="tag-dot"
class="size-1.5 shrink-0 rounded-full"
:class="tag.dot"
aria-hidden="true"
/>
<span class="truncate">#{{ tag.name }}</span>
</label> </label>
</li> </li>
</ul> </ul>
+50
View File
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it } from 'vitest'
import { createMemoryTaskRepository } from '@/data/taskRepository' import { createMemoryTaskRepository } from '@/data/taskRepository'
import { useTagColorStore } from '@/stores/tagColors'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import TagManagerPanel from './TagManagerPanel.vue' import TagManagerPanel from './TagManagerPanel.vue'
@@ -118,3 +119,52 @@ describe('TagManagerPanel 合并与删除', () => {
expect(wrapper.text()).not.toContain('确认删除') 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)
})
})
+58 -2
View File
@@ -8,8 +8,10 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useTagColorStore } from '@/stores/tagColors'
import { useTaskStore } from '@/stores/tasks' import { useTaskStore } from '@/stores/tasks'
import { useToastStore } from '@/stores/toast' import { useToastStore } from '@/stores/toast'
import { TAG_COLOR_OPTIONS, type TagColorId } from '@/utils/tagColors'
import { collectTagStats } from '@/utils/tags' import { collectTagStats } from '@/utils/tags'
import SideDrawer from './SideDrawer.vue' import SideDrawer from './SideDrawer.vue'
@@ -20,6 +22,20 @@ const emit = defineEmits<{ close: [] }>()
const store = useTaskStore() const store = useTaskStore()
const toast = useToastStore() 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<Record<string, string>>({}) const drafts = ref<Record<string, string>>({})
@@ -104,13 +120,53 @@ function remove(tag: string): void {
:key="item.tag" :key="item.tag"
class="flex flex-col gap-2 rounded-md border border-slate-200 px-3 py-2.5 dark:border-zinc-700" class="flex flex-col gap-2 rounded-md border border-slate-200 px-3 py-2.5 dark:border-zinc-700"
> >
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-zinc-200"> <span
class="inline-flex items-center gap-1.5 text-sm font-medium text-slate-700 dark:text-zinc-200"
>
<span
v-if="dotClass(item.tag)"
data-role="tag-dot"
class="size-2 shrink-0 rounded-full"
:class="dotClass(item.tag)"
aria-hidden="true"
/>
#{{ item.tag }} #{{ item.tag }}
</span> </span>
<span class="text-xs text-slate-400 dark:text-zinc-500">{{ item.count }} 个任务</span> <span class="text-xs text-slate-400 dark:text-zinc-500">{{ item.count }} 个任务</span>
</div> </div>
<div class="flex flex-wrap items-center gap-1.5">
<span class="mr-0.5 text-xs text-slate-400 dark:text-zinc-500">颜色</span>
<button
v-for="option in TAG_COLOR_OPTIONS"
:key="option.id"
type="button"
class="size-4 rounded-full transition hover:scale-110 motion-reduce:hover:scale-100"
:class="[
option.dot,
colorOf(item.tag) === option.id
? 'ring-2 ring-slate-500 ring-offset-2 ring-offset-white dark:ring-slate-300 dark:ring-offset-zinc-900'
: 'ring-1 ring-black/5 dark:ring-white/10',
]"
:aria-label="`把标签「${item.tag}」标为${option.label}色`"
:aria-pressed="colorOf(item.tag) === option.id"
@click="setColor(item.tag, option.id)"
/>
<button
type="button"
class="size-4 rounded-full border border-dashed border-slate-300 transition hover:scale-110 motion-reduce:hover:scale-100 dark:border-zinc-600"
:class="
colorOf(item.tag) === null
? 'ring-2 ring-slate-500 ring-offset-2 ring-offset-white dark:ring-slate-300 dark:ring-offset-zinc-900'
: ''
"
aria-label="清除颜色"
:aria-pressed="colorOf(item.tag) === null"
@click="setColor(item.tag, null)"
/>
</div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<input <input
type="text" type="text"
+51 -4
View File
@@ -1,12 +1,22 @@
import { mount } from '@vue/test-utils' import { mount } from '@vue/test-utils'
import type { VueWrapper } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { describe, expect, it } from 'vitest' import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { useTagColorStore } from '@/stores/tagColors'
import type { Task } from '@/types/task' import type { Task } from '@/types/task'
import TaskCard from './TaskCard.vue' import TaskCard from './TaskCard.vue'
/**
* 卡片要从 tagColors store 读圆点颜色,因此每个用例都要有独立的 Pinia,
* 否则上一个用例设的颜色会漏到下一个。
*/
beforeEach(() => {
setActivePinia(createPinia())
})
function makeTask(overrides: Partial<Task> = {}): Task { function makeTask(overrides: Partial<Task> = {}): Task {
return { return {
id: 'task-1', id: 'task-1',
@@ -58,9 +68,18 @@ describe('TaskCard 基础渲染', () => {
describe('优先级视觉区分', () => { describe('优先级视觉区分', () => {
it('三档优先级使用不同色系', () => { it('三档优先级使用不同色系', () => {
const high = priorityBadge(mountCard({ priority: 3 }))?.classes().join(' ') ?? '' const high =
const medium = priorityBadge(mountCard({ priority: 2 }))?.classes().join(' ') ?? '' priorityBadge(mountCard({ priority: 3 }))
const low = priorityBadge(mountCard({ priority: 1 }))?.classes().join(' ') ?? '' ?.classes()
.join(' ') ?? ''
const medium =
priorityBadge(mountCard({ priority: 2 }))
?.classes()
.join(' ') ?? ''
const low =
priorityBadge(mountCard({ priority: 1 }))
?.classes()
.join(' ') ?? ''
expect(high).toContain('rose') expect(high).toContain('rose')
expect(medium).toContain('amber') expect(medium).toContain('amber')
@@ -189,6 +208,34 @@ describe('标签展示', () => {
it('无标签时不渲染标签', () => { it('无标签时不渲染标签', () => {
expect(mountCard({ tags: [] }).text()).not.toContain('#') 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('交互', () => { describe('交互', () => {
+23 -3
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useTagColorStore } from '@/stores/tagColors'
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task' import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date' import { formatCompactDate, formatDateTime, formatDueDateForTask, isOverdue } from '@/utils/date'
@@ -20,6 +21,8 @@ const emit = defineEmits<{
select: [id: string] select: [id: string]
}>() }>()
const tagColors = useTagColorStore()
/** 方向键映射;左右换列,上下调顺序 */ /** 方向键映射;左右换列,上下调顺序 */
const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = { const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
ArrowLeft: 'left', ArrowLeft: 'left',
@@ -70,7 +73,13 @@ const dueLabel = computed(() =>
? formatDueDateForTask(props.task.dueDate, props.task.status, props.task.completedAt) ? formatDueDateForTask(props.task.dueDate, props.task.status, props.task.completedAt)
: null, : null,
) )
const visibleTags = computed(() => props.task.tags.slice(0, MAX_VISIBLE_TAGS)) const visibleTags = computed(() =>
props.task.tags.slice(0, MAX_VISIBLE_TAGS).map((tag) => ({
name: tag,
/** 未设置颜色的标签为 null,模板据此不画圆点 */
dot: tagColors.dotClassOf(tag),
})),
)
const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS)) const hiddenTagCount = computed(() => Math.max(0, props.task.tags.length - MAX_VISIBLE_TAGS))
/** /**
@@ -238,8 +247,19 @@ const priorityTextClass = computed(() => {
{{ dueLabel }} {{ dueLabel }}
</span> </span>
<span v-for="tag in visibleTags" :key="tag" class="text-slate-500 dark:text-zinc-400"> <span
#{{ tag }} v-for="tag in visibleTags"
:key="tag.name"
class="inline-flex items-center gap-1 text-slate-500 dark:text-zinc-400"
>
<span
v-if="tag.dot"
data-role="tag-dot"
class="size-1.5 shrink-0 rounded-full"
:class="tag.dot"
aria-hidden="true"
/>
#{{ tag.name }}
</span> </span>
<span v-if="hiddenTagCount > 0" class="text-slate-500 dark:text-zinc-400"> <span v-if="hiddenTagCount > 0" class="text-slate-500 dark:text-zinc-400">
+{{ hiddenTagCount }} +{{ hiddenTagCount }}
+159
View File
@@ -0,0 +1,159 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { TAG_COLORS_STORAGE_KEY, useTagColorStore } from './tagColors'
/** 每次换一个全新的 Pinia,并可按需预置落盘内容 */
function freshStore(colors?: Record<string, unknown>) {
setActivePinia(createPinia())
const store = useTagColorStore()
store.init()
if (colors) store.replaceAll(colors as never)
return store
}
/** 直接读回落盘内容,验证「真的写进去了」而不是只改了内存 */
function persisted(): unknown {
const raw = localStorage.getItem(TAG_COLORS_STORAGE_KEY)
return raw === null ? null : JSON.parse(raw)
}
beforeEach(() => {
localStorage.clear()
})
describe('初始化', () => {
it('没有落盘数据时为空', () => {
const store = freshStore()
expect(store.colors).toEqual({})
expect(store.hasAny).toBe(false)
})
it('读取已有的颜色', () => {
localStorage.setItem(TAG_COLORS_STORAGE_KEY, JSON.stringify({ 工作: 'rose' }))
const store = freshStore()
expect(store.colorOf('工作')).toBe('rose')
expect(store.hasAny).toBe(true)
})
it('内容不是合法 JSON 时退回空映射,不抛错', () => {
localStorage.setItem(TAG_COLORS_STORAGE_KEY, '{ 这不是 JSON')
const store = freshStore()
expect(store.colors).toEqual({})
})
it('丢弃非法色值,保留合法项', () => {
localStorage.setItem(TAG_COLORS_STORAGE_KEY, JSON.stringify({ 工作: 'rose', 坏值: 'burgundy' }))
const store = freshStore()
expect(store.colors).toEqual({ 工作: 'rose' })
})
it('重复 init 不会清空内存中的颜色', () => {
const store = freshStore()
store.setColor('工作', 'rose')
store.init()
expect(store.colorOf('工作')).toBe('rose')
})
})
describe('读取', () => {
it('未设置颜色的标签返回 null,界面据此不画圆点', () => {
const store = freshStore()
expect(store.colorOf('工作')).toBeNull()
expect(store.dotClassOf('工作')).toBeNull()
})
it('已设置颜色的标签返回圆点类名', () => {
const store = freshStore()
store.setColor('工作', 'rose')
expect(store.colorOf('工作')).toBe('rose')
expect(store.dotClassOf('工作')).toBe('bg-rose-500')
})
})
describe('设置与清除', () => {
it('设置颜色后立即落盘', () => {
const store = freshStore()
store.setColor('工作', 'teal')
expect(store.colorOf('工作')).toBe('teal')
expect(persisted()).toEqual({ 工作: 'teal' })
})
it('传 null 清除颜色并同步落盘', () => {
const store = freshStore()
store.setColor('工作', 'teal')
store.setColor('工作', null)
expect(store.colorOf('工作')).toBeNull()
expect(persisted()).toEqual({})
})
it('重复设置同一颜色不会改变引用', () => {
const store = freshStore()
store.setColor('工作', 'teal')
const before = store.colors
store.setColor('工作', 'teal')
expect(store.colors).toBe(before)
})
})
describe('跟随标签治理', () => {
it('重命名后颜色跟到新名字', () => {
const store = freshStore()
store.setColor('前端', 'sky')
store.renameTag('前端', 'Frontend')
expect(store.colorOf('前端')).toBeNull()
expect(store.colorOf('Frontend')).toBe('sky')
expect(persisted()).toEqual({ Frontend: 'sky' })
})
it('重命名到已有颜色的标签时保留目标的颜色', () => {
const store = freshStore()
store.setColor('前端', 'sky')
store.setColor('Frontend', 'rose')
store.renameTag('前端', 'Frontend')
expect(store.colors).toEqual({ Frontend: 'rose' })
})
it('合并后目标继承来源的颜色', () => {
const store = freshStore()
store.setColor('前端', 'sky')
store.mergeTags(['前端'], 'UI')
expect(store.colors).toEqual({ UI: 'sky' })
})
it('合并到已有颜色的标签时保留目标的颜色', () => {
const store = freshStore()
store.setColor('前端', 'sky')
store.setColor('UI', 'teal')
store.mergeTags(['前端'], 'UI')
expect(store.colors).toEqual({ UI: 'teal' })
})
it('移除标签后不再保留它的颜色', () => {
const store = freshStore()
store.setColor('工作', 'rose')
store.setColor('生活', 'teal')
store.removeTag('工作')
expect(store.colors).toEqual({ 生活: 'teal' })
expect(persisted()).toEqual({ 生活: 'teal' })
})
it('对没有颜色的标签做治理不会产生写入', () => {
const store = freshStore()
store.setColor('其它', 'rose')
const before = store.colors
store.renameTag('不存在', '随便')
store.mergeTags(['不存在'], '随便')
store.removeTag('不存在')
expect(store.colors).toBe(before)
})
})
+128
View File
@@ -0,0 +1,128 @@
/**
* 标签颜色 Store
*
* 与任务数据**分开持久化**:颜色是「怎么显示」,不是「有哪些任务」,
* 因此独立成键,不进入 `PersistedState`、不参与导入导出与快照恢复,
* 也就不用动 `schemaVersion`(见 ADR-011)。
*
* 读写失败一律静默降级:颜色丢了不影响任何任务,
* 不值得为了它弹一个告警打断使用。
*/
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import {
mergeColors as mergeColorsPure,
normalizeColors,
removeColors as removeColorsPure,
renameColor,
setColor as setColorPure,
tagDotClass,
type TagColorId,
type TagColorMap,
} from '@/utils/tagColors'
/** 标签颜色在 localStorage 中的键名,刻意不放在 `kanban.tasks` 里 */
export const TAG_COLORS_STORAGE_KEY = 'kanban.tagColors'
function readStorage(): TagColorMap {
try {
const raw = localStorage.getItem(TAG_COLORS_STORAGE_KEY)
if (raw === null || raw.trim() === '') return {}
return normalizeColors(JSON.parse(raw))
} catch {
return {}
}
}
function writeStorage(colors: TagColorMap): void {
try {
localStorage.setItem(TAG_COLORS_STORAGE_KEY, JSON.stringify(colors))
} catch {
// 存储不可用(隐私模式 / 配额满)时放弃写入,下次修改会再试
}
}
export const useTagColorStore = defineStore('tagColors', () => {
const colors = ref<TagColorMap>({})
const ready = ref(false)
/** 是否设置过任何颜色,供界面决定是否显示「颜色」入口 */
const hasAny = computed(() => Object.keys(colors.value).length > 0)
function init(): void {
if (ready.value) return
colors.value = readStorage()
ready.value = true
}
/**
* 统一落盘入口。
*
* 纯函数在没有变化时会返回**原引用**,因此这里可以直接用 `===` 判断
* 是否需要写盘 —— 重命名、合并这类可能「无事发生」的调用不会产生无谓写入。
*/
function persist(next: TagColorMap): void {
if (next === colors.value) return
colors.value = next
writeStorage(next)
}
/** 某个标签的颜色;未设置返回 null */
function colorOf(tag: string): TagColorId | null {
return colors.value[tag] ?? null
}
/** 某个标签的圆点类名;未设置返回 null —— 界面据此不画圆点 */
function dotClassOf(tag: string): string | null {
return tagDotClass(colors.value, tag)
}
/** 设置颜色,传 null 表示恢复为「未设置」 */
function setColor(tag: string, color: TagColorId | null): void {
persist(setColorPure(colors.value, tag, color))
}
/** 标签重命名后跟着改名(由 task store 的 renameTag 调用) */
function renameTag(from: string, to: string): void {
persist(renameColor(colors.value, from, to))
}
/** 标签合并后跟着改名(由 task store 的 mergeTags 调用) */
function mergeTags(from: readonly string[], into: string): void {
persist(mergeColorsPure(colors.value, from, into))
}
/** 标签被移除后不再保留颜色(由 task store 的 removeTag 调用) */
function removeTag(tag: string): void {
persist(removeColorsPure(colors.value, [tag]))
}
/** 整体替换,主要供测试与调试使用 */
function replaceAll(next: TagColorMap): void {
colors.value = next
writeStorage(next)
}
/** 清空并标记为未初始化,主要供测试使用 */
function reset(): void {
colors.value = {}
ready.value = false
}
return {
colors,
ready,
hasAny,
init,
colorOf,
dotClassOf,
setColor,
renameTag,
mergeTags,
removeTag,
replaceAll,
reset,
}
})
+46
View File
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest'
import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository' import { createMemoryTaskRepository, type TaskRepository } from '@/data/taskRepository'
import type { Task } from '@/types/task' import type { Task } from '@/types/task'
import { useTagColorStore } from './tagColors'
import { UNDO_LIMIT, useTaskStore } from './tasks' import { UNDO_LIMIT, useTaskStore } from './tasks'
/** /**
@@ -1292,3 +1293,48 @@ describe('撤销与重做', () => {
expect(store.canUndo).toBe(false) expect(store.canUndo).toBe(false)
}) })
}) })
describe('标签治理与颜色的联动', () => {
it('重命名时把颜色迁到新标签', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
const colors = useTagColorStore()
colors.setColor('前端', 'sky')
store.renameTag('前端', 'Frontend')
expect(colors.colorOf('前端')).toBeNull()
expect(colors.colorOf('Frontend')).toBe('sky')
})
it('合并时目标继承来源的颜色', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端', 'UI'] })
const colors = useTagColorStore()
colors.setColor('前端', 'rose')
store.mergeTags(['前端'], 'UI')
expect(colors.colors).toEqual({ UI: 'rose' })
})
it('移除标签时一并丢弃颜色', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
const colors = useTagColorStore()
colors.setColor('前端', 'teal')
store.removeTag('前端')
expect(colors.colorOf('前端')).toBeNull()
})
it('没有设置过颜色时标签治理不报错', () => {
const store = freshStore()
store.createTask({ title: 'A', tags: ['前端'] })
expect(() => store.renameTag('前端', 'Frontend')).not.toThrow()
expect(() => store.mergeTags(['Frontend'], 'UI')).not.toThrow()
expect(() => store.removeTag('UI')).not.toThrow()
})
})
+17 -4
View File
@@ -41,6 +41,8 @@ import {
type TaskPatch, type TaskPatch,
} from '@/utils/taskPatch' } from '@/utils/taskPatch'
import { useTagColorStore } from './tagColors'
/** 跨标签页同步使用的频道名 */ /** 跨标签页同步使用的频道名 */
export const SYNC_CHANNEL_NAME = 'kanban.sync' export const SYNC_CHANNEL_NAME = 'kanban.sync'
@@ -772,19 +774,30 @@ export const useTaskStore = defineStore('tasks', () => {
return count return count
} }
/** 重命名标签,返回受影响的任务数 */ /**
* 标签治理的三个操作都要顺带同步颜色的键名。
*
* 颜色存在独立的 store 里(见 ADR-011),不跟着改名就会留下「孤儿颜色」,
* 用户看到的现象是:重命名之后这个标签突然掉色了。
*/
function renameTag(from: string, to: string): number { function renameTag(from: string, to: string): number {
return applyTagTransform((tasks) => renameTagPure(tasks, from, to)) const count = applyTagTransform((tasks) => renameTagPure(tasks, from, to))
useTagColorStore().renameTag(from, to)
return count
} }
/** 把多个标签合并为 into,返回受影响的任务数 */ /** 把多个标签合并为 into,返回受影响的任务数 */
function mergeTags(from: readonly string[], into: string): number { function mergeTags(from: readonly string[], into: string): number {
return applyTagTransform((tasks) => mergeTagsPure(tasks, from, into)) const count = applyTagTransform((tasks) => mergeTagsPure(tasks, from, into))
useTagColorStore().mergeTags(from, into)
return count
} }
/** 从所有任务上删除某个标签(不删除任务),返回受影响的任务数 */ /** 从所有任务上删除某个标签(不删除任务),返回受影响的任务数 */
function removeTag(tag: string): number { function removeTag(tag: string): number {
return applyTagTransform((tasks) => removeTagPure(tasks, tag)) const count = applyTagTransform((tasks) => removeTagPure(tasks, tag))
useTagColorStore().removeTag(tag)
return count
} }
/** 释放跨标签页频道;主要供测试使用 */ /** 释放跨标签页频道;主要供测试使用 */
+158
View File
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import {
isTagColorId,
mergeColors,
normalizeColors,
removeColors,
renameColor,
setColor,
TAG_COLOR_OPTIONS,
tagDotClass,
type TagColorMap,
} from './tagColors'
describe('色板', () => {
it('每个色板都有完整类名,供 Tailwind 静态扫描', () => {
for (const option of TAG_COLOR_OPTIONS) {
expect(option.dot).toMatch(/^bg-[a-z]+-\d00$/)
}
})
it('色板 id 不重复', () => {
const ids = TAG_COLOR_OPTIONS.map((option) => option.id)
expect(new Set(ids).size).toBe(ids.length)
})
})
describe('isTagColorId', () => {
it('接受已知色板', () => {
expect(isTagColorId('rose')).toBe(true)
expect(isTagColorId('slate')).toBe(true)
})
it('拒绝未知值', () => {
expect(isTagColorId('burgundy')).toBe(false)
expect(isTagColorId('')).toBe(false)
expect(isTagColorId(null)).toBe(false)
expect(isTagColorId(1)).toBe(false)
expect(isTagColorId({})).toBe(false)
})
})
describe('tagDotClass', () => {
it('已设置颜色时返回对应类名', () => {
expect(tagDotClass({ 工作: 'rose' }, '工作')).toBe('bg-rose-500')
})
it('未设置颜色时返回 null', () => {
expect(tagDotClass({}, '工作')).toBeNull()
expect(tagDotClass({ 工作: 'rose' }, '生活')).toBeNull()
})
})
describe('normalizeColors', () => {
it('保留合法映射', () => {
expect(normalizeColors({ 工作: 'rose', 生活: 'teal' })).toEqual({
工作: 'rose',
生活: 'teal',
})
})
it('丢弃非法色值与空键', () => {
expect(normalizeColors({ 工作: 'rose', 坏键: 'burgundy', ' ': 'teal', 空值: null })).toEqual({
工作: 'rose',
})
})
it('非对象一律返回空映射', () => {
expect(normalizeColors(null)).toEqual({})
expect(normalizeColors([])).toEqual({})
expect(normalizeColors('rose')).toEqual({})
expect(normalizeColors(42)).toEqual({})
})
})
describe('renameColor', () => {
it('把颜色搬到新名字下', () => {
expect(renameColor({ 前端: 'sky' }, '前端', 'Frontend')).toEqual({ Frontend: 'sky' })
})
it('目标已有颜色时保留目标的', () => {
expect(renameColor({ 前端: 'sky', Frontend: 'rose' }, '前端', 'Frontend')).toEqual({
Frontend: 'rose',
})
})
it('来源没有颜色时原样返回引用', () => {
const colors: TagColorMap = { 前端: 'sky' }
expect(renameColor(colors, '后端', 'Backend')).toBe(colors)
})
it('同名时原样返回引用', () => {
const colors: TagColorMap = { 前端: 'sky' }
expect(renameColor(colors, '前端', '前端')).toBe(colors)
})
})
describe('mergeColors', () => {
it('目标没有颜色时继承来源的第一个颜色', () => {
expect(mergeColors({ 前端: 'sky', Frontend: 'rose' }, ['前端', 'Frontend'], 'UI')).toEqual({
UI: 'sky',
})
})
it('目标已有颜色时保留目标的', () => {
expect(mergeColors({ 前端: 'sky', UI: 'teal' }, ['前端'], 'UI')).toEqual({ UI: 'teal' })
})
it('没有任何来源有颜色时原样返回引用', () => {
const colors: TagColorMap = { 其它: 'sky' }
expect(mergeColors(colors, ['前端'], 'UI')).toBe(colors)
})
it('忽略与目标同名的来源', () => {
const colors: TagColorMap = { UI: 'teal' }
expect(mergeColors(colors, ['UI'], 'UI')).toBe(colors)
})
})
describe('removeColors', () => {
it('删除指定标签的颜色', () => {
expect(removeColors({ 工作: 'rose', 生活: 'teal' }, ['工作'])).toEqual({ 生活: 'teal' })
})
it('没有可删的键时原样返回引用', () => {
const colors: TagColorMap = { 工作: 'rose' }
expect(removeColors(colors, ['不存在'])).toBe(colors)
})
})
describe('setColor', () => {
it('设置新颜色', () => {
expect(setColor({}, '工作', 'rose')).toEqual({ 工作: 'rose' })
})
it('覆盖已有颜色', () => {
expect(setColor({ 工作: 'rose' }, '工作', 'teal')).toEqual({ 工作: 'teal' })
})
it('传 null 清除颜色', () => {
expect(setColor({ 工作: 'rose' }, '工作', null)).toEqual({})
})
it('值没有变化时返回原引用', () => {
const colors: TagColorMap = { 工作: 'rose' }
expect(setColor(colors, '工作', 'rose')).toBe(colors)
})
it('清除本来就没有的键时返回原引用', () => {
const colors: TagColorMap = { 工作: 'rose' }
expect(setColor(colors, '生活', null)).toBe(colors)
})
it('空标签名不产生记录', () => {
const colors: TagColorMap = { 工作: 'rose' }
expect(setColor(colors, ' ', 'teal')).toBe(colors)
})
})
+137
View File
@@ -0,0 +1,137 @@
/**
* 标签颜色(纯逻辑)
*
* 设计约定:
* - 颜色是**显示偏好**,不是任务数据,因此不进入 `PersistedState`,
* 也不参与导入导出与 schema 迁移(见 ADR-011)。
* - 数据里存的是**色板 id**(如 `rose`)而不是 class 字符串,
* 这样以后调整视觉样式不需要改用户数据。
* - 没设置颜色的标签不出现在映射里,界面上也不画圆点 ——
* 否则所有标签都顶着一个灰点,只是把噪音放大。
*/
/**
* 可选色板。
*
* `dot` 必须是**完整类名字符串**:Tailwind v4 在构建时扫描源码里的类名,
* 动态拼接(`bg-${color}-500`)不会被生成,样式会静默失效。
*/
export const TAG_COLOR_OPTIONS = [
{ id: 'slate', label: '灰', dot: 'bg-slate-400' },
{ id: 'rose', label: '红', dot: 'bg-rose-500' },
{ id: 'amber', label: '橙', dot: 'bg-amber-500' },
{ id: 'emerald', label: '绿', dot: 'bg-emerald-500' },
{ id: 'sky', label: '蓝', dot: 'bg-sky-500' },
{ id: 'violet', label: '紫', dot: 'bg-violet-500' },
{ id: 'pink', label: '粉', dot: 'bg-pink-500' },
{ id: 'teal', label: '青', dot: 'bg-teal-500' },
] as const
export type TagColorId = (typeof TAG_COLOR_OPTIONS)[number]['id']
export const TAG_COLOR_IDS: readonly TagColorId[] = TAG_COLOR_OPTIONS.map((option) => option.id)
/** 色板 id → 圆点类名 */
export const TAG_DOT_CLASS = Object.fromEntries(
TAG_COLOR_OPTIONS.map((option) => [option.id, option.dot]),
) as Record<TagColorId, string>
/** 标签 → 颜色的映射 */
export type TagColorMap = Record<string, TagColorId>
export function isTagColorId(value: unknown): value is TagColorId {
return typeof value === 'string' && (TAG_COLOR_IDS as readonly string[]).includes(value)
}
/** 某个标签的圆点类名;未设置颜色时返回 null,调用方据此决定不画圆点 */
export function tagDotClass(colors: TagColorMap, tag: string): string | null {
const color = colors[tag]
return color === undefined ? null : TAG_DOT_CLASS[color]
}
/**
* 把未知来源的数据收敛为合法映射(宽容策略)。
* 坏键、非法色值一律丢弃,不因为一条脏数据让整个偏好失效。
*/
export function normalizeColors(raw: unknown): TagColorMap {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
const result: TagColorMap = {}
for (const [tag, color] of Object.entries(raw)) {
const name = tag.trim()
if (name === '' || !isTagColorId(color)) continue
result[name] = color
}
return result
}
/**
* 重命名时迁移颜色。
*
* 目标标签已经有颜色时**保留目标的**:那多半是用户为它单独设过的,
* 而被重命名的来源更像「顺手被并过来」的一方。无变化时返回原引用,
* 便于调用方用 `===` 判断是否需要落盘。
*/
export function renameColor(colors: TagColorMap, from: string, to: string): TagColorMap {
const source = colors[from]
if (source === undefined || from === to) return colors
const next = { ...colors }
delete next[from]
if (next[to] === undefined) next[to] = source
return next
}
/** 合并时迁移颜色:目标没有颜色则继承来源里第一个有颜色的 */
export function mergeColors(
colors: TagColorMap,
from: readonly string[],
into: string,
): TagColorMap {
const next = { ...colors }
let inherited: TagColorId | undefined
let changed = false
for (const tag of from) {
if (tag === into) continue
const color = next[tag]
if (color === undefined) continue
if (inherited === undefined) inherited = color
delete next[tag]
changed = true
}
if (!changed) return colors
if (next[into] === undefined && inherited !== undefined) next[into] = inherited
return next
}
/** 标签被从所有任务上移除时,一并丢弃它的颜色 */
export function removeColors(colors: TagColorMap, tags: readonly string[]): TagColorMap {
const next = { ...colors }
let changed = false
for (const tag of tags) {
if (next[tag] === undefined) continue
delete next[tag]
changed = true
}
return changed ? next : colors
}
/** 设置 / 清除单条颜色;清除后等价于「从未设置过」 */
export function setColor(colors: TagColorMap, tag: string, color: TagColorId | null): TagColorMap {
const name = tag.trim()
if (name === '') return colors
if (color === null) {
if (colors[name] === undefined) return colors
const next = { ...colors }
delete next[name]
return next
}
if (colors[name] === color) return colors
return { ...colors, [name]: color }
}