feat: 补齐今日视图待办组、卡片键盘操作、深色模式
今日视图 - 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设 截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到 「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个 分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。 - 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成), 不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。 键盘操作(可访问性) - 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。 - 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全; 列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。 - 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。 深色模式 - 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。 - index.html 内联脚本在首帧之前应用主题,避免深色下闪白。 - 用 @custom-variant dark 做 class 控制(而不是纯 media query), 才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。 验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。 截图脚本新增深色与移动端深色两组,便于后续对照。
This commit is contained in:
@@ -5,8 +5,9 @@
|
|||||||
- 固定三列:**待办 / 进行中 / 已完成**
|
- 固定三列:**待办 / 进行中 / 已完成**
|
||||||
- 快速新增、拖拽排序、详情编辑、删除撤销
|
- 快速新增、拖拽排序、详情编辑、删除撤销
|
||||||
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
|
- 搜索与筛选(关键词 / 优先级 / 标签 / 今日到期 / 逾期)
|
||||||
- **今日视图**:按紧急度汇总今天要做的事,含本周完成等统计
|
- **今日视图**:按紧急度分组汇总(逾期 / 今天到期 / 进行中 / 待办),含本周完成等统计
|
||||||
- 键盘快捷键
|
- 键盘快捷键;任务卡片可用方向键换列与调序
|
||||||
|
- **深色模式**:跟随系统 / 浅色 / 深色三态
|
||||||
- 导出导入 JSON + 启动自动备份
|
- 导出导入 JSON + 启动自动备份
|
||||||
- **可安装为 PWA,支持离线使用**
|
- **可安装为 PWA,支持离线使用**
|
||||||
|
|
||||||
@@ -44,7 +45,15 @@ npm run dev
|
|||||||
| `?` | 打开快捷键帮助 |
|
| `?` | 打开快捷键帮助 |
|
||||||
| `Esc` | 关闭当前面板 |
|
| `Esc` | 关闭当前面板 |
|
||||||
|
|
||||||
在输入框内输入时不会触发快捷键。
|
卡片获得焦点(`Tab` 或点击)后:
|
||||||
|
|
||||||
|
| 按键 | 作用 |
|
||||||
|
|---|---|
|
||||||
|
| `←` `→` | 把卡片移到上一列 / 下一列 |
|
||||||
|
| `↑` `↓` | 在列内上移 / 下移 |
|
||||||
|
| `Enter` | 打开任务详情 |
|
||||||
|
|
||||||
|
在输入框内输入时不会触发全局快捷键。方向键移动在筛选态下只允许跨列(列内排序需要看到完整列表才有意义)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,11 +64,13 @@ npm run dev
|
|||||||
| 视图 | 路径 | 组织方式 |
|
| 视图 | 路径 | 组织方式 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 |
|
| 看板 | `/` | 按**状态**:待办 / 进行中 / 已完成 |
|
||||||
| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 |
|
| 今日 | `/today` | 按**紧急度**:已逾期 → 今天到期 → 进行中 → 待办 |
|
||||||
|
|
||||||
今日视图顶部有四张统计卡片(今日待办 / 逾期 / 今天到期 / 本周完成),下方是可直接勾选完成的待办列表。
|
今日视图顶部有四张统计卡片(逾期 / 今天到期 / 进行中 / 本周完成),下方是可直接勾选完成的待办列表。
|
||||||
|
|
||||||
分组是**互斥**的:一张卡片只会出现在一组里,归属优先级为「逾期 > 今天到期 > 进行中」,所以你能一眼数清今天到底有几件事。已完成和已归档的任务不会出现在今日视图。
|
分组是**互斥**的:一张卡片只会出现在一组里,归属优先级为「逾期 > 今天到期 > 进行中 > 待办」,所以你能一眼数清今天到底有几件事。已完成和已归档的任务不会出现在今日视图。
|
||||||
|
|
||||||
|
只要任务未完成且未归档,就一定会出现在某个分组里 —— 包括没设截止日期的待办(归入「待办」组)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -177,8 +188,8 @@ scripts/ # 图标生成等辅助脚本
|
|||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
- 单元测试:437 个用例,覆盖 `src/` 下的纯逻辑与组件行为,行覆盖率约 97%。
|
- 单元测试:480 个用例,覆盖 `src/` 下的纯逻辑与组件行为,行覆盖率约 96%。
|
||||||
- 端到端测试:23 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
|
- 端到端测试:25 个用例,分两组运行 —— 交互类跑 dev server,PWA 类跑生产构建(Service Worker 只在构建产物中生成)。覆盖「新增 → 拖拽 → 完成 → 刷新保持」「导出 → 清空 → 导入还原」「删除撤销」「搜索筛选」「键盘快捷键与方向键移动」「深色模式切换」「今日视图」「离线打开」等主链路,每个用例使用独立的浏览器上下文。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:coverage
|
npm run test:coverage
|
||||||
@@ -202,5 +213,6 @@ PLAYWRIGHT_CHANNEL=chromium npm run test:e2e # 用自带 Chromium(需先 npx
|
|||||||
- 标签是字符串数组,重命名标签需要逐个任务修改。
|
- 标签是字符串数组,重命名标签需要逐个任务修改。
|
||||||
- 看板列固定为三列,不支持自定义。
|
- 看板列固定为三列,不支持自定义。
|
||||||
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
- 归档阈值固定为「已完成超过 7 天」,暂不可配置。
|
||||||
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中),不可自定义。
|
- 今日视图的分组规则固定(逾期 > 今天到期 > 进行中 > 待办),不可自定义。
|
||||||
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
- 统计只覆盖本周完成数等基础指标,没有趋势图或历史留存。
|
||||||
|
- 深色模式只跟随系统或手动三态切换,没有按时间段自动切换。
|
||||||
|
|||||||
@@ -113,3 +113,39 @@ test('输入框内输入不会触发快捷键', async ({ page }) => {
|
|||||||
await expect(search).toBeFocused()
|
await expect(search).toBeFocused()
|
||||||
await expect(search).toHaveValue('n')
|
await expect(search).toHaveValue('n')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('深色模式可切换并在刷新后保持', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
const html = page.locator('html')
|
||||||
|
const themeButton = page.getByRole('button', { name: /主题/ })
|
||||||
|
|
||||||
|
await expect(html).not.toHaveClass(/dark/)
|
||||||
|
|
||||||
|
// 系统 → 浅色 → 深色
|
||||||
|
await themeButton.click()
|
||||||
|
await expect(html).not.toHaveClass(/dark/)
|
||||||
|
await themeButton.click()
|
||||||
|
await expect(html).toHaveClass(/dark/)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(html).toHaveClass(/dark/)
|
||||||
|
|
||||||
|
// 再点一次回到跟随系统
|
||||||
|
await themeButton.click()
|
||||||
|
await expect(html).not.toHaveClass(/dark/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('键盘方向键可以移动任务卡片', async ({ page }) => {
|
||||||
|
await addTask(page, '键盘移动我')
|
||||||
|
const card = page.locator('article').filter({ hasText: '键盘移动我' })
|
||||||
|
await card.focus()
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowRight')
|
||||||
|
await expect(page.locator('[data-column-id="doing"] article')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.keyboard.press('ArrowLeft')
|
||||||
|
await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(page.locator('[data-column-id="todo"] article')).toHaveCount(1)
|
||||||
|
})
|
||||||
|
|||||||
+1
-1
@@ -106,7 +106,7 @@ test('离线时深链 /today 也能打开(navigateFallback 生效)', async (
|
|||||||
await page.goto('/today')
|
await page.goto('/today')
|
||||||
|
|
||||||
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
||||||
await expect(page.getByText('今日待办')).toBeVisible()
|
await expect(page.getByText('本周完成')).toBeVisible()
|
||||||
|
|
||||||
await context.setOffline(false)
|
await context.setOffline(false)
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-4
@@ -34,7 +34,7 @@ test('导航可在看板与今日之间切换', async ({ page }) => {
|
|||||||
|
|
||||||
await page.getByRole('link', { name: '今日' }).click()
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '今日' })).toBeVisible()
|
||||||
await expect(page.getByText('今日待办')).toBeVisible()
|
await expect(page.getByText('本周完成')).toBeVisible()
|
||||||
|
|
||||||
await page.getByRole('link', { name: '看板' }).click()
|
await page.getByRole('link', { name: '看板' }).click()
|
||||||
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
await expect(page.getByText('共 0 个任务')).toBeVisible()
|
||||||
@@ -58,13 +58,15 @@ test('今日视图汇总今天到期与进行中的任务', async ({ page }) =>
|
|||||||
await expect(page.getByRole('heading', { level: 2, name: /进行中/ })).toBeVisible()
|
await expect(page.getByRole('heading', { level: 2, name: /进行中/ })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('未开始且无截止日期的任务不进入今日视图', async ({ page }) => {
|
test('未开始且无截止日期的任务出现在「待办」组', async ({ page }) => {
|
||||||
await addTask(page, '以后再说')
|
await addTask(page, '以后再说')
|
||||||
|
|
||||||
await page.getByRole('link', { name: '今日' }).click()
|
await page.getByRole('link', { name: '今日' }).click()
|
||||||
|
|
||||||
await expect(page.locator('article')).toHaveCount(0)
|
// 早期版本这类任务在今日视图里完全消失,是一个真实的缺口
|
||||||
await expect(page.getByText('今天没有待办事项')).toBeVisible()
|
await expect(page.locator('article')).toHaveCount(1)
|
||||||
|
await expect(page.locator('article').filter({ hasText: '以后再说' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { level: 2, name: /待办/ })).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('今日视图可以勾选完成', async ({ page }) => {
|
test('今日视图可以勾选完成', async ({ page }) => {
|
||||||
|
|||||||
+16
@@ -12,6 +12,22 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="看板" />
|
<meta name="apple-mobile-web-app-title" content="看板" />
|
||||||
<title>看板 · 个人任务管理</title>
|
<title>看板 · 个人任务管理</title>
|
||||||
|
<script>
|
||||||
|
// 在首帧之前应用主题,避免深色模式下先闪一下白屏。
|
||||||
|
// 键名与 src/stores/theme.ts 中的 THEME_STORAGE_KEY 保持一致。
|
||||||
|
;(function () {
|
||||||
|
try {
|
||||||
|
var mode = localStorage.getItem('kanban.theme') || 'system'
|
||||||
|
var prefersDark =
|
||||||
|
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
if (mode === 'dark' || (mode === 'system' && prefersDark)) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 读不到偏好就按浅色渲染,不影响使用
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
+28
-8
@@ -125,20 +125,40 @@ async function main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const page = await context.newPage()
|
const page = await context.newPage()
|
||||||
await page.goto(BASE_URL)
|
|
||||||
await page.evaluate((state) => {
|
|
||||||
localStorage.setItem('kanban.tasks', JSON.stringify(state))
|
|
||||||
}, STORAGE)
|
|
||||||
|
|
||||||
const shots = [
|
const shots = [
|
||||||
{ name: 'board', url: '/', viewport: { width: 1440, height: 900 } },
|
{ name: 'board', url: '/', viewport: { width: 1440, height: 900 }, theme: 'light' },
|
||||||
{ name: 'today', url: '/today', viewport: { width: 1440, height: 900 } },
|
{ name: 'today', url: '/today', viewport: { width: 1440, height: 900 }, theme: 'light' },
|
||||||
{ name: 'board-detail', url: '/', viewport: { width: 1440, height: 900 }, openDetail: true },
|
{
|
||||||
{ name: 'board-mobile', url: '/', viewport: { width: 390, height: 844 } },
|
name: 'board-detail',
|
||||||
|
url: '/',
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
theme: 'light',
|
||||||
|
openDetail: true,
|
||||||
|
},
|
||||||
|
{ name: 'board-mobile', url: '/', viewport: { width: 390, height: 844 }, theme: 'light' },
|
||||||
|
{ name: 'board-dark', url: '/', viewport: { width: 1440, height: 900 }, theme: 'dark' },
|
||||||
|
{ name: 'today-dark', url: '/today', viewport: { width: 1440, height: 900 }, theme: 'dark' },
|
||||||
|
{
|
||||||
|
name: 'board-dark-mobile',
|
||||||
|
url: '/',
|
||||||
|
viewport: { width: 390, height: 844 },
|
||||||
|
theme: 'dark',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const shot of shots) {
|
for (const shot of shots) {
|
||||||
await page.setViewportSize(shot.viewport)
|
await page.setViewportSize(shot.viewport)
|
||||||
|
await page.goto(BASE_URL)
|
||||||
|
|
||||||
|
// 先写入种子数据与主题偏好,再重新进入目标页面
|
||||||
|
await page.evaluate(
|
||||||
|
({ state, theme }) => {
|
||||||
|
localStorage.setItem('kanban.tasks', JSON.stringify(state))
|
||||||
|
localStorage.setItem('kanban.theme', theme)
|
||||||
|
},
|
||||||
|
{ state: STORAGE, theme: shot.theme },
|
||||||
|
)
|
||||||
await page.goto(`${BASE_URL}${shot.url}`)
|
await page.goto(`${BASE_URL}${shot.url}`)
|
||||||
await page.waitForSelector('article, [data-role="empty-state"], h1')
|
await page.waitForSelector('article, [data-role="empty-state"], h1')
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -1,14 +1,17 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { enableAutoUnmount, mount } from '@vue/test-utils'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { createMemoryHistory } from 'vue-router'
|
import { createMemoryHistory } from 'vue-router'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { SCHEMA_VERSION } from '@/data/schema'
|
import { SCHEMA_VERSION } from '@/data/schema'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import { createAppRouter } from './router'
|
import { createAppRouter } from './router'
|
||||||
|
|
||||||
|
// App 会在 document 上注册快捷键监听,必须逐个卸载避免相互干扰
|
||||||
|
enableAutoUnmount(afterEach)
|
||||||
|
|
||||||
/** PWA 的虚拟模块在测试环境下无法解析,用桩替代 */
|
/** PWA 的虚拟模块在测试环境下无法解析,用桩替代 */
|
||||||
vi.mock('virtual:pwa-register/vue', async () => {
|
vi.mock('virtual:pwa-register/vue', async () => {
|
||||||
const { ref } = await import('vue')
|
const { ref } = await import('vue')
|
||||||
@@ -111,7 +114,7 @@ describe('App 路由', () => {
|
|||||||
|
|
||||||
it('/today 渲染今日视图', async () => {
|
it('/today 渲染今日视图', async () => {
|
||||||
const { wrapper } = await mountApp('/today')
|
const { wrapper } = await mountApp('/today')
|
||||||
expect(wrapper.text()).toContain('今日待办')
|
expect(wrapper.text()).toContain('本周完成')
|
||||||
expect(wrapper.text()).not.toContain('共 0 个任务')
|
expect(wrapper.text()).not.toContain('共 0 个任务')
|
||||||
wrapper.unmount()
|
wrapper.unmount()
|
||||||
})
|
})
|
||||||
|
|||||||
+37
-4
@@ -9,6 +9,7 @@ import SideDrawer from '@/components/SideDrawer.vue'
|
|||||||
import ToastHost from '@/components/ToastHost.vue'
|
import ToastHost from '@/components/ToastHost.vue'
|
||||||
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
const SHORTCUTS = [
|
const SHORTCUTS = [
|
||||||
@@ -18,11 +19,21 @@ const SHORTCUTS = [
|
|||||||
{ keys: 'Esc', description: '关闭当前面板' },
|
{ keys: 'Esc', description: '关闭当前面板' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
/** 这些只在卡片获得焦点后生效 */
|
||||||
|
const CARD_SHORTCUTS = [
|
||||||
|
{ keys: 'Tab', description: '依次聚焦到任务卡片' },
|
||||||
|
{ keys: '← →', description: '把卡片移到上一列 / 下一列' },
|
||||||
|
{ keys: '↑ ↓', description: '在列内上移 / 下移' },
|
||||||
|
{ keys: 'Enter', description: '打开任务详情' },
|
||||||
|
] as const
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
const theme = useThemeStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
store.init()
|
store.init()
|
||||||
|
theme.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 帮助面板是应用级的,因此在任何视图下都能用 ?
|
// 帮助面板是应用级的,因此在任何视图下都能用 ?
|
||||||
@@ -34,7 +45,7 @@ useKeyboardShortcuts({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full min-h-0 flex-col bg-slate-100">
|
<div class="flex h-full min-h-0 flex-col bg-slate-100 dark:bg-slate-950">
|
||||||
<AppNav />
|
<AppNav />
|
||||||
|
|
||||||
<main class="flex min-h-0 flex-1 flex-col">
|
<main class="flex min-h-0 flex-1 flex-col">
|
||||||
@@ -45,17 +56,39 @@ useKeyboardShortcuts({
|
|||||||
<BackupPanel :open="ui.backupOpen" @close="ui.closeBackup()" />
|
<BackupPanel :open="ui.backupOpen" @close="ui.closeBackup()" />
|
||||||
|
|
||||||
<SideDrawer :open="ui.helpOpen" title="快捷键" @close="ui.closeHelp()">
|
<SideDrawer :open="ui.helpOpen" title="快捷键" @close="ui.closeHelp()">
|
||||||
|
<h3 class="mb-2 text-xs font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
|
||||||
|
全局
|
||||||
|
</h3>
|
||||||
<dl class="flex flex-col gap-3 text-sm">
|
<dl class="flex flex-col gap-3 text-sm">
|
||||||
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
|
<div v-for="item in SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
|
||||||
<dt
|
<dt
|
||||||
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600"
|
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||||
>
|
>
|
||||||
{{ item.keys }}
|
{{ item.keys }}
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="text-slate-600">{{ item.description }}</dd>
|
<dd class="text-slate-600 dark:text-slate-300">{{ item.description }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<p class="mt-4 text-xs text-slate-400">在输入框内输入时不会触发快捷键。</p>
|
|
||||||
|
<h3
|
||||||
|
class="mt-6 mb-2 text-xs font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500"
|
||||||
|
>
|
||||||
|
卡片聚焦后
|
||||||
|
</h3>
|
||||||
|
<dl class="flex flex-col gap-3 text-sm">
|
||||||
|
<div v-for="item in CARD_SHORTCUTS" :key="item.keys" class="flex items-center gap-3">
|
||||||
|
<dt
|
||||||
|
class="w-14 shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-center font-mono text-xs text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
|
||||||
|
>
|
||||||
|
{{ item.keys }}
|
||||||
|
</dt>
|
||||||
|
<dd class="text-slate-600 dark:text-slate-300">{{ item.description }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
在输入框内输入时不会触发快捷键。
|
||||||
|
</p>
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
|
|
||||||
<ToastHost />
|
<ToastHost />
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
|
|
||||||
|
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
|
|
||||||
const ui = useUiStore()
|
const ui = useUiStore()
|
||||||
|
const theme = useThemeStore()
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ to: '/', label: '看板' },
|
{ to: '/', label: '看板' },
|
||||||
@@ -13,11 +15,11 @@ const LINKS = [
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class="flex items-center gap-3 border-b border-slate-200 bg-white/85 px-4 py-2.5 backdrop-blur-sm md:px-6"
|
class="flex items-center gap-3 border-b border-slate-200 bg-white/85 px-4 py-2.5 backdrop-blur-sm md:px-6 dark:border-slate-800 dark:bg-slate-900/85"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-900 text-white"
|
class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 16 16" class="size-3.5" aria-hidden="true">
|
<svg viewBox="0 0 16 16" class="size-3.5" aria-hidden="true">
|
||||||
<rect x="1" y="2" width="3.4" height="12" rx="1.2" fill="currentColor" opacity="0.5" />
|
<rect x="1" y="2" width="3.4" height="12" rx="1.2" fill="currentColor" opacity="0.5" />
|
||||||
@@ -25,10 +27,14 @@ const LINKS = [
|
|||||||
<rect x="11.6" y="2" width="3.4" height="5" rx="1.2" fill="currentColor" opacity="0.75" />
|
<rect x="11.6" y="2" width="3.4" height="5" rx="1.2" fill="currentColor" opacity="0.75" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden text-sm font-semibold tracking-tight text-slate-900 sm:inline">看板</span>
|
<span
|
||||||
|
class="hidden text-sm font-semibold tracking-tight text-slate-900 sm:inline dark:text-slate-100"
|
||||||
|
>
|
||||||
|
看板
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="h-5 w-px bg-slate-200" aria-hidden="true" />
|
<span class="h-5 w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
|
||||||
|
|
||||||
<nav class="flex items-center gap-0.5" aria-label="主导航">
|
<nav class="flex items-center gap-0.5" aria-label="主导航">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
@@ -43,8 +49,8 @@ const LINKS = [
|
|||||||
class="rounded-md px-3 py-1.5 text-sm transition"
|
class="rounded-md px-3 py-1.5 text-sm transition"
|
||||||
:class="
|
:class="
|
||||||
isExactActive
|
isExactActive
|
||||||
? 'bg-slate-900 font-medium text-white shadow-sm'
|
? 'bg-slate-900 font-medium text-white shadow-sm dark:bg-slate-100 dark:text-slate-900'
|
||||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||||
"
|
"
|
||||||
:aria-current="isExactActive ? 'page' : undefined"
|
:aria-current="isExactActive ? 'page' : undefined"
|
||||||
@click="navigate"
|
@click="navigate"
|
||||||
@@ -57,7 +63,62 @@ const LINKS = [
|
|||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-8 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700"
|
class="flex size-8 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
|
:aria-label="`主题:${THEME_LABELS[theme.mode]},点击切换`"
|
||||||
|
:title="`主题:${THEME_LABELS[theme.mode]}`"
|
||||||
|
@click="theme.cycle()"
|
||||||
|
>
|
||||||
|
<!-- 跟随系统 -->
|
||||||
|
<svg
|
||||||
|
v-if="theme.mode === 'system'"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-4"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2.5"
|
||||||
|
y="4"
|
||||||
|
width="15"
|
||||||
|
height="10"
|
||||||
|
rx="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<path d="M7 17h6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 浅色 -->
|
||||||
|
<svg
|
||||||
|
v-else-if="theme.mode === 'light'"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
class="size-4"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="10" cy="10" r="3.5" stroke="currentColor" stroke-width="1.5" />
|
||||||
|
<path
|
||||||
|
d="M10 2.5v1.8M10 15.7v1.8M2.5 10h1.8M15.7 10h1.8M4.7 4.7l1.3 1.3M14 14l1.3 1.3M15.3 4.7 14 6M6 14l-1.3 1.3"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- 深色 -->
|
||||||
|
<svg v-else viewBox="0 0 20 20" class="size-4" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M16.5 11.8A6.9 6.9 0 0 1 8.2 3.5a6.9 6.9 0 1 0 8.3 8.3Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex size-8 items-center justify-center rounded-md text-slate-400 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
aria-label="快捷键帮助"
|
aria-label="快捷键帮助"
|
||||||
@click="ui.openHelp()"
|
@click="ui.openHelp()"
|
||||||
>
|
>
|
||||||
@@ -75,7 +136,7 @@ const LINKS = [
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
|
class="flex items-center gap-1.5 rounded-md border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||||
@click="ui.openBackup()"
|
@click="ui.openBackup()"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
|
<svg viewBox="0 0 20 20" class="size-3.5" fill="none" aria-hidden="true">
|
||||||
|
|||||||
@@ -88,30 +88,32 @@ function formatTime(iso: string): string {
|
|||||||
<template>
|
<template>
|
||||||
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
|
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<p class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800">
|
<p
|
||||||
|
class="rounded-md bg-amber-50 px-3 py-2 text-xs leading-relaxed text-amber-800 dark:bg-amber-950/60 dark:text-amber-300"
|
||||||
|
>
|
||||||
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
|
数据只保存在这台设备的浏览器里。清理浏览器数据会导致任务丢失,建议定期导出备份。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="message"
|
v-if="message"
|
||||||
role="status"
|
role="status"
|
||||||
class="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800"
|
class="rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
||||||
>
|
>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
v-if="error"
|
v-if="error"
|
||||||
role="alert"
|
role="alert"
|
||||||
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700"
|
class="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-sm font-medium text-slate-700">导出</h3>
|
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">导出</h3>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="self-start rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
|
class="self-start rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 dark:hover:bg-sky-500"
|
||||||
@click="exportNow"
|
@click="exportNow"
|
||||||
>
|
>
|
||||||
导出为 JSON
|
导出为 JSON
|
||||||
@@ -119,35 +121,35 @@ function formatTime(iso: string): string {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-sm font-medium text-slate-700">导入</h3>
|
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">导入</h3>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json,.json"
|
accept="application/json,.json"
|
||||||
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border file:border-slate-300 file:bg-white file:px-3 file:py-1.5 file:text-sm file:text-slate-600"
|
class="block w-full text-sm text-slate-600 file:mr-3 file:rounded-md file:border file:border-slate-300 file:bg-white file:px-3 file:py-1.5 file:text-sm file:text-slate-600 dark:text-slate-400 dark:file:border-slate-600 dark:file:bg-slate-800 dark:file:text-slate-300"
|
||||||
aria-label="选择导入文件"
|
aria-label="选择导入文件"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="pending"
|
v-if="pending"
|
||||||
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2"
|
class="flex flex-col gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-800 dark:bg-amber-950/60"
|
||||||
>
|
>
|
||||||
<p class="text-sm text-amber-900">
|
<p class="text-sm text-amber-900 dark:text-amber-200">
|
||||||
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
将从「{{ pending.fileName }}」导入 {{ pending.count }} 个任务,
|
||||||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
|
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700"
|
class="rounded-md bg-rose-600 px-3 py-1 text-sm font-medium text-white transition hover:bg-rose-700 dark:hover:bg-rose-500"
|
||||||
@click="confirmImport"
|
@click="confirmImport"
|
||||||
>
|
>
|
||||||
确认覆盖导入
|
确认覆盖导入
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white"
|
class="rounded-md border border-slate-300 px-3 py-1 text-sm text-slate-600 transition hover:bg-white dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
@click="pending = null"
|
@click="pending = null"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -157,26 +159,30 @@ function formatTime(iso: string): string {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="flex flex-col gap-2">
|
<section class="flex flex-col gap-2">
|
||||||
<h3 class="text-sm font-medium text-slate-700">自动备份</h3>
|
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">自动备份</h3>
|
||||||
<p class="text-xs text-slate-500">每次打开看板时自动留一份,最多保留 3 份。</p>
|
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
每次打开看板时自动留一份,最多保留 3 份。
|
||||||
|
</p>
|
||||||
|
|
||||||
<ul v-if="snapshots.length > 0" class="flex flex-col gap-2">
|
<ul v-if="snapshots.length > 0" class="flex flex-col gap-2">
|
||||||
<li
|
<li
|
||||||
v-for="item in snapshots"
|
v-for="item in snapshots"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2"
|
class="flex items-center justify-between rounded-md border border-slate-200 px-3 py-2 dark:border-slate-700"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-slate-600">{{ formatTime(item.savedAt) }}</span>
|
<span class="text-sm text-slate-600 dark:text-slate-300">
|
||||||
|
{{ formatTime(item.savedAt) }}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50"
|
class="rounded-md px-2 py-0.5 text-sm text-sky-700 transition hover:bg-sky-50 dark:text-sky-400 dark:hover:bg-sky-950"
|
||||||
@click="restore(item.key)"
|
@click="restore(item.key)"
|
||||||
>
|
>
|
||||||
恢复
|
恢复
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="text-sm text-slate-400">暂无自动备份</p>
|
<p v-else class="text-sm text-slate-400 dark:text-slate-500">暂无自动备份</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useDraggable } from 'vue-draggable-plus'
|
import { useDraggable } from 'vue-draggable-plus'
|
||||||
|
|
||||||
import type { ColumnDef, ColumnId, Task } from '@/types/task'
|
import type { ColumnDef, ColumnId, MoveDirection, Task } from '@/types/task'
|
||||||
import { resolveDrop, shouldAllowMove } from '@/utils/drag'
|
import { resolveDrop, shouldAllowMove } from '@/utils/drag'
|
||||||
|
|
||||||
import QuickAddTask from './QuickAddTask.vue'
|
import QuickAddTask from './QuickAddTask.vue'
|
||||||
@@ -14,13 +14,14 @@ const emit = defineEmits<{
|
|||||||
toggle: [id: string]
|
toggle: [id: string]
|
||||||
open: [id: string]
|
open: [id: string]
|
||||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||||
|
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 每列一个色点:三列在余光里也能区分开 */
|
/** 每列一个色点:三列在余光里也能区分开 */
|
||||||
const COLUMN_DOT: Record<ColumnId, string> = {
|
const COLUMN_DOT: Record<ColumnId, string> = {
|
||||||
todo: 'bg-slate-400',
|
todo: 'bg-slate-400 dark:bg-slate-500',
|
||||||
doing: 'bg-sky-500',
|
doing: 'bg-sky-500 dark:bg-sky-400',
|
||||||
done: 'bg-emerald-500',
|
done: 'bg-emerald-500 dark:bg-emerald-400',
|
||||||
}
|
}
|
||||||
|
|
||||||
const dotClass = computed(() => COLUMN_DOT[props.column.id])
|
const dotClass = computed(() => COLUMN_DOT[props.column.id])
|
||||||
@@ -55,13 +56,15 @@ useDraggable(listRef, {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="flex flex-col rounded-xl border border-slate-200 bg-slate-100/70 md:min-h-0">
|
<section
|
||||||
|
class="flex flex-col rounded-xl border border-slate-200 bg-slate-100/70 md:min-h-0 dark:border-slate-800 dark:bg-slate-900/60"
|
||||||
|
>
|
||||||
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
|
<header class="flex items-center gap-2 px-3 pt-3 pb-2">
|
||||||
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
|
<span class="size-1.5 shrink-0 rounded-full" :class="dotClass" aria-hidden="true" />
|
||||||
<h2 class="text-sm font-medium text-slate-700">{{ column.title }}</h2>
|
<h2 class="text-sm font-medium text-slate-700 dark:text-slate-300">{{ column.title }}</h2>
|
||||||
<span
|
<span
|
||||||
data-role="column-count"
|
data-role="column-count"
|
||||||
class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500"
|
class="ml-auto rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs font-medium text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400"
|
||||||
>
|
>
|
||||||
{{ tasks.length }}
|
{{ tasks.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -80,11 +83,12 @@ useDraggable(listRef, {
|
|||||||
:task="task"
|
:task="task"
|
||||||
@toggle="emit('toggle', $event)"
|
@toggle="emit('toggle', $event)"
|
||||||
@open="emit('open', $event)"
|
@open="emit('open', $event)"
|
||||||
|
@keymove="emit('keymove', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="tasks.length === 0"
|
v-if="tasks.length === 0"
|
||||||
class="rounded-lg border border-dashed border-slate-300/70 px-3 py-10 text-center text-xs text-slate-400"
|
class="rounded-lg border border-dashed border-slate-300/70 px-3 py-10 text-center text-xs text-slate-400 dark:border-slate-700 dark:text-slate-500"
|
||||||
>
|
>
|
||||||
暂无任务
|
暂无任务
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ defineExpose({ focusSearch })
|
|||||||
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 20 20"
|
viewBox="0 0 20 20"
|
||||||
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400"
|
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
|
||||||
fill="none"
|
fill="none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
@@ -67,14 +67,14 @@ defineExpose({ focusSearch })
|
|||||||
ref="searchRef"
|
ref="searchRef"
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
type="search"
|
type="search"
|
||||||
class="w-full rounded-md border border-slate-200 bg-white py-1.5 pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100"
|
class="w-full rounded-md border border-slate-200 bg-white py-1.5 pr-2.5 pl-8 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-sky-400 focus:ring-2 focus:ring-sky-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 dark:placeholder:text-slate-500 dark:focus:border-sky-600 dark:focus:ring-sky-950"
|
||||||
placeholder="搜索标题或描述"
|
placeholder="搜索标题或描述"
|
||||||
aria-label="搜索任务"
|
aria-label="搜索任务"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5"
|
class="flex items-center gap-0.5 rounded-md border border-slate-200 bg-white p-0.5 dark:border-slate-700 dark:bg-slate-900"
|
||||||
role="group"
|
role="group"
|
||||||
aria-label="按优先级筛选"
|
aria-label="按优先级筛选"
|
||||||
>
|
>
|
||||||
@@ -85,8 +85,8 @@ defineExpose({ focusSearch })
|
|||||||
class="rounded px-2.5 py-1 text-xs transition"
|
class="rounded px-2.5 py-1 text-xs transition"
|
||||||
:class="
|
:class="
|
||||||
priorities.includes(value)
|
priorities.includes(value)
|
||||||
? 'bg-slate-900 font-medium text-white'
|
? 'bg-slate-900 font-medium text-white dark:bg-slate-100 dark:text-slate-900'
|
||||||
: 'text-slate-600 hover:bg-slate-100'
|
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="priorities.includes(value)"
|
:aria-pressed="priorities.includes(value)"
|
||||||
@click="filters.togglePriority(value)"
|
@click="filters.togglePriority(value)"
|
||||||
@@ -108,8 +108,8 @@ defineExpose({ focusSearch })
|
|||||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
||||||
:class="
|
:class="
|
||||||
due === 'today'
|
due === 'today'
|
||||||
? 'border-amber-300 bg-amber-50 text-amber-800'
|
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="due === 'today'"
|
:aria-pressed="due === 'today'"
|
||||||
@click="filters.toggleDue('today')"
|
@click="filters.toggleDue('today')"
|
||||||
@@ -123,10 +123,10 @@ defineExpose({ focusSearch })
|
|||||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs transition"
|
||||||
:class="
|
:class="
|
||||||
due === 'overdue'
|
due === 'overdue'
|
||||||
? 'border-rose-300 bg-rose-50 text-rose-700'
|
? 'border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-700 dark:bg-rose-950 dark:text-rose-300'
|
||||||
: overdueCount > 0
|
: overdueCount > 0
|
||||||
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50'
|
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50 dark:border-slate-700 dark:bg-slate-900 dark:text-rose-400 dark:hover:border-rose-800 dark:hover:bg-rose-950'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="due === 'overdue'"
|
:aria-pressed="due === 'overdue'"
|
||||||
@click="filters.toggleDue('overdue')"
|
@click="filters.toggleDue('overdue')"
|
||||||
@@ -137,15 +137,15 @@ defineExpose({ focusSearch })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500">
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span>共 {{ store.tasks.length }} 个任务</span>
|
<span>共 {{ store.tasks.length }} 个任务</span>
|
||||||
<span v-if="hiddenNote" class="text-slate-400">{{ hiddenNote }}</span>
|
<span v-if="hiddenNote" class="text-slate-400 dark:text-slate-500">{{ hiddenNote }}</span>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1.5">
|
<div class="ml-auto flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
v-if="archivableCount > 0"
|
v-if="archivableCount > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50"
|
class="rounded-md border border-slate-200 bg-white px-2.5 py-1 text-xs text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
|
||||||
@click="emit('archive')"
|
@click="emit('archive')"
|
||||||
>
|
>
|
||||||
归档 {{ archivableCount }} 个已完成
|
归档 {{ archivableCount }} 个已完成
|
||||||
@@ -156,8 +156,8 @@ defineExpose({ focusSearch })
|
|||||||
class="rounded-md border px-2.5 py-1 text-xs transition"
|
class="rounded-md border px-2.5 py-1 text-xs transition"
|
||||||
:class="
|
:class="
|
||||||
includeArchived
|
includeArchived
|
||||||
? 'border-sky-300 bg-sky-50 text-sky-700'
|
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="includeArchived"
|
:aria-pressed="includeArchived"
|
||||||
@click="filters.toggleArchived()"
|
@click="filters.toggleArchived()"
|
||||||
@@ -168,7 +168,7 @@ defineExpose({ focusSearch })
|
|||||||
<button
|
<button
|
||||||
v-if="active"
|
v-if="active"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-2.5 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline"
|
class="rounded-md px-2.5 py-1 text-xs text-slate-500 underline-offset-2 transition hover:text-slate-800 hover:underline dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
@click="filters.clear()"
|
@click="filters.clear()"
|
||||||
>
|
>
|
||||||
清空筛选
|
清空筛选
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* 直接使用浏览器原生 `<input type="date">`:
|
* 直接使用浏览器原生 `<input type="date">`:
|
||||||
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
|
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
|
||||||
* - 自带可访问性、键盘操作与本地化日历,无需自己实现
|
* - 自带可访问性、键盘操作与本地化日历,无需自己实现
|
||||||
|
* - 深色模式下日历弹层跟随 html 的 color-scheme
|
||||||
*/
|
*/
|
||||||
|
|
||||||
defineProps<{ modelValue: string | null; label?: string }>()
|
defineProps<{ modelValue: string | null; label?: string }>()
|
||||||
@@ -21,7 +22,7 @@ function onChange(event: Event): void {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
class="min-w-0 flex-1 rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-sky-600"
|
||||||
:value="modelValue ?? ''"
|
:value="modelValue ?? ''"
|
||||||
:aria-label="label ?? '截止日期'"
|
:aria-label="label ?? '截止日期'"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
@@ -29,7 +30,7 @@ function onChange(event: Event): void {
|
|||||||
<button
|
<button
|
||||||
v-if="modelValue"
|
v-if="modelValue"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-100"
|
class="shrink-0 rounded px-2 py-1 text-xs text-slate-500 transition hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
|
||||||
@click="emit('update:modelValue', null)"
|
@click="emit('update:modelValue', null)"
|
||||||
>
|
>
|
||||||
清除
|
清除
|
||||||
|
|||||||
@@ -26,14 +26,14 @@ function reload(): void {
|
|||||||
<div v-if="visible" class="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
<div v-if="visible" class="fixed inset-x-0 bottom-4 z-50 flex justify-center px-4">
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
class="flex items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
|
class="flex items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-panel dark:bg-slate-100 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
<span>{{ needRefresh ? '有新版本可用' : '已缓存,可离线使用' }}</span>
|
<span>{{ needRefresh ? '有新版本可用' : '已缓存,可离线使用' }}</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="needRefresh"
|
v-if="needRefresh"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded px-2 py-0.5 font-medium text-sky-300 transition hover:bg-white/10"
|
class="shrink-0 rounded px-2 py-0.5 font-medium text-sky-300 transition hover:bg-white/10 dark:text-sky-700 dark:hover:bg-slate-900/10"
|
||||||
@click="reload"
|
@click="reload"
|
||||||
>
|
>
|
||||||
立即更新
|
立即更新
|
||||||
@@ -41,7 +41,7 @@ function reload(): void {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
|
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white dark:text-slate-500 dark:hover:text-slate-900"
|
||||||
aria-label="关闭提示"
|
aria-label="关闭提示"
|
||||||
@click="dismiss"
|
@click="dismiss"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ defineExpose({ focusInput })
|
|||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full rounded-lg border border-transparent bg-white/60 px-3 py-2 text-sm text-slate-700 transition outline-none placeholder:text-slate-400 hover:bg-white focus:border-sky-300 focus:bg-white focus:ring-2 focus:ring-sky-100"
|
class="w-full rounded-lg border border-transparent bg-white/60 px-3 py-2 text-sm text-slate-700 transition outline-none placeholder:text-slate-400 hover:bg-white focus:border-sky-300 focus:bg-white focus:ring-2 focus:ring-sky-100 dark:bg-slate-800/60 dark:text-slate-200 dark:placeholder:text-slate-500 dark:hover:bg-slate-800 dark:focus:border-sky-700 dark:focus:bg-slate-800 dark:focus:ring-sky-950"
|
||||||
placeholder="+ 添加任务"
|
placeholder="+ 添加任务"
|
||||||
aria-label="添加任务"
|
aria-label="添加任务"
|
||||||
@compositionstart="composing = true"
|
@compositionstart="composing = true"
|
||||||
|
|||||||
@@ -85,21 +85,23 @@ onBeforeUnmount(stopListening)
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div v-if="open" class="fixed inset-0 z-40">
|
<div v-if="open" class="fixed inset-0 z-40">
|
||||||
<div class="absolute inset-0 bg-slate-900/40" @click="emit('close')" />
|
<div class="absolute inset-0 bg-slate-900/40 dark:bg-black/60" @click="emit('close')" />
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
ref="panelRef"
|
ref="panelRef"
|
||||||
class="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-xl outline-none"
|
class="absolute inset-y-0 right-0 flex w-full max-w-md flex-col bg-white shadow-panel outline-none dark:bg-slate-900"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
:aria-label="title"
|
:aria-label="title"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<header class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
<header
|
||||||
<h2 class="text-base font-medium text-slate-800">{{ title }}</h2>
|
class="flex items-center justify-between border-b border-slate-200 px-4 py-3 dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<h2 class="text-base font-medium text-slate-800 dark:text-slate-100">{{ title }}</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded px-2 py-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600"
|
class="rounded px-2 py-1 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
aria-label="关闭"
|
aria-label="关闭"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
@@ -111,7 +113,10 @@ onBeforeUnmount(stopListening)
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer v-if="$slots.footer" class="border-t border-slate-200 px-4 py-3">
|
<footer
|
||||||
|
v-if="$slots.footer"
|
||||||
|
class="border-t border-slate-200 px-4 py-3 dark:border-slate-800"
|
||||||
|
>
|
||||||
<slot name="footer" />
|
<slot name="footer" />
|
||||||
</footer>
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ onBeforeUnmount(() => {
|
|||||||
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm transition"
|
class="flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm transition"
|
||||||
:class="
|
:class="
|
||||||
selected.length > 0
|
selected.length > 0
|
||||||
? 'border-sky-300 bg-sky-50 text-sky-700'
|
? 'border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950 dark:text-sky-300'
|
||||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
@@ -57,7 +57,7 @@ onBeforeUnmount(() => {
|
|||||||
标签
|
标签
|
||||||
<span
|
<span
|
||||||
v-if="selected.length > 0"
|
v-if="selected.length > 0"
|
||||||
class="rounded-full bg-sky-600 px-1.5 text-[11px] leading-4 font-medium text-white"
|
class="rounded-full bg-sky-600 px-1.5 text-[11px] leading-4 font-medium text-white dark:bg-sky-500"
|
||||||
>
|
>
|
||||||
{{ selected.length }}
|
{{ selected.length }}
|
||||||
</span>
|
</span>
|
||||||
@@ -80,20 +80,20 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="open"
|
v-if="open"
|
||||||
class="absolute top-full left-0 z-20 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop"
|
class="absolute top-full left-0 z-20 mt-1.5 w-56 rounded-lg border border-slate-200 bg-white p-1.5 shadow-pop dark:border-slate-700 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400">
|
<p v-if="tags.length === 0" class="px-2 py-3 text-center text-xs text-slate-400 dark:text-slate-500">
|
||||||
还没有任何标签
|
还没有任何标签
|
||||||
</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 tags" :key="tag">
|
||||||
<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"
|
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-slate-300 dark:hover:bg-slate-800"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="size-3.5 accent-sky-600"
|
class="size-3.5 accent-sky-600 dark:accent-sky-500"
|
||||||
:checked="selected.includes(tag)"
|
:checked="selected.includes(tag)"
|
||||||
@change="emit('toggle', tag)"
|
@change="emit('toggle', tag)"
|
||||||
/>
|
/>
|
||||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
v-if="selected.length > 0"
|
v-if="selected.length > 0"
|
||||||
type="button"
|
type="button"
|
||||||
class="mt-1 w-full rounded-md px-2 py-1.5 text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700"
|
class="mt-1 w-full rounded-md px-2 py-1.5 text-xs text-slate-500 transition hover:bg-slate-50 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
||||||
@click="emit('clear')"
|
@click="emit('clear')"
|
||||||
>
|
>
|
||||||
清除标签筛选
|
清除标签筛选
|
||||||
|
|||||||
@@ -36,17 +36,17 @@ function onBackspace(): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 focus-within:border-sky-400"
|
class="flex flex-wrap items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1.5 focus-within:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:focus-within:border-sky-600"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-for="tag in modelValue"
|
v-for="tag in modelValue"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700"
|
class="flex items-center gap-1 rounded bg-sky-50 px-1.5 py-0.5 text-xs text-sky-700 dark:bg-sky-950 dark:text-sky-300"
|
||||||
>
|
>
|
||||||
#{{ tag }}
|
#{{ tag }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-sky-500 transition hover:text-sky-800"
|
class="text-sky-500 transition hover:text-sky-800 dark:text-sky-400 dark:hover:text-sky-200"
|
||||||
:aria-label="`移除标签 ${tag}`"
|
:aria-label="`移除标签 ${tag}`"
|
||||||
@click="remove(tag)"
|
@click="remove(tag)"
|
||||||
>
|
>
|
||||||
@@ -57,7 +57,7 @@ function onBackspace(): void {
|
|||||||
<input
|
<input
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
type="text"
|
type="text"
|
||||||
class="min-w-24 flex-1 border-none bg-transparent px-1 py-0.5 text-sm text-slate-700 outline-none placeholder:text-slate-400"
|
class="min-w-24 flex-1 border-none bg-transparent px-1 py-0.5 text-sm text-slate-700 outline-none placeholder:text-slate-400 dark:text-slate-200 dark:placeholder:text-slate-500"
|
||||||
:placeholder="modelValue.length === 0 ? (placeholder ?? '回车添加标签') : ''"
|
:placeholder="modelValue.length === 0 ? (placeholder ?? '回车添加标签') : ''"
|
||||||
aria-label="添加标签"
|
aria-label="添加标签"
|
||||||
@keydown.enter.prevent="composing ? undefined : commit()"
|
@keydown.enter.prevent="composing ? undefined : commit()"
|
||||||
|
|||||||
@@ -148,3 +148,57 @@ describe('交互', () => {
|
|||||||
expect(mountCard().find('article').classes().join(' ')).toContain('cursor-pointer')
|
expect(mountCard().find('article').classes().join(' ')).toContain('cursor-pointer')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('TaskCard 键盘操作', () => {
|
||||||
|
it('卡片可聚焦', () => {
|
||||||
|
expect(mountCard().find('article').attributes('tabindex')).toBe('0')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('方向键 emit keymove 并带上方向', async () => {
|
||||||
|
const wrapper = mountCard({ id: 'task-1' })
|
||||||
|
const article = wrapper.find('article')
|
||||||
|
|
||||||
|
await article.trigger('keydown', { key: 'ArrowLeft' })
|
||||||
|
await article.trigger('keydown', { key: 'ArrowRight' })
|
||||||
|
await article.trigger('keydown', { key: 'ArrowUp' })
|
||||||
|
await article.trigger('keydown', { key: 'ArrowDown' })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('keymove')).toEqual([
|
||||||
|
[{ id: 'task-1', direction: 'left' }],
|
||||||
|
[{ id: 'task-1', direction: 'right' }],
|
||||||
|
[{ id: 'task-1', direction: 'up' }],
|
||||||
|
[{ id: 'task-1', direction: 'down' }],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Enter 与空格都能打开详情', async () => {
|
||||||
|
const wrapper = mountCard({ id: 'task-2' })
|
||||||
|
const article = wrapper.find('article')
|
||||||
|
|
||||||
|
await article.trigger('keydown', { key: 'Enter' })
|
||||||
|
await article.trigger('keydown', { key: ' ' })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('open')).toEqual([['task-2'], ['task-2']])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('其他按键不产生任何事件', async () => {
|
||||||
|
const wrapper = mountCard()
|
||||||
|
const article = wrapper.find('article')
|
||||||
|
|
||||||
|
await article.trigger('keydown', { key: 'a' })
|
||||||
|
await article.trigger('keydown', { key: 'Tab' })
|
||||||
|
|
||||||
|
expect(wrapper.emitted('keymove')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('open')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('方向键被 preventDefault(避免连带着滚动看板)', async () => {
|
||||||
|
const wrapper = mountCard()
|
||||||
|
const article = wrapper.find('article')
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })
|
||||||
|
article.element.dispatchEvent(event)
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+61
-14
@@ -1,14 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { PRIORITY_LABELS, type Task } from '@/types/task'
|
import { PRIORITY_LABELS, type MoveDirection, type Task } from '@/types/task'
|
||||||
import { formatDueDate, isOverdue } from '@/utils/date'
|
import { formatDueDate, isOverdue } from '@/utils/date'
|
||||||
|
|
||||||
const MAX_VISIBLE_TAGS = 3
|
const MAX_VISIBLE_TAGS = 3
|
||||||
|
|
||||||
const props = defineProps<{ task: Task }>()
|
const props = defineProps<{ task: Task }>()
|
||||||
|
|
||||||
const emit = defineEmits<{ toggle: [id: string]; open: [id: string] }>()
|
const emit = defineEmits<{
|
||||||
|
toggle: [id: string]
|
||||||
|
open: [id: string]
|
||||||
|
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
/** 方向键映射;左右换列,上下调顺序 */
|
||||||
|
const MOVE_KEYS: Readonly<Record<string, MoveDirection>> = {
|
||||||
|
ArrowLeft: 'left',
|
||||||
|
ArrowRight: 'right',
|
||||||
|
ArrowUp: 'up',
|
||||||
|
ArrowDown: 'down',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 卡片获得焦点后的键盘操作。
|
||||||
|
* 注意:方向键必须 preventDefault,否则会连带滚动整个看板。
|
||||||
|
*/
|
||||||
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('open', props.task.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const direction = MOVE_KEYS[event.key]
|
||||||
|
if (!direction) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
emit('keymove', { id: props.task.id, direction })
|
||||||
|
}
|
||||||
|
|
||||||
const isDone = computed(() => props.task.status === 'done')
|
const isDone = computed(() => props.task.status === 'done')
|
||||||
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
const overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
||||||
@@ -26,28 +56,34 @@ const priorityBarClass = computed(() => {
|
|||||||
case 2:
|
case 2:
|
||||||
return 'bg-amber-400'
|
return 'bg-amber-400'
|
||||||
default:
|
default:
|
||||||
return 'bg-slate-300'
|
return 'bg-slate-300 dark:bg-slate-600'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const priorityTextClass = computed(() => {
|
const priorityTextClass = computed(() => {
|
||||||
switch (props.task.priority) {
|
switch (props.task.priority) {
|
||||||
case 3:
|
case 3:
|
||||||
return 'text-rose-600'
|
return 'text-rose-600 dark:text-rose-400'
|
||||||
case 2:
|
case 2:
|
||||||
return 'text-amber-700'
|
return 'text-amber-700 dark:text-amber-400'
|
||||||
default:
|
default:
|
||||||
return 'text-slate-400'
|
return 'text-slate-400 dark:text-slate-500'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article
|
<article
|
||||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150"
|
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150 dark:border-slate-800 dark:bg-slate-900"
|
||||||
:class="isDone ? 'opacity-60' : 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover'"
|
:class="
|
||||||
|
isDone
|
||||||
|
? 'opacity-60'
|
||||||
|
: 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover dark:hover:border-slate-700'
|
||||||
|
"
|
||||||
:data-task-id="task.id"
|
:data-task-id="task.id"
|
||||||
|
tabindex="0"
|
||||||
@click="emit('open', task.id)"
|
@click="emit('open', task.id)"
|
||||||
|
@keydown="onKeydown"
|
||||||
>
|
>
|
||||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||||
|
|
||||||
@@ -59,7 +95,7 @@ const priorityTextClass = computed(() => {
|
|||||||
:class="
|
:class="
|
||||||
isDone
|
isDone
|
||||||
? 'border-emerald-500 bg-emerald-500 text-white'
|
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||||
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50'
|
: 'border-slate-300 hover:border-emerald-500 hover:bg-emerald-50 dark:border-slate-600 dark:hover:border-emerald-500 dark:hover:bg-emerald-950'
|
||||||
"
|
"
|
||||||
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
|
:aria-label="isDone ? '标记为未完成' : '标记为已完成'"
|
||||||
:aria-pressed="isDone"
|
:aria-pressed="isDone"
|
||||||
@@ -78,7 +114,11 @@ const priorityTextClass = computed(() => {
|
|||||||
|
|
||||||
<h3
|
<h3
|
||||||
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
class="min-w-0 flex-1 text-sm leading-snug wrap-break-word"
|
||||||
:class="isDone ? 'text-slate-400 line-through' : 'text-slate-800'"
|
:class="
|
||||||
|
isDone
|
||||||
|
? 'text-slate-400 line-through dark:text-slate-500'
|
||||||
|
: 'text-slate-800 dark:text-slate-100'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ task.title }}
|
{{ task.title }}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -91,14 +131,21 @@ const priorityTextClass = computed(() => {
|
|||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="task.dueDate"
|
v-if="task.dueDate"
|
||||||
class="font-medium"
|
:class="
|
||||||
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
|
overdue
|
||||||
|
? 'font-medium text-rose-600 dark:text-rose-400'
|
||||||
|
: 'text-slate-500 dark:text-slate-400'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
{{ formatDueDate(task.dueDate) }}
|
{{ formatDueDate(task.dueDate) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400">#{{ tag }}</span>
|
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-slate-500">
|
||||||
<span v-if="hiddenTagCount > 0" class="text-slate-400">+{{ hiddenTagCount }}</span>
|
#{{ tag }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hiddenTagCount > 0" class="text-slate-400 dark:text-slate-500">
|
||||||
|
+{{ hiddenTagCount }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -82,29 +82,29 @@ function remove(): void {
|
|||||||
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
|
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
|
||||||
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
||||||
<label class="flex flex-col gap-1.5">
|
<label class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-slate-600">标题</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-slate-400">标题</span>
|
||||||
<input
|
<input
|
||||||
v-model="title"
|
v-model="title"
|
||||||
type="text"
|
type="text"
|
||||||
class="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
class="rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-sky-600"
|
||||||
aria-label="标题"
|
aria-label="标题"
|
||||||
@input="titleError = null"
|
@input="titleError = null"
|
||||||
/>
|
/>
|
||||||
<span v-if="titleError" class="text-xs text-rose-600">{{ titleError }}</span>
|
<span v-if="titleError" class="text-xs text-rose-600 dark:text-rose-400">{{ titleError }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="flex flex-col gap-1.5">
|
<label class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-slate-600">描述</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-slate-400">描述</span>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="description"
|
v-model="description"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400"
|
class="resize-y rounded-md border border-slate-300 px-2.5 py-1.5 text-sm text-slate-700 outline-none focus:border-sky-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-sky-600"
|
||||||
aria-label="描述"
|
aria-label="描述"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<fieldset class="flex flex-col gap-1.5">
|
<fieldset class="flex flex-col gap-1.5">
|
||||||
<legend class="mb-1.5 text-sm font-medium text-slate-600">优先级</legend>
|
<legend class="mb-1.5 text-sm font-medium text-slate-600 dark:text-slate-400">优先级</legend>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
v-for="value in PRIORITIES"
|
v-for="value in PRIORITIES"
|
||||||
@@ -113,8 +113,8 @@ function remove(): void {
|
|||||||
class="rounded-md border px-3 py-1 text-sm transition"
|
class="rounded-md border px-3 py-1 text-sm transition"
|
||||||
:class="
|
:class="
|
||||||
priority === value
|
priority === value
|
||||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
? 'border-sky-400 bg-sky-50 text-sky-700 dark:border-sky-600 dark:bg-sky-950 dark:text-sky-300'
|
||||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
: 'border-slate-300 text-slate-600 hover:bg-slate-50 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-800'
|
||||||
"
|
"
|
||||||
:aria-pressed="priority === value"
|
:aria-pressed="priority === value"
|
||||||
@click="priority = value"
|
@click="priority = value"
|
||||||
@@ -125,12 +125,12 @@ function remove(): void {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-slate-600">截止日期</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-slate-400">截止日期</span>
|
||||||
<DatePicker v-model="dueDate" />
|
<DatePicker v-model="dueDate" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<span class="text-sm font-medium text-slate-600">标签</span>
|
<span class="text-sm font-medium text-slate-600 dark:text-slate-400">标签</span>
|
||||||
<TagInput v-model="tags" />
|
<TagInput v-model="tags" />
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -139,7 +139,7 @@ function remove(): void {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50"
|
class="rounded-md px-2.5 py-1.5 text-sm text-rose-600 transition hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-950"
|
||||||
@click="remove"
|
@click="remove"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -148,14 +148,14 @@ function remove(): void {
|
|||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50"
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-600 transition hover:bg-slate-50 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700"
|
class="rounded-md bg-sky-600 px-3 py-1.5 text-sm font-medium text-white transition hover:bg-sky-700 dark:hover:bg-sky-500"
|
||||||
@click="save"
|
@click="save"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ const { toasts } = storeToRefs(store)
|
|||||||
v-for="toast in toasts"
|
v-for="toast in toasts"
|
||||||
:key="toast.id"
|
:key="toast.id"
|
||||||
role="status"
|
role="status"
|
||||||
class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-lg"
|
class="pointer-events-auto flex w-full max-w-sm items-center gap-3 rounded-md bg-slate-800 px-3 py-2 text-sm text-white shadow-panel dark:bg-slate-100 dark:text-slate-900"
|
||||||
>
|
>
|
||||||
<span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span>
|
<span class="min-w-0 flex-1 wrap-break-word">{{ toast.message }}</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="toast.action"
|
v-if="toast.action"
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded px-2 py-0.5 text-sm font-medium text-sky-300 transition hover:bg-white/10"
|
class="shrink-0 rounded px-2 py-0.5 text-sm font-medium text-sky-300 transition hover:bg-white/10 dark:text-sky-700 dark:hover:bg-slate-900/10"
|
||||||
@click="store.runAction(toast.id)"
|
@click="store.runAction(toast.id)"
|
||||||
>
|
>
|
||||||
{{ toast.action.label }}
|
{{ toast.action.label }}
|
||||||
@@ -30,7 +30,7 @@ const { toasts } = storeToRefs(store)
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white"
|
class="shrink-0 rounded px-1.5 text-slate-400 transition hover:text-white dark:text-slate-500 dark:hover:text-slate-900"
|
||||||
aria-label="关闭提示"
|
aria-label="关闭提示"
|
||||||
@click="store.dismiss(toast.id)"
|
@click="store.dismiss(toast.id)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DARK_CLASS,
|
||||||
|
THEME_CYCLE,
|
||||||
|
THEME_STORAGE_KEY,
|
||||||
|
isThemeMode,
|
||||||
|
resolveDark,
|
||||||
|
useThemeStore,
|
||||||
|
} from './theme'
|
||||||
|
|
||||||
|
function stubMatchMedia(matches: boolean): void {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'matchMedia',
|
||||||
|
vi.fn(() => ({
|
||||||
|
matches,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDarkApplied(): boolean {
|
||||||
|
return document.documentElement.classList.contains(DARK_CLASS)
|
||||||
|
}
|
||||||
|
|
||||||
|
let stores: Array<ReturnType<typeof useThemeStore>> = []
|
||||||
|
|
||||||
|
function setup(): ReturnType<typeof useThemeStore> {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
const store = useThemeStore()
|
||||||
|
stores.push(store)
|
||||||
|
return store
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
document.documentElement.classList.remove(DARK_CLASS)
|
||||||
|
stubMatchMedia(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const store of stores) store.dispose()
|
||||||
|
stores = []
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isThemeMode', () => {
|
||||||
|
it('接受三个合法值', () => {
|
||||||
|
expect(isThemeMode('system')).toBe(true)
|
||||||
|
expect(isThemeMode('light')).toBe(true)
|
||||||
|
expect(isThemeMode('dark')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('拒绝其它值', () => {
|
||||||
|
expect(isThemeMode('DARK')).toBe(false)
|
||||||
|
expect(isThemeMode(null)).toBe(false)
|
||||||
|
expect(isThemeMode('')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveDark', () => {
|
||||||
|
it('强制深色/浅色不受系统影响', () => {
|
||||||
|
stubMatchMedia(false)
|
||||||
|
expect(resolveDark('dark')).toBe(true)
|
||||||
|
expect(resolveDark('light')).toBe(false)
|
||||||
|
|
||||||
|
stubMatchMedia(true)
|
||||||
|
expect(resolveDark('dark')).toBe(true)
|
||||||
|
expect(resolveDark('light')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跟随系统时取决于系统偏好', () => {
|
||||||
|
stubMatchMedia(true)
|
||||||
|
expect(resolveDark('system')).toBe(true)
|
||||||
|
|
||||||
|
stubMatchMedia(false)
|
||||||
|
expect(resolveDark('system')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('theme store', () => {
|
||||||
|
it('默认跟随系统', () => {
|
||||||
|
const store = setup()
|
||||||
|
expect(store.mode).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换到深色会给 html 加上 dark 类', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.setMode('dark')
|
||||||
|
|
||||||
|
expect(store.isDark).toBe(true)
|
||||||
|
expect(isDarkApplied()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('切换回浅色会移除 dark 类', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.setMode('dark')
|
||||||
|
store.setMode('light')
|
||||||
|
|
||||||
|
expect(store.isDark).toBe(false)
|
||||||
|
expect(isDarkApplied()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('选择会持久化到 localStorage', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.setMode('dark')
|
||||||
|
expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('init 会读取已保存的偏好', () => {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, 'dark')
|
||||||
|
const store = setup()
|
||||||
|
store.init()
|
||||||
|
|
||||||
|
expect(store.mode).toBe('dark')
|
||||||
|
expect(store.isDark).toBe(true)
|
||||||
|
expect(isDarkApplied()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('保存的值非法时回退到跟随系统', () => {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, 'neon')
|
||||||
|
const store = setup()
|
||||||
|
store.init()
|
||||||
|
|
||||||
|
expect(store.mode).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跟随系统时按系统偏好渲染', () => {
|
||||||
|
stubMatchMedia(true)
|
||||||
|
const store = setup()
|
||||||
|
store.init()
|
||||||
|
|
||||||
|
expect(store.mode).toBe('system')
|
||||||
|
expect(store.isDark).toBe(true)
|
||||||
|
expect(isDarkApplied()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cycle 按 系统 → 浅色 → 深色 → 系统 循环', () => {
|
||||||
|
const store = setup()
|
||||||
|
store.init()
|
||||||
|
expect(store.mode).toBe('system')
|
||||||
|
|
||||||
|
store.cycle()
|
||||||
|
expect(store.mode).toBe('light')
|
||||||
|
|
||||||
|
store.cycle()
|
||||||
|
expect(store.mode).toBe('dark')
|
||||||
|
|
||||||
|
store.cycle()
|
||||||
|
expect(store.mode).toBe('system')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('循环顺序常量与实现一致', () => {
|
||||||
|
expect([...THEME_CYCLE]).toEqual(['system', 'light', 'dark'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/**
|
||||||
|
* 主题(深色模式)
|
||||||
|
*
|
||||||
|
* 三态:跟随系统 / 强制浅色 / 强制深色。
|
||||||
|
* 选择保存在 localStorage,index.html 里的内联脚本会在首帧前应用,
|
||||||
|
* 避免深色模式下先闪一下白屏。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type ThemeMode = 'system' | 'light' | 'dark'
|
||||||
|
|
||||||
|
/** 与 index.html 内联脚本、useTheme 保持一致 */
|
||||||
|
export const THEME_STORAGE_KEY = 'kanban.theme'
|
||||||
|
export const DARK_CLASS = 'dark'
|
||||||
|
|
||||||
|
/** 循环切换的顺序 */
|
||||||
|
export const THEME_CYCLE: readonly ThemeMode[] = ['system', 'light', 'dark']
|
||||||
|
|
||||||
|
export const THEME_LABELS: Readonly<Record<ThemeMode, string>> = {
|
||||||
|
system: '跟随系统',
|
||||||
|
light: '浅色',
|
||||||
|
dark: '深色',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThemeMode(value: unknown): value is ThemeMode {
|
||||||
|
return value === 'system' || value === 'light' || value === 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStoredMode(): ThemeMode {
|
||||||
|
if (typeof localStorage === 'undefined') return 'system'
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
return isThemeMode(raw) ? raw : 'system'
|
||||||
|
} catch {
|
||||||
|
return 'system'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 系统当前是否偏好深色 */
|
||||||
|
export function systemPrefersDark(): boolean {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 该模式下是否应渲染为深色 */
|
||||||
|
export function resolveDark(mode: ThemeMode): boolean {
|
||||||
|
return mode === 'dark' || (mode === 'system' && systemPrefersDark())
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyToDocument(mode: ThemeMode): void {
|
||||||
|
if (typeof document === 'undefined') return
|
||||||
|
document.documentElement.classList.toggle(DARK_CLASS, resolveDark(mode))
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist(mode: ThemeMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(THEME_STORAGE_KEY, mode)
|
||||||
|
} catch {
|
||||||
|
// 隐私模式下写不了,不影响本次使用
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = defineStore('theme', () => {
|
||||||
|
const mode = ref<ThemeMode>(readStoredMode())
|
||||||
|
|
||||||
|
/** 主题是否真的处于深色(跟随系统时取决于系统设置) */
|
||||||
|
const isDark = ref(resolveDark(mode.value))
|
||||||
|
|
||||||
|
let mediaQuery: MediaQueryList | null = null
|
||||||
|
|
||||||
|
function sync(): void {
|
||||||
|
isDark.value = resolveDark(mode.value)
|
||||||
|
applyToDocument(mode.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSystemChange(): void {
|
||||||
|
if (mode.value === 'system') sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(): void {
|
||||||
|
mode.value = readStoredMode()
|
||||||
|
sync()
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
|
||||||
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
mediaQuery.addEventListener?.('change', onSystemChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(next: ThemeMode): void {
|
||||||
|
mode.value = next
|
||||||
|
persist(next)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycle(): void {
|
||||||
|
const current = THEME_CYCLE.indexOf(mode.value)
|
||||||
|
const next = THEME_CYCLE[(current + 1) % THEME_CYCLE.length] ?? 'system'
|
||||||
|
setMode(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放系统监听,主要供测试使用 */
|
||||||
|
function dispose(): void {
|
||||||
|
mediaQuery?.removeEventListener?.('change', onSystemChange)
|
||||||
|
mediaQuery = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, isDark, init, setMode, cycle, dispose }
|
||||||
|
})
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 深色模式用 class 控制而不是纯 media query ——
|
||||||
|
* 这样才能支持「跟随系统 / 强制浅色 / 强制深色」三态。
|
||||||
|
*/
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
|
system-ui, 'Segoe UI', 'Microsoft YaHei', 'PingFang SC', Roboto, sans-serif;
|
||||||
@@ -18,13 +24,28 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
background: var(--color-slate-100);
|
||||||
|
color: var(--color-slate-900);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark body {
|
||||||
|
background: var(--color-slate-950);
|
||||||
|
color: var(--color-slate-100);
|
||||||
|
}
|
||||||
|
|
||||||
/* 统一的键盘焦点样式,鼠标点击不显示 */
|
/* 统一的键盘焦点样式,鼠标点击不显示 */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-sky-500);
|
outline: 2px solid var(--color-sky-500);
|
||||||
@@ -36,9 +57,17 @@
|
|||||||
background: var(--color-sky-200);
|
background: var(--color-sky-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark ::selection {
|
||||||
|
background: var(--color-sky-800);
|
||||||
|
}
|
||||||
|
|
||||||
/* 细滚动条,避免在列里出现粗重的系统滚动条 */
|
/* 细滚动条,避免在列里出现粗重的系统滚动条 */
|
||||||
* {
|
* {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: var(--color-slate-300) transparent;
|
scrollbar-color: var(--color-slate-300) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark * {
|
||||||
|
scrollbar-color: var(--color-slate-700) transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export type ColumnId = (typeof COLUMN_IDS)[number]
|
|||||||
/** 优先级:1 = 低,2 = 中,3 = 高 */
|
/** 优先级:1 = 低,2 = 中,3 = 高 */
|
||||||
export type Priority = 1 | 2 | 3
|
export type Priority = 1 | 2 | 3
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘移动方向:左右换列,上下调同列顺序。
|
||||||
|
* 用户可以在卡片上聚焦后用方向键调整,不必依赖拖拽。
|
||||||
|
*/
|
||||||
|
export type MoveDirection = 'left' | 'right' | 'up' | 'down'
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
|
|||||||
+33
-4
@@ -44,15 +44,44 @@ describe('buildTodayBuckets 分组', () => {
|
|||||||
expect(ids(buildTodayBuckets([task], TODAY).inProgress)).toEqual(['a'])
|
expect(ids(buildTodayBuckets([task], TODAY).inProgress)).toEqual(['a'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未开始且没有截止日期的任务不出现', () => {
|
it('未开始且没有截止日期的任务进入 pending 组', () => {
|
||||||
const task = makeTask({ id: 'a', status: 'todo', dueDate: null })
|
const task = makeTask({ id: 'a', status: 'todo', dueDate: null })
|
||||||
const buckets = buildTodayBuckets([task], TODAY)
|
const buckets = buildTodayBuckets([task], TODAY)
|
||||||
expect(countTodayTasks(buckets)).toBe(0)
|
|
||||||
|
expect(ids(buckets.pending)).toEqual(['a'])
|
||||||
|
expect(countTodayTasks(buckets)).toBe(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未来截止但未开始的任务不出现', () => {
|
it('未来截止但未开始的任务进入 pending 组', () => {
|
||||||
const task = makeTask({ id: 'a', status: 'todo', dueDate: '2026-12-01' })
|
const task = makeTask({ id: 'a', status: 'todo', dueDate: '2026-12-01' })
|
||||||
expect(countTodayTasks(buildTodayBuckets([task], TODAY))).toBe(0)
|
expect(ids(buildTodayBuckets([task], TODAY).pending)).toEqual(['a'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('所有未完成且未归档的任务都会出现在某个分组里', () => {
|
||||||
|
const tasks = [
|
||||||
|
makeTask({ id: 'overdue', dueDate: '2026-10-01' }),
|
||||||
|
makeTask({ id: 'today', dueDate: TODAY }),
|
||||||
|
makeTask({ id: 'doing', status: 'doing' }),
|
||||||
|
makeTask({ id: 'todo-future', status: 'todo', dueDate: '2026-12-01' }),
|
||||||
|
makeTask({ id: 'todo-none', status: 'todo', dueDate: null }),
|
||||||
|
]
|
||||||
|
|
||||||
|
const buckets = buildTodayBuckets(tasks, TODAY)
|
||||||
|
const all = [
|
||||||
|
...buckets.overdue,
|
||||||
|
...buckets.dueToday,
|
||||||
|
...buckets.inProgress,
|
||||||
|
...buckets.pending,
|
||||||
|
]
|
||||||
|
|
||||||
|
// 按字典序:today 排在 todo-* 之前
|
||||||
|
expect(all.map((task) => task.id).sort()).toEqual([
|
||||||
|
'doing',
|
||||||
|
'overdue',
|
||||||
|
'today',
|
||||||
|
'todo-future',
|
||||||
|
'todo-none',
|
||||||
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('已完成任务不出现', () => {
|
it('已完成任务不出现', () => {
|
||||||
|
|||||||
+19
-1
@@ -18,6 +18,14 @@ export interface TodayBuckets {
|
|||||||
dueToday: Task[]
|
dueToday: Task[]
|
||||||
/** 正在进行、且没有更紧急的截止日期 */
|
/** 正在进行、且没有更紧急的截止日期 */
|
||||||
inProgress: Task[]
|
inProgress: Task[]
|
||||||
|
/**
|
||||||
|
* 还没开始、也没有紧迫截止时间的任务。
|
||||||
|
*
|
||||||
|
* 这个分组是补上的:早期版本只收「逾期 / 今天到期 / 进行中」,
|
||||||
|
* 导致待办列里没设截止日期的任务在今日视图里完全消失 ——
|
||||||
|
* 用户明明有一堆事没做,却看到「今天没有待办事项」。
|
||||||
|
*/
|
||||||
|
pending: Task[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +55,7 @@ export function buildTodayBuckets(tasks: readonly Task[], today: string = todayI
|
|||||||
const overdue: Task[] = []
|
const overdue: Task[] = []
|
||||||
const dueToday: Task[] = []
|
const dueToday: Task[] = []
|
||||||
const inProgress: Task[] = []
|
const inProgress: Task[] = []
|
||||||
|
const pending: Task[] = []
|
||||||
|
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
if (!isActionable(task)) continue
|
if (!isActionable(task)) continue
|
||||||
@@ -57,6 +66,9 @@ export function buildTodayBuckets(tasks: readonly Task[], today: string = todayI
|
|||||||
dueToday.push(task)
|
dueToday.push(task)
|
||||||
} else if (task.status === 'doing') {
|
} else if (task.status === 'doing') {
|
||||||
inProgress.push(task)
|
inProgress.push(task)
|
||||||
|
} else {
|
||||||
|
// 未开始,且截止日期在未来或未设置
|
||||||
|
pending.push(task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +76,16 @@ export function buildTodayBuckets(tasks: readonly Task[], today: string = todayI
|
|||||||
overdue: sortByUrgency(overdue),
|
overdue: sortByUrgency(overdue),
|
||||||
dueToday: sortByUrgency(dueToday),
|
dueToday: sortByUrgency(dueToday),
|
||||||
inProgress: sortByUrgency(inProgress),
|
inProgress: sortByUrgency(inProgress),
|
||||||
|
pending: sortByUrgency(pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 今日视图中的任务总数 */
|
/** 今日视图中的任务总数 */
|
||||||
export function countTodayTasks(buckets: TodayBuckets): number {
|
export function countTodayTasks(buckets: TodayBuckets): number {
|
||||||
return buckets.overdue.length + buckets.dueToday.length + buckets.inProgress.length
|
return (
|
||||||
|
buckets.overdue.length +
|
||||||
|
buckets.dueToday.length +
|
||||||
|
buckets.inProgress.length +
|
||||||
|
buckets.pending.length
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,3 +425,132 @@ describe('BoardView 快捷键', () => {
|
|||||||
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
expect(document.activeElement).not.toBe(wrapper.find('input[aria-label="添加任务"]').element)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BoardView 键盘移动', () => {
|
||||||
|
type Direction = 'left' | 'right' | 'up' | 'down'
|
||||||
|
|
||||||
|
/** 模拟卡片发出 keymove(任意一列转发都会到达 BoardView 的处理函数) */
|
||||||
|
function emitKeyMove(
|
||||||
|
wrapper: ReturnType<typeof mountBoard>,
|
||||||
|
id: string,
|
||||||
|
direction: Direction,
|
||||||
|
): void {
|
||||||
|
wrapper.findAllComponents(BoardColumn)[0]?.vm.$emit('keymove', { id, direction })
|
||||||
|
}
|
||||||
|
|
||||||
|
it('右移把任务移到下一列末尾', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, task?.id ?? '', 'right')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo).toHaveLength(0)
|
||||||
|
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('左移把任务移回上一列', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'doing' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, task?.id ?? '', 'left')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.doing).toHaveLength(0)
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最左列再左移无效果', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, task?.id ?? '', 'left')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('最右列再右移无效果', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A', status: 'done' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, task?.id ?? '', 'right')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.done.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('上移调整列内顺序', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const a = store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
store.createTask({ title: 'C' })
|
||||||
|
// 快速新增插入列首,当前顺序为 C, B, A
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, a?.id ?? '', 'up')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['C', 'A', 'B'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('下移调整列内顺序', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
store.createTask({ title: 'B' })
|
||||||
|
const c = store.createTask({ title: 'C' })
|
||||||
|
// 当前顺序 C, B, A
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, c?.id ?? '', 'down')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'C', 'A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('已在列首时上移无效果', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
store.createTask({ title: 'A' })
|
||||||
|
const first = store.createTask({ title: 'B' })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, first?.id ?? '', 'up')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('列内渲染子集时禁止上下移动(避免写坏全量顺序)', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const a = store.createTask({ title: 'A', priority: 3 })
|
||||||
|
store.createTask({ title: 'B', priority: 1 })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, a?.id ?? '', 'down')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 顺序保持原样
|
||||||
|
expect(store.byColumn.todo.map((item) => item.title)).toEqual(['B', 'A'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('跨列移动在筛选态下依然可用', async () => {
|
||||||
|
const { pinia, store } = setup()
|
||||||
|
const task = store.createTask({ title: 'A', priority: 3 })
|
||||||
|
const wrapper = mountBoard(pinia)
|
||||||
|
|
||||||
|
useFilterStore().togglePriority(3)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
emitKeyMove(wrapper, task?.id ?? '', 'right')
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(store.byColumn.doing.map((item) => item.title)).toEqual(['A'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+34
-2
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
import BoardColumn from '@/components/BoardColumn.vue'
|
import BoardColumn from '@/components/BoardColumn.vue'
|
||||||
import BoardToolbar from '@/components/BoardToolbar.vue'
|
import BoardToolbar from '@/components/BoardToolbar.vue'
|
||||||
@@ -10,7 +10,7 @@ import { useFilterStore } from '@/stores/filters'
|
|||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { useUiStore } from '@/stores/ui'
|
import { useUiStore } from '@/stores/ui'
|
||||||
import { COLUMNS, type ColumnId } from '@/types/task'
|
import { COLUMNS, type ColumnId, type MoveDirection } from '@/types/task'
|
||||||
import { findArchivableTasks } from '@/utils/archive'
|
import { findArchivableTasks } from '@/utils/archive'
|
||||||
import { filterTasks } from '@/utils/filter'
|
import { filterTasks } from '@/utils/filter'
|
||||||
import { groupByColumn } from '@/utils/sortOrder'
|
import { groupByColumn } from '@/utils/sortOrder'
|
||||||
@@ -100,6 +100,37 @@ function closeDetail(): void {
|
|||||||
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
|
function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }): void {
|
||||||
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
|
store.moveTask(payload.id, payload.toColumn, payload.toIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 键盘移动(方向键)。
|
||||||
|
*
|
||||||
|
* 左右换列时追加到目标列末尾 —— 用全量长度而不是可见下标,
|
||||||
|
* 因此即使当前处于筛选态也不会把顺序写坏。
|
||||||
|
* 上下调顺序则要求该列渲染的是全量,否则下标没有意义。
|
||||||
|
*/
|
||||||
|
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
||||||
|
const task = store.tasks.find((item) => item.id === payload.id)
|
||||||
|
if (!task) return
|
||||||
|
|
||||||
|
if (payload.direction === 'left' || payload.direction === 'right') {
|
||||||
|
const currentIndex = COLUMNS.findIndex((column) => column.id === task.status)
|
||||||
|
const target = COLUMNS[currentIndex + (payload.direction === 'left' ? -1 : 1)]
|
||||||
|
if (!target) return
|
||||||
|
store.moveTask(task.id, target.id, store.byColumn[target.id].length)
|
||||||
|
} else {
|
||||||
|
if (hiddenCounts.value[task.status] > 0) return
|
||||||
|
const column = store.byColumn[task.status]
|
||||||
|
const currentIndex = column.findIndex((item) => item.id === task.id)
|
||||||
|
const nextIndex = currentIndex + (payload.direction === 'up' ? -1 : 1)
|
||||||
|
if (nextIndex < 0 || nextIndex >= column.length) return
|
||||||
|
store.moveTask(task.id, task.status, nextIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片跨列后会被重建,焦点必须手动找回,否则连续按键会失效
|
||||||
|
void nextTick(() => {
|
||||||
|
document.querySelector<HTMLElement>(`[data-task-id="${payload.id}"]`)?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -132,6 +163,7 @@ function handleMove(payload: { id: string; toColumn: ColumnId; toIndex: number }
|
|||||||
@toggle="toggleTask"
|
@toggle="toggleTask"
|
||||||
@open="openTask"
|
@open="openTask"
|
||||||
@move="handleMove"
|
@move="handleMove"
|
||||||
|
@keymove="handleKeyMove"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ describe('TodayView 顶部', () => {
|
|||||||
it('显示四张统计卡片', () => {
|
it('显示四张统计卡片', () => {
|
||||||
const { wrapper } = setup()
|
const { wrapper } = setup()
|
||||||
const labels = wrapper.findAll('dt').map((node) => node.text())
|
const labels = wrapper.findAll('dt').map((node) => node.text())
|
||||||
expect(labels).toEqual(['今日待办', '逾期', '今天到期', '本周完成'])
|
expect(labels).toEqual(['逾期', '今天到期', '进行中', '本周完成'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('统计卡片数值正确', () => {
|
it('统计卡片数值正确', () => {
|
||||||
@@ -66,8 +66,8 @@ describe('TodayView 顶部', () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const values = wrapper.findAll('dd').map((node) => node.text())
|
const values = wrapper.findAll('dd').map((node) => node.text())
|
||||||
// 今日待办 3(逾期 + 今天到期 + 进行中),逾期 1,今天到期 1,本周完成 1
|
// 逾期 1、今天到期 1、进行中 1、本周完成 1
|
||||||
expect(values).toEqual(['3', '1', '1', '1'])
|
expect(values).toEqual(['1', '1', '1', '1'])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -106,14 +106,19 @@ describe('TodayView 分组', () => {
|
|||||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
expect(wrapper.findAll('article')).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未开始且无截止日期的任务不出现', () => {
|
it('未开始且无截止日期的任务出现在「待办」组(不再凭空消失)', () => {
|
||||||
const { wrapper } = setup([{ title: '以后再说' }])
|
const { wrapper } = setup([{ title: '以后再说' }])
|
||||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('以后再说')
|
||||||
|
expect(sectionTitles(wrapper).some((title) => title.includes('待办'))).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('未来截止但未开始的任务不出现', () => {
|
it('未来截止但未开始的任务也进入「待办」组', () => {
|
||||||
const { wrapper } = setup([{ title: '下周的事', dueDate: tomorrow }])
|
const { wrapper } = setup([{ title: '下周的事', dueDate: tomorrow }])
|
||||||
expect(wrapper.findAll('article')).toHaveLength(0)
|
|
||||||
|
expect(wrapper.findAll('article')).toHaveLength(1)
|
||||||
|
expect(wrapper.text()).toContain('下周的事')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('同一任务只出现一次', () => {
|
it('同一任务只出现一次', () => {
|
||||||
|
|||||||
+59
-12
@@ -6,6 +6,7 @@ import { computed, ref } from 'vue'
|
|||||||
import TaskCard from '@/components/TaskCard.vue'
|
import TaskCard from '@/components/TaskCard.vue'
|
||||||
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
import TaskDetailDrawer from '@/components/TaskDetailDrawer.vue'
|
||||||
import { useTaskStore } from '@/stores/tasks'
|
import { useTaskStore } from '@/stores/tasks'
|
||||||
|
import type { MoveDirection } from '@/types/task'
|
||||||
import { computeStats } from '@/utils/stats'
|
import { computeStats } from '@/utils/stats'
|
||||||
import { buildTodayBuckets, countTodayTasks } from '@/utils/today'
|
import { buildTodayBuckets, countTodayTasks } from '@/utils/today'
|
||||||
|
|
||||||
@@ -13,9 +14,26 @@ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '
|
|||||||
|
|
||||||
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
|
/** 用完整类名字符串,避免 Tailwind 扫描不到动态拼接的类 */
|
||||||
const TONES = {
|
const TONES = {
|
||||||
rose: { dot: 'bg-rose-500', badge: 'border-rose-200 bg-rose-50 text-rose-700' },
|
rose: {
|
||||||
amber: { dot: 'bg-amber-500', badge: 'border-amber-200 bg-amber-50 text-amber-700' },
|
dot: 'bg-rose-500',
|
||||||
sky: { dot: 'bg-sky-500', badge: 'border-sky-200 bg-sky-50 text-sky-700' },
|
badge:
|
||||||
|
'border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950 dark:text-rose-300',
|
||||||
|
},
|
||||||
|
amber: {
|
||||||
|
dot: 'bg-amber-500',
|
||||||
|
badge:
|
||||||
|
'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
sky: {
|
||||||
|
dot: 'bg-sky-500',
|
||||||
|
badge:
|
||||||
|
'border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950 dark:text-sky-300',
|
||||||
|
},
|
||||||
|
slate: {
|
||||||
|
dot: 'bg-slate-400',
|
||||||
|
badge:
|
||||||
|
'border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
const store = useTaskStore()
|
const store = useTaskStore()
|
||||||
@@ -30,10 +48,13 @@ const buckets = computed(() => buildTodayBuckets(tasks.value))
|
|||||||
const stats = computed(() => computeStats(tasks.value))
|
const stats = computed(() => computeStats(tasks.value))
|
||||||
const todayCount = computed(() => countTodayTasks(buckets.value))
|
const todayCount = computed(() => countTodayTasks(buckets.value))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计卡片与下方分组一一对应,避免「数字和列表对不上」的困惑。
|
||||||
|
*/
|
||||||
const statCards = computed(() => [
|
const statCards = computed(() => [
|
||||||
{ label: '今日待办', value: todayCount.value, dot: 'bg-slate-400' },
|
|
||||||
{ label: '逾期', value: stats.value.overdue, dot: 'bg-rose-500' },
|
{ label: '逾期', value: stats.value.overdue, dot: 'bg-rose-500' },
|
||||||
{ label: '今天到期', value: stats.value.dueToday, dot: 'bg-amber-500' },
|
{ label: '今天到期', value: stats.value.dueToday, dot: 'bg-amber-500' },
|
||||||
|
{ label: '进行中', value: buckets.value.inProgress.length, dot: 'bg-sky-500' },
|
||||||
{ label: '本周完成', value: stats.value.completedThisWeek, dot: 'bg-emerald-500' },
|
{ label: '本周完成', value: stats.value.completedThisWeek, dot: 'bg-emerald-500' },
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -65,6 +86,13 @@ const sections = computed(() =>
|
|||||||
tone: 'sky' as const,
|
tone: 'sky' as const,
|
||||||
items: buckets.value.inProgress,
|
items: buckets.value.inProgress,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'pending',
|
||||||
|
title: '待办',
|
||||||
|
hint: '没有紧迫截止时间,有空再做',
|
||||||
|
tone: 'slate' as const,
|
||||||
|
items: buckets.value.pending,
|
||||||
|
},
|
||||||
].filter((section) => section.items.length > 0),
|
].filter((section) => section.items.length > 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,6 +104,18 @@ function toggleTask(id: string): void {
|
|||||||
if (!task) return
|
if (!task) return
|
||||||
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
store.updateTask(id, { status: task.status === 'done' ? 'todo' : 'done' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 今日视图没有列概念,左右方向键不适用;上下仍可调 order */
|
||||||
|
function handleKeyMove(payload: { id: string; direction: MoveDirection }): void {
|
||||||
|
if (payload.direction === 'left' || payload.direction === 'right') return
|
||||||
|
const task = store.tasks.find((item) => item.id === payload.id)
|
||||||
|
if (!task) return
|
||||||
|
const column = store.byColumn[task.status]
|
||||||
|
const currentIndex = column.findIndex((item) => item.id === payload.id)
|
||||||
|
const nextIndex = currentIndex + (payload.direction === 'up' ? -1 : 1)
|
||||||
|
if (nextIndex < 0 || nextIndex >= column.length) return
|
||||||
|
store.moveTask(task.id, task.status, nextIndex)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -83,21 +123,23 @@ function toggleTask(id: string): void {
|
|||||||
<!-- 限宽居中:否则 1440px 下卡片会被拉满整屏,标题与元信息之间出现大片空白 -->
|
<!-- 限宽居中:否则 1440px 下卡片会被拉满整屏,标题与元信息之间出现大片空白 -->
|
||||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-5 p-4 md:p-6">
|
<div class="mx-auto flex w-full max-w-3xl flex-col gap-5 p-4 md:p-6">
|
||||||
<header class="flex flex-col gap-0.5">
|
<header class="flex flex-col gap-0.5">
|
||||||
<h1 class="text-xl font-semibold tracking-tight text-slate-900 md:text-2xl">今日</h1>
|
<h1 class="text-xl font-semibold tracking-tight text-slate-900 md:text-2xl dark:text-slate-100">
|
||||||
<p class="text-sm text-slate-500">{{ todayLabel }}</p>
|
今日
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">{{ todayLabel }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<dl class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div
|
<div
|
||||||
v-for="card in statCards"
|
v-for="card in statCards"
|
||||||
:key="card.label"
|
:key="card.label"
|
||||||
class="rounded-xl border border-slate-200 bg-white px-4 py-3.5 shadow-card"
|
class="rounded-xl border border-slate-200 bg-white px-4 py-3.5 shadow-card dark:border-slate-800 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<dt class="flex items-center gap-1.5 text-xs text-slate-500">
|
<dt class="flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
|
<span class="size-1.5 rounded-full" :class="card.dot" aria-hidden="true" />
|
||||||
{{ card.label }}
|
{{ card.label }}
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900">
|
<dd class="mt-1.5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-100">
|
||||||
{{ card.value }}
|
{{ card.value }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,13 +147,15 @@ function toggleTask(id: string): void {
|
|||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="todayCount === 0"
|
v-if="todayCount === 0"
|
||||||
class="rounded-xl border border-dashed border-slate-300 bg-white/60 px-4 py-16 text-center text-sm text-slate-400"
|
class="rounded-xl border border-dashed border-slate-300 bg-white/60 px-4 py-16 text-center text-sm text-slate-400 dark:border-slate-700 dark:bg-slate-900/40 dark:text-slate-500"
|
||||||
>
|
>
|
||||||
今天没有待办事项
|
今天没有待办事项
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
|
<section v-for="section in sections" :key="section.key" class="flex flex-col gap-2">
|
||||||
<h2 class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-700">
|
<h2
|
||||||
|
class="flex flex-wrap items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
|
||||||
|
>
|
||||||
<span class="size-1.5 rounded-full" :class="TONES[section.tone].dot" aria-hidden="true" />
|
<span class="size-1.5 rounded-full" :class="TONES[section.tone].dot" aria-hidden="true" />
|
||||||
{{ section.title }}
|
{{ section.title }}
|
||||||
<span
|
<span
|
||||||
@@ -121,7 +165,9 @@ function toggleTask(id: string): void {
|
|||||||
>
|
>
|
||||||
{{ section.items.length }}
|
{{ section.items.length }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs font-normal text-slate-400">{{ section.hint }}</span>
|
<span class="text-xs font-normal text-slate-400 dark:text-slate-500">
|
||||||
|
{{ section.hint }}
|
||||||
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
@@ -131,6 +177,7 @@ function toggleTask(id: string): void {
|
|||||||
:task="task"
|
:task="task"
|
||||||
@toggle="toggleTask"
|
@toggle="toggleTask"
|
||||||
@open="editingId = $event"
|
@open="editingId = $event"
|
||||||
|
@keymove="handleKeyMove"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user