1836 lines
116 KiB
Vue
1836 lines
116 KiB
Vue
<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, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, 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'
|
||
import { loadTaskOpenTotal } from './lib/task-open-total'
|
||
|
||
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 taskSelectionGeneration = ref(0)
|
||
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 taskOpenTotal = ref<number | null>(null)
|
||
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 taskComposeGeneration = ref(0)
|
||
const creatingTask = 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 removingTaskId = ref<string | null>(null)
|
||
const addingSubtask = ref(false)
|
||
const removingSubtaskId = ref<string | null>(null)
|
||
const taskDetailBusy = computed(() => savingSelectedTask.value || recurrenceLoading.value || removingTaskId.value !== null || addingSubtask.value || removingSubtaskId.value !== null)
|
||
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
|
||
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
|
||
let recurrenceLoadToken = 0
|
||
let todaySummaryLoadToken = 0
|
||
let taskOpenTotalLoadToken = 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() {
|
||
taskComposeGeneration.value += 1
|
||
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() {
|
||
if (creatingTask.value) return
|
||
taskComposeGeneration.value += 1
|
||
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
|
||
const selectionGeneration = taskSelectionGeneration.value
|
||
const selectionIsCurrent = () => token === recurrenceLoadToken && taskSelectionGeneration.value === selectionGeneration && selectedTask.value?.id === task.id
|
||
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 (!selectionIsCurrent()) 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 (selectionIsCurrent()) fail(reason)
|
||
} finally {
|
||
if (selectionIsCurrent()) recurrenceLoading.value = false
|
||
}
|
||
}
|
||
async function submitTaskCompose() {
|
||
if (creatingTask.value) return
|
||
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
|
||
const composeGeneration = taskComposeGeneration.value
|
||
const targetListId = composeListId.value
|
||
creatingTask.value = true
|
||
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('请先设置截止时间')
|
||
await taskMutationReconciler.run(
|
||
() => api('/tasks', { method: 'POST', body: JSON.stringify({
|
||
title: taskTitle,
|
||
list_id: targetListId,
|
||
due_at: fromDateTimeLocal(dueValue),
|
||
due_has_time: composeHasTime.value,
|
||
priority: composePriority.value,
|
||
description: composeDescription.value,
|
||
...recurrencePayload,
|
||
}) }) as Promise<Task>,
|
||
() => {
|
||
if (taskComposeGeneration.value === composeGeneration) taskComposeOpen.value = false
|
||
toast('任务已添加')
|
||
},
|
||
(reason) => {
|
||
if (taskComposeGeneration.value !== composeGeneration) return
|
||
composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'
|
||
fail(reason)
|
||
},
|
||
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
|
||
)
|
||
} catch (reason) {
|
||
if (taskComposeGeneration.value === composeGeneration) {
|
||
composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'
|
||
fail(reason)
|
||
}
|
||
} finally { creatingTask.value = false }
|
||
}
|
||
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 todayTaskRemaining = computed(() => Math.max(0, todayTaskTotal.value - todayTaskCompleted.value))
|
||
const todayHabitRemaining = computed(() => Math.max(0, todayHabitTotal.value - todayHabitCompleted.value))
|
||
function persistTodaySectionCollapse() {
|
||
writeTodaySectionCollapse(window.localStorage, TODAY_SECTION_COLLAPSE_KEY, todaySectionCollapse.value)
|
||
}
|
||
function toggleTodaySection(section: keyof TodaySectionCollapse) {
|
||
todaySectionCollapse.value[section] = !todaySectionCollapse.value[section]
|
||
persistTodaySectionCollapse()
|
||
}
|
||
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
|
||
if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value
|
||
if (activeView.value === 'tasks' && (showCompleted.value || query.value)) {
|
||
const token = ++taskOpenTotalLoadToken
|
||
const listId = activeList.value
|
||
const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' })
|
||
if (listId) openParams.set('list_id', listId)
|
||
loadTaskOpenTotal(
|
||
() => api(`/tasks?${openParams}`),
|
||
() => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === 'tasks' && activeList.value === listId,
|
||
value => { taskOpenTotal.value = value },
|
||
)
|
||
}
|
||
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')
|
||
if (activeView.value === 'tasks') {
|
||
taskOpenTotal.value = null
|
||
++taskOpenTotalLoadToken
|
||
}
|
||
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 === 'settings') await habitComposer.value?.refreshSettings()
|
||
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
|
||
taskCompletionExitCoordinator.clearAll()
|
||
taskReorderMode.value = false
|
||
cancelTaskReorder()
|
||
if (view !== 'today') {
|
||
++todayEnvironmentLoadToken
|
||
todayEnvironmentLoading.value = false
|
||
}
|
||
activeView.value = view
|
||
taskOpenTotal.value = null
|
||
++taskOpenTotalLoadToken
|
||
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; taskSelectionGeneration.value += 1; mobileSidebar.value = false; mobileDetail.value = false; taskComposeGeneration.value += 1; 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 result: { value?: Task } = {}
|
||
let mutationError: unknown
|
||
await taskMutationReconciler.run(
|
||
() => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(taskVersionedPatchPayload(task, patch)) }) as Promise<Task>,
|
||
(updated) => {
|
||
result.value = updated
|
||
applyTaskUpdate(task, updated, updateSelected)
|
||
},
|
||
(reason) => { mutationError = reason },
|
||
)
|
||
if (mutationError) throw mutationError
|
||
return result.value ?? false
|
||
}
|
||
const taskMutationNavigation = ref(0)
|
||
type TaskViewContext = { navigation: number; view: View; listId: string; page: number; query: string; showCompleted: boolean }
|
||
const currentTaskViewContext = (): TaskViewContext => ({ navigation: taskMutationNavigation.value, view: activeView.value, listId: activeList.value, page: page.value, query: query.value, showCompleted: showCompleted.value })
|
||
const sameTaskViewContext = (left: TaskViewContext, right: TaskViewContext) => left.navigation === right.navigation && left.view === right.view && left.listId === right.listId && left.page === right.page && left.query === right.query && left.showCompleted === right.showCompleted
|
||
async function reconcileCurrentViewAfterTaskMutation(options: { affectsTrash?: boolean; affectsTaskView?: boolean } = {}) {
|
||
await reconcileCurrentTaskView({
|
||
capture: currentTaskViewContext,
|
||
isTaskBacked: context => isTaskView(context.view),
|
||
isTrash: context => context.view === 'trash',
|
||
sameContext: sameTaskViewContext,
|
||
affectsTrash: options.affectsTrash,
|
||
affectsTaskView: options.affectsTaskView,
|
||
loadTrash: async (ownership) => {
|
||
if (ownership.current()) await loadTrash()
|
||
},
|
||
loadTaskView: async (context, ownership) => {
|
||
const request = beginLatestRequest('tasks')
|
||
loading.value = true
|
||
error.value = ''
|
||
try {
|
||
if (context.view === 'today') {
|
||
await startPrimaryWithBackground(
|
||
[() => loadTasksPage(request), () => loadOverdueTasks(request)],
|
||
loadTodayTaskSummary,
|
||
)
|
||
} else {
|
||
await loadTasksPage(request)
|
||
if (ownership.current() && isLatestRequest('tasks', request)) overdueTasks.value = []
|
||
}
|
||
if (ownership.current() && isLatestRequest('tasks', request) && page.value > totalPages.value) {
|
||
page.value = totalPages.value
|
||
await loadTasksPage(request)
|
||
}
|
||
} catch (reason) {
|
||
if (ownership.current() && isLatestRequest('tasks', request)) fail(reason)
|
||
} finally {
|
||
if (ownership.current() && isLatestRequest('tasks', request)) loading.value = false
|
||
}
|
||
},
|
||
})
|
||
}
|
||
const taskMutationReconciler = createMutationReconciler(
|
||
currentTaskViewContext,
|
||
sameTaskViewContext,
|
||
reconcileCurrentViewAfterTaskMutation,
|
||
)
|
||
const taskToggleCoordinator = createTaskToggleCoordinator<Task>()
|
||
const taskCompletionExitCoordinator = createTaskCompletionExitCoordinator(setTaskCompletionExiting)
|
||
async function toggle(task: Task) {
|
||
taskCompletionExitCoordinator.supersede(task.id)
|
||
const preserveSelectedDraft = selectedTask.value?.id === task.id
|
||
const navigation = taskMutationNavigation.value
|
||
const view = activeView.value
|
||
const listId = activeList.value
|
||
const selectionGeneration = taskSelectionGeneration.value
|
||
const isCurrentToggle = () => taskMutationNavigation.value === navigation && activeView.value === view && activeList.value === listId
|
||
await taskToggleCoordinator.toggle(
|
||
task.id,
|
||
task,
|
||
(payload) => api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify(payload) }) as Promise<Task>,
|
||
{
|
||
current: isCurrentToggle,
|
||
beforeReconcile: async (updated, ownership) => {
|
||
if (!ownership.current()) return
|
||
const completing = updated.completed
|
||
const animateExit = shouldAnimateCompletionExit({ completing, showCompleted: showCompleted.value })
|
||
const exitOwnership = taskCompletionExitCoordinator.begin(task.id, animateExit)
|
||
applyTaskUpdate(task, updated, !preserveSelectedDraft)
|
||
if (preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
|
||
selectedTask.value = mergeTaskToggleResponse(selectedTask.value, updated)
|
||
}
|
||
if (completing) markTaskJustCompleted(task.id)
|
||
await taskCompletionExitCoordinator.wait(task.id, exitOwnership, waitForCompletionExit)
|
||
},
|
||
reconcile: async (confirmed, ownership) => {
|
||
await reconcileCurrentViewAfterTaskMutation()
|
||
if (ownership.current() && preserveSelectedDraft && selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
|
||
selectedTask.value = mergeTaskToggleResponse(selectedTask.value, confirmed)
|
||
}
|
||
},
|
||
success: async (updated, ownership) => {
|
||
const completing = updated.completed
|
||
if (ownership.current()) toast(completing ? '完成啦' : '已重新打开')
|
||
},
|
||
error: (_confirmed, reason, ownership) => { if (ownership.current()) fail(reason) },
|
||
},
|
||
)
|
||
}
|
||
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)
|
||
}
|
||
await taskMutationReconciler.run(
|
||
() => api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) }),
|
||
() => toast('顺序已保存'),
|
||
(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)
|
||
if (!updated) return 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) {
|
||
const selectionMatches = options?.expectedSelectionToken === undefined || recurrenceLoadToken === options.expectedSelectionToken
|
||
if (selectionMatches && selectedTask.value?.id === task.id) 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 (taskDetailBusy.value || removingTaskId.value === task.id) return
|
||
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
|
||
if (taskDetailBusy.value || selectedTask.value?.id !== task.id) return
|
||
const selectionGeneration = taskSelectionGeneration.value
|
||
removingTaskId.value = task.id
|
||
try {
|
||
await taskMutationReconciler.run(
|
||
() => api(`/tasks/${task.id}`, { method: 'DELETE' }),
|
||
() => {
|
||
if (selectedTask.value?.id === task.id && taskSelectionGeneration.value === selectionGeneration) {
|
||
selectedTask.value = null
|
||
taskSelectionGeneration.value += 1
|
||
mobileDetail.value = false
|
||
}
|
||
toast('已移到回收站')
|
||
},
|
||
fail,
|
||
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
|
||
{ affectsTrash: true },
|
||
)
|
||
} finally { if (removingTaskId.value === task.id) removingTaskId.value = null }
|
||
}
|
||
async function mutateTrashTask(task: Task, mutation: () => Promise<unknown>, successMessage: string, affectsTaskView: boolean) {
|
||
await taskMutationReconciler.run(
|
||
mutation,
|
||
() => toast(successMessage),
|
||
fail,
|
||
undefined,
|
||
{ affectsTrash: true, affectsTaskView },
|
||
)
|
||
}
|
||
async function restoreTask(task: Task) {
|
||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复', true)
|
||
}
|
||
async function purgeTask(task: Task) {
|
||
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
|
||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除', false)
|
||
}
|
||
async function addSubtask() {
|
||
if (!selectedTask.value || addingSubtask.value) return
|
||
const parent = selectedTask.value
|
||
const parentId = parent.id
|
||
const parentListId = parent.list_id
|
||
const selectionGeneration = taskSelectionGeneration.value
|
||
const subtaskTitle = (await askText('添加子任务', '子任务名称', '', '添加'))?.trim()
|
||
if (!subtaskTitle || addingSubtask.value) return
|
||
addingSubtask.value = true
|
||
try {
|
||
await taskMutationReconciler.run(
|
||
() => api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: parentListId, parent_id: parentId }) }) as Promise<Task>,
|
||
(child) => {
|
||
if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) {
|
||
selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]
|
||
}
|
||
toast('子任务已添加')
|
||
},
|
||
(reason) => {
|
||
if (selectedTask.value?.id === parentId && taskSelectionGeneration.value === selectionGeneration) fail(reason)
|
||
},
|
||
() => { if (activeView.value === 'today') void loadTodayTaskSummary() },
|
||
)
|
||
} finally {
|
||
addingSubtask.value = false
|
||
}
|
||
}
|
||
async function removeSubtask(subtask: Task) {
|
||
if (taskDetailBusy.value) return
|
||
const parent = selectedTask.value
|
||
if (!parent || subtask.parent_id !== parent.id) return
|
||
if (!(await confirmAction(`删除子任务“${subtask.title}”?`, '子任务将移到回收站。', true))) return
|
||
if (taskDetailBusy.value || selectedTask.value?.id !== parent.id) return
|
||
const selectionGeneration = taskSelectionGeneration.value
|
||
removingSubtaskId.value = subtask.id
|
||
try {
|
||
await taskMutationReconciler.run(
|
||
() => api(`/tasks/${subtask.id}`, { method: 'DELETE' }),
|
||
() => {
|
||
if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) {
|
||
selectedTask.value.subtasks = (selectedTask.value.subtasks ?? []).filter((item) => item.id !== subtask.id)
|
||
}
|
||
toast('子任务已删除')
|
||
},
|
||
(reason) => {
|
||
if (selectedTask.value?.id === parent.id && taskSelectionGeneration.value === selectionGeneration) fail(reason)
|
||
},
|
||
undefined,
|
||
{ affectsTaskView: true },
|
||
)
|
||
} finally { if (removingSubtaskId.value === subtask.id) removingSubtaskId.value = null }
|
||
}
|
||
function closeTaskDetail() {
|
||
if (taskDetailBusy.value) return
|
||
recurrenceLoadToken += 1
|
||
taskSelectionGeneration.value += 1
|
||
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) {
|
||
taskSelectionGeneration.value += 1
|
||
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="{ 'today-active': activeView==='today', '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)"><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)"><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 :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
|
||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||
<div v-if="!['today','tasks','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
|
||
<CompletedFilterPill v-if="activeView==='upcoming'" 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>
|
||
<button v-if="activeView==='settings'" class="icon topbar-refresh settings-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新设置" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||
<div v-if="taskSearchAvailable && activeView!=='tasks'" 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" @update:show-completed="showCompleted=$event" @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-context" aria-label="今日概览">
|
||
<TodayEnvironmentStrip :environment="todayEnvironment" :loading="todayEnvironmentLoading" :failed="todayEnvironmentError" />
|
||
<div class="today-heading">
|
||
<div><h1 class="today-page-title">今天</h1><p class="today-remaining">还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成</p></div>
|
||
<CompletedFilterPill v-model="showCompleted" class="today-inline-filter" />
|
||
</div>
|
||
</section>
|
||
<section v-if="activeView==='tasks'" class="list-page-context">
|
||
<div><h1 class="list-page-title" :title="activeName">{{ activeName }}</h1><p class="list-page-summary">{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}</p></div>
|
||
<CompletedFilterPill v-model="showCompleted" class="list-inline-filter" />
|
||
</section>
|
||
<div v-if="activeView==='tasks'" class="list-search-reveal" :class="{'mobile-search-open':mobileSearchOpen}">
|
||
<label id="task-list-search-panel" class="list-search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @keydown="handleTaskSearchKeydown"><kbd>⌘ K</kbd></label>
|
||
<button v-if="query" class="list-search-clear" type="button" @click="query=''">清除搜索</button>
|
||
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-list-search-panel" @click="toggleTaskSearch"><Search/></button>
|
||
</div>
|
||
<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">逾期</span><span class="today-section-summary">{{overdueTaskTree.length}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.overdue ? '›' : '⌄' }}</span></button>
|
||
<div v-show="!todaySectionCollapse.overdue" id="today-overdue" class="task-list plain-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">今天</span><span class="today-section-summary">{{totalTasks}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.tasks ? '›' : '⌄' }}</span></button>
|
||
</template>
|
||
<div v-if="activeView==='tasks'" id="task-list-heading" class="list-section-heading"><span id="task-list-title" class="list-section-title">任务</span><span class="list-section-count">{{ totalTasks }}</span><button v-if="taskReorderAvailable" class="list-section-action" type="button" :aria-pressed="taskReorderMode" @click="taskReorderMode=!taskReorderMode;cancelTaskReorder()">{{ taskReorderMode ? '完成' : '调整顺序' }}</button></div>
|
||
<div v-if="activeView!=='today' && activeView!=='tasks'" 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==='tasks' && totalPages > 1" class="list-page-meta"><span>第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项</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 plain-list" :class="{loading}" v-show="activeView!=='today' || !todaySectionCollapse.tasks" :role="activeView==='today' ? 'region' : undefined" :aria-labelledby="activeView==='today' ? 'today-tasks-heading' : activeView==='tasks' ? 'task-list-title' : 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,'task-row--trash':activeView==='trash','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="activeView==='trash' ? undefined : 'button'" :tabindex="activeView==='trash' ? undefined : 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></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">习惯</span><span class="today-section-summary">{{todayHabitTotal}}</span><span class="today-section-chevron" aria-hidden="true">{{ todaySectionCollapse.habits ? '›' : '⌄' }}</span></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" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
|
||
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||
<fieldset class="detail-form" :disabled="taskDetailBusy">
|
||
<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>
|
||
<section class="task-detail-arrangement" aria-label="安排">
|
||
<label class="task-detail-field"><span class="task-detail-field-label">清单</span><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-date-time">
|
||
<label class="task-detail-field"><span class="task-detail-field-label">截止日期</span><span class="task-detail-date-control"><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.prevent="clearSelectedDueDate"><X/></button></span></label>
|
||
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><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><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
|
||
</div>
|
||
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><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>
|
||
</section>
|
||
<section class="field markdown task-detail-notes">
|
||
<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>'"/>
|
||
</section>
|
||
<section class="subtasks task-detail-subtasks"><div class="field-label"><span>子任务</span><button class="link" type="button" :disabled="taskDetailBusy" @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" :disabled="taskDetailBusy" :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><button class="subtask-remove" type="button" :disabled="taskDetailBusy" :aria-label="`删除子任务${subtask.title}`" @click="removeSubtask(subtask)"><Trash2/></button></div><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></section>
|
||
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body"><label class="task-detail-field"><span class="task-detail-field-label">优先级</span><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>
|
||
</fieldset>
|
||
<footer class="detail-actions"><button class="danger-text detail-trash" type="button" :disabled="taskDetailBusy" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button><button class="primary detail-save" type="submit" :disabled="taskDetailBusy">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button></footer>
|
||
</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" :disabled="creatingTask" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="creatingTask || !composeTitle.trim() || !composeListId">{{ creatingTask ? '正在添加…' : '添加任务' }}</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>
|