fix: ui interactions — subtask detail, calendar events, modal prompts, search visibility
ci / docker (push) Successful in 3m23s

This commit is contained in:
2026-09-06 12:56:10 +08:00
parent 4e888ffe1c
commit 5924a80a39
6 changed files with 89 additions and 26 deletions
+81 -18
View File
@@ -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>