今日视图 - 补上「待办」组。此前只收 逾期 / 今天到期 / 进行中,导致待办列里没设 截止日期的任务在今日视图里完全消失 —— 用户明明有一堆事没做,却看到 「今天没有待办事项」。现在只要任务未完成且未归档,就必定出现在某个 分组里(归属优先级:逾期 > 今天到期 > 进行中 > 待办)。 - 统计卡片改为与分组一一对应(逾期 / 今天到期 / 进行中 / 本周完成), 不再用「今日待办」这种把四组混在一起、与列表长度对不上的指标。 键盘操作(可访问性) - 卡片可聚焦(Tab / 点击),← → 换列、↑ ↓ 列内调序、Enter 打开详情。 - 换列时追加到目标列末尾:用全量长度而不是可见下标,筛选态下也安全; 列内调序要求该列渲染的是全量,否则禁用(与拖拽的规则一致)。 - 移动后手动恢复焦点 —— 卡片跨列会被重建,不恢复的话连续按键会失效。 深色模式 - 三态:跟随系统 / 浅色 / 深色,选择持久化到 localStorage。 - index.html 内联脚本在首帧之前应用主题,避免深色下闪白。 - 用 @custom-variant dark 做 class 控制(而不是纯 media query), 才能支持「强制浅色」;html 的 color-scheme 让原生控件与滚动条跟随。 验证:单元测试 480 个、端到端测试 25 个全部通过,lint 与构建无错误。 截图脚本新增深色与移动端深色两组,便于后续对照。
190 lines
6.7 KiB
Vue
190 lines
6.7 KiB
Vue
<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>
|