feat: strengthen backup and mobile workflows
This commit is contained in:
+35
-79
@@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
@@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue'
|
||||
import CalendarPicker from './components/CalendarPicker.vue'
|
||||
import TaskDueDisplay from './components/TaskDueDisplay.vue'
|
||||
import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue'
|
||||
import AppSheet from './components/AppSheet.vue'
|
||||
import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue'
|
||||
import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock'
|
||||
import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse'
|
||||
|
||||
@@ -49,8 +50,6 @@ let archivedListActionTrigger: HTMLElement | null = null
|
||||
const purgeListTarget = ref<TaskList | null>(null)
|
||||
const purgeListSubmitting = ref(false)
|
||||
const purgeListError = ref('')
|
||||
const purgeCancelButton = ref<HTMLButtonElement | null>(null)
|
||||
const purgeListDialog = ref<HTMLElement | null>(null)
|
||||
let purgeListTrigger: HTMLElement | null = null
|
||||
const tasks = ref<Task[]>([])
|
||||
const overdueTasks = ref<Task[]>([])
|
||||
@@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock()
|
||||
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 taskNoteEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
@@ -296,39 +294,16 @@ function toggleSidebar() {
|
||||
}
|
||||
}
|
||||
|
||||
const modalVisible = ref(false)
|
||||
const modalTitle = ref('')
|
||||
const modalLabel = ref('')
|
||||
const modalValue = ref('')
|
||||
const modalError = 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
|
||||
modalError.value = ''
|
||||
modalConfirmText.value = confirmText
|
||||
modalVisible.value = true
|
||||
modalResolve.value = resolve
|
||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||
async function confirmAction(title: string, description?: string, danger = false) {
|
||||
return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true
|
||||
}
|
||||
async function askText(title: string, label = '', initial = '', confirmText = '确定') {
|
||||
const result = await appDialog.value?.show({
|
||||
title, label, initial, confirmText,
|
||||
validate: label ? (value) => normalizeRequiredName(value).error : undefined,
|
||||
})
|
||||
}
|
||||
function closeModal() {
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null }
|
||||
}
|
||||
function confirmModal() {
|
||||
if (modalLabel.value) {
|
||||
const normalized = normalizeRequiredName(modalValue.value)
|
||||
if (normalized.error) {
|
||||
modalError.value = normalized.error
|
||||
return
|
||||
}
|
||||
modalValue.value = normalized.value
|
||||
}
|
||||
modalVisible.value = false
|
||||
if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null }
|
||||
return typeof result === 'string' ? result.trim() : null
|
||||
}
|
||||
|
||||
const activeName = computed(() => {
|
||||
@@ -679,7 +654,7 @@ async function loadTrash() {
|
||||
})
|
||||
}
|
||||
async function switchView(view: View, listId?: string) {
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return
|
||||
if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return
|
||||
taskMutationNavigation.value += 1
|
||||
taskReorderMode.value = false
|
||||
cancelTaskReorder()
|
||||
@@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) {
|
||||
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; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null
|
||||
if (view !== 'memos') memoDetailOpen.value = false
|
||||
if (view === 'trash') await loadTrash()
|
||||
else if (view === 'today') await loadTodayView()
|
||||
@@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() {
|
||||
}
|
||||
}
|
||||
async function removeTask(task: Task) {
|
||||
if (!window.confirm(`把“${task.title}”移到回收站?`)) return
|
||||
if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return
|
||||
try {
|
||||
await api(`/tasks/${task.id}`, { method: 'DELETE' })
|
||||
tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id)
|
||||
@@ -1014,7 +989,7 @@ async function restoreTask(task: Task) {
|
||||
await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复')
|
||||
}
|
||||
async function purgeTask(task: Task) {
|
||||
if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return
|
||||
if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return
|
||||
await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除')
|
||||
}
|
||||
async function addSubtask() {
|
||||
@@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) {
|
||||
purgeListError.value = ''
|
||||
archivedListAction.value = null
|
||||
archivedListActionTrigger = null
|
||||
nextTick(() => purgeCancelButton.value?.focus())
|
||||
}
|
||||
function focusPurgeListTrigger() {
|
||||
const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value
|
||||
@@ -1164,15 +1138,6 @@ function closePurgeList() {
|
||||
purgeListError.value = ''
|
||||
focusPurgeListTrigger()
|
||||
}
|
||||
function handlePurgeDialogKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList()
|
||||
if (event.key !== 'Tab' || !purgeListDialog.value) return
|
||||
const controls = [...purgeListDialog.value.querySelectorAll<HTMLElement>('button:not(:disabled)')]
|
||||
if (!controls.length) return
|
||||
const activeIndex = controls.indexOf(document.activeElement as HTMLElement)
|
||||
const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey)
|
||||
if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() }
|
||||
}
|
||||
async function confirmPurgeList() {
|
||||
if (!purgeListTarget.value || purgeListSubmitting.value) return
|
||||
purgeListSubmitting.value = true
|
||||
@@ -1467,8 +1432,8 @@ onUnmounted(() => {
|
||||
<button :class="{active:activeView==='trash'}" @click="switchView('trash')"><Trash2 />回收站</button>
|
||||
<button :class="{active:activeView==='settings'}" @click="switchView('settings')"><Settings />设置</button>
|
||||
</nav>
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction">
|
||||
<section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`">
|
||||
<AppSheet :open="Boolean(sidebarAction)" variant="actions" panel-class="sidebar-action-sheet" :label="sidebarAction ? `${sidebarAction.item.name}操作` : undefined" initial-focus=".app-sheet__header button" @close="closeSidebarAction">
|
||||
<template v-if="sidebarAction">
|
||||
<template v-if="!listMoveMenuOpen">
|
||||
<header class="app-sheet__header sidebar-action-header">
|
||||
<div><span class="sidebar-action-kind">{{sidebarAction.kind==='folders'?'文件夹':'清单'}}</span><b>{{sidebarAction.item.name}}</b></div>
|
||||
@@ -1505,8 +1470,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</AppSheet>
|
||||
</aside>
|
||||
|
||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
@@ -1573,8 +1538,8 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</main>
|
||||
|
||||
<aside v-if="selectedTask" class="detail" :class="{open:mobileDetail}">
|
||||
<div class="detail-head"><span>任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" @close="closeTaskDetail">
|
||||
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||
<div class="detail-form">
|
||||
<div class="detail-title"><button class="task-check detail-task-check" type="button" :aria-label="selectedTask.completed ? `重新打开${selectedTask.title}` : `完成${selectedTask.title}`" :aria-pressed="selectedTask.completed" @click="toggle(selectedTask)"><span class="task-check-mark" :class="`p${selectedTask.priority}`"><Check v-if="selectedTask.completed" /></span></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题"/></div>
|
||||
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
|
||||
@@ -1608,14 +1573,11 @@ onUnmounted(() => {
|
||||
</div></details>
|
||||
<div class="detail-actions"><button class="secondary" :disabled="savingSelectedTask || recurrenceLoading" @click="saveSelectedTaskChanges">{{savingSelectedTask?'正在保存…':recurrenceLoading?'正在读取…':'保存更改'}}</button><button class="danger-text" @click="removeTask(selectedTask)"><Trash2/>移到回收站</button></div>
|
||||
</div>
|
||||
</aside>
|
||||
</AppSheet>
|
||||
|
||||
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
|
||||
<nav class="bottom" :inert="memoBackgroundInert ? true : undefined" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
|
||||
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns','memos'].includes(activeView)" :show="showFloatingAdd" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : activeView==='memos' ? '添加备忘录' : '添加任务'" @activate="activateFloatingAdd" />
|
||||
<Transition name="task-compose">
|
||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||
<form class="task-compose-sheet app-sheet app-sheet--create" :style="taskComposeStyle" role="dialog" aria-modal="true" aria-labelledby="task-compose-title" @submit.prevent="submitTaskCompose">
|
||||
<AppSheet :open="taskComposeOpen" variant="create" panel-class="task-compose-sheet" title-id="task-compose-title" initial-focus=".task-compose-input" :style="taskComposeStyle" @close="closeTaskCompose" @submit.prevent="submitTaskCompose">
|
||||
<header class="app-sheet__header"><div><h2 id="task-compose-title">{{ taskComposeTitle }}</h2></div><button class="icon" type="button" aria-label="关闭添加任务" @click="closeTaskCompose"><X/></button></header>
|
||||
<div class="app-sheet__body">
|
||||
<label>任务名称<input v-model="composeTitle" class="task-compose-input" placeholder="准备做点什么?" autocomplete="off" :aria-invalid="Boolean(composeTitleError)" aria-describedby="compose-title-error" @input="composeTitleError=''"><small v-if="composeTitleError" id="compose-title-error" role="alert" class="field-error">{{ composeTitleError }}</small></label>
|
||||
@@ -1638,28 +1600,22 @@ onUnmounted(() => {
|
||||
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选,支持 Markdown"/></label>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button type="button" class="secondary" @click="closeTaskCompose">取消</button><button class="primary-small" :disabled="!composeTitle.trim() || !composeListId">添加任务</button></footer>
|
||||
</form>
|
||||
</div>
|
||||
</Transition>
|
||||
</AppSheet>
|
||||
<Transition name="toast"><div v-if="notice" class="toast" role="status">{{notice}}</div></Transition>
|
||||
<div v-if="error" class="error-toast" role="alert">{{error}}<button @click="error=''"><X/></button></div>
|
||||
<Teleport to="body">
|
||||
<span v-if="archivedListAction" class="archived-action-mask" @click.self="closeArchivedListAction()"><span ref="archivedMenu" class="archived-row-actions" :style="archivedMenuStyle" role="menu"><button role="menuitem" @click="restoreList(archivedListAction)"><ArchiveRestore/>恢复清单</button><button role="menuitem" class="danger-text" @click="openPurgeList(archivedListAction)"><Trash2/>永久删除清单</button></span></span>
|
||||
</Teleport>
|
||||
<div v-if="purgeListTarget" class="modal-mask purge-list-mask" @click.self="closePurgeList">
|
||||
<section ref="purgeListDialog" class="modal-box purge-list-dialog" role="alertdialog" aria-modal="true" aria-labelledby="purge-list-title" aria-describedby="purge-list-description" @keydown="handlePurgeDialogKeydown">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
<div class="modal-actions"><button ref="purgeCancelButton" class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></div>
|
||||
</section>
|
||||
</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 :aria-invalid="Boolean(modalError)" aria-describedby="modal-name-error" @input="modalError=''" @keyup.enter="confirmModal"><small v-if="modalError" id="modal-name-error" role="alert" class="field-error">{{ modalError }}</small></label>
|
||||
<div class="modal-actions"><button class="secondary" @click="closeModal">取消</button><button class="primary-small" @click="confirmModal">{{ modalConfirmText }}</button></div>
|
||||
</div>
|
||||
</div>
|
||||
<AppSheet :open="Boolean(purgeListTarget)" variant="actions" panel-class="purge-list-dialog" title-id="purge-list-title" description-id="purge-list-description" initial-focus=".secondary" :busy="purgeListSubmitting" @close="closePurgeList">
|
||||
<template v-if="purgeListTarget">
|
||||
<div class="app-sheet__body">
|
||||
<h3 id="purge-list-title">永久删除清单「{{ purgeListTarget.name }}」?</h3>
|
||||
<p id="purge-list-description">将永久删除其中的全部任务、子任务、重复规则、附件及实体文件。此操作无法撤销。</p>
|
||||
<p v-if="purgeListError" role="alert" class="purge-list-error">{{ purgeListError }}</p>
|
||||
</div>
|
||||
<footer class="app-sheet__footer"><button class="secondary" :disabled="purgeListSubmitting" @click="closePurgeList">取消</button><button class="danger-button" :disabled="purgeListSubmitting" @click="confirmPurgeList">{{ purgeListSubmitting ? '正在删除…' : '永久删除' }}</button></footer>
|
||||
</template>
|
||||
</AppSheet>
|
||||
<AppDialog ref="appDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user