+64
-1
@@ -15,6 +15,8 @@ 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; subtasks?: Task[] }
|
||||
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly'
|
||||
type Recurrence = { id: string; task_id: string; rrule: string }
|
||||
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
|
||||
|
||||
const initialized = ref<boolean | null>(null)
|
||||
@@ -58,6 +60,10 @@ const composeListId = ref('')
|
||||
const composeDueAt = ref('')
|
||||
const composePriority = ref(0)
|
||||
const composeDescription = ref('')
|
||||
const composeRepeat = ref<RepeatOption>('none')
|
||||
const selectedTaskRepeat = ref<RepeatOption>('none')
|
||||
const selectedTaskRecurrence = ref<Recurrence | null>(null)
|
||||
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 })
|
||||
@@ -72,6 +78,7 @@ function openTaskCompose() {
|
||||
composeDueAt.value = activeView.value === 'today' ? toDateTimeLocal(quickTaskFields('today', activeList.value, inboxId).due_at ?? null) : ''
|
||||
composePriority.value = 0
|
||||
composeDescription.value = ''
|
||||
composeRepeat.value = 'none'
|
||||
taskComposeOpen.value = true
|
||||
nextTick(() => document.querySelector<HTMLInputElement>('.task-compose-input')?.focus())
|
||||
}
|
||||
@@ -83,6 +90,47 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
|
||||
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
|
||||
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
|
||||
}
|
||||
function repeatRrule(value: RepeatOption) {
|
||||
return value === 'none' ? '' : `FREQ=${value.toUpperCase()}`
|
||||
}
|
||||
function repeatOption(rrule?: string): RepeatOption {
|
||||
const value = /FREQ=(DAILY|WEEKLY|MONTHLY|YEARLY)/.exec(rrule ?? '')?.[1]?.toLowerCase()
|
||||
return (value as RepeatOption | undefined) ?? 'none'
|
||||
}
|
||||
async function saveRepeat(task: Task, value: RepeatOption) {
|
||||
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)
|
||||
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)
|
||||
} 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
|
||||
@@ -94,6 +142,7 @@ async function submitTaskCompose() {
|
||||
priority: composePriority.value,
|
||||
description: composeDescription.value,
|
||||
}) })
|
||||
if (composeRepeat.value !== 'none') await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule: repeatRrule(composeRepeat.value) }) })
|
||||
if (isTaskView(activeView.value)) {
|
||||
tasks.value.push(task)
|
||||
totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value)
|
||||
@@ -485,7 +534,18 @@ async function saveTask() {
|
||||
if (!selectedTask.value?.title.trim()) return
|
||||
try {
|
||||
const task = selectedTask.value
|
||||
await patchTask(task, { title: task.title.trim(), description: task.description, priority: Number(task.priority), due_at: fromDateTimeLocal(toDateTimeLocal(task.due_at)), list_id: task.list_id } as Partial<Task>)
|
||||
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) }
|
||||
}
|
||||
@@ -509,6 +569,7 @@ async function addSubtask() {
|
||||
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
|
||||
@@ -642,6 +703,7 @@ onMounted(bootstrap)
|
||||
<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></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
|
||||
<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">
|
||||
@@ -663,6 +725,7 @@ onMounted(bootstrap)
|
||||
<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>
|
||||
<label>截止时间<input v-model="composeDueAt" type="datetime-local"></label>
|
||||
<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></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
|
||||
<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>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient(rgba(255,253,248,.92),rgba(255,253,248,.92)),repeating-linear-gradient(0deg,transparent,transparent 31px,#eee3d2 32px)}.topbar{display:flex;align-items:center;gap:14px}.topbar>div{flex:1}.topbar p{margin:0;color:var(--muted);font-size:12px}.topbar h1{font-size:27px;margin:3px 0 21px;letter-spacing:-.03em}.topbar.today-topbar h1{margin-bottom:4px}.today-board{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:18px 0 8px}.today-stat{background:#fff;border:1px solid rgba(231,221,204,.86);border-radius:14px;padding:12px 14px;box-shadow:0 3px 14px rgba(81,61,38,.04);display:grid;gap:3px}.today-stat strong{font-size:23px;line-height:1;color:#3c372f}.today-stat span{font-size:12px;color:var(--muted)}.today-stat.muted strong{color:#8b8275}.search{margin-bottom:18px;display:flex;align-items:center;gap:8px;width:min(260px,36%);padding:8px 10px;background:#faf7f0;border:1px solid var(--line);border-radius:9px;color:var(--muted)}.search input{min-width:0;width:100%;border:0;outline:0;background:transparent}.search kbd{font-size:10px;white-space:nowrap;border:1px solid var(--line);padding:2px 4px;border-radius:4px}.list-toolbar{height:42px;display:flex;align-items:center;gap:14px;color:var(--muted);font-size:12px}.list-toolbar label{margin-right:auto}.link{border:0;background:transparent;color:var(--accent);padding:3px;display:inline-flex;gap:3px;align-items:center}.link svg{width:14px}.task-list{transition:opacity .2s}.task-list.loading{opacity:.45}.task-row{border-left:0;--swipe-x:0px;min-height:53px;display:flex;align-items:center;gap:10px;border-bottom:1px solid var(--line);padding:4px 8px;transition:background .15s,transform .18s ease;position:relative;overflow:hidden;isolation:isolate}.task-row.swipeable{touch-action:pan-y;transform:translate(var(--swipe-x),var(--reorder-y,0px))}.task-row.swipeable:not([style*="0px"]){transition:none}.task-row:hover,.task-row.selected{background:rgba(250,240,229,.75)}.task-row:hover:not(.reordering){transform:translate(calc(var(--swipe-x) + 2px),var(--reorder-y,0px))}.check{width:19px;height:19px;flex:0 0 19px;border:1.6px solid #c6baa8;background:#fff;border-radius:5px;padding:0;display:grid;place-items:center}.check svg{width:13px}.task-check{width:44px;height:44px;flex:0 0 44px;border:0;background:transparent;padding:0;display:grid;place-items:center;border-radius:9px}.task-check-mark{width:19px;height:19px;border:1.6px solid #c6baa8;background:#fff;border-radius:50%;display:grid;place-items:center;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .15s ease}.task-check-mark svg{width:13px}.task-check:hover .task-check-mark{transform:scale(1.06);border-color:#9d8f7b}.task-check:focus-visible{outline:2px solid rgba(241,90,41,.32);outline-offset:1px}.task-check[aria-pressed="true"] .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.task-check-mark.p1{border-color:#4b93d1}.task-check-mark.p2{border-color:#d79b25}.task-check-mark.p3{border-color:#dc4b30}.check.p1{border-color:#4b93d1}.check.p2{border-color:#d79b25}.check.p3{border-color:#dc4b30}.done .check{background:#afa595;color:white}.task-main{border:0;background:transparent;flex:1;min-width:0;text-align:left;padding:8px 0}.task-main strong{display:block;font-size:14px;font-weight:590;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.done .task-main strong{text-decoration:line-through;color:#9b9388}.meta{display:flex;align-items:center;gap:9px;color:#9b9286;font-size:11px;margin-top:3px}.meta span{display:flex;align-items:center;gap:3px}.meta svg{width:12px}.priority{font-size:10px;font-weight:750;padding:3px 6px;border-radius:5px}.priority.p1{color:#3f80ba;background:#e5f2fc}.priority.p2{color:#a56b05;background:#fff1cb}.priority.p3{color:#bd3827;background:#fde2dc}.icon,.ghost{min-width:44px;min-height:44px;border:0;background:transparent;display:grid;place-items:center;padding:5px;border-radius:6px}.mobile-only{display:none}.ghost{opacity:0;color:#9d9387}.task-row:hover .ghost{opacity:1}.ghost:hover{color:var(--danger);background:#fce7e2}.restore{display:flex;align-items:center;gap:5px;border:1px solid var(--line);background:#fff;border-radius:7px;padding:6px 8px;font-size:12px}.restore svg{width:14px}.subtask{padding-left:53px;min-height:42px;color:#6d655b}.subtask>svg{width:13px;color:#bbb0a2}.empty{min-height:300px;display:grid;place-items:center;align-content:center;gap:8px;color:#aaa094;text-align:center}.empty>svg{width:38px;height:38px;color:#d8cabb}.empty b{color:#6f675c}.empty span{font-size:13px}
|
||||
.drag-handle{width:34px;height:44px;flex:0 0 34px;display:grid;place-items:center;border:0;background:transparent;color:#b3a795;cursor:grab;touch-action:none;border-radius:8px}.drag-handle:active{cursor:grabbing}.drag-handle:disabled{opacity:.3;cursor:not-allowed}.drag-handle svg{width:17px;height:17px}.reordering{opacity:.78;z-index:4;box-shadow:0 12px 28px rgba(78,58,34,.18)!important;transition:none!important;pointer-events:none}.reorder-target{box-shadow:inset 0 2px 0 var(--accent)}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.today-summary{align-items:stretch;text-align:left;place-items:stretch}.today-summary>svg{justify-self:start}.today-summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;width:100%;margin-top:4px}.today-summary-grid span{display:grid;gap:2px;background:#faf7f0;border-radius:10px;padding:10px;text-align:center;color:var(--muted);font-size:11px}.today-summary-grid strong{font-size:18px;color:#4f473d}.detail-form{padding:19px;display:grid;gap:15px}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px){.shell{grid-template-columns:220px minmax(400px,1fr) 310px}main{padding-inline:24px}}
|
||||
@media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%)}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar{align-items:flex-start}.topbar h1{font-size:24px;margin:0 0 3px}.topbar.today-topbar{gap:10px}.today-board{gap:7px;margin:14px 0 4px}.today-stat{padding:10px 11px;border-radius:12px}.today-stat strong{font-size:20px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
.mvp-view{display:grid;gap:16px;padding-bottom:36px}.view-intro{display:flex;align-items:end;justify-content:space-between;border-bottom:1px dashed var(--line);padding-bottom:12px}.view-intro h2{margin:2px 0 0;font-size:22px}.view-intro small,.view-intro>span{color:var(--muted);font-size:12px}.tool-card,.empty-panel{background:#fff;border:1px solid var(--line);border-radius:12px;padding:16px;box-shadow:0 3px 14px rgba(81,61,38,.05)}.soft-button,.primary-small,.danger-button,.file-button{min-height:44px;display:inline-flex;align-items:center;justify-content:center;gap:6px;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px 11px;font-size:12px}.soft-button svg,.file-button svg{width:15px}.primary-small{background:var(--accent);border-color:var(--accent);color:#fff}.danger-button{color:var(--danger);border-color:#e5b7ad}.habit-list{display:grid;gap:10px}.tool-card h3{margin:0 0 4px}
|
||||
|
||||
@@ -172,6 +172,16 @@ describe('mobile touch targets', () => {
|
||||
})
|
||||
|
||||
describe('unified floating add interaction', () => {
|
||||
it('lets task create and detail forms configure a repeat rule', () => {
|
||||
expect(app).toContain('const composeRepeat = ref')
|
||||
expect(app).toContain('const selectedTaskRepeat = ref')
|
||||
expect(app).toContain('重复<select v-model="composeRepeat"')
|
||||
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
||||
expect(app).toContain("api('/recurrences'")
|
||||
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
|
||||
expect(app).toContain("api(`/recurrences/${selectedTaskRecurrence.value.id}`")
|
||||
})
|
||||
|
||||
it('preloads countdowns after authentication', () => {
|
||||
expect(app).toContain('void preloadCountdowns()')
|
||||
expect(app).toContain("const active = await api('/countdowns')")
|
||||
|
||||
Reference in New Issue
Block a user