feat: redesign settings as paper ledger
This commit is contained in:
@@ -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<HTMLElement>(':scope > .settings-group')]
|
||||
const page = element as HTMLElement
|
||||
const main = page.closest('main') as HTMLElement
|
||||
const heading = page.querySelector<HTMLElement>('.settings-heading h1')!
|
||||
const sectionHeaders = [...page.querySelectorAll<HTMLElement>('.settings-group > header')]
|
||||
const rows = [...page.querySelectorAll<HTMLElement>('.settings-row')]
|
||||
const menu = main.querySelector<HTMLElement>('.topbar > .icon')!.getBoundingClientRect()
|
||||
const refresh = main.querySelector<HTMLElement>('.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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(() => {
|
||||
</aside>
|
||||
|
||||
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='habits'}" @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
<header class="topbar" :inert="memoBackgroundInert ? true : undefined">
|
||||
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
|
||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||||
<div v-if="!['today','tasks','habits'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||
<div v-if="!['today','tasks','habits','settings'].includes(activeView)" class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||
<div v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" class="topbar-actions">
|
||||
<CompletedFilterPill v-if="activeView==='upcoming'" v-model="showCompleted" class="topbar-filter" />
|
||||
<button class="icon topbar-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新当前页面" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||
</div>
|
||||
<button v-if="activeView==='settings'" class="icon topbar-refresh settings-refresh" :class="{spinning:refreshing}" type="button" aria-label="刷新设置" title="刷新" :disabled="refreshing || loading" @click="refreshCurrentView"><RefreshCw /></button>
|
||||
<div v-if="taskSearchAvailable && activeView!=='tasks'" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen,'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
||||
<button ref="taskSearchToggle" class="task-search-toggle" type="button" :aria-label="mobileSearchOpen ? '收起搜索任务' : '展开搜索任务'" :aria-expanded="mobileSearchOpen" aria-controls="task-search-panel" @click="toggleTaskSearch"><Search/></button>
|
||||
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
|
||||
|
||||
+35
-10
@@ -32,6 +32,10 @@ const archiveFlags = computed(() => archivePanelFlags(archiveState.value, archiv
|
||||
const habitArchiveToggle = ref<HTMLButtonElement | null>(null)
|
||||
const sessions = ref<Session[]>([])
|
||||
const audit = ref<any[]>([])
|
||||
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<Session[] | { items?: Session[] }>('/sessions').catch(() => []), request<any[] | { items?: any[] }>('/audit-logs?limit=20').catch(() => [])])
|
||||
sessions.value = mergePage<Session>(s).items
|
||||
audit.value = mergePage<any>(a).items
|
||||
})
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
sessionsState.value = 'loading'
|
||||
auditState.value = 'loading'
|
||||
sessionsError.value = ''
|
||||
auditError.value = ''
|
||||
const [sessionResult, auditResult] = await Promise.allSettled([
|
||||
request<Session[] | { items?: Session[] }>('/sessions'),
|
||||
request<any[] | { items?: any[] }>('/audit-logs?limit=20'),
|
||||
])
|
||||
if (sessionResult.status === 'fulfilled') {
|
||||
sessions.value = mergePage<Session>(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<any>(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(() => {
|
||||
<!-- 设置与数据 -->
|
||||
<template v-else>
|
||||
<div class="settings-sections">
|
||||
<section class="settings-group settings-data"><header><h2>数据</h2><p>完整备份包含全部数据与附件;CSV / JSON 继续用于旧格式兼容。</p></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small><small v-if="restoreMode==='replace'" class="restore-replace-warning">替换恢复会覆盖当前数据,请先导出完整备份。</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
||||
<section class="settings-group"><header><h2>账户与安全</h2><p>修改密码后当前设备保持登录,其他设备自动退出。</p></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></section>
|
||||
<section class="settings-group"><header><h2>登录设备</h2><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div></header><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||
<section class="settings-group"><header><h2>活动</h2><p>最近的账户和数据操作。</p></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||
<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>
|
||||
<section class="settings-group settings-data"><header><h2>数据与恢复</h2><small>完整备份</small></header><div class="settings-row"><span><b>完整 ZIP 备份</b><small>下载可完整恢复的版本化归档</small></span><button class="soft-button" :disabled="backupBusy" @click="exportData"><Download />导出 ZIP</button></div><div class="settings-row settings-restore-row"><span><b>恢复备份</b><small>{{ restoreFile?.name || '支持 .zip、.csv、.json' }}</small></span><label class="file-button" :class="{ disabled: backupBusy }"><ArchiveRestore />选择文件<input ref="restoreInput" type="file" accept=".zip,.csv,.json,application/zip,application/json,text/csv" :disabled="backupBusy" @change="selectRestoreFile"></label></div><div v-if="restoreFile" class="settings-row"><span><b>恢复方式</b><small>{{ legacyRestore ? '旧格式仅支持合并恢复' : '变更方式后需要重新预检' }}</small><small v-if="restoreMode==='replace'" class="restore-replace-warning">替换恢复会覆盖当前数据,请先导出完整备份。</small></span><select v-model="restoreMode" aria-label="恢复方式" :disabled="backupBusy || legacyRestore"><option value="merge">合并</option><option v-if="!legacyRestore" value="replace">替换现有数据</option></select></div><div v-if="restoreFile && !legacyRestore" class="settings-row"><span><b>备份预检</b><small>恢复前检查格式、关联与附件</small></span><button class="soft-button" :disabled="backupBusy" @click="runPreflight">{{ backupBusy ? '检查中…' : '开始预检' }}</button></div><div v-if="legacyRestore" class="backup-preflight legacy"><b>旧格式兼容恢复</b><p>旧格式将在恢复时校验,不支持完整预检或 Replace。</p><button class="danger-button" :disabled="backupBusy" @click="restore">合并旧格式</button></div><div v-else-if="restorePreflight" class="backup-preflight" :class="{ invalid: !restorePreflight.valid }"><b>{{ restorePreflight.valid ? '预检通过' : '备份不可恢复' }}</b><dl><div v-if="restorePreflight.version"><dt>版本</dt><dd>v{{ restorePreflight.version }}</dd></div><div><dt>数据记录</dt><dd>{{ backupEntityTotal }}</dd></div><div><dt>附件</dt><dd>{{ restorePreflight.attachment_count ?? restorePreflight.entities.attachments ?? 0 }} 个</dd></div></dl><ul v-if="restorePreflight.warnings?.length"><li v-for="warning in restorePreflight.warnings" :key="warning">{{ warning }}</li></ul><ul v-if="restorePreflight.destructive_summary?.length" class="destructive-summary"><li v-for="item in restorePreflight.destructive_summary" :key="item">{{ item }}</li></ul><button class="danger-button" :disabled="backupBusy || !restorePreflight.valid" @click="restore">{{ restoreMode === 'replace' ? '替换并恢复' : '合并并恢复' }}</button></div><p v-if="backupError" class="inline-error" role="alert">{{ backupError }}</p></section>
|
||||
<section class="settings-group"><header><h2>账户与安全</h2></header><form class="password-form settings-form" @submit.prevent="changePassword"><label>当前密码<input v-model="currentPassword" type="password" autocomplete="current-password" required aria-label="当前密码"></label><label>新密码<input v-model="newPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="新密码" placeholder="至少 12 位"></label><label>确认新密码<input v-model="confirmPassword" type="password" autocomplete="new-password" minlength="12" required aria-label="确认新密码"></label><p v-if="passwordError" class="inline-error" role="alert">{{passwordError}}</p><button class="primary-small" :disabled="passwordBusy || !currentPassword || !newPassword || !confirmPassword">{{passwordBusy?'正在修改…':'修改密码'}}</button></form></section>
|
||||
<section class="settings-group"><header><h2>登录设备</h2><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small></header><div class="session-card-actions"><p>可撤销其他设备的登录。</p><button v-if="sessions.some((s) => !s.current)" type="button" class="danger-text session-revoke-all" :disabled="busy" @click="revokeOtherSessions">撤销其他所有会话</button></div><div v-for="s in sessions" :key="s.id" class="settings-row session-row"><span class="session-copy"><b class="session-title">{{ s.current ? '当前设备' : '其他设备' }}</b><small class="session-meta"><span class="session-device">{{ formatUserAgent(s.user_agent) }}</span><span aria-hidden="true"> · </span><time :datetime="s.last_seen_at ?? s.created_at">{{ formatLocalShortDateTime(s.last_seen_at ?? s.created_at) }}</time></small></span><button v-if="!s.current" class="danger-text session-revoke" :aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`" @click="revoke(s.id)">撤销</button></div><p v-if="sessionsState==='loading'" class="settings-empty" role="status">正在加载登录设备…</p><div v-else-if="sessionsState==='error'" class="settings-status-error" role="alert"><span>登录设备加载失败:{{ sessionsError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!sessions.length" class="settings-empty">没有可显示的会话。</p></section>
|
||||
<section class="settings-group"><header><h2>活动记录</h2><small>最近操作</small></header><div v-for="(row, i) in audit" :key="row.id || i" class="settings-row audit-row"><div class="audit-copy"><span class="audit-action">{{ formatAuditAction(row.action ?? row.event) }}{{ formatAuditAction(row.action ?? row.event) === '其他操作' ? '' : formatAuditEntity(row.entity_type) }}</span><time :datetime="row.created_at ?? row.timestamp">{{ formatLocalShortDateTime(row.created_at ?? row.timestamp) }}</time></div></div><p v-if="auditState==='loading'" class="settings-empty" role="status">正在加载活动记录…</p><div v-else-if="auditState==='error'" class="settings-status-error" role="alert"><span>活动记录加载失败:{{ auditError }}</span><button type="button" class="soft-button" @click="loadSettings">重试</button></div><p v-else-if="!audit.length" class="settings-empty">暂无活动记录。</p></section>
|
||||
</div>
|
||||
</template>
|
||||
<AppDialog ref="appDialog" />
|
||||
|
||||
@@ -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("<div v-if=\"!['today','tasks','habits'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||
expect(main).toContain("<div v-if=\"!['today','tasks','habits','settings'].includes(activeView)\" class=\"topbar-title\"><h1")
|
||||
expect(main).toContain("<div v-if=\"['today', 'tasks', 'upcoming', 'habits'].includes(activeView)\" class=\"topbar-actions\">")
|
||||
expect(main).toContain('<CompletedFilterPill v-if="activeView===\'upcoming\'" v-model="showCompleted" class="topbar-filter" />')
|
||||
expect(main).toContain('aria-label="刷新当前页面"')
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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('<header class="settings-heading"><h1>设置</h1><p>管理数据、账户与登录设备</p></header>')
|
||||
expect(mvpPanel).toContain('<h2>数据与恢复</h2><small>完整备份</small>')
|
||||
expect(mvpPanel).toContain("<h2>登录设备</h2><small>{{ sessionsState === 'success' ? `${sessions.length} 台` : '状态' }}</small>")
|
||||
expect(mvpPanel).toContain('<h2>活动记录</h2><small>最近操作</small>')
|
||||
})
|
||||
|
||||
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('<h2>习惯</h2>')
|
||||
expect(mvpPanel).not.toContain('<h2>设置与数据</h2>')
|
||||
expect(mvpPanel).not.toContain('class="view-intro"')
|
||||
for (const title of ['数据', '账户与安全', '登录设备', '活动']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||
for (const title of ['数据与恢复', '账户与安全', '登录设备', '活动记录']) expect(mvpPanel).toContain(`<h2>${title}</h2>`)
|
||||
expect(mvpPanel).not.toContain('<h2>危险操作</h2>')
|
||||
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('<h2>数据</h2>')
|
||||
expect(mvpPanel).toContain('<h2>数据与恢复</h2>')
|
||||
expect(mvpPanel).toContain("downloadFullBackup()")
|
||||
expect(mvpPanel).toContain("'dodo-backup-v2.zip'")
|
||||
expect(mvpPanel).toContain('导出 ZIP')
|
||||
|
||||
Reference in New Issue
Block a user