From aa789c74f259b68e0a748896fd37be65817cc78c Mon Sep 17 00:00:00 2001 From: bboysoul Date: Fri, 18 Sep 2026 16:16:38 +0800 Subject: [PATCH] feat: redesign settings as paper ledger --- frontend/e2e/mobile-ui.spec.ts | 69 ++++++++++++++++++++++----- frontend/playwright.config.ts | 8 +++- frontend/src/App.vue | 6 ++- frontend/src/MvpPanel.vue | 45 +++++++++++++---- frontend/src/TodayEnvironment.test.ts | 2 +- frontend/src/style.css | 36 ++++++++++++++ frontend/src/style.test.ts | 56 ++++++++++++++++++++-- 7 files changed, 190 insertions(+), 32 deletions(-) diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index cc7dd7f..e2ac142 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -5,6 +5,21 @@ function bottomTab(page: Page, name: string) { return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) } +async function openSettings(page: Page) { + const mobileTab = bottomTab(page, '设置') + if (await mobileTab.isVisible()) { + await mobileTab.click() + return + } + const desktopSettings = page.getByRole('navigation', { name: '管理' }).getByRole('button', { name: '设置', exact: true }) + const box = await desktopSettings.boundingBox() + if (box && box.x + box.width > 0 && box.y + box.height > 0 && box.x < (await page.viewportSize())!.width) await desktopSettings.click() + else { + await page.getByRole('button', { name: /^(展开|收起)菜单$/ }).click() + await desktopSettings.click() + } +} + async function openTaskComposer(page: Page) { await page.getByRole('button', { name: '添加任务' }).click() return page.getByRole('dialog', { name: /添加(?:今天)?任务/ }) @@ -133,29 +148,57 @@ test('all bottom destinations expose one active page and desktop layout stays un expect(desktopBottomGap).toBe(84) }) -test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => { +test('settings match the approved paper-ledger geometry and action hierarchy', async ({ page }) => { await page.goto('/') - await bottomTab(page, '设置').click() - await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') + await openSettings(page) + if ((await page.viewportSize())!.width <= 720) await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') const groups = page.locator('.settings-group') await expect(groups).toHaveCount(4) const layout = await page.locator('.settings-sections').evaluate(element => { - const groups = [...element.querySelectorAll(':scope > .settings-group')] + const page = element as HTMLElement + const main = page.closest('main') as HTMLElement + const heading = page.querySelector('.settings-heading h1')! + const sectionHeaders = [...page.querySelectorAll('.settings-group > header')] + const rows = [...page.querySelectorAll('.settings-row')] + const menu = main.querySelector('.topbar > .icon')!.getBoundingClientRect() + const refresh = main.querySelector('.settings-refresh')!.getBoundingClientRect() + const rect = page.getBoundingClientRect() + const mainRect = main.getBoundingClientRect() return { bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, - gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom), + leftPadding: rect.left - mainRect.left, + rightPadding: mainRect.right - rect.right, + headingSize: getComputedStyle(heading).fontSize, + sectionHeights: sectionHeaders.map(header => header.getBoundingClientRect().height), + rowHeights: rows.map(row => row.getBoundingClientRect().height), + controlsOverlap: !(menu.right <= refresh.left || refresh.right <= menu.left || menu.bottom <= refresh.top || refresh.bottom <= menu.top), } }) expect(layout.bodyOverflow).toBe(0) - expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy() - const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ }) - for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) { - const box = await target.boundingBox() - expect(box).not.toBeNull() - expect(box!.width).toBeGreaterThanOrEqual(44) - expect(box!.height).toBeGreaterThanOrEqual(44) + const isMobileContract = (await page.viewportSize())!.width <= 720 + if (isMobileContract) { + expect(layout.leftPadding).toBeCloseTo(29, 0) + expect(layout.rightPadding).toBeCloseTo(29, 0) + expect(layout.headingSize).toBe('24px') + } else { + const pageWidth = await page.locator('.settings-sections').evaluate(element => element.getBoundingClientRect().width) + expect(pageWidth).toBeLessThanOrEqual(900) + expect(layout.leftPadding).toBeCloseTo(layout.rightPadding, 0) + expect(layout.headingSize).toBe('34px') } - for (const target of await sessionButtons.all()) { + expect(layout.sectionHeights.every(height => height === 44)).toBeTruthy() + expect(layout.rowHeights.every(height => height >= (isMobileContract ? 64 : 62))).toBeTruthy() + expect(layout.controlsOverlap).toBe(false) + + const exportButton = page.getByRole('button', { name: '导出 ZIP' }) + const fileInput = page.locator('input[type=file]') + await fileInput.setInputFiles({ name: 'settings-geometry.zip', mimeType: 'application/zip', buffer: Buffer.from('zip-placeholder') }) + const preflightButton = page.getByRole('button', { name: '开始预检' }) + const actionColors = await Promise.all([exportButton, preflightButton].map(control => control.evaluate(element => ({ background: getComputedStyle(element).backgroundColor, color: getComputedStyle(element).color })))) + expect(actionColors[0]).toEqual({ background: 'rgb(241, 90, 41)', color: 'rgb(255, 255, 255)' }) + expect(actionColors[1].background).not.toBe('rgb(241, 90, 41)') + + for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) { const box = await target.boundingBox() expect(box).not.toBeNull() expect(box!.width).toBeGreaterThanOrEqual(44) diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index f466e19..8d8c80f 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -7,7 +7,13 @@ const project = projectName === 'mobile-390' ? { name: 'mobile-390', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } } : projectName === 'mobile-375' ? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } } - : null + : projectName === 'desktop-1440' + ? { name: 'desktop-1440', use: { viewport: { width: 1440, height: 900 } } } + : projectName === 'desktop-721' + ? { name: 'desktop-721', use: { viewport: { width: 721, height: 900 } } } + : projectName === 'desktop-720' + ? { name: 'desktop-720', use: { viewport: { width: 720, height: 900 } } } + : null if (!project) throw new Error(`unknown DODO_E2E_PROJECT: ${projectName}`) const runtimeRoot = path.resolve('playwright-runtime', projectName) diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 9e3090e..c614611 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -642,6 +642,7 @@ async function refreshCurrentView() { refreshing.value = true try { if (activeView.value === 'habits') await habitComposer.value?.refreshHabits() + else if (activeView.value === 'settings') await habitComposer.value?.refreshSettings() else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()]) else await refreshAll() } finally { @@ -1526,13 +1527,14 @@ onUnmounted(() => {
-
+
-

{{ activeName }}

+

{{ activeName }}

+
diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index ecd2e9d..aa0d2f6 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -32,6 +32,10 @@ const archiveFlags = computed(() => archivePanelFlags(archiveState.value, archiv const habitArchiveToggle = ref(null) const sessions = ref([]) const audit = ref([]) +const sessionsState = ref<'loading' | 'success' | 'error'>('loading') +const auditState = ref<'loading' | 'success' | 'error'>('loading') +const sessionsError = ref('') +const auditError = ref('') const busy = ref(false) const error = ref('') const habitName = ref('') @@ -508,7 +512,7 @@ function closeHabitDetail() { else habitArchiveToggle.value?.focus() }) } -defineExpose({ openHabitComposer, refreshHabits: loadHabits }) +defineExpose({ openHabitComposer, refreshHabits: loadHabits, refreshSettings: loadSettings }) async function archiveHabit(h: Habit) { if (!(await confirmAction(`归档习惯“${h.name}”?`, '历史打卡记录会保留。'))) return await safe(async () => { @@ -593,11 +597,31 @@ async function loadHabits() { } } async function loadSettings() { - await safe(async () => { - const [s, a] = await Promise.all([request('/sessions').catch(() => []), request('/audit-logs?limit=20').catch(() => [])]) - sessions.value = mergePage(s).items - audit.value = mergePage(a).items - }) + busy.value = true + error.value = '' + sessionsState.value = 'loading' + auditState.value = 'loading' + sessionsError.value = '' + auditError.value = '' + const [sessionResult, auditResult] = await Promise.allSettled([ + request('/sessions'), + request('/audit-logs?limit=20'), + ]) + if (sessionResult.status === 'fulfilled') { + sessions.value = mergePage(sessionResult.value).items + sessionsState.value = 'success' + } else { + sessionsState.value = 'error' + sessionsError.value = sessionResult.reason instanceof Error ? sessionResult.reason.message : '登录设备加载失败' + } + if (auditResult.status === 'fulfilled') { + audit.value = mergePage(auditResult.value).items + auditState.value = 'success' + } else { + auditState.value = 'error' + auditError.value = auditResult.reason instanceof Error ? auditResult.reason.message : '活动记录加载失败' + } + busy.value = false } async function revoke(id: string) { await safe(async () => { await request(`/sessions/${id}`, { method: 'DELETE' }); await loadSettings(); emit('notice', '会话已撤销') }) @@ -816,10 +840,11 @@ onBeforeUnmount(() => { diff --git a/frontend/src/TodayEnvironment.test.ts b/frontend/src/TodayEnvironment.test.ts index d904ef5..2101aef 100644 --- a/frontend/src/TodayEnvironment.test.ts +++ b/frontend/src/TodayEnvironment.test.ts @@ -84,7 +84,7 @@ describe('Today environment integration', () => { expect(filter).toBeGreaterThan(remaining) expect(overdue).toBeGreaterThan(filter) expect(main.match(/>今天<\/h1>/g)).toHaveLength(1) - expect(main).toContain("
") expect(main).toContain('') expect(main).toContain('aria-label="刷新当前页面"') diff --git a/frontend/src/style.css b/frontend/src/style.css index 3ff4347..2c245b2 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -160,3 +160,39 @@ main.today-main .topbar{margin-bottom:18px} @media(max-width:930px){main.today-main{padding-left:max(44px,calc((100% - 630px)/2));padding-right:max(44px,calc((100% - 630px)/2))}} @media(max-width:720.98px){main.today-main{padding-left:29px!important;padding-right:29px!important}.today-environment{grid-template-columns:minmax(0,1fr) minmax(0,1fr);grid-template-rows:21px auto;align-items:stretch;column-gap:0;row-gap:11px;padding-bottom:14px}.today-environment__calendar{grid-column:1/-1;grid-row:1;height:21px!important;padding:0;justify-content:space-between;align-items:start;border-left:0}.today-environment__weather,.today-environment__gold{grid-row:2;height:clamp(42px,calc(25vw - 51.75px),45.75px);padding-top:10px;border-top:1px solid #e8e0d5}.today-environment__weather{grid-column:1;padding-left:0;padding-right:10px;border-left:0}.today-environment__gold{grid-column:2;padding-left:12px}.today-environment__weather strong,.today-environment__gold strong,.today-environment__gold-primary{line-height:16px}.today-environment__item small{line-height:13px}.today-page-title{margin-top:23px;font-size:24px}.today-main .task-row,.today-main .habit-row{height:58px;min-height:58px;max-height:58px}.today-context .completed-filter-pill{width:87px;height:44px;min-height:44px}.today-context .completed-filter-pill__track{width:30px;height:18px}.today-context .completed-filter-pill__thumb{width:14px;height:14px}.today-context .completed-filter-pill[aria-checked="true"] .completed-filter-pill__thumb{transform:translateX(12px)}} @media(max-width:380px){.today-environment__weather,.today-environment__gold{height:43px;padding-top:7px;padding-bottom:7px}} + +/* Approved Settings 01: continuous paper ledger. */ +main:has(>.mvp-view .settings-sections){background:#fffdf8} +.settings-topbar{margin-bottom:0} +.topbar>.settings-refresh{grid-area:filter} +.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px} +.settings-heading{padding-bottom:22px;border-bottom:1px solid #e8e0d5} +.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;letter-spacing:-1.2px;margin:0} +.settings-heading p{margin:9px 0 0;color:var(--muted);font-size:13px} +.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px} +.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5} +.settings-group>header h2{margin:0;font-size:13px;font-weight:750} +.settings-group>header>small{margin-left:auto;color:var(--muted);font-size:12px;font-weight:500} +.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;display:flex;align-items:center;justify-content:space-between;gap:14px;background:transparent} +.settings-row>span{min-width:0;flex:1;display:grid;gap:3px} +.settings-row>span>b,.session-title,.audit-action{font-size:14px;font-weight:620} +.settings-row small,.settings-empty,.audit-row time{color:var(--muted);font-size:11px;line-height:1.4;overflow-wrap:anywhere} +.settings-form{padding:16px 0 18px;border-bottom:1px solid #e8e0d5} +.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px} +.password-form.settings-form label{min-width:0;display:grid;gap:6px;color:#72695e;font-size:11px;font-weight:680} +.password-form.settings-form input{height:44px;min-width:0;padding:0 11px;border:1px solid #ded5c8;border-radius:9px;background:#fff;box-shadow:none} +.password-form.settings-form .inline-error{grid-column:1/-1;margin:0} +.password-form.settings-form>.primary-small{grid-column:4;grid-row:1;align-self:end;justify-self:auto} +main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){min-height:44px;border-radius:9px;border-color:#dcd2c4;background:#fffdf9} +main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff} +main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)} +.settings-row select{height:44px;min-width:150px;padding:0 11px;border:1px solid #ded5c8;border-radius:9px;background:#fff;box-shadow:none} +main:has(>.mvp-view .settings-sections) .session-card-actions{min-height:62px;padding:6px 0;border-bottom:1px solid #e8e0d5} +main:has(>.mvp-view .settings-sections) .session-card-actions p{margin:0;color:var(--muted);font-size:12px} +main:has(>.mvp-view .settings-sections) .session-row,main:has(>.mvp-view .settings-sections) .audit-row{padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5} +main:has(>.mvp-view .settings-sections) .settings-empty{min-height:62px;margin:0;padding:6px 0;display:flex;align-items:center;border-top:0;border-bottom:1px solid #e8e0d5} +main:has(>.mvp-view .settings-sections) .settings-status-error{min-height:62px;padding:6px 0;display:flex;align-items:center;justify-content:space-between;gap:12px;border-bottom:1px solid #e8e0d5;color:var(--danger);font-size:12px} +main:has(>.mvp-view .settings-sections) .settings-status-error>span{min-width:0;overflow-wrap:anywhere} +main:has(>.mvp-view .settings-sections) .backup-preflight{margin:14px 0 2px} +main:has(>.mvp-view .settings-sections) .settings-data>.inline-error{margin:14px 0 0} +@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}.settings-sections{width:100%;padding-top:23px;padding-bottom:0}.settings-heading{padding-bottom:18px}.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}.settings-heading p{font-size:12px;line-height:1.5}.settings-group{padding-top:20px}.settings-row{min-height:64px}.password-form.settings-form{grid-template-columns:1fr}.password-form.settings-form>.primary-small{grid-column:1;grid-row:auto;justify-self:start}main:has(>.mvp-view .settings-sections) .session-card-actions{min-height:64px}main:has(>.mvp-view .settings-sections) .settings-empty,main:has(>.mvp-view .settings-sections) .settings-status-error{min-height:64px}} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 0f6e196..6609075 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -115,6 +115,52 @@ describe('mobile navigation styles', () => { }) }) +describe('approved Settings 01 paper ledger', () => { + it('moves Settings identity into the body and suppresses the duplicate shell title', () => { + expect(app).not.toContain("'settings-main':activeView==='settings'") + expect(app).toContain(":class=\"{'settings-topbar':activeView==='settings'}\"") + expect(app).toContain("v-if=\"!['today','tasks','habits','settings'].includes(activeView)\" class=\"topbar-title\"") + expect(app).toContain('v-if="activeView===\'settings\'" class="icon topbar-refresh settings-refresh"') + expect(mvpPanel).toContain('

设置

管理数据、账户与登录设备

') + expect(mvpPanel).toContain('

数据与恢复

完整备份') + expect(mvpPanel).toContain("

登录设备

{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}") + expect(mvpPanel).toContain('

活动记录

最近操作') + }) + + it('uses the selected continuous-paper geometry on desktop and mobile', () => { + expect(css).toContain('main:has(>.mvp-view .settings-sections){background:#fffdf8}') + expect(css).toContain('.settings-sections{width:min(100%,900px);margin:0 auto;display:grid;gap:0;padding:34px 0 64px}') + expect(css).toContain('.settings-heading h1{font-size:34px;line-height:1.1;font-weight:700;') + expect(css).toContain('.settings-group{min-width:0;background:transparent;border:0;border-radius:0;box-shadow:none;overflow:visible;padding-top:25px}') + expect(css).toContain('.settings-group>header{height:44px;padding:0;display:flex;align-items:center;border-bottom:1px solid #e8e0d5}') + expect(css).toContain('.settings-row{min-height:62px;padding:6px 0;border-top:0;border-bottom:1px solid #e8e0d5;') + expect(css).toContain('.settings-topbar{margin-bottom:0}') + expect(css).toContain('.topbar>.settings-refresh{grid-area:filter}') + expect(css).toContain('@media(max-width:720px){main:has(>.mvp-view .settings-sections){padding-left:29px;padding-right:29px;padding-bottom:calc(102px + env(safe-area-inset-bottom))}') + expect(css).toContain('.settings-sections{width:100%;padding-top:23px;padding-bottom:0}') + expect(css).toContain('.settings-heading h1{font-size:24px;line-height:1.1;font-weight:700}') + expect(css).toContain('.settings-row{min-height:64px}') + }) + + it('keeps controls and password errors aligned with the approved form contract', () => { + expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.soft-button,.primary-small,.danger-button,.file-button){min-height:44px;border-radius:9px;border-color:#dcd2c4;background:#fffdf9}') + expect(css).toContain('main:has(>.mvp-view .settings-sections) :is(.settings-data>.settings-row:first-of-type .soft-button,.backup-preflight .danger-button,.password-form .primary-small){background:#f15a29;border-color:#f15a29;color:#fff}') + expect(css).toContain('main:has(>.mvp-view .settings-sections) .danger-text{min-height:44px;border:0;background:transparent;color:var(--danger)}') + expect(css).toContain('.password-form.settings-form{width:100%;display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;gap:10px}') + expect(css).toContain('.password-form.settings-form .inline-error{grid-column:1/-1;margin:0}') + expect(mvpPanel).toContain("const sessionsState = ref<'loading' | 'success' | 'error'>('loading')") + expect(mvpPanel).toContain("const auditState = ref<'loading' | 'success' | 'error'>('loading')") + expect(mvpPanel).toContain('await Promise.allSettled([') + expect(mvpPanel).toContain("v-if=\"sessionsState==='loading'\"") + expect(mvpPanel).toContain("v-else-if=\"sessionsState==='error'\"") + expect(mvpPanel).toContain("v-if=\"auditState==='loading'\"") + expect(mvpPanel).toContain("v-else-if=\"auditState==='error'\"") + expect(mvpPanel).not.toContain("'/sessions').catch(() => [])") + expect(css).toContain('@media(max-width:720px){') + expect(css).toContain('.password-form.settings-form{grid-template-columns:1fr}') + }) +}) + describe('settings sessions and audit activity', () => { it('renders readable session metadata, local time, and an accessible revoke action', () => { expect(mvpPanel).toContain('class="session-copy"') @@ -798,7 +844,7 @@ describe('task and habit row decoration', () => { expect(app).toContain(':disabled="refreshing || loading"') expect(app).toContain("else if (activeView.value === 'today') await Promise.all([refreshAll(), habitComposer.value?.refreshHabits()])") expect(app).toContain("if (activeView.value === 'habits') await habitComposer.value?.refreshHabits()") - expect(mvpPanel).toContain('defineExpose({ openHabitComposer, refreshHabits: loadHabits })') + expect(mvpPanel).toContain('defineExpose({ openHabitComposer, refreshHabits: loadHabits, refreshSettings: loadSettings })') expect(app).toContain("readStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, true)") expect(app).toContain("writeStoredBoolean(window.localStorage, SHOW_COMPLETED_STORAGE_KEY, value)") expect(app).toContain(':show-completed="showCompleted"') @@ -1022,13 +1068,13 @@ describe('approved habit safety and U2 title hierarchy', () => { expect(mvpPanel).not.toContain('

习惯

') expect(mvpPanel).not.toContain('

设置与数据

') expect(mvpPanel).not.toContain('class="view-intro"') - for (const title of ['数据', '账户与安全', '登录设备', '活动']) expect(mvpPanel).toContain(`

${title}

`) + for (const title of ['数据与恢复', '账户与安全', '登录设备', '活动记录']) expect(mvpPanel).toContain(`

${title}

`) expect(mvpPanel).not.toContain('

危险操作

') expect(mvpPanel).toContain('class="settings-sections"') expect(mvpPanel).not.toContain('class="settings-grid"') expect(mvpPanel).not.toContain('class="tool-card') - expect(css).toContain('.settings-sections{width:min(100%,760px);') - expect(css).toContain('.settings-row{min-height:56px;') + expect(css).toContain('.settings-sections{width:min(100%,900px);') + expect(css).toContain('.settings-row{min-height:62px;') expect(css).toContain('.backup-preflight .danger-button{min-height:44px}') expect(css).toContain('.backup-preflight.invalid{background:#fff2ef;') expect(mvpPanel).toContain("v-if=\"restoreMode==='replace'\" class=\"restore-replace-warning\"") @@ -1062,7 +1108,7 @@ describe('approved habit safety and U2 title hierarchy', () => { describe('settings data tools', () => { it('uses complete ZIP backup with preflight, restore modes and legacy compatibility', () => { - expect(mvpPanel).toContain('

数据

') + expect(mvpPanel).toContain('

数据与恢复

') expect(mvpPanel).toContain("downloadFullBackup()") expect(mvpPanel).toContain("'dodo-backup-v2.zip'") expect(mvpPanel).toContain('导出 ZIP')