feat: strengthen backup and mobile workflows
ci / gitleaks (push) Successful in 1m19s
ci / docker (push) Successful in 5m48s

This commit is contained in:
2026-09-16 21:12:52 +08:00
parent 6f38190c92
commit 6c234d7d82
64 changed files with 5059 additions and 635 deletions
+35 -79
View File
@@ -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>