Files
dodo/frontend/src/App.vue
T
bboysoul 7c05ec2a85
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m27s
fix: refine mobile navigation and settings
2026-09-10 10:38:39 +08:00

1307 lines
84 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 { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import {
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, normalizeRequiredName, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { nextDialogFocusIndex } from './lib/list-purge'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CalendarPicker from './components/CalendarPicker.vue'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
type Recurrence = { id: string; task_id: string; rrule: string }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
const initialized = ref<boolean | null>(null)
const authReady = ref(false)
const authenticated = ref(false)
const username = ref('')
const password = ref('')
const folders = ref<FolderItem[]>([])
const lists = ref<TaskList[]>([])
const archivedLists = ref<TaskList[]>([])
const archivedListsExpanded = ref(false)
const archivedListAction = ref<TaskList | null>(null)
const archivedListsToggle = ref<HTMLButtonElement | null>(null)
const archivedMenu = ref<HTMLElement | null>(null)
const archivedMenuStyle = ref<Record<string, string>>({})
let archivedListActionTrigger: HTMLElement | null = null
const purgeListTarget = ref<TaskList | null>(null)
const purgeListSubmitting = ref(false)
const purgeListError = ref('')
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
const purgeListDialog = ref<HTMLElement | null>(null)
let purgeListTrigger: HTMLElement | null = null
const tasks = ref<Task[]>([])
const overdueTasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const NAVIGATION_STORAGE_KEY = 'dodo.navigation'
const restoredNavigation = readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)
const activeList = ref(restoredNavigation.listId)
const activeView = ref<View>(restoredNavigation.view)
const selectedTask = ref<Task | null>(null)
const sidebarCreateOpen = ref(false)
const sidebarAction = ref<{ kind: 'folders' | 'lists'; item: FolderItem | TaskList } | null>(null)
const listMoveMenuOpen = ref(false)
const listDrag = ref<{ id: string; pointerId: number; startX: number; startY: number; offsetY: number; lastX: number; lastY: number } | null>(null)
const listDropFolderId = ref<string | null | undefined>(undefined)
const listReorderTarget = ref('')
const listReorderPlacement = ref<'before' | 'after'>('after')
let listHandleLongPressTimer: number | undefined
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
let suppressListClickId = ''
const query = ref('')
const error = ref('')
const notice = ref('')
const loading = ref(false)
const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false)
const mobileDetail = ref(false)
const mobileMore = ref(false)
const moreSettingsOpen = ref(false)
const markdownPreview = ref(false)
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
const page = ref(1)
const pageSize = 50
const totalTasks = ref(0)
const hiddenCompletedTaskCount = ref(0)
const todayTaskTotal = ref(0)
const todayTaskCompleted = ref(0)
const todayHabitTotal = ref(0)
const todayHabitCompleted = ref(0)
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const expandedFolders = ref(new Set<string>())
const collapsedTaskIds = ref(new Set<string>())
const justCompletedTaskIds = ref(new Set<string>())
const markTaskJustCompleted = createCompletionPulse(
(id) => { justCompletedTaskIds.value = new Set(justCompletedTaskIds.value).add(id) },
(id) => { const next = new Set(justCompletedTaskIds.value); next.delete(id); justCompletedTaskIds.value = next },
)
const navigationLoaded = ref(false)
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
const taskSwipeOffsets = ref<Record<string, number>>({})
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
const taskReorderTarget = ref('')
const taskComposeOpen = ref(false)
const composeTitle = ref('')
const composeTitleError = ref('')
const composeListId = ref('')
const composeDueAt = ref('')
const composeHasTime = ref(false)
const composeTime = ref('12:00')
const composeCalendarOpen = ref(false)
const composeDateButton = ref<HTMLButtonElement | null>(null)
const composeTimePicker = ref<HTMLInputElement | null>(null)
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
let recurrenceLoadToken = 0
let todaySummaryLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
let suppressTaskClickId = ''
const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` }))
function openTaskCompose() {
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
composeTitle.value = ''
composeTitleError.value = ''
composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId
composeDueAt.value = activeView.value === 'today' ? defaultTaskDueAt() : ''
composeHasTime.value = false
composeTime.value = '12:00'
composePriority.value = 0
composeDescription.value = ''
composeRepeat.value = 'none'
composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false
taskComposeOpen.value = true
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
}
const taskComposeTitle = computed(() => activeView.value === 'today' ? '添加今天任务' : '添加任务')
const composeDueLabel = computed(() => {
if (!composeDueAt.value) return '设置截止日期'
const [year, month, day] = composeDueAt.value.split('-').map(Number)
const value = new Date(year, month - 1, day)
return Number.isNaN(value.getTime()) ? '设置截止日期' : new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', weekday: 'short' }).format(value)
})
function clearComposeDueDate() {
composeDueAt.value = ''
composeHasTime.value = false
composeRepeat.value = 'none'
}
function addComposeTime() {
composeHasTime.value = true
nextTick(() => {
const picker = composeTimePicker.value as (HTMLInputElement & { showPicker?: () => void }) | null
try { picker?.showPicker?.() } catch { picker?.focus() }
})
}
function closeTaskCompose() { composeCalendarOpen.value = false; taskComposeOpen.value = false }
function activateFloatingAdd(origin: { x: number; y: number }) {
composeOrigin.value = origin
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
function repeatRrule(value: RepeatOption, config: TaskRepeatConfig) {
if (value === 'none') return ''
if (value === 'custom') return buildTaskRrule(config)
return `FREQ=${value.toUpperCase()}`
}
function repeatOption(rrule?: string): RepeatOption {
if (!rrule) return 'none'
const parsed = parseTaskRrule(rrule)
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return simple ? parsed.frequency : 'custom'
}
async function saveRepeat(task: Task, value: RepeatOption, config = selectedRepeatConfig.value) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
return
}
const rrule = repeatRrule(value, config)
if (selectedTaskRecurrence.value) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
} else {
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
}
selectedTaskRepeat.value = value
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
selectedTaskRecurrence.value = recurrence
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
selectedRepeatConfig.value = recurrence ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
}
async function updateSelectedTaskRepeat() {
if (!selectedTask.value) return
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
}
async function submitTaskCompose() {
const normalized = normalizeRequiredName(composeTitle.value)
if (normalized.error) {
composeTitleError.value = normalized.error
return
}
const taskTitle = normalized.value
composeTitle.value = taskTitle
composeTitleError.value = ''
if (!composeListId.value) return
try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
title: taskTitle,
list_id: composeListId.value,
due_at: fromDateTimeLocal(dueValue),
due_has_time: composeHasTime.value,
priority: composePriority.value,
description: composeDescription.value,
rrule,
}) })
if (isTaskView(activeView.value)) {
tasks.value.push(task)
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
}
if (activeView.value === 'today') void loadTodayTaskSummary()
taskComposeOpen.value = false
toast('任务已添加')
} catch (reason) { fail(reason) }
}
function toggleSidebar() {
const compact = window.matchMedia('(max-width: 930px)').matches
if (compact) {
mobileSidebar.value = !mobileSidebar.value
} else {
sidebarCollapsed.value = !sidebarCollapsed.value
}
}
const modalVisible = ref(false)
const modalTitle = ref('')
const modalLabel = ref('')
const modalValue = ref('')
const modalError = ref('')
const modalConfirmText = ref('确定')
const modalResolve = ref<((value: string | null) => void) | null>(null)
function askText(title: string, label = '', initial = '', confirmText = '确定') {
return new Promise<string | null>((resolve) => {
modalTitle.value = title
modalLabel.value = label
modalValue.value = initial
modalError.value = ''
modalConfirmText.value = confirmText
modalVisible.value = true
modalResolve.value = resolve
})
}
function closeModal() {
modalVisible.value = false
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
}
function confirmModal() {
if (modalLabel.value) {
const normalized = normalizeRequiredName(modalValue.value)
if (normalized.error) {
modalError.value = normalized.error
return
}
modalValue.value = normalized.value
}
modalVisible.value = false
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
}
const activeName = computed(() => {
if (activeView.value === 'trash') return '回收站'
if (activeView.value === 'today') return '今天'
if (activeView.value === 'upcoming') return '最近 7 天'
if (activeView.value === 'habits') return '习惯'
if (activeView.value === 'countdowns') return '倒数日'
if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
const progressWidth = (completed: number, total: number) => `${total > 0 ? Math.min(100, Math.round(completed / total * 100)) : 0}%`
const todayTaskProgressPercent = computed(() => progressWidth(todayTaskCompleted.value, todayTaskTotal.value))
const todayHabitProgressPercent = computed(() => progressWidth(todayHabitCompleted.value, todayHabitTotal.value))
function scrollTodaySection(id: 'today-tasks' | 'today-habits') {
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
function updateTodayHabitSummary(value: { total: number; completed: number }) {
todayHabitTotal.value = value.total
todayHabitCompleted.value = value.completed
}
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
const visibleTasks = computed(() => {
const now = new Date()
const end = new Date(now); end.setDate(end.getDate() + 7)
let result = sourceTasks.value
if (['habits','settings','countdowns'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
return query.value.trim() ? filterTasks(result, query.value) : result
})
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
const taskTree = computed(() => filteredTaskTree.value)
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
let searchTimer: number | undefined
watch(composeDueAt, (value) => {
if (!value) { composeHasTime.value = false; composeRepeat.value = 'none' }
})
watch(query, () => {
if (searchTimer) window.clearTimeout(searchTimer)
if (!isTaskView(activeView.value)) return
page.value = 1
searchTimer = window.setTimeout(() => loadAll(), 250)
})
watch(showCompleted, (value) => {
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
})
async function api(path: string, options: RequestInit = {}) {
const headers = new Headers(options.headers || {})
if (!headers.has('Content-Type') && options.body && !(options.body instanceof FormData)) {
headers.set('Content-Type', 'application/json')
}
const csrf = csrfHeader(options.method)
if (csrf['x-csrf-token']) headers.set('x-csrf-token', csrf['x-csrf-token'])
const response = await fetch('/api/v1' + path, {
credentials: 'include',
headers,
...options,
})
if (!response.ok) {
let message = '请求失败'
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
throw new Error(message)
}
return response.status === 204 ? null : response.json()
}
function toast(message: string) {
notice.value = message
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
}
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
function restoreNavigation(inboxId: string) {
if (restoredNavigation.view === 'tasks' && restoredNavigation.listId) {
activeList.value = lists.value.some((item) => item.id === restoredNavigation.listId) ? restoredNavigation.listId : inboxId
} else {
activeList.value = inboxId
}
}
async function loadRestoredView() {
if (activeView.value === 'trash') await loadTrash()
else if (isTaskView(activeView.value)) {
await loadAll()
if (activeView.value === 'tasks' || activeView.value === 'upcoming') collapseLoadedTaskChildren()
}
else { tasks.value = []; totalTasks.value = 0 }
}
async function bootstrap() {
authReady.value = false
try {
const status = await api('/setup/status')
initialized.value = status.initialized
if (status.initialized) {
const data = await api('/bootstrap')
authenticated.value = true
folders.value = data.folders ?? []
lists.value = data.lists ?? []
navigationLoaded.value = true
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
await loadArchivedLists()
void preloadCountdowns()
await loadRestoredView()
}
} catch {
authenticated.value = false
initialized.value ??= true
} finally {
authReady.value = true
}
}
async function submitAuth() {
error.value = ''
try {
const path = initialized.value ? '/auth/login' : '/setup/initialize'
await api(path, { method: 'POST', body: JSON.stringify({ username: username.value, password: password.value }) })
initialized.value = true; authenticated.value = true
const data = await api('/bootstrap')
folders.value = data.folders ?? []
lists.value = data.lists ?? []
navigationLoaded.value = true
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
void preloadCountdowns()
await loadRestoredView()
} catch (reason) { fail(reason) }
}
async function loadTaskPages(path: string) {
const rows: Task[] = []
let cursor: string | null = null
do {
const separator = path.includes('?') ? '&' : '?'
const data = await api(cursor ? `${path}${separator}cursor=${encodeURIComponent(cursor)}` : path)
rows.push(...(data.items ?? data))
cursor = data.next_cursor ?? null
} while (cursor)
return rows
}
function isoAtLocalDayOffset(offset: number) {
const day = new Date()
day.setDate(day.getDate() + offset)
day.setHours(0, 0, 0, 0)
return day.toISOString()
}
async function loadTodayTaskSummary() {
const token = ++todaySummaryLoadToken
try {
const summaryTotal = async (completed: boolean) => {
const params = new URLSearchParams({ page: '1', page_size: '1' })
params.set('due_from', isoAtLocalDayOffset(0))
params.set('due_to', isoAtLocalDayOffset(1))
params.set('completed', String(completed))
const data = await api(`/tasks?${params}`)
return Number(data.total ?? data.items?.length ?? 0)
}
const overdueTotal = async () => {
const params = new URLSearchParams({ page: '1', page_size: '1' })
params.set('due_to', isoAtLocalDayOffset(0))
params.set('completed', 'false')
const data = await api(`/tasks?${params}`)
return Number(data.total ?? data.items?.length ?? 0)
}
const [open, completed, overdue] = await Promise.all([summaryTotal(false), summaryTotal(true), overdueTotal()])
if (token !== todaySummaryLoadToken || activeView.value !== 'today') return
todayTaskCompleted.value = completed
todayTaskTotal.value = overdue + open + completed
} catch { /* 概览统计失败不阻断今天页 */ }
}
async function loadOverdueTasks(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams()
params.set('due_to', isoAtLocalDayOffset(0))
params.set('completed', 'false')
const loaded = await loadTaskPages(`/tasks?${params}`)
if (isLatestRequest('tasks', request)) overdueTasks.value = loaded
}
async function loadTasksPage(request = beginLatestRequest('tasks')) {
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
if (query.value) params.set('q', query.value)
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
if (activeView.value === 'today') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(1)) }
if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) }
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
const data = await api(`/tasks?${params}`)
if (!isLatestRequest('tasks', request)) return
tasks.value = data.items ?? []
totalTasks.value = data.total ?? tasks.value.length
hiddenCompletedTaskCount.value = 0
if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) {
const completedParams = new URLSearchParams(params)
completedParams.set('page', '1')
completedParams.set('page_size', '1')
completedParams.set('completed', 'true')
const completedData = await api(`/tasks?${completedParams}`)
if (!isLatestRequest('tasks', request)) return
hiddenCompletedTaskCount.value = Number(completedData.total ?? completedData.items?.length ?? 0)
}
}
async function loadNavigation(force = false) {
if (!force && navigationLoaded.value) return
const [folderData, listData] = await Promise.all([api('/folders'), api('/lists')])
folders.value = folderData; lists.value = listData
navigationLoaded.value = true
if (!lists.value.some((item) => item.id === activeList.value)) {
activeList.value = lists.value.find((item) => item.is_inbox)?.id || lists.value[0]?.id || ''
}
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
await loadArchivedLists()
}
async function preloadCountdowns() {
try {
await loadCountdownCache(async () => {
const [active, archived] = await Promise.all([api('/countdowns'), api('/countdowns?archived=true')])
return { items: active ?? [], archived: archived ?? [] }
})
} catch { /* 倒数日页会在打开时重试 */ }
}
async function loadAll() {
const request = beginLatestRequest('tasks')
loading.value = true
error.value = ''
try {
if (!navigationLoaded.value) await loadNavigation()
if (!isLatestRequest('tasks', request)) return
if (activeView.value === 'today') {
await startPrimaryWithBackground(
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
loadTodayTaskSummary,
)
} else {
await loadTasksPage(request)
if (isLatestRequest('tasks', request)) overdueTasks.value = []
}
if (isLatestRequest('tasks', request) && page.value > totalPages.value) {
page.value = totalPages.value
await loadTasksPage(request)
}
} catch (reason) {
if (isLatestRequest('tasks', request)) fail(reason)
} finally {
if (isLatestRequest('tasks', request)) loading.value = false
}
}
async function refreshAll() {
navigationLoaded.value = false
await loadAll()
}
type TrashPage = { items?: Task[]; total?: number }
async function loadTrashPage() {
return api(`/trash?page=${page.value}&page_size=${pageSize}`) as Promise<TrashPage>
}
async function loadTrash() {
loading.value = true
error.value = ''
await runLatestRequest('trash', loadTrashPage, {
success: (data) => {
trash.value = data.items ?? []
totalTasks.value = data.total ?? trash.value.length
},
error: fail,
finally: () => { loading.value = false },
})
}
async function switchView(view: View, listId?: string) {
taskMutationNavigation.value += 1
activeView.value = view
if (view !== 'trash') beginLatestRequest('trash')
if (!isTaskView(view)) {
beginLatestRequest('tasks')
loading.value = false
error.value = ''
}
if (listId) activeList.value = listId
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
page.value = 1
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView()
else if (!isTaskView(view)) tasks.value = []
else {
await loadAll()
if (view === 'tasks' || view === 'upcoming') collapseLoadedTaskChildren()
}
}
async function loadTodayView() {
await loadAll()
}
function applyTaskUpdate(task: Task, updated: Task) {
const index = tasks.value.findIndex((item) => item.id === task.id)
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
if (overdueIndex >= 0) {
if (updated.completed) overdueTasks.value.splice(overdueIndex, 1)
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
}
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
}
async function patchTask(task: Task, patch: Partial<Task>) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task
applyTaskUpdate(task, updated)
return updated
}
const taskMutationNavigation = ref(0)
const taskMutationReconciler = createMutationReconciler(
() => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value }),
(left, right) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted,
loadAll,
)
async function toggle(task: Task) {
const completing = !task.completed
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
() => toast(completing ? '完成啦' : '已重新打开'),
fail,
(updated) => {
applyTaskUpdate(task, updated)
if (completing) markTaskJustCompleted(task.id)
},
)
}
function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startTaskReorder(task: Task, event: PointerEvent) {
if (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
taskReorderTarget.value = task.id
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
}
function taskById(id: string) {
return tasks.value.find((item) => item.id === id)
?? tasks.value.flatMap((item) => item.subtasks ?? []).find((item) => item.id === id)
}
function moveTaskReorder(task: Task, event: PointerEvent) {
const drag = taskReorder.value
if (!drag || drag.id !== task.id) return
drag.offsetY = event.clientY - drag.startY
const handle = event.currentTarget as HTMLElement
const row = document.elementsFromPoint(event.clientX, event.clientY)
.map((element) => element.closest<HTMLElement>('[data-task-id]'))
.find((element) => element && element !== handle.closest('[data-task-id]'))
if (row?.dataset.taskId) taskReorderTarget.value = row.dataset.taskId
}
async function finishTaskReorder(task: Task, event: PointerEvent) {
const drag = taskReorder.value
const targetId = taskReorderTarget.value
taskReorder.value = null
taskReorderTarget.value = ''
if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return
const target = taskById(targetId)
if (!target || (target.parent_id ?? null) !== (task.parent_id ?? null)) return
const placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = tasks.value
const previousSelected = selectedTask.value
let ids: string[]
if (task.parent_id) {
const parent = tasks.value.find((item) => item.id === task.parent_id)
if (!parent) return
const reordered = moveItemWithinScope(parent.subtasks ?? [], task.id, targetId, placement)
if (reordered === parent.subtasks) return
tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item)
if (selectedTask.value?.id === parent.id) selectedTask.value = { ...selectedTask.value, subtasks: reordered }
ids = reordered.map((item) => item.id)
} else {
const next = moveItemWithinScope(previous, task.id, targetId, placement)
if (next === previous) return
tasks.value = next
ids = next.map((item) => item.id)
}
try {
await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) })
toast('顺序已保存')
} catch (reason) {
tasks.value = previous
selectedTask.value = previousSelected
fail(reason)
}
}
function cancelTaskReorder() {
taskReorder.value = null
taskReorderTarget.value = ''
}
function startTaskSwipe(task: Task, event: TouchEvent) {
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0]
if (touch) {
taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
taskSwipeOffsets.value[task.id] = 0
}
}
function startTaskPointer(task: Task, event: PointerEvent) {
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
if (event.pointerType === 'touch') return
taskPointerStart.value = { id: task.id, x: event.clientX, y: event.clientY }
}
function moveTaskPointer(task: Task, event: PointerEvent) {
const start = taskPointerStart.value
if (!start || start.id !== task.id) return
const deltaX = event.clientX - start.x
const deltaY = event.clientY - start.y
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
function finishTaskPointer(task: Task, event: PointerEvent) {
const start = taskPointerStart.value
if (!start || start.id !== task.id) return
taskPointerStart.value = null
const deltaX = event.clientX - start.x
const deltaY = event.clientY - start.y
taskSwipeOffsets.value[task.id] = 0
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
suppressTaskClickId = task.id
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
void toggle(task)
}
}
function cancelTaskPointer(task?: Task) {
taskPointerStart.value = null
if (task) taskSwipeOffsets.value[task.id] = 0
}
function moveTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
const touch = event.touches[0]
if (!start || start.id !== task.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
async function finishTaskSwipe(task: Task, event: TouchEvent) {
const start = taskSwipeStart.value
taskSwipeStart.value = null
const offset = taskSwipeOffsets.value[task.id] ?? 0
taskSwipeOffsets.value[task.id] = 0
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
const touch = event.changedTouches[0]
const deltaX = touch ? touch.clientX - start.x : 0
const deltaY = touch ? touch.clientY - start.y : 0
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
suppressTaskClickId = task.id
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
await toggle(task)
} else if (offset) {
taskSwipeOffsets.value[task.id] = 0
}
}
function cancelTaskSwipe(task?: Task) {
taskSwipeStart.value = null
if (task) taskSwipeOffsets.value[task.id] = 0
}
function collapseLoadedTaskChildren() {
collapsedTaskIds.value = new Set(tasks.value.filter((task) => task.subtasks?.length).map((task) => task.id))
}
function toggleTaskChildren(task: Task) {
if (!task.subtasks?.length) return
const next = new Set(collapsedTaskIds.value)
next.has(task.id) ? next.delete(task.id) : next.add(task.id)
collapsedTaskIds.value = next
}
function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
if (suppressTaskClickId === task.id) {
suppressTaskClickId = ''
return
}
if (toggleChildren) toggleTaskChildren(task)
selectTask(task)
}
async function saveTask() {
if (!selectedTask.value) return
const normalized = normalizeRequiredName(selectedTask.value.title)
if (normalized.error) {
error.value = normalized.error
return
}
selectedTask.value.title = normalized.value
try {
const task = selectedTask.value
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>)
if (selectedTaskRecurrence.value) {
if (dueAt) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
} else {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
}
}
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
if (activeView.value === 'today') void loadTodayTaskSummary()
toast('已保存')
} catch (reason) { fail(reason) }
}
async function removeTask(task: Task) {
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
try {
await api(`/tasks/${task.id}`, { method: 'DELETE' })
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
selectedTask.value = null
mobileDetail.value = false
if (activeView.value === 'today') void loadTodayTaskSummary()
toast('已移到回收站')
} catch (reason) { fail(reason) }
}
async function restoreTask(task: Task) {
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
}
async function purgeTask(task: Task) {
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
try { await api(`/trash/${task.id}`, { method: 'DELETE' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('已永久删除') } catch (reason) { fail(reason) }
}
async function addSubtask() {
if (!selectedTask.value) return
const subtaskTitle = (await askText('添加子任务', '子任务名称', '', '添加'))?.trim()
if (!subtaskTitle) return
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
}
function closeTaskDetail() {
mobileDetail.value = false
selectedTask.value = null
}
function selectTask(task: Task) {
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
}
async function createFolder() {
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
try { folders.value.push(await api('/folders', { method: 'POST', body: JSON.stringify({ name }) })); toast('文件夹已创建') } catch (reason) { fail(reason) }
}
async function createList(folderId: string | null = null) {
const name = (await askText('新建清单', '清单名称', '', '创建'))?.trim(); if (!name) return
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
}
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
}
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
if (kind === 'lists') {
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
if (answer === null) return
try {
const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id
await api(`/${kind}/${item.id}`, { method: 'DELETE' })
await loadArchivedLists()
await refreshAll()
if (wasCurrentList) {
selectedTask.value = null
mobileDetail.value = false
const inboxId = lists.value.find((list) => list.is_inbox)?.id || ''
await switchView('tasks', inboxId)
}
toast('清单已归档')
} catch (reason) { fail(reason) }
return
}
const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除')
if (answer === null) return
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await refreshAll(); toast('已删除') } catch (reason) { fail(reason) }
}
async function loadArchivedLists() {
try { archivedLists.value = await api('/lists?archived=true') } catch { archivedLists.value = [] }
}
async function restoreList(item: TaskList) {
archivedListAction.value = null
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
finally { focusArchivedListTrigger() }
}
function toggleArchivedLists() {
if (!archivedLists.value.length) return
archivedListsExpanded.value = !archivedListsExpanded.value
closeArchivedListAction(false)
}
function focusArchivedListTrigger() {
const target = resolveArchivedMenuFocusTarget(archivedListActionTrigger, archivedListsToggle.value)
archivedListActionTrigger = null
nextTick(() => target?.focus())
}
function updateArchivedMenuPosition() {
if (!archivedListAction.value || !archivedListActionTrigger || !window.matchMedia('(min-width: 931px)').matches) return
const triggerRect = archivedListActionTrigger.getBoundingClientRect()
const menuRect = archivedMenu.value?.getBoundingClientRect()
const position = positionArchivedMenu(triggerRect, menuRect?.width || 164, menuRect?.height || 102, window.innerWidth, window.innerHeight)
archivedMenuStyle.value = { left: `${position.left}px`, top: `${position.top}px` }
}
function closeArchivedListAction(restoreFocus = true) {
if (!archivedListAction.value) return
archivedListAction.value = null
if (restoreFocus) focusArchivedListTrigger()
else archivedListActionTrigger = null
}
function toggleArchivedListAction(item: TaskList, trigger?: EventTarget | null) {
closeSidebarAction()
if (archivedListAction.value?.id === item.id) {
closeArchivedListAction()
return
}
archivedListActionTrigger = trigger instanceof HTMLElement ? trigger : null
archivedListAction.value = item
archivedMenuStyle.value = {}
nextTick(() => {
updateArchivedMenuPosition()
archivedMenu.value?.querySelector<HTMLElement>('[role="menuitem"]')?.focus()
})
}
function handleArchivedListViewportChange() {
if (archivedListAction.value && window.matchMedia('(min-width: 931px)').matches) updateArchivedMenuPosition()
}
function handleArchivedListOutsidePointer(event: PointerEvent) {
if (!archivedListAction.value || !window.matchMedia('(min-width: 931px)').matches) return
const target = event.target
if (target instanceof Element && !target.closest('.archived-row-menu,.archived-row-actions')) closeArchivedListAction(false)
}
function handleArchivedListEscape(event: KeyboardEvent) {
if (event.key === 'Escape' && archivedListAction.value) closeArchivedListAction()
}
function openPurgeList(item: TaskList) {
purgeListTrigger = archivedListActionTrigger
purgeListTarget.value = item
purgeListError.value = ''
archivedListAction.value = null
archivedListActionTrigger = null
nextTick(() => purgeCancelButton.value?.focus())
}
function focusPurgeListTrigger() {
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
purgeListTrigger = null
nextTick(() => target?.focus())
}
function closePurgeList() {
if (purgeListSubmitting.value) return
purgeListTarget.value = null
purgeListError.value = ''
focusPurgeListTrigger()
}
function handlePurgeDialogKeydown(event: KeyboardEvent) {
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
if (event.key !== 'Tab' || !purgeListDialog.value) return
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
if (!controls.length) return
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
}
async function confirmPurgeList() {
if (!purgeListTarget.value || purgeListSubmitting.value) return
purgeListSubmitting.value = true
purgeListError.value = ''
try {
const purgedId = purgeListTarget.value.id
await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' })
archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId)
purgeListTarget.value = null
focusPurgeListTrigger()
toast('清单已永久删除')
} catch (reason) {
purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
} finally {
purgeListSubmitting.value = false
}
}
function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null }
function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
closeArchivedListAction(false)
sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item }
sidebarCreateOpen.value = false
}
function runSidebarCreate(kind: 'folder' | 'list') {
sidebarCreateOpen.value = false
kind === 'folder' ? void createFolder() : void createList(null)
}
function closeSidebarAction() { sidebarAction.value = null; listMoveMenuOpen.value = false }
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
function beginListDrag(list: TaskList, pointer: ListDragPointer) {
if (list.is_inbox) return
clearListHandlePress()
listDrag.value = { id: list.id, pointerId: pointer.pointerId, startX: pointer.clientX, startY: pointer.clientY, offsetY: 0, lastX: pointer.clientX, lastY: pointer.clientY }
listDropFolderId.value = list.folder_id
listReorderTarget.value = list.id
captureListDragPointer(pointer)
}
function startListHandlePress(list: TaskList, event: PointerEvent) {
const pointer = snapshotListDragPointer(event)
if (event.pointerType !== 'touch') {
beginListDrag(list, pointer)
return
}
clearListHandlePress()
listHandlePending = { id: list.id, pointer }
listHandleLongPressTimer = window.setTimeout(() => beginListDrag(list, pointer), 450)
}
function moveListHandle(list: TaskList, event: PointerEvent) {
if (!listDrag.value && listHandlePending?.id === list.id && listHandlePending.pointer.pointerId === event.pointerId) {
if (hasExceededLongPressMovement(
{ x: listHandlePending.pointer.clientX, y: listHandlePending.pointer.clientY },
{ x: event.clientX, y: event.clientY },
5,
)) clearListHandlePress()
return
}
moveListDrag(list, event)
}
function clearListHandlePress() {
if (listHandleLongPressTimer) window.clearTimeout(listHandleLongPressTimer)
listHandleLongPressTimer = undefined
listHandlePending = undefined
}
function resolveListDrop(event: PointerEvent) {
const activeId = listDrag.value?.id
const elements = document.elementsFromPoint(event.clientX, event.clientY)
const row = elements.map((element) => element.closest<HTMLElement>('[data-list-id]')).find((element) => element?.dataset.listId !== activeId)
if (row?.dataset.listId) {
const target = lists.value.find((item) => item.id === row.dataset.listId)
if (target && !target.is_inbox) {
listDropFolderId.value = target.folder_id
listReorderTarget.value = target.id
const rect = row.getBoundingClientRect()
listReorderPlacement.value = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
return
}
}
const folder = elements.map((element) => element.closest<HTMLElement>('[data-folder-id]')).find(Boolean)
if (folder?.dataset.folderId) {
listDropFolderId.value = folder.dataset.folderId
listReorderTarget.value = ''
expandedFolders.value = new Set(expandedFolders.value).add(folder.dataset.folderId)
return
}
if (elements.some((element) => element.closest('.list-root-drop'))) {
listDropFolderId.value = null
listReorderTarget.value = ''
}
}
function moveListDrag(list: TaskList, event: PointerEvent) {
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
drag.offsetY = event.clientY - drag.startY
drag.lastX = event.clientX
drag.lastY = event.clientY
resolveListDrop(event)
}
async function persistListMove(list: TaskList, folderId: string | null, targetId?: string, placement: 'before' | 'after' = 'after') {
if (list.is_inbox) return
const previous = lists.value
const result = moveListToScope(previous, list.id, folderId, targetId, placement)
if (result.items === previous) return
lists.value = result.items
if (folderId) expandedFolders.value = new Set(expandedFolders.value).add(folderId)
try {
if (list.folder_id !== folderId) {
await api(`/lists/${list.id}/move`, { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
} else {
await api('/lists/reorder', { method: 'PUT', body: JSON.stringify({ folder_id: folderId, list_ids: result.orderedIds }) })
}
toast(folderId ? '清单已移动' : '清单已移出文件夹')
} catch (reason) {
lists.value = previous
fail(reason)
}
}
function finishListDrag(list: TaskList, event: PointerEvent) {
clearListHandlePress()
const drag = listDrag.value
if (!drag || drag.id !== list.id) return
const moved = Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) > 5
const folderId = listDropFolderId.value ?? null
const targetId = listReorderTarget.value && listReorderTarget.value !== list.id ? listReorderTarget.value : undefined
const placement = listReorderPlacement.value
cancelListDrag()
if (!moved && folderId === list.folder_id && !targetId) return
suppressListClickId = list.id
window.setTimeout(() => { if (suppressListClickId === list.id) suppressListClickId = '' }, 400)
void persistListMove(list, folderId, targetId, placement)
}
function selectListUnlessDragged(list: TaskList) {
if (suppressListClickId === list.id) { suppressListClickId = ''; return }
void switchView('tasks', list.id)
}
function cancelListDrag() {
clearListHandlePress()
listDrag.value = null
listDropFolderId.value = undefined
listReorderTarget.value = ''
}
function openListMoveMenu() { listMoveMenuOpen.value = !listMoveMenuOpen.value }
function moveListFromMenu(folderId: string | null) {
const item = sidebarAction.value?.kind === 'lists' ? sidebarAction.value.item as TaskList : null
if (!item) return
listMoveMenuOpen.value = false
void persistListMove(item, folderId)
closeSidebarAction()
}
function canMoveListWithinScope(item: TaskList, direction: 'up' | 'down') {
return getAdjacentListMove(lists.value, item.id, direction) !== null
}
function moveListWithinScope(item: TaskList, direction: 'up' | 'down') {
const move = getAdjacentListMove(lists.value, item.id, direction)
if (!move) return
void persistListMove(item, item.folder_id, move.targetId, move.placement)
closeSidebarAction()
}
function formatDue(value: string | null, hasTime = true) {
if (!value) return ''
const date = new Date(value)
return hasTime
? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date)
: new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
}
function previousPage() {
if (page.value <= 1 || loading.value) return
page.value -= 1
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
}
function nextPage() {
if (page.value >= totalPages.value || loading.value) return
page.value += 1
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
}
onMounted(() => {
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
document.addEventListener('keydown', handleArchivedListEscape)
window.addEventListener('resize', handleArchivedListViewportChange)
document.addEventListener('scroll', handleArchivedListViewportChange, true)
void bootstrap()
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
document.removeEventListener('keydown', handleArchivedListEscape)
window.removeEventListener('resize', handleArchivedListViewportChange)
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
})
</script>
<template>
<div v-if="!authReady" class="center"><span class="loader" />正在打开 dodo</div>
<div v-else-if="!authenticated" class="auth-shell">
<section class="auth-card">
<div class="brand">do<span>do</span></div><p>{{ initialized ? '欢迎回来,继续把生活理顺。' : '创建你的 dodo' }}</p>
<label>用户名<input v-model="username" autocomplete="username" placeholder="你的用户名"></label>
<label>密码<input v-model="password" type="password" autocomplete="current-password" placeholder="至少 12 位" @keyup.enter="submitAuth"></label>
<button class="primary" @click="submitAuth">{{ initialized ? '登录' : '开始使用' }}</button>
<small v-if="error" role="alert">{{ error }}</small>
</section>
</div>
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask), 'mobile-sidebar-open': mobileSidebar }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
<nav class="primary-nav">
<button :class="{ active: activeView==='today' }" @click="switchView('today')"><ListTodo />今天</button>
<button :class="{ active: activeView==='tasks' && lists.find(l=>l.id===activeList)?.is_inbox }" @click="switchView('tasks', lists.find(l=>l.is_inbox)?.id)"><Inbox />收集箱</button>
<button :class="{ active: activeView==='upcoming' }" @click="switchView('upcoming')"><CalendarDays />最近 7 </button>
<button :class="{ active: activeView==='habits' }" @click="switchView('habits')"><Repeat2 />习惯</button>
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</button>
</nav>
<div class="section-title list-root-drop" :class="{'list-drop-target':listDrag&&listDropFolderId===null&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><span>我的清单</span><span class="sidebar-create-wrap"><button class="mini-icon list-create-trigger" aria-label="新建清单或文件夹" :aria-expanded="sidebarCreateOpen" @click="toggleSidebarCreate"><Plus /></button><span v-if="sidebarCreateOpen" class="sidebar-popover sidebar-create-menu"><button @click="runSidebarCreate('list')"><ListTodo/>新建清单</button><button @click="runSidebarCreate('folder')"><Folder/>新建文件夹</button></span></span></div>
<div class="folders">
<div v-for="folder in folders" :key="folder.id" class="folder-block" :data-folder-id="folder.id">
<div class="folder-row" :class="{'list-drop-target':listDrag&&listDropFolderId===folder.id&&!listReorderTarget}" @pointermove="listDrag&&resolveListDrop($event)"><button :title="folder.name" :aria-label="folder.name" @click="toggleFolder(folder.id)"><ChevronDown v-if="expandedFolders.has(folder.id)"/><ChevronRight v-else/><Folder/><span>{{folder.name}}</span></button><span class="row-actions"><button aria-label="打开文件夹操作" :aria-expanded="sidebarAction?.item.id===folder.id" @click="openSidebarAction('folders',folder)"><Ellipsis/></button></span></div>
<div v-for="list in lists.filter(l=>l.folder_id===folder.id && !l.is_inbox)" v-show="expandedFolders.has(folder.id)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
</div>
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" :data-list-id="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id,'list-dragging':listDrag?.id===list.id,'list-reorder-target':listReorderTarget===list.id&&listDrag?.id!==list.id}" :style="{'--list-drag-y':`${listDrag?.id===list.id?listDrag.offsetY:0}px`}" @pointermove="moveListDrag(list,$event)" @pointerup="finishListDrag(list,$event)" @pointercancel="cancelListDrag"><button class="list-drag-handle" aria-label="拖动清单排序或移动到文件夹" title="长按拖动清单" @pointerdown.stop="startListHandlePress(list,$event)" @pointermove.stop="moveListHandle(list,$event)" @pointerup.stop="finishListDrag(list,$event)" @pointercancel.stop="cancelListDrag"><GripVertical/></button><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="selectListUnlessDragged(list)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="打开清单操作" :aria-expanded="sidebarAction?.item.id===list.id" @click="openSidebarAction('lists',list)"><Ellipsis/></button></span></div>
<div class="archived-lists">
<button ref="archivedListsToggle" class="archived-lists-toggle" :class="{ empty: archivedLists.length === 0 }" :aria-expanded="archivedLists.length > 0 && archivedListsExpanded" aria-controls="archived-task-lists" :disabled="archivedLists.length === 0" @click="toggleArchivedLists"><ChevronRight :class="{ expanded: archivedListsExpanded }"/><span>已归档 {{ archivedLists.length }}</span></button>
<div id="archived-task-lists" v-show="archivedListsExpanded" class="archived-list-items">
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><span class="archived-row-label" :title="list.name">{{list.name}}</span><span class="archived-row-menu"><button class="archived-row-menu-trigger" :aria-label="`${list.name}操作`" aria-haspopup="menu" :aria-expanded="archivedListAction?.id===list.id" @click="toggleArchivedListAction(list,$event.currentTarget)"><Ellipsis/></button></span></div>
</div>
</div>
</div>
<nav class="sidebar-management" aria-label="管理">
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
</nav>
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><template v-if="sidebarAction.kind==='lists'"><button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')">上移</button><button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')">下移</button><button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/>移动到文件夹</button><div v-if="listMoveMenuOpen" class="list-move-menu" role="menu" aria-label="选择目标文件夹"><button v-if="(sidebarAction.item as TaskList).folder_id" role="menuitem" @click="moveListFromMenu(null)">移出文件夹</button><button v-for="folder in folders" :key="folder.id" role="menuitem" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)">{{folder.name}}</button></div></template><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
</aside>
<main>
<header class="topbar" :class="{ 'today-topbar': activeView==='today' }">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div><h1>{{ activeName }}</h1></div>
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @update:show-completed="showCompleted = $event" @changed="refreshAll" @notice="toast" />
</template>
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<div v-if="activeView==='today'" class="list-toolbar today-completed-toolbar"><label><input v-model="showCompleted" type="checkbox"> 显示已完成</label></div>
<section v-if="activeView==='today'" class="today-board" aria-label="今日进度">
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="scrollTodaySection('today-tasks')">
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日任务进度" aria-valuemin="0" :aria-valuemax="todayTaskTotal" :aria-valuenow="todayTaskCompleted"><i class="today-track-fill" :style="{ width: todayTaskProgressPercent }" /></span>
</button>
<button class="today-track today-habit-track" type="button" aria-controls="today-habits" @click="scrollTodaySection('today-habits')">
<span class="today-track-head"><strong>习惯 {{ todayHabitProgress }}</strong></span>
<span class="today-track-rail" role="progressbar" aria-label="今日习惯进度" aria-valuemin="0" :aria-valuemax="todayHabitTotal" :aria-valuenow="todayHabitCompleted"><i class="today-track-fill" :style="{ width: todayHabitProgressPercent }" /></span>
</button>
</section>
<template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section">
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
<div class="task-list overdue-list">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span></span></div><span class="overdue-badge">已过期</span></article>
</template>
</div>
</section>
<h3 id="today-tasks" class="section-heading today-section-anchor"><ListTodo/>任务</h3>
</template>
<div v-if="activeView!=='today'" class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
<section class="task-list" :class="{loading}">
<template v-for="node in taskTree" :key="node.task.id">
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,'just-completed': justCompletedTaskIds.has(node.task.id),selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span>
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
</article>
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,'just-completed': justCompletedTaskIds.has(subtask.id),ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
</template>
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{ query ? '没有匹配的任务' : hiddenCompletedTaskCount > 0 ? '已完成任务已隐藏' : '这里还很安静' }}</b><span>{{query?'换个关键词试试':hiddenCompletedTaskCount > 0 ? '开启“显示已完成”即可查看。':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query && hiddenCompletedTaskCount === 0" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
</section>
<div v-if="activeView==='today'" id="today-habits" class="today-section-anchor">
<h3 class="section-heading"><Repeat2/>习惯</h3>
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
</div>
</template>
</main>
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
<div class="detail-form">
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label><button type="button" class="soft-button" @click="updateSelectedTaskRepeat">保存自定义重复</button></section>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown" @blur="saveTask"/></div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
</div></details>
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
</aside>
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose">
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
<header class="app-sheet__header"><div><small>NEW TASK</small><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
<div class="app-sheet__body">
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
<div class="task-compose-row"><label>清单<select v-model="composeListId"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label><label>优先级<select v-model.number="composePriority"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label></div>
<div class="task-compose-date-actions" aria-label="截止时间">
<div class="task-compose-date-control">
<button ref="composeDateButton" class="task-compose-date-chip" :class="{selected:composeDueAt}" type="button" aria-haspopup="dialog" :aria-expanded="composeCalendarOpen" @click="composeCalendarOpen=true">
<CalendarDays/><span>{{ composeDueLabel }}</span>
</button>
<CalendarPicker v-model:open="composeCalendarOpen" v-model="composeDueAt" :anchor="composeDateButton"/>
<button v-if="composeDueAt" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearComposeDueDate"><X/></button>
</div>
<button v-if="composeDueAt && !composeHasTime" class="task-compose-time-add" type="button" @click="addComposeTime">添加时间</button>
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
</div>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选支持 Markdown"/></label>
</div>
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
</form>
</div>
</Transition>
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
<Teleport to="body">
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
</Teleport>
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
</section>
</div>
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
<div class="modal-box" role="dialog" aria-modal="true">
<h3>{{ modalTitle }}</h3>
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
</div>
</div>
</div>
</template>