Files
dodo/frontend/src/App.vue
T
bboysoul 40162aaa54
ci / gitleaks (push) Successful in 9s
ci / docker (push) Successful in 7m11s
fix: match Today paper concept
2026-09-17 16:05:19 +08:00

1674 lines
106 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, Bold, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Code, Folder,
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote, Search,
Settings, Trash2, X, Repeat2, RefreshCw, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
import { deriveMemoShellState } from './lib/app-shell-state'
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
import MemoPanel from './MemoPanel.vue'
import FloatingAddButton from './components/FloatingAddButton.vue'
import CompletedFilterPill from './components/CompletedFilterPill.vue'
import CalendarPicker from './components/CalendarPicker.vue'
import TaskDueDisplay from './components/TaskDueDisplay.vue'
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
import AppSheet from './components/AppSheet.vue'
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
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; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | '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('')
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 sidebarActionFolderListCount = computed(() => {
const action = sidebarAction.value
if (!action || action.kind !== 'folders') return 0
return lists.value.filter((list) => list.folder_id === action.item.id).length
})
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 searchInput = ref<HTMLInputElement | null>(null)
const taskSearchToggle = ref<HTMLButtonElement | null>(null)
const mobileSearchOpen = ref(false)
const searchPullDistance = ref(0)
let searchTouchStartY: number | null = null
const taskSearchAvailable = computed(() => ['tasks', 'today', 'upcoming', 'trash'].includes(activeView.value))
const searchRevealStyle = computed(() => ({
'--search-pull': `${searchPullDistance.value}px`,
'--search-pull-opacity': String(Math.min(1, searchPullDistance.value / 56)),
}))
const error = ref('')
const notice = ref('')
const loading = ref(false)
const refreshing = ref(false)
const taskDueNowMs = useTaskDueClock()
const mobileSidebar = ref(false)
const sidebarCollapsed = ref(false)
const mobileDetail = ref(false)
const moreSettingsOpen = ref(false)
const markdownPreview = ref(false)
const taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
const TODAY_SECTION_COLLAPSE_KEY = 'dodo.today-section-collapse.v1'
const todaySectionCollapse = ref<TodaySectionCollapse>(readTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY))
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 todayEnvironment = ref<TodayEnvironment | null>(null)
const todayEnvironmentLoading = ref(false)
const todayEnvironmentError = ref(false)
const todayEnvironmentDateKey = ref('')
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
const taskReorderAvailable = computed(() => activeView.value === 'tasks' && !query.value && totalPages.value === 1 && taskTree.value.length > 1)
const expandedFolders = ref(new Set<string>())
const justCompletedTaskIds = ref(new Set<string>())
const completionExitingTaskIds = ref(new Set<string>())
function setTaskCompletionExiting(id: string, active: boolean) {
const next = new Set(completionExitingTaskIds.value)
active ? next.add(id) : next.delete(id)
completionExitingTaskIds.value = next
}
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 taskReorderBlocked = ref(false)
const taskReorderMode = ref(false)
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 selectedDueDate = ref('')
const selectedDueHasTime = ref(false)
const selectedDueTime = ref('12:00')
const selectedDueTimePicker = ref<HTMLInputElement | null>(null)
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1')
const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1')
const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false)
const savingSelectedTask = ref(false)
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
let todayEnvironmentLoadToken = 0
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
const memoTrash = ref(false)
const memoDetailOpen = ref(false)
const compactLayout = ref(window.innerWidth <= 930)
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
const showFloatingAdd = computed(() => memoShellState.value.showFab)
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'
composeAfterCompletionDays.value = '1'
composeRepeatError.value = ''
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 (activeView.value === 'memos') void memoPanel.value?.createMemo()
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
return null
}
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
if (recurrence) {
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
}
return await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, ...recurrencePayload }) }) as Recurrence
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
recurrenceLoading.value = true
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
selectedAfterCompletionDays.value = '1'
selectedRepeatError.value = ''
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
selectedTaskRecurrence.value = recurrence
const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) {
if (token === recurrenceLoadToken) fail(reason)
} finally {
if (token === recurrenceLoadToken) recurrenceLoading.value = false
}
}
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 recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
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,
...recurrencePayload,
}) })
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) { composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'; fail(reason) }
}
function toggleSidebar() {
const compact = window.matchMedia('(max-width: 930px)').matches
if (compact) {
mobileSidebar.value = !mobileSidebar.value
} else {
sidebarCollapsed.value = !sidebarCollapsed.value
}
}
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
async function confirmAction(title: string, description?: string, danger = false) {
return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
}
async function askText(title: string, label = '', initial = '', confirmText = '确定') {
const result = await appDialog.value?.show({
title, label, initial, confirmText,
validate: label ? (value) => normalizeRequiredName(value).error : undefined,
})
return typeof result === 'string' ? result.trim() : 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 === 'memos') return '备忘录'
if (activeView.value === 'settings') return '设置与数据'
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
})
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
const todayTaskRemaining = computed(() => Math.max(0, todayTaskTotal.value - todayTaskCompleted.value))
const todayHabitRemaining = computed(() => Math.max(0, todayHabitTotal.value - todayHabitCompleted.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 persistTodaySectionCollapse() {
writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)
}
function toggleTodaySection(section: keyof TodaySectionCollapse) {
todaySectionCollapse.value[section] = !todaySectionCollapse.value[section]
persistTodaySectionCollapse()
}
async function navigateTodaySection(section: 'tasks' | 'habits') {
if (todaySectionCollapse.value[section]) {
todaySectionCollapse.value[section] = false
persistTodaySectionCollapse()
}
await nextTick()
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
const heading = document.getElementById(`today-${section}-heading`)
heading?.focus({ preventScroll: true })
document.getElementById(`today-${section}`)?.scrollIntoView({ behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : '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','memos'].includes(activeView.value)) return []
if (activeView.value === 'today') result = result.filter((task) => {
const dueToday = task.due_at && new Date(task.due_at).toDateString() === now.toDateString()
const completedToday = task.completed_at && new Date(task.completed_at).toDateString() === now.toDateString()
return Boolean(dueToday || completedToday)
})
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(taskReorderAvailable, () => {
if (!taskReorderAvailable.value) taskReorderMode.value = false
})
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 */ }
const requestError = new Error(message) as Error & { status: number }
requestError.status = response.status
throw requestError
}
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 : '请求失败' }
async function syncBrowserTimezone(currentTimezone?: string) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (!timezone || timezone === currentTimezone) return
await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })
}
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()
}
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 syncBrowserTimezone(data.user?.timezone)
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))
await syncBrowserTimezone(data.user?.timezone)
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))
if (completed) {
params.set('completed_from', isoAtLocalDayOffset(0))
params.set('completed_to', isoAtLocalDayOffset(1))
}
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 loadTodayEnvironment() {
const token = ++todayEnvironmentLoadToken
todayEnvironmentLoading.value = true
todayEnvironmentError.value = false
try {
const data = await api('/today/environment') as TodayEnvironment
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironment.value = data
todayEnvironmentDateKey.value = data.date?.solar_date || shanghaiDateKey()
} catch {
if (token !== todayEnvironmentLoadToken || activeView.value !== 'today') return
todayEnvironmentError.value = true
} finally {
if (token === todayEnvironmentLoadToken && activeView.value === 'today') todayEnvironmentLoading.value = false
}
}
function handleTodayEnvironmentResume() {
if (document.visibilityState === 'hidden' || activeView.value !== 'today') return
if (todayEnvironmentDateKey.value !== shanghaiDateKey()) void loadTodayEnvironment()
}
watchShanghaiDateRollover(taskDueNowMs, handleTodayEnvironmentResume)
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 (showCompleted.value) {
params.set('completed_from', isoAtLocalDayOffset(0))
params.set('completed_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')
if (activeView.value === 'today') {
completedParams.set('completed_from', isoAtLocalDayOffset(0))
completedParams.set('completed_to', isoAtLocalDayOffset(1))
}
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') {
void loadTodayEnvironment()
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()
}
async function refreshCurrentView() {
if (refreshing.value || loading.value) return
refreshing.value = true
try {
if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()
else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])
else await refreshAll()
} finally {
refreshing.value = false
}
}
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 = ''
return 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) {
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
taskMutationNavigation.value += 1
taskReorderMode.value = false
cancelTaskReorder()
if (view !== 'today') {
++todayEnvironmentLoadToken
todayEnvironmentLoading.value = false
}
activeView.value = view
if (!query.value) mobileSearchOpen.value = false
searchPullDistance.value = 0
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; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
if (view !== 'memos') memoDetailOpen.value = false
if (view === 'trash') await loadTrash()
else if (view === 'today') await loadTodayView()
else if (!isTaskView(view)) tasks.value = []
else {
await loadAll()
}
}
async function loadTodayView() {
await loadAll()
}
function applyTaskUpdate(task: Task, updated: Task, updateSelected = true) {
const updateNestedSubtasks = (items: Task[]) => items.map((item) => ({
...item,
subtasks: item.subtasks?.map((subtask) => subtask.id === task.id ? { ...subtask, ...updated } : subtask),
}))
const index = tasks.value.findIndex((item) => item.id === task.id)
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
else tasks.value = updateNestedSubtasks(tasks.value)
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
if (overdueIndex >= 0) {
if (updated.completed && !completionExitingTaskIds.value.has(task.id)) overdueTasks.value.splice(overdueIndex, 1)
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
} else overdueTasks.value = updateNestedSubtasks(overdueTasks.value)
if (updateSelected && selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
else if (updateSelected && selectedTask.value?.subtasks) {
selectedTask.value.subtasks = selectedTask.value.subtasks.map((subtask) => subtask.id === task.id ? { ...subtask, ...updated } : subtask)
}
}
async function patchTask(task: Task, patch: Partial<Task>, updateSelected = true) {
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) }) as Task
applyTaskUpdate(task, updated, updateSelected)
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
const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value })
await taskMutationReconciler.run(
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ completed: completing, version: task.version }) }) as Promise<Task>,
() => toast(completing ? '完成啦' : '已重新打开'),
fail,
async (updated) => {
if (animateExit) setTaskCompletionExiting(task.id, true)
applyTaskUpdate(task, updated)
if (completing) markTaskJustCompleted(task.id)
if (animateExit) {
await waitForCompletionExit()
setTaskCompletionExiting(task.id, false)
}
if (activeView.value === 'today' && showCompleted.value) await loadAll()
},
)
}
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 (!taskReorderMode.value || !taskReorderAvailable.value || loading.value) return
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
taskReorderTarget.value = task.id
taskReorderBlocked.value = false
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]'))
const target = row?.dataset.taskId ? taskById(row.dataset.taskId) : undefined
if (target && target.list_id === task.list_id && (target.parent_id ?? null) === (task.parent_id ?? null) && isSameTaskSortTier(task, target)) {
taskReorderTarget.value = target.id
taskReorderBlocked.value = false
} else {
taskReorderTarget.value = ''
taskReorderBlocked.value = Boolean(target)
}
}
async function finishTaskReorder(task: Task, event: PointerEvent) {
const drag = taskReorder.value
const targetId = taskReorderTarget.value
const blocked = taskReorderBlocked.value
taskReorder.value = null
taskReorderTarget.value = ''
taskReorderBlocked.value = false
if (blocked) {
toast('只能调整相同完成状态和截止时间档的任务顺序')
return
}
if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return
const target = taskById(targetId)
if (!target || target.list_id !== task.list_id || (target.parent_id ?? null) !== (task.parent_id ?? null)) return
if (!isSameTaskSortTier(task, target)) {
toast('只能调整相同完成状态和截止时间档的任务顺序')
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.filter((item) => isSameTaskSortTier(task, item)).map((item) => item.id)
} else {
const next = moveItemWithinScope(previous, task.id, targetId, placement)
if (next === previous) return
tasks.value = next
ids = next.filter((item) => isSameTaskSortTier(task, item)).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 = ''
taskReorderBlocked.value = false
}
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 selectTaskUnlessSwiped(task: Task) {
if (suppressTaskClickId === task.id) {
suppressTaskClickId = ''
return
}
selectTask(task)
}
async function refreshTodayAfterTaskSave() {
if (activeView.value !== 'today') return
const request = beginLatestRequest('tasks')
await Promise.all([
loadTasksPage(request),
loadOverdueTasks(request),
loadTodayTaskSummary(),
])
}
async function saveTask(options?: { showSuccess?: boolean, expectedTaskId?: string, expectedSelectionToken?: number }) {
const task = selectedTask.value
if (!task || (options?.expectedTaskId && task.id !== options.expectedTaskId)) return false
const normalized = normalizeRequiredName(task.title)
if (normalized.error) {
error.value = normalized.error
return false
}
task.title = normalized.value
const due = buildTaskDueDraft({ date: selectedDueDate.value, hasTime: selectedDueHasTime.value, time: selectedDueTime.value })
try {
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), ...due, list_id: task.list_id } as Partial<Task>, false)
const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken
if (selectedTask.value?.id === task.id && selectionMatches) {
selectedTask.value = { ...selectedTask.value, ...updated }
selectedDueHasTime.value = Boolean(updated.due_has_time)
}
await refreshTodayAfterTaskSave()
if (options?.showSuccess !== false) toast('已保存')
return updated
} catch (reason) {
fail(reason)
return false
}
}
async function saveSelectedTaskChanges() {
if (savingSelectedTask.value || recurrenceLoading.value) return
const taskId = selectedTask.value?.id
if (!taskId) return
savingSelectedTask.value = true
const selectionToken = recurrenceLoadToken
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
const afterCompletionDays = selectedAfterCompletionDays.value
const recurrence = selectedTaskRecurrence.value
selectedRepeatError.value = ''
try {
const taskSaved = await saveTask({ showSuccess: false, expectedTaskId: taskId, expectedSelectionToken: selectionToken })
if (!taskSaved || recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
if (!taskSaved.due_at) {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
} else {
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence
}
toast('已保存')
} catch (reason) {
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'
fail(reason)
} finally {
savingSelectedTask.value = false
}
}
async function removeTask(task: Task) {
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) 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 mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string) {
const result = await performTrashMutation(
mutation,
() => {
trash.value = trash.value.filter((item) => item.id !== task.id)
totalTasks.value = nextTotalAfterLocalTaskRemoval(totalTasks.value)
if (page.value > totalPages.value) page.value = totalPages.value
},
loadTrash,
)
if (!result.mutated) {
fail(result.error)
return
}
if (!result.refreshed) toast(`${successMessage},但列表刷新失败,请重试刷新`)
else toast(successMessage)
}
async function restoreTask(task: Task) {
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
}
async function purgeTask(task: Task) {
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
}
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 addSelectedDueTime() {
selectedDueHasTime.value = true
nextTick(() => {
const picker = selectedDueTimePicker.value as (HTMLInputElement & { showPicker?: () => void }) | null
try { picker?.showPicker?.() } catch { picker?.focus() }
})
}
function clearSelectedDueDate() {
selectedDueDate.value = ''
selectedDueHasTime.value = false
selectedTaskRepeat.value = 'none'
}
function selectTask(task: Task) {
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
const due = parseTaskDueDraft(task.due_at, task.due_has_time)
selectedDueDate.value = due.date
selectedDueHasTime.value = due.hasTime
selectedDueTime.value = due.time
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
void loadTaskRecurrence(task)
}
function formatTaskNote(format: MarkdownFormat) {
if (!selectedTask.value) return
const editor = taskNoteEditor.value
const start = editor?.selectionStart ?? selectedTask.value.description.length
const end = editor?.selectionEnd ?? start
const formatted = applyMarkdownFormat(selectedTask.value.description, start, end, format)
selectedTask.value.description = formatted.value
markdownPreview.value = false
void nextTick(() => {
const target = taskNoteEditor.value
if (!target) return
target.focus()
target.setSelectionRange(formatted.start, formatted.end)
})
}
function handleTaskNoteShortcut(event: KeyboardEvent) {
if (!(event.metaKey || event.ctrlKey)) return
const format = event.key.toLowerCase() === 'b' ? 'bold' : event.key.toLowerCase() === 'i' ? 'italic' : event.key.toLowerCase() === 'k' ? 'link' : null
if (!format) return
event.preventDefault()
formatTaskNote(format)
}
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
}
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()
}
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 = true }
function closeListMoveMenu() { listMoveMenuOpen.value = false }
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 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
}
function isMobileSearchLayout() {
return window.matchMedia('(max-width: 930px)').matches
}
function openTaskSearch() {
if (!taskSearchAvailable.value) return
if (isMobileSearchLayout()) mobileSearchOpen.value = true
searchPullDistance.value = 0
void nextTick(() => searchInput.value?.focus())
}
function closeTaskSearch() {
if (!isMobileSearchLayout()) return
mobileSearchOpen.value = false
searchPullDistance.value = 0
void nextTick(() => taskSearchToggle.value?.focus())
}
function toggleTaskSearch() {
if (mobileSearchOpen.value) closeTaskSearch()
else openTaskSearch()
}
function handleTaskSearchKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isMobileSearchLayout()) return
event.preventDefault()
closeTaskSearch()
}
function startSearchPull(event: TouchEvent) {
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
const target = event.target as Element | null
if (target?.closest('input, textarea, select, button, .task-row, .habit-row, .countdown-row')) return
const main = event.currentTarget as HTMLElement
const shell = main.closest('.shell') as HTMLElement | null
if (!isAtSearchPullOrigin(main.scrollTop, shell?.scrollTop ?? 0, window.scrollY) || event.touches.length !== 1) return
searchTouchStartY = event.touches[0].clientY
searchPullDistance.value = 0
}
function moveSearchPull(event: TouchEvent) {
if (searchTouchStartY === null || event.touches.length !== 1) return
const deltaY = event.touches[0].clientY - searchTouchStartY
if (mobileSearchOpen.value) {
searchPullDistance.value = Math.min(0, deltaY)
if (deltaY < 0 && !query.value.trim()) event.preventDefault()
return
}
searchPullDistance.value = clampSearchPullDistance(deltaY)
if (deltaY > 0) event.preventDefault()
}
function finishSearchPull(event: TouchEvent) {
if (searchTouchStartY === null) return
const endY = event.changedTouches[0]?.clientY ?? searchTouchStartY
const deltaY = endY - searchTouchStartY
searchTouchStartY = null
if (!mobileSearchOpen.value && shouldRevealSearchAfterPull(deltaY)) openTaskSearch()
else if (mobileSearchOpen.value && shouldHideSearchAfterSwipe(deltaY, query.value)) {
mobileSearchOpen.value = false
searchInput.value?.blur()
}
searchPullDistance.value = 0
}
function cancelSearchPull() {
searchTouchStartY = null
searchPullDistance.value = 0
}
function handleTaskSearchShortcut(event: KeyboardEvent) {
if (!isSearchShortcut(event) || !taskSearchAvailable.value) return
event.preventDefault()
openTaskSearch()
}
function handleSearchBlur() {
if (!query.value.trim()) mobileSearchOpen.value = false
}
function handleViewportResize() {
compactLayout.value = window.innerWidth <= 930
handleArchivedListViewportChange()
}
onMounted(() => {
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
document.addEventListener('keydown', handleArchivedListEscape)
document.addEventListener('keydown', handleTaskSearchShortcut)
window.addEventListener('resize', handleViewportResize)
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
window.addEventListener('focus', handleTodayEnvironmentResume)
window.addEventListener('pageshow', handleTodayEnvironmentResume)
document.addEventListener('scroll', handleArchivedListViewportChange, true)
void bootstrap()
})
onUnmounted(() => {
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
document.removeEventListener('keydown', handleArchivedListEscape)
document.removeEventListener('keydown', handleTaskSearchShortcut)
window.removeEventListener('resize', handleViewportResize)
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
window.removeEventListener('focus', handleTodayEnvironmentResume)
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
})
</script>
<template>
<div v-if="!authReady" class="center loading-brand"><img class="loading-logo" src="/dodo-logo.svg" alt=""><span class="loader" /><span>正在打开 dodo</span></div>
<div v-else-if="!authenticated" class="auth-shell">
<section class="auth-card">
<div class="brand brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</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), 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</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>
<button :class="{ active: activeView==='memos' }" @click="switchView('memos')"><StickyNote />备忘录</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 draggable-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 draggable-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>
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
<template v-if="sidebarAction">
<template v-if="!listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-header">
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
<button class="icon" :aria-label="`关闭${sidebarAction.kind==='folders'?'文件夹':'清单'}操作`" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<section class="sidebar-action-group" aria-label="常用操作">
<span class="sidebar-action-group-title">常用操作</span>
<button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/><span>重命名</span></button>
<button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/><span>新建清单</span></button>
</section>
<section v-if="sidebarAction.kind==='lists'" class="sidebar-action-group" aria-label="整理清单">
<span class="sidebar-action-group-title">整理清单</span>
<button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')"><ChevronDown class="sidebar-action-up"/><span>上移</span></button>
<button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')"><ChevronDown/><span>下移</span></button>
<button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/><span>{{(sidebarAction.item as TaskList).folder_id?'更改所在文件夹':'移动到文件夹'}}</span><ChevronRight class="sidebar-action-chevron"/></button>
</section>
<section class="sidebar-action-danger">
<span>{{sidebarAction.kind==='folders' ? `删除后,其中 ${sidebarActionFolderListCount} 个清单会移到“我的清单”` : '任务会保留,可从“已归档”恢复'}}</span>
<button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/><span>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</span></button>
</section>
</div>
</template>
<template v-if="listMoveMenuOpen">
<header class="app-sheet__header sidebar-action-move-view">
<button class="sidebar-action-move-back" aria-label="返回清单操作" @click="closeListMoveMenu"><ChevronRight/></button>
<div><span class="sidebar-action-kind">清单位置</span><b class="sidebar-action-move-title">选择目标位置</b></div>
<button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button>
</header>
<div class="app-sheet__body sidebar-action-body">
<div class="list-move-menu" role="menu" aria-label="选择目标文件夹">
<button role="menuitem" :class="{'list-move-current':!(sidebarAction.item as TaskList).folder_id}" :disabled="!(sidebarAction.item as TaskList).folder_id" @click="moveListFromMenu(null)"><ListTodo/><span>我的清单</span><Check v-if="!(sidebarAction.item as TaskList).folder_id"/></button>
<button v-for="folder in folders" :key="folder.id" role="menuitem" :class="{'list-move-current':(sidebarAction.item as TaskList).folder_id===folder.id}" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)"><Folder/><span>{{folder.name}}</span><Check v-if="(sidebarAction.item as TaskList).folder_id===folder.id"/></button>
</div>
</div>
</template>
</template>
</AppSheet>
</aside>
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
<header class="topbar" :inert="memoBackgroundInert ? true : undefined">
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
<div class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
<CompletedFilterPill v-model="showCompleted" class="topbar-filter" />
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
</div>
<div v-if="taskSearchAvailable" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
<label id="task-search-panel" class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
</div>
</header>
<template v-if="['habits','settings'].includes(activeView)">
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
</template>
<MemoPanel v-else-if="activeView==='memos'" ref="memoPanel" :request="api" @scope="memoTrash=$event==='trash'" @detail="memoDetailOpen=$event" @notice="toast" />
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
<template v-else>
<section v-if="activeView==='today'" class="today-board" aria-labelledby="today-board-title">
<h2 id="today-board-title" class="sr-only">今日纸笺</h2>
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
<div class="today-board__progress" aria-label="今日任务与习惯进度">
<button class="today-track today-task-track" type="button" aria-controls="today-tasks" @click="navigateTodaySection('tasks')">
<span class="today-track-head"><strong>任务 {{ todayTaskCompleted }} / {{ todayTaskTotal }}</strong><span>还有 {{ todayTaskRemaining }} 项</span></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="navigateTodaySection('habits')">
<span class="today-track-head"><strong>习惯 {{ todayHabitProgress }}</strong><span>还有 {{ todayHabitRemaining }} 项</span></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>
</div>
</section>
<template v-if="activeView==='today'">
<section v-if="overdueTaskTree.length" class="overdue-section today-collapsible-section">
<button id="today-overdue-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.overdue" aria-controls="today-overdue" @click="toggleTodaySection('overdue')"><span class="today-section-title"><CalendarDays/>已过期</span><span class="today-section-summary">{{overdueTaskTree.length}} 项</span><ChevronRight v-if="todaySectionCollapse.overdue" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list overdue-list" role="region" aria-labelledby="today-overdue-heading">
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
<article :data-task-id="node.task.id" class="task-row overdue-task swipeable" :class="{'just-completed':justCompletedTaskIds.has(node.task.id),'completion-exiting':completionExitingTaskIds.has(node.task.id),ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 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 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="selectTaskUnlessSwiped(node.task)" @keydown.enter="selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div><span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span></article>
</template>
</div>
</section>
<button id="today-tasks-heading" class="today-section-toggle today-section-anchor" type="button" :aria-expanded="!todaySectionCollapse.tasks" aria-controls="today-tasks" @click="toggleTodaySection('tasks')"><span class="today-section-title"><ListTodo/>任务</span><span class="today-section-summary">{{totalTasks}} 项</span><ChevronRight v-if="todaySectionCollapse.tasks" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
</template>
<div v-if="activeView!=='today'" class="list-toolbar"><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><span class="list-toolbar-actions"><button v-if="query" class="link" @click="query=''">清除搜索</button><button v-if="taskReorderAvailable" class="soft-button reorder-mode-toggle task-reorder-toggle" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></span></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 :id="activeView==='today' ? 'today-tasks' : undefined" class="task-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : undefined">
<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),'completion-exiting':completionExitingTaskIds.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="taskReorderMode" 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" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task)"><strong :title="node.task.title">{{node.task.title}}</strong><span v-if="node.subtasks.length" class="meta"><span class="meta-item"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span><span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span></div>
<span v-if="node.task.due_at" class="task-tail"><TaskDueDisplay :due-at="node.task.due_at" :due-has-time="node.task.due_has_time" :completed="node.task.completed" :now-ms="taskDueNowMs" /></span><span v-if="activeView==='trash'" class="task-actions"><button class="restore" @click.stop="restoreTask(node.task)"><ArchiveRestore/>恢复</button><button class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button></span>
</article>
</template>
<div v-if="activeView==='today' && !query && hiddenCompletedTaskCount > 0 && !visibleTasks.length && !loading" class="today-filtered-empty-note"><span>已隐藏已完成任务</span><button type="button" class="link" @click="showCompleted=true">显示</button></div>
<div v-else-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'" class="today-habits-section today-section-anchor">
<button id="today-habits-heading" class="today-section-toggle" type="button" :aria-expanded="!todaySectionCollapse.habits" aria-controls="today-habits" @click="toggleTodaySection('habits')"><span class="today-section-title"><Repeat2/>习惯</span><span class="today-section-summary">{{todayHabitCompleted}} / {{todayHabitTotal}}</span><ChevronRight v-if="todaySectionCollapse.habits" aria-hidden="true"/><ChevronDown v-else aria-hidden="true"/></button>
<div v-show="!todaySectionCollapse.habits" id="today-habits" role="region" aria-labelledby="today-habits-heading">
<MvpPanel ref="habitComposer" view="today-habits" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
</div>
</div>
</template>
</main>
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" @close="closeTaskDetail">
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
<div class="detail-form">
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<div class="task-detail-due-row">
<span>截止时间</span>
<div class="task-detail-due-controls"><input v-model="selectedDueDate" class="task-detail-due-input task-detail-field-input" type="date" aria-label="截止日期"><button v-if="selectedDueDate" class="task-compose-date-clear" type="button" aria-label="清除截止日期" @click="clearSelectedDueDate"><X/></button><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip"><span>时间</span><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label></div>
</div>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<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></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
<div class="field markdown">
<div class="field-label"><span>任务备注</span><span class="markdown-mode-switch"><button type="button" :aria-pressed="!markdownPreview" :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button type="button" :aria-pressed="markdownPreview" :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div>
<div v-if="!markdownPreview" class="markdown-editor-shell">
<div class="markdown-toolbar" role="toolbar" aria-label="Markdown 格式">
<button type="button" aria-label="标题" title="标题" @click="formatTaskNote('heading')"><Heading2/></button>
<button type="button" aria-label="粗体" title="粗体" @click="formatTaskNote('bold')"><Bold/></button>
<button type="button" aria-label="斜体" title="斜体" @click="formatTaskNote('italic')"><Italic/></button>
<button type="button" aria-label="无序列表" title="无序列表" @click="formatTaskNote('bullet')"><List/></button>
<button type="button" aria-label="有序列表" title="有序列表" @click="formatTaskNote('ordered')"><ListOrdered/></button>
<button type="button" aria-label="待办" title="待办" @click="formatTaskNote('task')"><ListChecks/></button>
<button type="button" aria-label="链接" title="链接" @click="formatTaskNote('link')"><Link/></button>
<button type="button" aria-label="行内代码" title="行内代码" @click="formatTaskNote('code')"><Code/></button>
<button type="button" aria-label="代码块" title="代码块" class="markdown-codeblock" @click="formatTaskNote('codeblock')">{ }</button>
<button type="button" aria-label="引用" title="引用" @click="formatTaskNote('quote')"><Quote/></button>
</div>
<textarea ref="taskNoteEditor" v-model="selectedTask.description" rows="9" placeholder="写备注选中文字后可用上方工具栏添加格式" @keydown="handleTaskNoteShortcut"/>
</div>
<div v-else class="markdown-preview" :class="{'markdown-preview-empty':!selectedTask.description.trim()}" v-html="selectedTask.description.trim() ? renderMarkdown(selectedTask.description) : '<p>暂无备注,切回编辑开始书写。</p>'"/>
</div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><div v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail"><button class="task-check subtask-check" type="button" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><span :class="{strike:subtask.completed}">{{subtask.title}}</span></div><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"><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" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
</div>
</AppSheet>
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" 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','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
<header class="app-sheet__header"><div><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="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数"> 天重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
<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>
</AppSheet>
<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>
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
<template v-if="purgeListTarget">
<div class="app-sheet__body">
<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>
<footer class="app-sheet__footer"><button class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></footer>
</template>
</AppSheet>
<AppDialog ref="appDialog" />
</div>
</template>