From 978c4b87edf6411a2ed3e3cd90ce4c7ede521f31 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 18 Sep 2026 10:51:57 +0800 Subject: [PATCH] feat: unify task and habit pages with Today --- frontend/e2e/visual-polish.spec.ts | 23 ++++++- frontend/src/App.vue | 46 +++++++++++--- frontend/src/MvpPanel.vue | 9 ++- frontend/src/TodayEnvironment.test.ts | 6 +- frontend/src/TodaySections.test.ts | 2 +- frontend/src/lib/task-open-total.ts | 13 ++++ frontend/src/style.css | 24 ++++++++ frontend/src/style.test.ts | 15 +++-- frontend/src/visual-polish.test.ts | 86 +++++++++++++++++++++++++++ 9 files changed, 204 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/task-open-total.ts diff --git a/frontend/e2e/visual-polish.spec.ts b/frontend/e2e/visual-polish.spec.ts index f53eb23..0a80576 100644 --- a/frontend/e2e/visual-polish.spec.ts +++ b/frontend/e2e/visual-polish.spec.ts @@ -135,6 +135,20 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await page.goto('/') await openSidebarView(page, '收集箱') + const mobileGeometry = await page.evaluate(() => { + const main = document.querySelector('main.list-main')!.getBoundingClientRect() + const header = document.querySelector('.list-page-context')!.getBoundingClientRect() + const filter = document.querySelector('.list-inline-filter')!.getBoundingClientRect() + const search = document.querySelector('.list-search-reveal')!.getBoundingClientRect() + const toggle = document.querySelector('.list-search-reveal .task-search-toggle')!.getBoundingClientRect() + return { main: { left: main.left, right: main.right }, header: { left: header.left, right: header.right, bottom: header.bottom }, filter: { left: filter.left, bottom: filter.bottom }, search: { left: search.left, right: search.right, top: search.top }, toggle: { left: toggle.left, top: toggle.top } } + }) + expect(mobileGeometry.header.left - mobileGeometry.main.left).toBeCloseTo(29, 0) + expect(mobileGeometry.main.right - mobileGeometry.header.right).toBeCloseTo(29, 0) + expect(mobileGeometry.search.left).toBeGreaterThanOrEqual(mobileGeometry.header.left) + expect(mobileGeometry.search.right).toBeLessThanOrEqual(mobileGeometry.header.right) + expect(mobileGeometry.toggle.top).toBeGreaterThanOrEqual(mobileGeometry.header.bottom - 1) + expect(mobileGeometry.toggle.left).toBeGreaterThanOrEqual(mobileGeometry.filter.left) const searchToggle = page.getByRole('button', { name: '展开搜索任务' }) await expect(searchToggle).toHaveAttribute('aria-expanded', 'false') await searchToggle.click() @@ -219,10 +233,17 @@ test('approved polish keeps search state, dense rows, title-only memos, and uniq await expect(page.getByRole('button', { name: /展开搜索任务|收起搜索任务/ })).toBeHidden() const desktopSearch = page.getByRole('textbox', { name: '搜索任务' }) await expect(desktopSearch).toBeVisible() - const desktopPanel = page.locator('.search-reveal') + const desktopPanel = page.locator('.list-search-reveal') const desktopBox = await desktopPanel.boundingBox() expect(desktopBox).not.toBeNull() expect(desktopBox!.width).toBeGreaterThanOrEqual(320) + const desktopHeader = page.locator('.list-page-context') + const desktopList = page.locator('.task-list') + const desktopGeometry = await Promise.all([desktopHeader, desktopPanel, desktopList].map(async locator => locator.boundingBox())) + for (const box of desktopGeometry) expect(box).not.toBeNull() + expect(Math.abs(desktopGeometry[0]!.x - desktopGeometry[1]!.x)).toBeLessThanOrEqual(1) + expect(Math.abs(desktopGeometry[1]!.x - desktopGeometry[2]!.x)).toBeLessThanOrEqual(1) + expect(desktopGeometry[0]!.width).toBeLessThanOrEqual(900) await desktopSearch.fill(`搜索保留-${suffix}`) const desktopTaskRow = page.locator('.task-row').filter({ hasText: taskTitle }) await expect(desktopTaskRow).toHaveCount(1) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5858653..9e3090e 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -25,6 +25,7 @@ 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' +import { loadTaskOpenTotal } from './lib/task-open-total' type FolderItem = { id: string; name: string } type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean } @@ -103,6 +104,7 @@ const todaySectionCollapse = ref(readTodaySectionCollapse( const page = ref(1) const pageSize = 50 const totalTasks = ref(0) +const taskOpenTotal = ref(null) const hiddenCompletedTaskCount = ref(0) const todayTaskTotal = ref(0) const todayTaskCompleted = ref(0) @@ -165,6 +167,7 @@ const selectedRepeatConfig = ref(defaultRepeatConfig()) const weekdayOptions = [{ value: 'MO', label: '一' }, { value: 'TU', label: '二' }, { value: 'WE', label: '三' }, { value: 'TH', label: '四' }, { value: 'FR', label: '五' }, { value: 'SA', label: '六' }, { value: 'SU', label: '日' }] let recurrenceLoadToken = 0 let todaySummaryLoadToken = 0 +let taskOpenTotalLoadToken = 0 let todayEnvironmentLoadToken = 0 const habitComposer = ref | null>(null) const countdownComposer = ref | null>(null) @@ -553,6 +556,18 @@ async function loadTasksPage(request = beginLatestRequest('tasks')) { if (!isLatestRequest('tasks', request)) return tasks.value = data.items ?? [] totalTasks.value = data.total ?? tasks.value.length + if (activeView.value === 'tasks' && !showCompleted.value && !query.value) taskOpenTotal.value = totalTasks.value + if (activeView.value === 'tasks' && (showCompleted.value || query.value)) { + const token = ++taskOpenTotalLoadToken + const listId = activeList.value + const openParams = new URLSearchParams({ page: '1', page_size: '1', completed: 'false' }) + if (listId) openParams.set('list_id', listId) + loadTaskOpenTotal( + () => api(`/tasks?${openParams}`), + () => token === taskOpenTotalLoadToken && isLatestRequest('tasks', request) && activeView.value === 'tasks' && activeList.value === listId, + value => { taskOpenTotal.value = value }, + ) + } hiddenCompletedTaskCount.value = 0 if (!showCompleted.value && !query.value && activeView.value !== 'trash' && tasks.value.length === 0) { const completedParams = new URLSearchParams(params) @@ -589,6 +604,10 @@ async function preloadCountdowns() { } async function loadAll() { const request = beginLatestRequest('tasks') + if (activeView.value === 'tasks') { + taskOpenTotal.value = null + ++taskOpenTotalLoadToken + } loading.value = true error.value = '' try { @@ -655,6 +674,8 @@ async function switchView(view: View, listId?: string) { todayEnvironmentLoading.value = false } activeView.value = view + taskOpenTotal.value = null + ++taskOpenTotalLoadToken if (!query.value) mobileSearchOpen.value = false searchPullDistance.value = 0 if (view !== 'trash') beginLatestRequest('trash') @@ -1504,22 +1525,22 @@ onUnmounted(() => { -
+
-

{{ activeName }}

+

{{ activeName }}

- +
-
+
@@ -1530,6 +1551,15 @@ onUnmounted(() => {

还有 {{ todayTaskRemaining + todayHabitRemaining }} 项待完成

+
+

{{ activeName }}

{{ taskOpenTotal === null ? '待完成统计暂不可用' : `还有 ${taskOpenTotal} 项待完成` }}

+ +
+
+ + + +
-
{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项
+
任务{{ totalTasks }}
+
{{ totalPages > 1 ? `第 ${page} / ${totalPages} 页 · ` : '' }}共 {{totalTasks}} 项
+
第 {{ page }} / {{ totalPages }} 页 · 共 {{ totalTasks }} 项
{{page}} / {{totalPages}}
-
+