fix: ui interactions — subtask detail, calendar events, modal prompts, search visibility
ci / docker (push) Successful in 3m23s
ci / docker (push) Successful in 3m23s
This commit is contained in:
+81
-18
@@ -6,7 +6,7 @@ import {
|
||||
Settings, Trash2, X, Repeat2,
|
||||
} from 'lucide-vue-next'
|
||||
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
|
||||
import { defaultView, isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||||
import { dateKey, defaultView, isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
|
||||
@@ -15,6 +15,7 @@ type TaskList = { id: string; folder_id: string | null; name: string; is_inbox:
|
||||
type CalendarSubscription = { id: string; name: string; url: string; color?: string }
|
||||
type Tag = { id: string; name: string; color: string }
|
||||
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; recurrence_rule?: string | null; recurrence_end_at?: string | null; tags?: Tag[]; subtasks?: Task[] }
|
||||
type CalendarEvent = { id: string; title: string; starts_at: string; ends_at?: string | null; all_day: boolean; source_name: string; color?: string }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'calendar' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
@@ -26,6 +27,7 @@ const folders = ref<FolderItem[]>([])
|
||||
const lists = ref<TaskList[]>([])
|
||||
const archivedLists = ref<TaskList[]>([])
|
||||
const calendarSubscriptions = ref<CalendarSubscription[]>([])
|
||||
const calendarEvents = ref<CalendarEvent[]>([])
|
||||
const calendarName = ref('合并日历')
|
||||
const calendarUrl = ref('https://cal.bboy.app/all.ics')
|
||||
const tags = ref<Tag[]>([])
|
||||
@@ -53,6 +55,31 @@ const taskSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
|
||||
const taskSwipeOffsets = ref<Record<string, number>>({})
|
||||
let suppressTaskClickId = ''
|
||||
|
||||
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 '今天'
|
||||
@@ -67,11 +94,12 @@ const visibleTasks = computed(() => {
|
||||
const now = new Date()
|
||||
const end = new Date(now); end.setDate(end.getDate() + 7)
|
||||
let result = sourceTasks.value
|
||||
if (['habits','settings'].includes(activeView.value)) return []
|
||||
if (['habits','settings','calendar'].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)
|
||||
let searchTimer: number | undefined
|
||||
@@ -219,6 +247,7 @@ async function switchView(view: View, listId?: string) {
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
else if (view === 'calendar') await loadCalendarTab()
|
||||
else if (view === 'today') await loadTodayView()
|
||||
else if (!isTaskView(view)) tasks.value = []
|
||||
else await loadAll()
|
||||
}
|
||||
@@ -229,6 +258,9 @@ async function addTask() {
|
||||
tasks.value.push(task); title.value = ''; selectTask(task); toast('任务已添加')
|
||||
} catch (reason) { fail(reason) }
|
||||
}
|
||||
async function loadTodayView() {
|
||||
await Promise.all([loadAll(), loadTodayEvents()])
|
||||
}
|
||||
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)
|
||||
@@ -312,33 +344,40 @@ async function purgeTask(task: Task) {
|
||||
}
|
||||
async function addSubtask() {
|
||||
if (!selectedTask.value) return
|
||||
const subtaskTitle = window.prompt('子任务名称')?.trim()
|
||||
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 }) }); tasks.value.push(child); toast('子任务已添加') } catch (reason) { fail(reason) }
|
||||
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, tags: task.tags ? [...task.tags] : [] }
|
||||
markdownPreview.value = false; mobileDetail.value = true
|
||||
}
|
||||
async function createFolder() {
|
||||
const name = window.prompt('文件夹名称')?.trim(); if (!name) return
|
||||
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 = window.prompt('清单名称')?.trim(); if (!name) return
|
||||
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 = window.prompt('新名称', item.name)?.trim(); if (!name || name === item.name) return
|
||||
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 createTag() {
|
||||
const name = (await askText('新建标签', '标签名称', '', '创建'))?.trim(); if (!name) return
|
||||
const color = (await askText('标签颜色', '颜色(十六进制)', '#F15A29', '创建'))?.trim() || '#F15A29'
|
||||
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskList) {
|
||||
if (kind === 'lists') {
|
||||
if (!window.confirm(`归档“${item.name}”?任务会移到收集箱,历史数据保留。`)) return
|
||||
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
|
||||
}
|
||||
if (!window.confirm(`删除“${item.name}”?`)) 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() {
|
||||
@@ -348,7 +387,14 @@ async function restoreList(item: TaskList) {
|
||||
try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function loadCalendarTab() {
|
||||
try { calendarSubscriptions.value = await api('/calendar-subscriptions') } catch { calendarSubscriptions.value = [] }
|
||||
try {
|
||||
calendarSubscriptions.value = await api('/calendar-subscriptions')
|
||||
if (calendarSubscriptions.value.length) {
|
||||
calendarEvents.value = await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`)
|
||||
} else {
|
||||
calendarEvents.value = []
|
||||
}
|
||||
} catch { calendarSubscriptions.value = []; calendarEvents.value = [] }
|
||||
}
|
||||
async function addCalendarSubscription() {
|
||||
if (!calendarUrl.value.trim()) return
|
||||
@@ -361,11 +407,6 @@ async function removeCalendarSubscription(id: string) {
|
||||
if (!window.confirm('取消这个日历订阅?')) return
|
||||
try { await api(`/calendar-subscriptions/${id}`, { method: 'DELETE' }); await loadCalendarTab(); toast('已取消订阅') } catch (reason) { fail(reason) }
|
||||
}
|
||||
async function createTag() {
|
||||
const name = window.prompt('标签名称')?.trim(); if (!name) return
|
||||
const color = window.prompt('标签颜色', '#F15A29') || '#F15A29'
|
||||
try { tags.value.push(await api('/tags', { method: 'POST', body: JSON.stringify({ name, color }) })); toast('标签已创建') } catch (reason) { fail(reason) }
|
||||
}
|
||||
function taskHasTag(tag: Tag) { return !!selectedTask.value?.tags?.some((item) => item.id === tag.id) }
|
||||
function toggleTag(tag: Tag) {
|
||||
if (!selectedTask.value) return
|
||||
@@ -373,6 +414,17 @@ function toggleTag(tag: Tag) {
|
||||
}
|
||||
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) { return value ? new Intl.DateTimeFormat('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }).format(new Date(value)) : '' }
|
||||
function formatEventTime(item: CalendarEvent) {
|
||||
if (item.all_day) return '全天'
|
||||
const date = new Date(item.starts_at)
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`
|
||||
}
|
||||
async function loadTodayEvents() {
|
||||
try {
|
||||
calendarSubscriptions.value = await api('/calendar-subscriptions')
|
||||
calendarEvents.value = calendarSubscriptions.value.length ? await api(`/calendar-subscriptions/today?day=${dateKey(new Date())}`) : []
|
||||
} catch { calendarEvents.value = [] }
|
||||
}
|
||||
function focusQuick() { nextTick(() => document.querySelector<HTMLInputElement>('.quick-input')?.focus()) }
|
||||
function previousPage() {
|
||||
if (page.value <= 1 || loading.value) return
|
||||
@@ -430,7 +482,7 @@ onMounted(bootstrap)
|
||||
<header class="topbar">
|
||||
<button class="icon mobile-only" aria-label="打开菜单" @click="mobileSidebar=true"><Menu /></button>
|
||||
<div><p>今天也慢慢来</p><h1>{{ activeName }}</h1></div>
|
||||
<label class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||
<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','calendar'].includes(activeView)">
|
||||
<MvpPanel v-if="['habits','settings'].includes(activeView)" :key="activeView" :view="activeView as 'habits'|'settings'" @changed="refreshAll" @notice="toast" />
|
||||
@@ -438,12 +490,12 @@ onMounted(bootstrap)
|
||||
<header class="view-intro"><div><small>订阅外部日历,每天自动同步</small><h2>日历</h2></div><button class="soft-button" @click="loadCalendarTab"><RefreshCw />刷新</button></header>
|
||||
<div class="settings-grid">
|
||||
<article class="tool-card wide"><Upload /><h3>添加订阅</h3><p>粘贴 ICS / webcal 链接,例如 cal.bboy.app 的合并日历。</p><div class="sub-add"><input v-model="calendarName" placeholder="名称(可选)" aria-label="订阅名称"><input v-model="calendarUrl" placeholder="https://…/all.ics" aria-label="订阅地址"><button class="primary-small" @click="addCalendarSubscription">订阅</button></div><div v-if="calendarSubscriptions.length"><div v-for="sub in calendarSubscriptions" :key="sub.id" class="session-row"><span><b>{{sub.name}}</b><small>{{sub.url}}</small></span><button class="danger-text" @click="removeCalendarSubscription(sub.id)">取消订阅</button></div></div><p v-else class="hint">还没有日历订阅</p></article>
|
||||
<article v-if="calendarSubscriptions.length" class="tool-card wide"><CalendarDays /><h3>今日事件</h3><div v-if="calendarEvents.length" class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div><p v-else class="hint">今天没有日历事件</p></article>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="activeView==='today'">
|
||||
<div class="list-toolbar"><label><input v-model="showCompleted" type="checkbox"> 显示已完成</label><span>共 {{visibleTasks.length}} 项</span></div>
|
||||
<h3 class="section-heading"><ListTodo/>今日任务</h3>
|
||||
</template>
|
||||
<form v-if="activeView!=='trash'" class="quick" @submit.prevent="addTask"><CirclePlus/><input v-model="title" class="quick-input" placeholder="添加任务,按回车保存"><button>添加</button></form>
|
||||
@@ -464,6 +516,10 @@ onMounted(bootstrap)
|
||||
</template>
|
||||
<div v-if="!visibleTasks.length&&!loading" class="empty"><ListTodo/><b>{{query?'没有匹配的任务':'这里还很安静'}}</b><span>{{query?'换个关键词试试':'写下第一件想完成的小事吧'}}</span></div>
|
||||
</section>
|
||||
<div v-if="activeView==='today' && calendarEvents.length">
|
||||
<h3 class="section-heading"><CalendarDays/>今日日历事件</h3>
|
||||
<div class="calendar-events"><div v-for="ev in calendarEvents" :key="ev.id + ev.starts_at" class="calendar-event"><span class="ev-time">{{ formatEventTime(ev) }}</span><b>{{ ev.title }}</b><small>{{ ev.source_name }}</small></div></div>
|
||||
</div>
|
||||
<div v-if="activeView==='today'">
|
||||
<h3 class="section-heading"><Repeat2/>今日习惯</h3>
|
||||
<MvpPanel view="today-habits" @changed="refreshAll" @notice="toast" />
|
||||
@@ -482,7 +538,7 @@ onMounted(bootstrap)
|
||||
<label v-if="selectedTask.recurrence_rule">重复截止<input v-model="selectedTask.recurrence_end_at" type="date" @change="saveTask"></label>
|
||||
<div class="field"><div class="field-label"><span>标签</span><button class="link" @click="createTag"><Plus/>新建</button></div><div class="tag-picker"><button v-for="tag in tags" :key="tag.id" :class="{chosen:taskHasTag(tag)}" @click="toggleTag(tag);saveTask()"><i :style="{background:tag.color}"/>{{tag.name}}</button><span v-if="!tags.length" class="hint">还没有标签</span></div></div>
|
||||
<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 tasks.filter(t=>t.parent_id===selectedTask?.id)" :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="!tasks.some(t=>t.parent_id===selectedTask?.id)" class="hint">把这件事拆成更小的步骤</span></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>
|
||||
<div class="detail-actions"><button class="secondary" @click="saveTask">保存更改</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||||
</div>
|
||||
<div v-else class="paper"><ListChecks/><b>选中一个任务</b><p>日期、优先级、标签、子任务和 Markdown 备注会出现在这里。</p></div>
|
||||
@@ -492,5 +548,12 @@ onMounted(bootstrap)
|
||||
<button v-if="['tasks','today','upcoming'].includes(activeView)" class="fab" aria-label="添加任务" @click="focusQuick"><CirclePlus/></button>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user