830 lines
56 KiB
Vue
830 lines
56 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||
import {
|
||
ArchiveRestore, CalendarDays, CalendarHeart, Check, ChevronDown, ChevronRight, Folder,
|
||
GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
|
||
Settings, Trash2, X, Repeat2,
|
||
} from 'lucide-vue-next'
|
||
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
|
||
import { isTaskView, nextTotalAfterLocalTaskAdd, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, writeCountdownCache, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
|
||
import { csrfHeader } from './lib/csrf'
|
||
import MvpPanel from './MvpPanel.vue'
|
||
import CountdownPanel from './CountdownPanel.vue'
|
||
import FloatingAddButton from './components/FloatingAddButton.vue'
|
||
|
||
type FolderItem = { id: string; name: string }
|
||
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
|
||
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
|
||
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
|
||
type Recurrence = { id: string; task_id: string; rrule: string }
|
||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||
|
||
const initialized = ref<boolean | null>(null)
|
||
const authReady = ref(false)
|
||
const authenticated = ref(false)
|
||
const username = ref('')
|
||
const password = ref('')
|
||
const folders = ref<FolderItem[]>([])
|
||
const lists = ref<TaskList[]>([])
|
||
const archivedLists = ref<TaskList[]>([])
|
||
const 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 query = ref('')
|
||
const error = ref('')
|
||
const notice = ref('')
|
||
const loading = ref(false)
|
||
const mobileSidebar = ref(false)
|
||
const sidebarCollapsed = ref(false)
|
||
const mobileDetail = ref(false)
|
||
const mobileMore = ref(false)
|
||
const moreSettingsOpen = ref(false)
|
||
const markdownPreview = ref(false)
|
||
const SHOW_COMPLETED_STORAGE_KEY = 'dodo.show-completed'
|
||
const showCompleted = ref(readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true))
|
||
const page = ref(1)
|
||
const pageSize = 50
|
||
const totalTasks = ref(0)
|
||
const todayHabitTotal = ref(0)
|
||
const todayHabitCompleted = ref(0)
|
||
const totalPages = computed(() => Math.max(1, Math.ceil(totalTasks.value / pageSize)))
|
||
const expandedFolders = ref(new Set<string>())
|
||
const collapsedTaskIds = ref(new Set<string>())
|
||
const navigationLoaded = ref(false)
|
||
const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||
const taskPointerStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||
const taskSwipeOffsets = ref<Record<string, number>>({})
|
||
const taskReorder = ref<{ id: string; startY: number; offsetY: number } | null>(null)
|
||
const taskReorderTarget = ref('')
|
||
const taskComposeOpen = ref(false)
|
||
const composeTitle = ref('')
|
||
const composeListId = ref('')
|
||
const composeDueAt = ref('')
|
||
const composeHasTime = ref(false)
|
||
const composeTime = ref('12:00')
|
||
const composePriority = ref(0)
|
||
const composeDescription = ref('')
|
||
const composeRepeat = ref<RepeatOption>('none')
|
||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
|
||
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||
const selectedRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
|
||
const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }]
|
||
let recurrenceLoadToken = 0
|
||
const habitComposer = ref<InstanceType<typeof MvpPanel> | null>(null)
|
||
const countdownComposer = ref<InstanceType<typeof CountdownPanel> | null>(null)
|
||
const composeOrigin = ref({ x: window.innerWidth - 43, y: window.innerHeight - 104 })
|
||
let suppressTaskClickId = ''
|
||
|
||
const taskComposeStyle = computed(() => ({ '--fab-origin-x': `${composeOrigin.value.x}px`, '--fab-origin-y': `${composeOrigin.value.y}px` }))
|
||
|
||
function openTaskCompose() {
|
||
const inboxId = lists.value.find((item) => item.is_inbox)?.id || activeList.value
|
||
composeTitle.value = ''
|
||
composeListId.value = activeView.value === 'tasks' && activeList.value ? activeList.value : inboxId
|
||
composeDueAt.value = defaultTaskDueAt()
|
||
composeHasTime.value = false
|
||
composeTime.value = '12:00'
|
||
composePriority.value = 0
|
||
composeDescription.value = ''
|
||
composeRepeat.value = 'none'
|
||
composeRepeatConfig.value = defaultRepeatConfig()
|
||
taskComposeOpen.value = true
|
||
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
|
||
}
|
||
const taskComposeTitle = computed(() => activeView.value === 'today' ? '添加今天任务' : '添加任务')
|
||
function closeTaskCompose() { taskComposeOpen.value = false }
|
||
function activateFloatingAdd(origin: { x: number; y: number }) {
|
||
composeOrigin.value = origin
|
||
if (activeView.value === 'habits') habitComposer.value?.openHabitComposer(origin)
|
||
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
|
||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||
}
|
||
function repeatRrule(value: RepeatOption, config: TaskRepeatConfig) {
|
||
if (value === 'none') return ''
|
||
if (value === 'custom') return buildTaskRrule(config)
|
||
return `FREQ=${value.toUpperCase()}`
|
||
}
|
||
function repeatOption(rrule?: string): RepeatOption {
|
||
if (!rrule) return 'none'
|
||
const parsed = parseTaskRrule(rrule)
|
||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||
return simple ? parsed.frequency : 'custom'
|
||
}
|
||
async function saveRepeat(task: Task, value: RepeatOption, config = selectedRepeatConfig.value) {
|
||
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
|
||
if (value === 'none') {
|
||
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
|
||
selectedTaskRecurrence.value = null
|
||
selectedTaskRepeat.value = 'none'
|
||
return
|
||
}
|
||
const rrule = repeatRrule(value, config)
|
||
if (selectedTaskRecurrence.value) {
|
||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
|
||
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
|
||
} else {
|
||
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
|
||
}
|
||
selectedTaskRepeat.value = value
|
||
}
|
||
async function loadTaskRecurrence(task: Task) {
|
||
const token = ++recurrenceLoadToken
|
||
selectedTaskRecurrence.value = null
|
||
selectedTaskRepeat.value = 'none'
|
||
try {
|
||
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
|
||
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
|
||
selectedTaskRecurrence.value = recurrence
|
||
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
|
||
selectedRepeatConfig.value = recurrence ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
|
||
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
|
||
}
|
||
async function updateSelectedTaskRepeat() {
|
||
if (!selectedTask.value) return
|
||
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
|
||
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
|
||
}
|
||
|
||
async function submitTaskCompose() {
|
||
const taskTitle = composeTitle.value.trim()
|
||
if (!taskTitle || !composeListId.value) return
|
||
try {
|
||
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
|
||
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
|
||
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
|
||
title: taskTitle,
|
||
list_id: composeListId.value,
|
||
due_at: fromDateTimeLocal(dueValue),
|
||
due_has_time: composeHasTime.value,
|
||
priority: composePriority.value,
|
||
description: composeDescription.value,
|
||
rrule,
|
||
}) })
|
||
if (isTaskView(activeView.value)) {
|
||
tasks.value.push(task)
|
||
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
||
}
|
||
taskComposeOpen.value = false
|
||
toast('任务已添加')
|
||
} catch (reason) { fail(reason) }
|
||
}
|
||
function toggleSidebar() {
|
||
const compact = window.matchMedia('(max-width: 930px)').matches
|
||
if (compact) {
|
||
mobileSidebar.value = !mobileSidebar.value
|
||
} else {
|
||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||
}
|
||
}
|
||
|
||
const modalVisible = ref(false)
|
||
const modalTitle = ref('')
|
||
const modalLabel = ref('')
|
||
const modalValue = ref('')
|
||
const modalConfirmText = ref('确定')
|
||
const modalResolve = ref<((value: string | null) => void) | null>(null)
|
||
function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||
return new Promise<string | null>((resolve) => {
|
||
modalTitle.value = title
|
||
modalLabel.value = label
|
||
modalValue.value = initial
|
||
modalConfirmText.value = confirmText
|
||
modalVisible.value = true
|
||
modalResolve.value = resolve
|
||
})
|
||
}
|
||
function closeModal() {
|
||
modalVisible.value = false
|
||
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
|
||
}
|
||
function confirmModal() {
|
||
modalVisible.value = false
|
||
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
|
||
}
|
||
|
||
const activeName = computed(() => {
|
||
if (activeView.value === 'trash') return '回收站'
|
||
if (activeView.value === 'today') return '今天'
|
||
if (activeView.value === 'upcoming') return '最近 7 天'
|
||
if (activeView.value === 'habits') return '习惯'
|
||
if (activeView.value === 'countdowns') return '倒数日'
|
||
if (activeView.value === 'settings') return '设置与数据'
|
||
return lists.value.find((item) => item.id === activeList.value)?.name || '收集箱'
|
||
})
|
||
const todayCompletedTasks = computed(() => activeView.value === 'today' ? tasks.value.filter((task) => task.completed).length : 0)
|
||
const todayOpenTasks = computed(() => activeView.value === 'today' ? Math.max(totalTasks.value - todayCompletedTasks.value, 0) : 0)
|
||
const todayHabitProgress = computed(() => `${todayHabitCompleted.value} / ${todayHabitTotal.value}`)
|
||
function updateTodayHabitSummary(value: { total: number; completed: number }) {
|
||
todayHabitTotal.value = value.total
|
||
todayHabitCompleted.value = value.completed
|
||
}
|
||
const todayProgressText = computed(() => {
|
||
if (activeView.value !== 'today') return ''
|
||
if (!totalTasks.value) return '今天还没有任务'
|
||
if (!todayOpenTasks.value) return '今天任务都清掉了'
|
||
return `还有 ${todayOpenTasks.value} 件事要处理`
|
||
})
|
||
const sourceTasks = computed(() => activeView.value === 'trash' ? trash.value : tasks.value)
|
||
const visibleTasks = computed(() => {
|
||
const now = new Date()
|
||
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||
let result = sourceTasks.value
|
||
if (['habits','settings','countdowns'].includes(activeView.value)) return []
|
||
if (activeView.value === 'today') result = result.filter((task) => task.due_at && new Date(task.due_at).toDateString() === now.toDateString())
|
||
if (activeView.value === 'upcoming') result = result.filter((task) => task.due_at && new Date(task.due_at) >= now && new Date(task.due_at) <= end)
|
||
return query.value.trim() ? filterTasks(result, query.value) : result
|
||
})
|
||
const selectedTaskSubtasks = computed(() => selectedTask.value?.subtasks ?? [])
|
||
const filteredTaskTree = computed(() => groupTaskTree(visibleTasks.value))
|
||
const taskTree = computed(() => filteredTaskTree.value)
|
||
const overdueTaskTree = computed(() => groupTaskTree(overdueTasks.value))
|
||
let searchTimer: number | undefined
|
||
watch(query, () => {
|
||
if (searchTimer) window.clearTimeout(searchTimer)
|
||
if (!isTaskView(activeView.value)) return
|
||
page.value = 1
|
||
searchTimer = window.setTimeout(() => loadAll(), 250)
|
||
})
|
||
watch(showCompleted, (value) => {
|
||
writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)
|
||
if (isTaskView(activeView.value)) { page.value = 1; loadAll() }
|
||
})
|
||
|
||
async function api(path: string, options: RequestInit = {}) {
|
||
const headers = new Headers(options.headers || {})
|
||
if (!headers.has('Content-Type') && options.body && !(options.body instanceof FormData)) {
|
||
headers.set('Content-Type', 'application/json')
|
||
}
|
||
const csrf = csrfHeader(options.method)
|
||
if (csrf['x-csrf-token']) headers.set('x-csrf-token', csrf['x-csrf-token'])
|
||
const response = await fetch('/api/v1' + path, {
|
||
credentials: 'include',
|
||
headers,
|
||
...options,
|
||
})
|
||
if (!response.ok) {
|
||
let message = '请求失败'
|
||
try { const body = await response.json(); message = typeof body.detail === 'string' ? body.detail : message } catch { /* noop */ }
|
||
throw new Error(message)
|
||
}
|
||
return response.status === 204 ? null : response.json()
|
||
}
|
||
|
||
function toast(message: string) {
|
||
notice.value = message
|
||
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
|
||
}
|
||
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
|
||
|
||
function restoreNavigation(inboxId: string) {
|
||
if (restoredNavigation.view === 'tasks' && restoredNavigation.listId) {
|
||
activeList.value = lists.value.some((item) => item.id === restoredNavigation.listId) ? restoredNavigation.listId : inboxId
|
||
} else {
|
||
activeList.value = inboxId
|
||
}
|
||
}
|
||
|
||
async function loadRestoredView() {
|
||
if (activeView.value === 'trash') await loadTrash()
|
||
else if (isTaskView(activeView.value)) await loadAll()
|
||
else { tasks.value = []; totalTasks.value = 0 }
|
||
}
|
||
|
||
async function bootstrap() {
|
||
authReady.value = false
|
||
try {
|
||
const status = await api('/setup/status')
|
||
initialized.value = status.initialized
|
||
if (status.initialized) {
|
||
const data = await api('/bootstrap')
|
||
authenticated.value = true
|
||
folders.value = data.folders ?? []
|
||
lists.value = data.lists ?? []
|
||
navigationLoaded.value = true
|
||
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
|
||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||
await loadArchivedLists()
|
||
void preloadCountdowns()
|
||
await loadRestoredView()
|
||
}
|
||
} catch {
|
||
authenticated.value = false
|
||
initialized.value ??= true
|
||
} finally {
|
||
authReady.value = true
|
||
}
|
||
}
|
||
async function submitAuth() {
|
||
error.value = ''
|
||
try {
|
||
const path = initialized.value ? '/auth/login' : '/setup/initialize'
|
||
await api(path, { method: 'POST', body: JSON.stringify({ username: username.value, password: password.value }) })
|
||
initialized.value = true; authenticated.value = true
|
||
const data = await api('/bootstrap')
|
||
folders.value = data.folders ?? []
|
||
lists.value = data.lists ?? []
|
||
navigationLoaded.value = true
|
||
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
|
||
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
|
||
void preloadCountdowns()
|
||
await loadRestoredView()
|
||
} catch (reason) { fail(reason) }
|
||
}
|
||
async function loadTaskPages(path: string) {
|
||
const rows: Task[] = []
|
||
let cursor: string | null = null
|
||
do {
|
||
const separator = path.includes('?') ? '&' : '?'
|
||
const data = await api(cursor ? `${path}${separator}cursor=${encodeURIComponent(cursor)}` : path)
|
||
rows.push(...(data.items ?? data))
|
||
cursor = data.next_cursor ?? null
|
||
} while (cursor)
|
||
return rows
|
||
}
|
||
function isoAtLocalDayOffset(offset: number) {
|
||
const day = new Date()
|
||
day.setDate(day.getDate() + offset)
|
||
day.setHours(0, 0, 0, 0)
|
||
return day.toISOString()
|
||
}
|
||
async function loadOverdueTasks() {
|
||
const params = new URLSearchParams()
|
||
params.set('due_to', isoAtLocalDayOffset(0))
|
||
params.set('completed', 'false')
|
||
overdueTasks.value = await loadTaskPages(`/tasks?${params}`)
|
||
}
|
||
async function loadTasksPage() {
|
||
const params = new URLSearchParams({ page: String(page.value), page_size: String(pageSize) })
|
||
if (query.value) params.set('q', query.value)
|
||
else if (activeView.value === 'tasks' && activeList.value) params.set('list_id', activeList.value)
|
||
if (activeView.value === 'today') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(1)) }
|
||
if (activeView.value === 'upcoming') { params.set('due_from', isoAtLocalDayOffset(0)); params.set('due_to', isoAtLocalDayOffset(8)) }
|
||
if (!showCompleted.value && activeView.value !== 'trash') params.set('completed', 'false')
|
||
const data = await api(`/tasks?${params}`)
|
||
tasks.value = data.items ?? []
|
||
totalTasks.value = data.total ?? tasks.value.length
|
||
}
|
||
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 {
|
||
const active = await api('/countdowns')
|
||
writeCountdownCache(active ?? [], [])
|
||
const archived = await api('/countdowns?archived=true')
|
||
writeCountdownCache(active ?? [], archived ?? [])
|
||
} catch { /* 倒数日页会在打开时重试 */ }
|
||
}
|
||
async function loadAll() {
|
||
loading.value = true; error.value = ''
|
||
try {
|
||
if (!navigationLoaded.value) await loadNavigation()
|
||
await loadTasksPage()
|
||
if (activeView.value === 'today') await loadOverdueTasks()
|
||
else overdueTasks.value = []
|
||
if (page.value > totalPages.value) { page.value = totalPages.value; await loadTasksPage() }
|
||
} catch (reason) { fail(reason) } finally { loading.value = false }
|
||
}
|
||
async function refreshAll() {
|
||
navigationLoaded.value = false
|
||
await loadAll()
|
||
}
|
||
async function loadTrashPage() {
|
||
const data = await api(`/trash?page=${page.value}&page_size=${pageSize}`)
|
||
trash.value = data.items ?? []
|
||
totalTasks.value = data.total ?? trash.value.length
|
||
}
|
||
async function loadTrash() {
|
||
loading.value = true; error.value = ''
|
||
try { await loadTrashPage() } catch (reason) { fail(reason) } finally { loading.value = false }
|
||
}
|
||
async function switchView(view: View, listId?: string) {
|
||
activeView.value = view
|
||
if (listId) activeList.value = listId
|
||
writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)
|
||
page.value = 1
|
||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false
|
||
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()
|
||
}
|
||
async function patchTask(task: Task, patch: Partial<Task>) {
|
||
const updated = await api(`/tasks/${task.id}`, { method: 'PATCH', body: JSON.stringify({ ...patch, version: task.version }) })
|
||
const index = tasks.value.findIndex((item) => item.id === task.id)
|
||
if (index >= 0) tasks.value[index] = { ...tasks.value[index], ...updated }
|
||
const overdueIndex = overdueTasks.value.findIndex((item) => item.id === task.id)
|
||
if (overdueIndex >= 0) {
|
||
if (updated.completed) overdueTasks.value.splice(overdueIndex, 1)
|
||
else overdueTasks.value[overdueIndex] = { ...overdueTasks.value[overdueIndex], ...updated }
|
||
}
|
||
if (selectedTask.value?.id === task.id) selectedTask.value = { ...selectedTask.value, ...updated }
|
||
return updated as Task
|
||
}
|
||
async function toggle(task: Task) {
|
||
try { await patchTask(task, { completed: !task.completed }); toast(task.completed ? '已重新打开' : '完成啦') } catch (reason) { 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 (activeView.value === 'trash' || loading.value || query.value || totalPages.value > 1) return
|
||
taskReorder.value = { id: task.id, startY: event.clientY, offsetY: 0 }
|
||
taskReorderTarget.value = task.id
|
||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
|
||
}
|
||
function taskById(id: string) {
|
||
return tasks.value.find((item) => item.id === id)
|
||
?? tasks.value.flatMap((item) => item.subtasks ?? []).find((item) => item.id === id)
|
||
}
|
||
function moveTaskReorder(task: Task, event: PointerEvent) {
|
||
const drag = taskReorder.value
|
||
if (!drag || drag.id !== task.id) return
|
||
drag.offsetY = event.clientY - drag.startY
|
||
const handle = event.currentTarget as HTMLElement
|
||
const row = document.elementsFromPoint(event.clientX, event.clientY)
|
||
.map((element) => element.closest<HTMLElement>('[data-task-id]'))
|
||
.find((element) => element && element !== handle.closest('[data-task-id]'))
|
||
if (row?.dataset.taskId) taskReorderTarget.value = row.dataset.taskId
|
||
}
|
||
async function finishTaskReorder(task: Task, event: PointerEvent) {
|
||
const drag = taskReorder.value
|
||
const targetId = taskReorderTarget.value
|
||
taskReorder.value = null
|
||
taskReorderTarget.value = ''
|
||
if (!drag || drag.id !== task.id || !targetId || targetId === task.id) return
|
||
const target = taskById(targetId)
|
||
if (!target || (target.parent_id ?? null) !== (task.parent_id ?? null)) return
|
||
const placement = event.clientY >= drag.startY ? 'after' : 'before'
|
||
const previous = tasks.value
|
||
const previousSelected = selectedTask.value
|
||
let ids: string[]
|
||
if (task.parent_id) {
|
||
const parent = tasks.value.find((item) => item.id === task.parent_id)
|
||
if (!parent) return
|
||
const reordered = moveItemWithinScope(parent.subtasks ?? [], task.id, targetId, placement)
|
||
if (reordered === parent.subtasks) return
|
||
tasks.value = tasks.value.map((item) => item.id === parent.id ? { ...item, subtasks: reordered } : item)
|
||
if (selectedTask.value?.id === parent.id) selectedTask.value = { ...selectedTask.value, subtasks: reordered }
|
||
ids = reordered.map((item) => item.id)
|
||
} else {
|
||
const next = moveItemWithinScope(previous, task.id, targetId, placement)
|
||
if (next === previous) return
|
||
tasks.value = next
|
||
ids = next.map((item) => item.id)
|
||
}
|
||
try {
|
||
await api('/tasks/reorder', { method: 'PUT', body: JSON.stringify({ task_ids: ids }) })
|
||
toast('顺序已保存')
|
||
} catch (reason) {
|
||
tasks.value = previous
|
||
selectedTask.value = previousSelected
|
||
fail(reason)
|
||
}
|
||
}
|
||
function cancelTaskReorder() {
|
||
taskReorder.value = null
|
||
taskReorderTarget.value = ''
|
||
}
|
||
|
||
function startTaskSwipe(task: Task, event: TouchEvent) {
|
||
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
||
const touch = event.touches[0]
|
||
if (touch) {
|
||
taskSwipeStart.value = { id: task.id, x: touch.clientX, y: touch.clientY }
|
||
taskSwipeOffsets.value[task.id] = 0
|
||
}
|
||
}
|
||
function startTaskPointer(task: Task, event: PointerEvent) {
|
||
if (activeView.value === 'trash' || loading.value || isInteractiveTarget(event.target)) return
|
||
if (event.pointerType === 'touch') return
|
||
taskPointerStart.value = { id: task.id, x: event.clientX, y: event.clientY }
|
||
}
|
||
function moveTaskPointer(task: Task, event: PointerEvent) {
|
||
const start = taskPointerStart.value
|
||
if (!start || start.id !== task.id) return
|
||
const deltaX = event.clientX - start.x
|
||
const deltaY = event.clientY - start.y
|
||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
|
||
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
|
||
}
|
||
}
|
||
}
|
||
function finishTaskPointer(task: Task, event: PointerEvent) {
|
||
const start = taskPointerStart.value
|
||
if (!start || start.id !== task.id) return
|
||
taskPointerStart.value = null
|
||
const deltaX = event.clientX - start.x
|
||
const deltaY = event.clientY - start.y
|
||
taskSwipeOffsets.value[task.id] = 0
|
||
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
|
||
if (expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||
suppressTaskClickId = task.id
|
||
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
|
||
void toggle(task)
|
||
}
|
||
}
|
||
function cancelTaskPointer(task?: Task) {
|
||
taskPointerStart.value = null
|
||
if (task) taskSwipeOffsets.value[task.id] = 0
|
||
}
|
||
function moveTaskSwipe(task: Task, event: TouchEvent) {
|
||
const start = taskSwipeStart.value
|
||
const touch = event.touches[0]
|
||
if (!start || start.id !== task.id || !touch) return
|
||
const deltaX = touch.clientX - start.x
|
||
const deltaY = touch.clientY - start.y
|
||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||
if ((!task.completed && deltaX > 0) || (task.completed && deltaX < 0)) {
|
||
taskSwipeOffsets.value[task.id] = Math.max(-92, Math.min(deltaX, 92))
|
||
}
|
||
}
|
||
}
|
||
async function finishTaskSwipe(task: Task, event: TouchEvent) {
|
||
const start = taskSwipeStart.value
|
||
taskSwipeStart.value = null
|
||
const offset = taskSwipeOffsets.value[task.id] ?? 0
|
||
taskSwipeOffsets.value[task.id] = 0
|
||
if (!start || start.id !== task.id || activeView.value === 'trash' || loading.value) return
|
||
const touch = event.changedTouches[0]
|
||
const deltaX = touch ? touch.clientX - start.x : 0
|
||
const deltaY = touch ? touch.clientY - start.y : 0
|
||
const expectedDirection = task.completed ? deltaX < 0 : deltaX > 0
|
||
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
|
||
suppressTaskClickId = task.id
|
||
window.setTimeout(() => { if (suppressTaskClickId === task.id) suppressTaskClickId = '' }, 400)
|
||
await toggle(task)
|
||
} else if (offset) {
|
||
taskSwipeOffsets.value[task.id] = 0
|
||
}
|
||
}
|
||
function cancelTaskSwipe(task?: Task) {
|
||
taskSwipeStart.value = null
|
||
if (task) taskSwipeOffsets.value[task.id] = 0
|
||
}
|
||
function toggleTaskChildren(task: Task) {
|
||
if (!task.subtasks?.length) return
|
||
const next = new Set(collapsedTaskIds.value)
|
||
next.has(task.id) ? next.delete(task.id) : next.add(task.id)
|
||
collapsedTaskIds.value = next
|
||
}
|
||
function selectTaskUnlessSwiped(task: Task, toggleChildren = false) {
|
||
if (suppressTaskClickId === task.id) {
|
||
suppressTaskClickId = ''
|
||
return
|
||
}
|
||
if (toggleChildren) toggleTaskChildren(task)
|
||
selectTask(task)
|
||
}
|
||
async function saveTask() {
|
||
if (!selectedTask.value?.title.trim()) return
|
||
try {
|
||
const task = selectedTask.value
|
||
const dueAt = fromDateTimeLocal(toDateTimeLocal(task.due_at))
|
||
const updated = await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: dueAt, list_id: task.list_id } as Partial<Task>)
|
||
if (selectedTaskRecurrence.value) {
|
||
if (dueAt) {
|
||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
|
||
} else {
|
||
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
|
||
selectedTaskRecurrence.value = null
|
||
selectedTaskRepeat.value = 'none'
|
||
}
|
||
}
|
||
if (selectedTask.value) selectedTask.value = { ...selectedTask.value, ...updated }
|
||
toast('已保存')
|
||
} catch (reason) { fail(reason) }
|
||
}
|
||
async function removeTask(task: Task) {
|
||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||
try { await api(`/tasks/${task.id}`, { method: 'DELETE' }); tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id); selectedTask.value = null; mobileDetail.value = false; toast('已移到回收站') } catch (reason) { fail(reason) }
|
||
}
|
||
async function restoreTask(task: Task) {
|
||
try { await api(`/tasks/${task.id}/restore`, { method: 'POST' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('任务已恢复') } catch (reason) { fail(reason) }
|
||
}
|
||
async function purgeTask(task: Task) {
|
||
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
||
try { await api(`/trash/${task.id}`, { method: 'DELETE' }); trash.value = trash.value.filter((item) => item.id !== task.id); toast('已永久删除') } catch (reason) { fail(reason) }
|
||
}
|
||
async function addSubtask() {
|
||
if (!selectedTask.value) return
|
||
const subtaskTitle = (await askText('添加子任务', '子任务名称', '', '添加'))?.trim()
|
||
if (!subtaskTitle) return
|
||
try { const child = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: subtaskTitle, list_id: selectedTask.value.list_id, parent_id: selectedTask.value.id }) }); if (selectedTask.value) selectedTask.value.subtasks = [...(selectedTask.value.subtasks ?? []), child]; tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
|
||
}
|
||
function selectTask(task: Task) {
|
||
selectedTask.value = { ...task, subtasks: task.subtasks ? [...task.subtasks] : [] }
|
||
markdownPreview.value = false; moreSettingsOpen.value = false; mobileDetail.value = true
|
||
void loadTaskRecurrence(task)
|
||
}
|
||
async function createFolder() {
|
||
const name = (await askText('新建文件夹', '文件夹名称', '', '创建'))?.trim(); if (!name) return
|
||
try { folders.value.push(await api('/folders', { method: 'POST', body: JSON.stringify({ name }) })); toast('文件夹已创建') } catch (reason) { fail(reason) }
|
||
}
|
||
async function createList(folderId: string | null = null) {
|
||
const name = (await askText('新建清单', '清单名称', '', '创建'))?.trim(); if (!name) return
|
||
try { const item = await api('/lists', { method: 'POST', body: JSON.stringify({ name, folder_id: folderId }) }); lists.value.push(item); await switchView('tasks', item.id); toast('清单已创建') } catch (reason) { fail(reason) }
|
||
}
|
||
async function renameEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||
const name = (await askText('重命名', kind === 'lists' ? '清单名称' : '文件夹名称', item.name, '保存'))?.trim(); if (!name || name === item.name) return
|
||
try { const updated = await api(`/${kind}/${item.id}`, { method: 'PATCH', body: JSON.stringify({ name }) }); Object.assign(item, updated); toast('已重命名') } catch (reason) { fail(reason) }
|
||
}
|
||
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||
if (kind === 'lists') {
|
||
const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档')
|
||
if (answer === null) return
|
||
try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); 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) {
|
||
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
|
||
}
|
||
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
|
||
function formatDue(value: string | null, hasTime = true) {
|
||
if (!value) return ''
|
||
const date = new Date(value)
|
||
return hasTime
|
||
? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(date)
|
||
: new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric' }).format(date)
|
||
}
|
||
function previousPage() {
|
||
if (page.value <= 1 || loading.value) return
|
||
page.value -= 1
|
||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||
}
|
||
function nextPage() {
|
||
if (page.value >= totalPages.value || loading.value) return
|
||
page.value += 1
|
||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||
}
|
||
|
||
onMounted(bootstrap)
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="!authReady" class="center"><span class="loader" />正在打开 dodo…</div>
|
||
<div v-else-if="!authenticated" class="auth-shell">
|
||
<section class="auth-card">
|
||
<div class="brand">do<span>do</span></div><p>{{ initialized ? '欢迎回来,继续把生活理顺。' : '创建你的 dodo' }}</p>
|
||
<label>用户名<input v-model="username" autocomplete="username" placeholder="你的用户名"></label>
|
||
<label>密码<input v-model="password" type="password" autocomplete="current-password" placeholder="至少 12 位" @keyup.enter="submitAuth"></label>
|
||
<button class="primary" @click="submitAuth">{{ initialized ? '登录' : '开始使用' }}</button>
|
||
<small v-if="error" role="alert">{{ error }}</small>
|
||
</section>
|
||
</div>
|
||
<div v-else class="shell" :class="{ 'sidebar-collapsed': sidebarCollapsed }">
|
||
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
||
<aside class="sidebar" :class="{ open: mobileSidebar }">
|
||
<div class="brand-row"><div class="brand small">do<span>do</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
||
<nav class="primary-nav">
|
||
<button :class="{ active: activeView==='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==='today' }" @click="switchView('today')"><ListTodo />今天</button>
|
||
<button :class="{ active: activeView==='countdowns' }" @click="switchView('countdowns')"><CalendarHeart />倒数日</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==='trash' }" @click="switchView('trash')"><Trash2 />回收站</button>
|
||
</nav>
|
||
<div class="section-title"><span>我的清单</span><span><button class="mini-icon" aria-label="新建文件夹" @click="createFolder"><Folder /></button><button class="mini-icon" aria-label="新建清单" @click="createList(null)"><Plus /></button></span></div>
|
||
<div class="folders">
|
||
<div v-for="folder in folders" :key="folder.id" class="folder-block">
|
||
<div class="folder-row"><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="重命名文件夹" @click="renameEntity('folders',folder)"><Pencil/></button><button aria-label="删除文件夹" @click="deleteEntity('folders',folder)"><Trash2/></button><button aria-label="在文件夹中新建清单" @click="createList(folder.id)"><Plus/></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" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span></div>
|
||
</div>
|
||
<div v-for="list in lists.filter(l=>!l.folder_id&&!l.is_inbox)" :key="list.id" class="list-row" :class="{active:activeView==='tasks'&&activeList===list.id}"><button class="list-row-main" :title="list.name" :aria-label="list.name" @click="switchView('tasks',list.id)"><i/><span>{{list.name}}</span></button><span class="row-actions"><button aria-label="重命名清单" @click="renameEntity('lists',list)"><Pencil/></button><button aria-label="归档清单" @click="deleteEntity('lists',list)"><ArchiveRestore/></button></span></div>
|
||
<template v-if="archivedLists.length">
|
||
<div class="section-title"><span>已归档清单</span></div>
|
||
<div v-for="list in archivedLists" :key="list.id" class="list-row archived-row"><div class="list-row-main archived-row-label" :title="list.name" :aria-label="`已归档清单:${list.name}`"><ArchiveRestore/><span>{{list.name}}</span></div><span class="row-actions archived-actions"><button aria-label="恢复清单" @click="restoreList(list)"><ArchiveRestore/>恢复</button></span></div>
|
||
</template>
|
||
</div>
|
||
<button class="settings" :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||
</aside>
|
||
|
||
<main>
|
||
<header class="topbar" :class="{ 'today-topbar': activeView==='today' }">
|
||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||
<div><h1>{{ activeName }}</h1><p v-if="activeView==='today'">{{ todayProgressText }},慢慢来。</p></div>
|
||
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||
</header>
|
||
<template v-if="['habits','settings'].includes(activeView)">
|
||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||
</template>
|
||
<CountdownPanel ref="countdownComposer" v-else-if="activeView==='countdowns'" @notice="toast" />
|
||
<template v-else>
|
||
<section v-if="activeView==='today'" class="today-board" aria-label="今日概览">
|
||
<div class="today-stat"><strong>{{ todayOpenTasks }}</strong><span>待处理</span></div>
|
||
<div class="today-stat"><strong>{{ todayCompletedTasks }}</strong><span>已完成</span></div>
|
||
<div class="today-stat habit-stat"><strong>{{ todayHabitProgress }}</strong><span>习惯打卡</span></div>
|
||
</section>
|
||
<template v-if="activeView==='today'">
|
||
<section v-if="overdueTaskTree.length" class="overdue-section">
|
||
<h3 class="section-heading overdue-heading"><CalendarDays/>已过期 <span>{{overdueTaskTree.length}}</span></h3>
|
||
<div class="task-list overdue-list">
|
||
<template v-for="node in overdueTaskTree" :key="`overdue-${node.task.id}`">
|
||
<article class="task-row overdue-task"><button class="task-check" :aria-label="`完成${node.task.title}`" :aria-pressed="false" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"></span></button><div class="task-main" role="button" tabindex="0" @click="selectTask(node.task)" @keydown.enter="selectTask(node.task)"><strong>{{node.task.title}}</strong><span class="meta"><span><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span></span></div><span class="overdue-badge">已过期</span></article>
|
||
</template>
|
||
</div>
|
||
</section>
|
||
<h3 class="section-heading"><ListTodo/>任务</h3>
|
||
</template>
|
||
<div class="list-toolbar"><label v-if="activeView!=='trash'"><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span v-if="activeView==='trash' || totalPages > 1 || totalTasks > 0">{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项</span><button v-if="query" class="link" @click="query=''">清除搜索</button></div>
|
||
<div v-if="activeView!=='trash' && totalPages > 1" class="pager"><button class="secondary" :disabled="page<=1 || loading" @click="previousPage">上一页</button><span>{{page}} / {{totalPages}}</span><button class="secondary" :disabled="page>=totalPages || loading" @click="nextPage">下一页</button></div>
|
||
<section class="task-list" :class="{loading}">
|
||
<template v-for="node in taskTree" :key="node.task.id">
|
||
<article :data-task-id="node.task.id" class="task-row swipeable" :class="{done:node.task.completed,selected:selectedTask?.id===node.task.id,ready:Math.abs(taskSwipeOffsets[node.task.id] ?? 0) >= 64,reordering:taskReorder?.id===node.task.id,'reorder-target':taskReorderTarget===node.task.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[node.task.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === node.task.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(node.task, $event)" @pointermove="moveTaskPointer(node.task, $event)" @pointerup="finishTaskPointer(node.task, $event)" @pointercancel="cancelTaskPointer(node.task)" @touchstart.passive="startTaskSwipe(node.task, $event)" @touchmove.passive="moveTaskSwipe(node.task, $event)" @touchend="finishTaskSwipe(node.task, $event)" @touchcancel="cancelTaskSwipe(node.task)">
|
||
<button v-if="activeView!=='trash'" class="drag-handle task-drag-handle" :disabled="Boolean(query) || totalPages > 1" aria-label="上下拖动任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(node.task, $event)" @pointermove.stop="moveTaskReorder(node.task, $event)" @pointerup.stop="finishTaskReorder(node.task, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button>
|
||
<button v-if="activeView!=='trash'" class="task-check" :aria-label="node.task.completed ? `重新打开${node.task.title}` : `完成${node.task.title}`" :aria-pressed="node.task.completed" @click.stop="toggle(node.task)"><span class="task-check-mark" :class="`p${node.task.priority}`"><Check v-if="node.task.completed" /></span></button>
|
||
<div class="task-main" role="button" tabindex="0" :aria-expanded="!collapsedTaskIds.has(node.task.id)" @click="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.enter="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)" @keydown.space.prevent="activeView==='trash'?undefined:selectTaskUnlessSwiped(node.task, true)"><strong>{{node.task.title}}</strong><span class="meta"><span v-if="node.task.due_at"><CalendarDays/>{{formatDue(node.task.due_at,node.task.due_has_time)}}</span><span v-if="node.subtasks.length"><ListChecks/>{{node.subtasks.filter(t=>t.completed).length}}/{{node.subtasks.length}}</span></span></div>
|
||
<span v-if="node.task.priority" class="priority" :class="`p${node.task.priority}`">{{['','低','中','高'][node.task.priority]}}</span>
|
||
<button v-if="activeView==='trash'" class="restore" @click="restoreTask(node.task)"><ArchiveRestore/>恢复</button>
|
||
<button v-else class="icon ghost" aria-label="删除任务" @click.stop="removeTask(node.task)"><Trash2/></button>
|
||
<button v-if="activeView==='trash'" class="icon danger ghost" aria-label="永久删除" @click.stop="purgeTask(node.task)"><X/></button>
|
||
</article>
|
||
<article v-if="!collapsedTaskIds.has(node.task.id)" v-for="subtask in node.subtasks" :key="subtask.id" :data-task-id="subtask.id" class="task-row subtask swipeable" :class="{done:subtask.completed,ready:Math.abs(taskSwipeOffsets[subtask.id] ?? 0) >= 64,reordering:taskReorder?.id===subtask.id,'reorder-target':taskReorderTarget===subtask.id}" :style="{ '--swipe-x': `${taskSwipeOffsets[subtask.id] ?? 0}px`, '--reorder-y': `${taskReorder?.id === subtask.id ? taskReorder.offsetY : 0}px` }" @pointerdown="startTaskPointer(subtask, $event)" @pointermove="moveTaskPointer(subtask, $event)" @pointerup="finishTaskPointer(subtask, $event)" @pointercancel="cancelTaskPointer(subtask)" @touchstart.passive="startTaskSwipe(subtask, $event)" @touchmove.passive="moveTaskSwipe(subtask, $event)" @touchend="finishTaskSwipe(subtask, $event)" @touchcancel="cancelTaskSwipe(subtask)"><button class="drag-handle task-drag-handle" aria-label="上下拖动子任务排序" title="上下拖动排序" @pointerdown.stop="startTaskReorder(subtask, $event)" @pointermove.stop="moveTaskReorder(subtask, $event)" @pointerup.stop="finishTaskReorder(subtask, $event)" @pointercancel.stop="cancelTaskReorder"><GripVertical/></button><button class="task-check" :aria-label="subtask.completed ? `重新打开${subtask.title}` : `完成${subtask.title}`" :aria-pressed="subtask.completed" @click.stop="toggle(subtask)"><span class="task-check-mark" :class="`p${subtask.priority}`"><Check v-if="subtask.completed" /></span></button><div class="task-main" role="button" tabindex="0" @click="selectTaskUnlessSwiped(subtask)" @keydown.enter="selectTaskUnlessSwiped(subtask)" @keydown.space.prevent="selectTaskUnlessSwiped(subtask)"><strong>{{subtask.title}}</strong></div></article>
|
||
</template>
|
||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span><button v-if="activeView==='today' && !query" class="soft-button empty-action" @click="openTaskCompose"><Plus/>添加今天任务</button></div>
|
||
</section>
|
||
<div v-if="activeView==='today'">
|
||
<h3 class="section-heading"><Repeat2/>习惯</h3>
|
||
<MvpPanel ref="habitComposer" view="today-habits" @changed="refreshAll" @notice="toast" @summary="updateTodayHabitSummary" />
|
||
</div>
|
||
</template>
|
||
</main>
|
||
|
||
<aside class="detail" :class="{open:mobileDetail}">
|
||
<div class="detail-head"><span>任务详情</span><button class="icon mobile-only" aria-label="关闭详情" @click="mobileDetail=false"><X/></button></div>
|
||
<div v-if="selectedTask" class="detail-form">
|
||
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
|
||
<label>清单<select v-model="selectedTask.list_id" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||
<label>截止时间<input :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
|
||
<label>重复<select v-model="selectedTaskRepeat" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
|
||
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label><button type="button" class="soft-button" @click="updateSelectedTaskRepeat">保存自定义重复</button></section>
|
||
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown…" @blur="saveTask"/></div>
|
||
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
|
||
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
|
||
<label>优先级<select v-model.number="selectedTask.priority" @change="saveTask"><option :value="0">无</option><option :value="1">低</option><option :value="2">中</option><option :value="3">高</option></select></label>
|
||
</div></details>
|
||
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||
</div>
|
||
<div v-else-if="activeView==='today'" class="paper today-summary"><ListTodo/><b>{{ todayProgressText }}</b><p>点左侧任务看详情;没任务时就从今天加一件小事开始。</p><div class="today-summary-grid"><span><strong>{{ todayOpenTasks }}</strong>待处理</span><span><strong>{{ todayCompletedTasks }}</strong>已完成</span></div></div>
|
||
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、子任务和 Markdown 备注会出现在这里。</p></div>
|
||
</aside>
|
||
|
||
<div v-if="mobileMore" class="more-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><button @click="switchView('settings')"><Settings/>设置与数据</button></section></div>
|
||
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
|
||
<Transition name="task-compose">
|
||
<div v-if="taskComposeOpen" class="task-compose-mask" @click.self="closeTaskCompose">
|
||
<form class="task-compose-sheet" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||
<header><div><small>NEW TASK</small><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off"></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-due-row">
|
||
<label>截止日期<input v-model="composeDueAt" type="date"></label>
|
||
<button v-if="!composeHasTime" class="task-compose-time-add" type="button" @click="composeHasTime=true">添加时间</button>
|
||
<label v-else class="task-compose-time-field">时间<span><input v-model="composeTime" type="time"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click="composeHasTime=false"><X/></button></span></label>
|
||
</div>
|
||
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
|
||
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
|
||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||
<footer><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||
</form>
|
||
</div>
|
||
</Transition>
|
||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||
<div v-if="modalVisible" class="modal-mask" @click.self="closeModal">
|
||
<div class="modal-box" role="dialog" aria-modal="true">
|
||
<h3>{{ modalTitle }}</h3>
|
||
<label v-if="modalLabel">{{ modalLabel }}<input v-model="modalValue" class="modal-input" autofocus @keyup.enter="confirmModal"></label>
|
||
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|