feat: 补齐今日视图待办组、卡片键盘操作、深色模式
今日视图 - 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设 截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到 「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个 分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。 - 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成), 不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。 键盘操作(可访问性) - 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。 - 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全; 列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。 - 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。 深色模式 - 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。 - index.html 内联脚本在首帧之前应用主题,避免深色下闪白。 - 用 @custom-variant dark 做 class 控制(而不是纯 media query), 才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。 验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。 截图脚本新增深色与移动端深色两组,便于后续对照。
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { useThemeStore, THEME_LABELS } from '@/stores/theme'
|
||||
import { useUiStore } from '@/stores/ui'
|
||||
|
||||
const ui = useUiStore()
|
||||
const theme = useThemeStore()
|
||||
|
||||
const LINKS = [
|
||||
{ to: '/', label: '看板' },
|
||||
@@ -13,11 +15,11 @@ const LINKS = [
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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">
|
||||
<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" />
|
||||
</svg>
|
||||
</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>
|
||||
|
||||
<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="主导航">
|
||||
<RouterLink
|
||||
@@ -43,8 +49,8 @@ const LINKS = [
|
||||
class="rounded-md px-3 py-1.5 text-sm transition"
|
||||
:class="
|
||||
isExactActive
|
||||
? 'bg-slate-900 font-medium text-white shadow-sm'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
? '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 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100'
|
||||
"
|
||||
:aria-current="isExactActive ? 'page' : undefined"
|
||||
@click="navigate"
|
||||
@@ -57,7 +63,62 @@ const LINKS = [
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<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="快捷键帮助"
|
||||
@click="ui.openHelp()"
|
||||
>
|
||||
@@ -75,7 +136,7 @@ const LINKS = [
|
||||
|
||||
<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()"
|
||||
>
|
||||
<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>
|
||||
<SideDrawer :open="props.open" title="备份与恢复" @close="emit('close')">
|
||||
<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
|
||||
v-if="message"
|
||||
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 }}
|
||||
</p>
|
||||
<p
|
||||
v-if="error"
|
||||
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 }}
|
||||
</p>
|
||||
|
||||
<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
|
||||
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"
|
||||
>
|
||||
导出为 JSON
|
||||
@@ -119,35 +121,35 @@ function formatTime(iso: string): string {
|
||||
</section>
|
||||
|
||||
<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
|
||||
type="file"
|
||||
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="选择导入文件"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
|
||||
<div
|
||||
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 }} 个任务,
|
||||
<strong>并覆盖当前全部 {{ store.tasks.length }} 个任务</strong>。此操作不可撤销。
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<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"
|
||||
>
|
||||
确认覆盖导入
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
取消
|
||||
@@ -157,26 +159,30 @@ function formatTime(iso: string): string {
|
||||
</section>
|
||||
|
||||
<section class="flex flex-col gap-2">
|
||||
<h3 class="text-sm font-medium text-slate-700">自动备份</h3>
|
||||
<p class="text-xs text-slate-500">每次打开看板时自动留一份,最多保留 3 份。</p>
|
||||
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">自动备份</h3>
|
||||
<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">
|
||||
<li
|
||||
v-for="item in snapshots"
|
||||
: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
|
||||
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)"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
</li>
|
||||
</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>
|
||||
</div>
|
||||
</SideDrawer>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
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 QuickAddTask from './QuickAddTask.vue'
|
||||
@@ -14,13 +14,14 @@ const emit = defineEmits<{
|
||||
toggle: [id: string]
|
||||
open: [id: string]
|
||||
move: [payload: { id: string; toColumn: ColumnId; toIndex: number }]
|
||||
keymove: [payload: { id: string; direction: MoveDirection }]
|
||||
}>()
|
||||
|
||||
/** 每列一个色点:三列在余光里也能区分开 */
|
||||
const COLUMN_DOT: Record<ColumnId, string> = {
|
||||
todo: 'bg-slate-400',
|
||||
doing: 'bg-sky-500',
|
||||
done: 'bg-emerald-500',
|
||||
todo: 'bg-slate-400 dark:bg-slate-500',
|
||||
doing: 'bg-sky-500 dark:bg-sky-400',
|
||||
done: 'bg-emerald-500 dark:bg-emerald-400',
|
||||
}
|
||||
|
||||
const dotClass = computed(() => COLUMN_DOT[props.column.id])
|
||||
@@ -55,13 +56,15 @@ useDraggable(listRef, {
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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
|
||||
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 }}
|
||||
</span>
|
||||
@@ -80,11 +83,12 @@ useDraggable(listRef, {
|
||||
:task="task"
|
||||
@toggle="emit('toggle', $event)"
|
||||
@open="emit('open', $event)"
|
||||
@keymove="emit('keymove', $event)"
|
||||
/>
|
||||
|
||||
<p
|
||||
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>
|
||||
|
||||
@@ -56,7 +56,7 @@ defineExpose({ focusSearch })
|
||||
<div class="relative min-w-56 flex-1 sm:max-w-xs">
|
||||
<svg
|
||||
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"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -67,14 +67,14 @@ defineExpose({ focusSearch })
|
||||
ref="searchRef"
|
||||
v-model="keyword"
|
||||
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="搜索标题或描述"
|
||||
aria-label="搜索任务"
|
||||
/>
|
||||
</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"
|
||||
aria-label="按优先级筛选"
|
||||
>
|
||||
@@ -85,8 +85,8 @@ defineExpose({ focusSearch })
|
||||
class="rounded px-2.5 py-1 text-xs transition"
|
||||
:class="
|
||||
priorities.includes(value)
|
||||
? 'bg-slate-900 font-medium text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
? 'bg-slate-900 font-medium text-white dark:bg-slate-100 dark:text-slate-900'
|
||||
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800'
|
||||
"
|
||||
:aria-pressed="priorities.includes(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="
|
||||
due === 'today'
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||
? '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 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'"
|
||||
@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="
|
||||
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
|
||||
? 'border-slate-200 bg-white text-rose-600 hover:border-rose-200 hover:bg-rose-50'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-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 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'"
|
||||
@click="filters.toggleDue('overdue')"
|
||||
@@ -137,15 +137,15 @@ defineExpose({ focusSearch })
|
||||
</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 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">
|
||||
<button
|
||||
v-if="archivableCount > 0"
|
||||
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')"
|
||||
>
|
||||
归档 {{ archivableCount }} 个已完成
|
||||
@@ -156,8 +156,8 @@ defineExpose({ focusSearch })
|
||||
class="rounded-md border px-2.5 py-1 text-xs transition"
|
||||
:class="
|
||||
includeArchived
|
||||
? 'border-sky-300 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||
? '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 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"
|
||||
@click="filters.toggleArchived()"
|
||||
@@ -168,7 +168,7 @@ defineExpose({ focusSearch })
|
||||
<button
|
||||
v-if="active"
|
||||
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()"
|
||||
>
|
||||
清空筛选
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* 直接使用浏览器原生 `<input type="date">`:
|
||||
* - 其 value 规范就是 YYYY-MM-DD,与 ADR-003 的存储约定天然一致
|
||||
* - 自带可访问性、键盘操作与本地化日历,无需自己实现
|
||||
* - 深色模式下日历弹层跟随 html 的 color-scheme
|
||||
*/
|
||||
|
||||
defineProps<{ modelValue: string | null; label?: string }>()
|
||||
@@ -21,7 +22,7 @@ function onChange(event: Event): void {
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
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 ?? ''"
|
||||
:aria-label="label ?? '截止日期'"
|
||||
@change="onChange"
|
||||
@@ -29,7 +30,7 @@ function onChange(event: Event): void {
|
||||
<button
|
||||
v-if="modelValue"
|
||||
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)"
|
||||
>
|
||||
清除
|
||||
|
||||
@@ -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
|
||||
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>
|
||||
|
||||
<button
|
||||
v-if="needRefresh"
|
||||
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"
|
||||
>
|
||||
立即更新
|
||||
@@ -41,7 +41,7 @@ function reload(): void {
|
||||
|
||||
<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="关闭提示"
|
||||
@click="dismiss"
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ defineExpose({ focusInput })
|
||||
ref="inputRef"
|
||||
v-model="title"
|
||||
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="+ 添加任务"
|
||||
aria-label="添加任务"
|
||||
@compositionstart="composing = true"
|
||||
|
||||
@@ -85,21 +85,23 @@ onBeforeUnmount(stopListening)
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<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
|
||||
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"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||
<h2 class="text-base font-medium text-slate-800">{{ title }}</h2>
|
||||
<header
|
||||
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
|
||||
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="关闭"
|
||||
@click="emit('close')"
|
||||
>
|
||||
@@ -111,7 +113,10 @@ onBeforeUnmount(stopListening)
|
||||
<slot />
|
||||
</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" />
|
||||
</footer>
|
||||
</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="
|
||||
selected.length > 0
|
||||
? 'border-sky-300 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50'
|
||||
? '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 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-haspopup="true"
|
||||
@@ -57,7 +57,7 @@ onBeforeUnmount(() => {
|
||||
标签
|
||||
<span
|
||||
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 }}
|
||||
</span>
|
||||
@@ -80,20 +80,20 @@ onBeforeUnmount(() => {
|
||||
|
||||
<div
|
||||
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>
|
||||
|
||||
<ul v-else class="max-h-64 overflow-y-auto">
|
||||
<li v-for="tag in tags" :key="tag">
|
||||
<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
|
||||
type="checkbox"
|
||||
class="size-3.5 accent-sky-600"
|
||||
class="size-3.5 accent-sky-600 dark:accent-sky-500"
|
||||
:checked="selected.includes(tag)"
|
||||
@change="emit('toggle', tag)"
|
||||
/>
|
||||
@@ -105,7 +105,7 @@ onBeforeUnmount(() => {
|
||||
<button
|
||||
v-if="selected.length > 0"
|
||||
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')"
|
||||
>
|
||||
清除标签筛选
|
||||
|
||||
@@ -36,17 +36,17 @@ function onBackspace(): void {
|
||||
|
||||
<template>
|
||||
<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
|
||||
v-for="tag in modelValue"
|
||||
: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 }}
|
||||
<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}`"
|
||||
@click="remove(tag)"
|
||||
>
|
||||
@@ -57,7 +57,7 @@ function onBackspace(): void {
|
||||
<input
|
||||
v-model="draft"
|
||||
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 ?? '回车添加标签') : ''"
|
||||
aria-label="添加标签"
|
||||
@keydown.enter.prevent="composing ? undefined : commit()"
|
||||
|
||||
@@ -148,3 +148,57 @@ describe('交互', () => {
|
||||
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">
|
||||
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'
|
||||
|
||||
const MAX_VISIBLE_TAGS = 3
|
||||
|
||||
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 overdue = computed(() => isOverdue(props.task.dueDate, props.task.status))
|
||||
@@ -26,28 +56,34 @@ const priorityBarClass = computed(() => {
|
||||
case 2:
|
||||
return 'bg-amber-400'
|
||||
default:
|
||||
return 'bg-slate-300'
|
||||
return 'bg-slate-300 dark:bg-slate-600'
|
||||
}
|
||||
})
|
||||
|
||||
const priorityTextClass = computed(() => {
|
||||
switch (props.task.priority) {
|
||||
case 3:
|
||||
return 'text-rose-600'
|
||||
return 'text-rose-600 dark:text-rose-400'
|
||||
case 2:
|
||||
return 'text-amber-700'
|
||||
return 'text-amber-700 dark:text-amber-400'
|
||||
default:
|
||||
return 'text-slate-400'
|
||||
return 'text-slate-400 dark:text-slate-500'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="relative shrink-0 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white shadow-card transition duration-150"
|
||||
:class="isDone ? 'opacity-60' : 'hover:-translate-y-px hover:border-slate-300 hover:shadow-card-hover'"
|
||||
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 dark:hover:border-slate-700'
|
||||
"
|
||||
:data-task-id="task.id"
|
||||
tabindex="0"
|
||||
@click="emit('open', task.id)"
|
||||
@keydown="onKeydown"
|
||||
>
|
||||
<span class="absolute inset-y-0 left-0 w-[3px]" :class="priorityBarClass" aria-hidden="true" />
|
||||
|
||||
@@ -59,7 +95,7 @@ const priorityTextClass = computed(() => {
|
||||
:class="
|
||||
isDone
|
||||
? '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-pressed="isDone"
|
||||
@@ -78,7 +114,11 @@ const priorityTextClass = computed(() => {
|
||||
|
||||
<h3
|
||||
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 }}
|
||||
</h3>
|
||||
@@ -91,14 +131,21 @@ const priorityTextClass = computed(() => {
|
||||
|
||||
<span
|
||||
v-if="task.dueDate"
|
||||
class="font-medium"
|
||||
:class="overdue ? 'text-rose-600' : 'text-slate-500'"
|
||||
:class="
|
||||
overdue
|
||||
? 'font-medium text-rose-600 dark:text-rose-400'
|
||||
: 'text-slate-500 dark:text-slate-400'
|
||||
"
|
||||
>
|
||||
{{ formatDueDate(task.dueDate) }}
|
||||
</span>
|
||||
|
||||
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400">#{{ tag }}</span>
|
||||
<span v-if="hiddenTagCount > 0" class="text-slate-400">+{{ hiddenTagCount }}</span>
|
||||
<span v-for="tag in visibleTags" :key="tag" class="text-slate-400 dark:text-slate-500">
|
||||
#{{ tag }}
|
||||
</span>
|
||||
<span v-if="hiddenTagCount > 0" class="text-slate-400 dark:text-slate-500">
|
||||
+{{ hiddenTagCount }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -82,29 +82,29 @@ function remove(): void {
|
||||
<SideDrawer :open="task !== null" title="任务详情" @close="emit('close')">
|
||||
<form v-if="task" class="flex flex-col gap-4" @submit.prevent="save">
|
||||
<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
|
||||
v-model="title"
|
||||
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="标题"
|
||||
@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 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
|
||||
v-model="description"
|
||||
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="描述"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<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">
|
||||
<button
|
||||
v-for="value in PRIORITIES"
|
||||
@@ -113,8 +113,8 @@ function remove(): void {
|
||||
class="rounded-md border px-3 py-1 text-sm transition"
|
||||
:class="
|
||||
priority === value
|
||||
? 'border-sky-400 bg-sky-50 text-sky-700'
|
||||
: 'border-slate-300 text-slate-600 hover:bg-slate-50'
|
||||
? '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 dark:border-slate-600 dark:text-slate-400 dark:hover:bg-slate-800'
|
||||
"
|
||||
:aria-pressed="priority === value"
|
||||
@click="priority = value"
|
||||
@@ -125,12 +125,12 @@ function remove(): void {
|
||||
</fieldset>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -139,7 +139,7 @@ function remove(): void {
|
||||
<div class="flex items-center justify-between">
|
||||
<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"
|
||||
>
|
||||
删除
|
||||
@@ -148,14 +148,14 @@ function remove(): void {
|
||||
<div class="flex gap-2">
|
||||
<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')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
保存
|
||||
|
||||
@@ -15,14 +15,14 @@ const { toasts } = storeToRefs(store)
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
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>
|
||||
|
||||
<button
|
||||
v-if="toast.action"
|
||||
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)"
|
||||
>
|
||||
{{ toast.action.label }}
|
||||
@@ -30,7 +30,7 @@ const { toasts } = storeToRefs(store)
|
||||
|
||||
<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="关闭提示"
|
||||
@click="store.dismiss(toast.id)"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user