diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts
index e3cd6b8..32c889a 100644
--- a/frontend/e2e/mobile-ui.spec.ts
+++ b/frontend/e2e/mobile-ui.spec.ts
@@ -271,8 +271,8 @@ test('settings match the approved paper-ledger geometry and action hierarchy', a
expect(layout.bodyOverflow).toBe(0)
const isMobileContract = (await page.viewportSize())!.width <= 720
if (isMobileContract) {
- expect(layout.leftPadding).toBeCloseTo(29, 0)
- expect(layout.rightPadding).toBeCloseTo(29, 0)
+ expect(layout.leftPadding).toBeCloseTo(28, 0)
+ expect(layout.rightPadding).toBeCloseTo(28, 0)
expect(layout.headingSize).toBe('24px')
} else {
const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width)
diff --git a/frontend/e2e/today-plain-list.spec.ts b/frontend/e2e/today-plain-list.spec.ts
index e80305c..eb85791 100644
--- a/frontend/e2e/today-plain-list.spec.ts
+++ b/frontend/e2e/today-plain-list.spec.ts
@@ -60,7 +60,7 @@ test('Today plain-list layout matches the approved responsive geometry', async (
}
})).toEqual({
media720: width <= 720,
- contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 58,
+ contentWidth: width >= 1440 ? 1080 : width > 930 ? Math.min(1080, width - 324) : width === 721 ? 630 : width - 56,
environmentHeight: width >= 721 ? 55 : width === 375 ? 79 : 81,
})
await expect(page.locator('.today-heading')).toHaveCount(1)
@@ -270,9 +270,9 @@ test('Today plain-list layout matches the approved responsive geometry', async (
expect(metrics.directions.weather).toBe('column')
expect(metrics.directions.gold).toBe('column')
if (width <= 720) {
- expect(metrics.content.width).toBeCloseTo(width - 58, 0)
- expect(metrics.content.left).toBeCloseTo(29, 0)
- expect(metrics.styles.mainPaddingLeft).toBe('29px')
+ expect(metrics.content.width).toBeCloseTo(width - 56, 0)
+ expect(metrics.content.left).toBeCloseTo(28, 0)
+ expect(metrics.styles.mainPaddingLeft).toBe('28px')
expect(metrics.styles.titleFontSize).toBe('24px')
expect(metrics.environment.height).toBeCloseTo(width === 375 ? 79 : 81, 0)
}
diff --git a/frontend/e2e/upcoming-list-parity.spec.ts b/frontend/e2e/upcoming-list-parity.spec.ts
index ddbc33e..b295840 100644
--- a/frontend/e2e/upcoming-list-parity.spec.ts
+++ b/frontend/e2e/upcoming-list-parity.spec.ts
@@ -78,8 +78,8 @@ test('Upcoming shares the task-list hierarchy while keeping its date scope and n
expect(geometry.section.left).toBeCloseTo(geometry.list.left, 0)
expect(geometry.header.width).toBeCloseTo(geometry.list.width, 0)
if (geometry.viewportWidth <= 720) {
- expect(geometry.header.left).toBeCloseTo(29, 0)
- expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 29, 0)
+ expect(geometry.header.left).toBeCloseTo(28, 0)
+ expect(geometry.list.right).toBeCloseTo(geometry.viewportWidth - 28, 0)
}
const fab = page.getByRole('button', { name: '添加任务' })
diff --git a/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png b/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png
index 8c6a4de..2a4f31b 100644
Binary files a/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png and b/frontend/e2e/visual-regression.spec.ts-snapshots/inbox-mobile-390.png differ
diff --git a/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png b/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png
index a311b43..7c4dbb3 100644
Binary files a/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png and b/frontend/e2e/visual-regression.spec.ts-snapshots/task-composer-mobile-390.png differ
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index c82d829..a37f38b 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -28,6 +28,7 @@ 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'
+import { failureHint } from './failureHint'
type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
@@ -470,7 +471,7 @@ async function api(path: string, options: RequestInit = {}) {
...options,
})
if (!response.ok) {
- let message = '请求失败'
+ let message = '请求失败,请检查网络后重试'
try { const body = await response.json(); message = formatApiErrorDetail(body.detail) } catch { /* noop */ }
const requestError = new Error(message) as Error & { status: number }
requestError.status = response.status
@@ -483,7 +484,7 @@ function toast(message: string) {
notice.value = message
window.setTimeout(() => { if (notice.value === message) notice.value = '' }, 2400)
}
-function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
+function fail(reason: unknown) { error.value = failureHint('请求失败', reason) }
async function syncBrowserTimezone(currentTimezone?: string) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
@@ -1138,7 +1139,7 @@ async function saveSelectedTaskChanges() {
toast('已保存')
} catch (reason) {
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
- selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'
+ selectedRepeatError.value = failureHint('保存失败', reason)
fail(reason)
} finally {
savingSelectedTask.value = false
@@ -1443,7 +1444,7 @@ async function confirmPurgeList() {
focusPurgeListTrigger()
toast('清单已永久删除')
} catch (reason) {
- purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败'
+ purgeListError.value = failureHint('永久删除失败', reason)
} finally {
purgeListSubmitting.value = false
}
@@ -1512,7 +1513,7 @@ async function saveListEditor() {
listEditorFolderId.value = current.folder_id ?? ''
}
listEditorError.value = '名称已保存,但移动文件夹失败,请重试。'
- } else listEditorError.value = reason instanceof Error ? reason.message : '保存失败'
+ } else listEditorError.value = reason instanceof Error ? reason.message : '保存失败,请检查网络后重试'
} finally { listEditorBusy.value = false }
}
async function archiveListFromEditor() {
@@ -1534,7 +1535,7 @@ async function archiveListFromEditor() {
}
toast('清单已归档')
} catch (reason) {
- listEditorError.value = reason instanceof Error ? reason.message : '归档失败'
+ listEditorError.value = failureHint('归档失败', reason)
} finally { listEditorBusy.value = false }
}
function toggleFolder(id: string) { const next = new Set(expandedFolders.value); next.has(id) ? next.delete(id) : next.add(id); expandedFolders.value = next }
diff --git a/frontend/src/CalendarIntegration.test.ts b/frontend/src/CalendarIntegration.test.ts
index 1f42306..f7e54bf 100644
--- a/frontend/src/CalendarIntegration.test.ts
+++ b/frontend/src/CalendarIntegration.test.ts
@@ -4,7 +4,7 @@ const app=readFileSync('src/App.vue','utf8');const utils=readFileSync('src/lib/m
describe('calendar shell integration',()=>{
it('puts calendar beside the other first-level tools on desktop and mobile',()=>{const nav=app.slice(app.indexOf('',app.indexOf('