Files
Kanban/src/components/BackupPanel.vue
T
chenshoulin 8721e8ab0b feat: 补齐今日视图待办组、卡片键盘操作、深色模式
今日视图
- 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设
  截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到
  「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个
  分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。
- 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成),
  不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。

键盘操作(可访问性)
- 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。
- 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全;
  列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。
- 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。

深色模式
- 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。
- index.html 内联脚本在首帧之前应用主题,避免深色下闪白。
- 用 @custom-variant dark 做 class 控制(而不是纯 media query),
  才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。

验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。
截图脚本新增深色与移动端深色两组,便于后续对照。
2026-10-18 17:48:55 +08:00

190 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import dayjs from 'dayjs'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { parseImportJson } from '@/data/schema'
import { useTaskStore } from '@/stores/tasks'
import { downloadText, exportFileName } from '@/utils/exportFile'
import SideDrawer from './SideDrawer.vue'
const props = defineProps<{ open: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useTaskStore()
const { snapshots } = storeToRefs(store)
const message = ref<string | null>(null)
const error = ref<string | null>(null)
/** 已选好文件但还没确认的导入(覆盖式操作需要二次确认) */
const pending = ref<{ text: string; fileName: string; count: number } | null>(null)
function exportNow(): void {
const fileName = exportFileName()
try {
downloadText(store.exportJson(), fileName)
message.value = `已导出 ${store.tasks.length} 个任务到 ${fileName}`
error.value = null
} catch (cause) {
// 浏览器可能因权限或存储限制拒绝下载,这里必须让用户看到失败原因
error.value = cause instanceof Error ? cause.message : '导出失败'
}
}
async function onFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
// 清空以便连续选择同一个文件时也能触发 change
input.value = ''
if (!file) return
message.value = null
error.value = null
try {
const text = await file.text()
// 先做一次纯校验,确认合法后才让用户确认覆盖
const tasks = parseImportJson(text)
pending.value = { text, fileName: file.name, count: tasks.length }
} catch (cause) {
pending.value = null
error.value = cause instanceof Error ? cause.message : '导入文件无法解析'
}
}
function confirmImport(): void {
const target = pending.value
if (!target) return
try {
const count = store.importJson(target.text)
message.value = `已从 ${target.fileName} 导入 ${count} 个任务`
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '导入失败'
} finally {
pending.value = null
}
}
function restore(key: string): void {
try {
const count = store.restoreSnapshot(key)
message.value = count > 0 ? `已从备份恢复 ${count} 个任务` : '该备份是空的,未做改动'
error.value = null
} catch (cause) {
error.value = cause instanceof Error ? cause.message : '恢复失败'
}
}
function formatTime(iso: string): string {
return dayjs(iso).format('YYYY-MM-DD HH:mm:ss')
}
</script>
<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 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 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 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 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 dark:hover:bg-sky-500"
@click="exportNow"
>
导出为 JSON
</button>
</section>
<section class="flex flex-col gap-2">
<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 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 dark:border-amber-800 dark:bg-amber-950/60"
>
<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 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 dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800"
@click="pending = null"
>
取消
</button>
</div>
</div>
</section>
<section class="flex flex-col gap-2">
<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 dark:border-slate-700"
>
<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 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 dark:text-slate-500">暂无自动备份</p>
</section>
</div>
</SideDrawer>
</template>