feat: 添加标签颜色功能并更新相关组件和测试用例
This commit is contained in:
@@ -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
@@ -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
@@ -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/)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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('前端')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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('交互', () => {
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 释放跨标签页频道;主要供测试使用 */
|
/** 释放跨标签页频道;主要供测试使用 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user