fix: refine mobile navigation and settings
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m27s

This commit is contained in:
2026-09-10 10:38:39 +08:00
parent 125e6d3a61
commit 7c05ec2a85
6 changed files with 136 additions and 9 deletions
+1 -1
View File
@@ -1254,7 +1254,7 @@ onUnmounted(() => {
</aside> </aside>
<div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div> <div v-if="mobileMore" class="more-mask app-sheet-mask" @click.self="mobileMore=false;switchView('settings')"><section id="mobile-more-menu" class="more-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" aria-label="更多导航" @click.stop><div class="more-sheet-head app-sheet__header"><b>更多</b><button class="icon" aria-label="关闭更多菜单" @click="mobileMore=false"><X/></button></div><div class="app-sheet__body"><button @click="switchView('settings')"><Settings/>设置与数据</button></div></section></div>
<nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'}" @click="switchView('settings')"><Settings/><span>设置</span></button></nav> <nav class="bottom" aria-label="主要导航"><button :class="{active:activeView==='today'}" :aria-current="activeView==='today' ? 'page' : undefined" @click="switchView('today')"><ListTodo/><span>今天</span></button><button :class="{active:activeView==='habits'}" :aria-current="activeView==='habits' ? 'page' : undefined" @click="switchView('habits')"><Repeat2/><span>习惯</span></button><button :class="{active:activeView==='countdowns'}" :aria-current="activeView==='countdowns' ? 'page' : undefined" @click="switchView('countdowns')"><CalendarHeart/><span>倒数日</span></button><button :class="{active:activeView==='settings'}" :aria-current="activeView==='settings' ? 'page' : undefined" @click="switchView('settings')"><Settings/><span>设置</span></button></nav>
<FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" /> <FloatingAddButton v-if="['tasks','today','upcoming','habits','countdowns'].includes(activeView)" :label="activeView==='habits' ? '添加习惯' : activeView==='countdowns' ? '添加倒数日' : '添加任务'" @activate="activateFloatingAdd" />
<Transition name="task-compose"> <Transition name="task-compose">
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose"> <div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
+3 -3
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next' import { Activity, ArchiveRestore, Check, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
import { moveItemWithinScope } from './lib/task-utils' import { moveItemWithinScope } from './lib/task-utils'
import { changedHabitFields, dateKey, formatHabitApiError, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils' import { changedHabitFields, dateKey, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion' import { createCompletionPulse } from './lib/completion-motion'
@@ -606,8 +606,8 @@ onBeforeUnmount(() => {
<div class="settings-grid"> <div class="settings-grid">
<article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><p>导出完整 CSV 数据或从 CSV / JSON 备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article> <article class="tool-card"><FileJson /><h2>数据导出与恢复</h2><p>导出完整 CSV 数据或从 CSV / JSON 备份恢复</p><button class="soft-button" @click="exportData"><Download />导出 CSV</button><label class="file-button"><ArchiveRestore />选择备份<input type="file" accept=".csv,application/json" @change="restoreFile=($event.target as HTMLInputElement).files?.[0]||null"></label><button v-if="restoreFile" class="danger-button" @click="restore">确认恢复</button></article>
<article class="tool-card password-card"><Activity /><h2>修改密码</h2><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-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></article> <article class="tool-card password-card"><Activity /><h2>修改密码</h2><p>修改后当前设备保持登录其他设备会自动退出</p><form class="password-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></article>
<article class="tool-card wide"><LogOut /><h2>登录会话</h2><div v-for="s in sessions" :key="s.id" class="session-row"><span><b>{{ s.current ? '当前设备' : '其他设备' }}</b><small>{{ s.user_agent || '未知设备' }} · {{ s.last_seen_at || s.created_at }}</small></span><button v-if="!s.current" class="danger-text" @click="revoke(s.id)">撤销</button></div><p v-if="!sessions.length">没有可显示的会话</p></article> <article class="tool-card wide"><LogOut /><h2>登录会话</h2><div v-for="s in sessions" :key="s.id" class="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">没有可显示的会话</p></article>
<article v-if="audit.length" class="tool-card wide"><Activity /><h2>最近活动</h2><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><span>{{ row.action || row.event || '变更' }}</span><small>{{ row.created_at || row.timestamp }}</small></div></article> <article v-if="audit.length" class="tool-card wide"><Activity /><h2>最近活动</h2><div v-for="(row, i) in audit" :key="row.id || i" class="audit-row"><div class="audit-copy"><span class="audit-action" :title="row.action ?? row.event ?? undefined">{{ 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></article>
</div> </div>
</template> </template>
</section> </section>
+35 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatHabitApiError, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils' import { calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -171,7 +171,40 @@ describe('MVP view utilities', () => {
expect(habitActionState({ scheduled: true, paused: false }, true)).toEqual({ writable: false, reason: '该习惯已归档' }) expect(habitActionState({ scheduled: true, paused: false }, true)).toEqual({ writable: false, reason: '该习惯已归档' })
}) })
it('distinguishes tapping the add button from dragging it', () => { it('formats conservative device and browser summaries without leaking unknown user agents', () => {
expect(formatUserAgent('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('iPhone · Safari')
expect(formatUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/128.0 Safari/537.36')).toBe('Mac · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128.0 Safari/537.36 Edg/128.0')).toBe('Windows · Edge')
expect(formatUserAgent('Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/128.0 Mobile Safari/537.36')).toBe('Android 手机 · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Linux; Android 14; SM-X710) AppleWebKit/537.36 Chrome/128.0 Safari/537.36')).toBe('Android 平板 · Chrome')
expect(formatUserAgent('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/605.1.15 Version/17.0 Mobile/15E148 Safari/604.1')).toBe('iPad · Safari')
expect(formatUserAgent('curl/8.7.1')).toBe('未知设备 · 未知浏览器')
expect(formatUserAgent(null)).toBe('未知设备 · 未知浏览器')
})
it('formats zoned and timezone-less ISO values in the selected local timezone', () => {
expect(formatLocalShortDateTime('2026-12-31T23:30:00Z', { timeZone: 'Asia/Shanghai' })).toMatch(/2027\/1\/1\s+07:30/)
expect(formatLocalShortDateTime('2026-09-10T14:08:00+02:00', { timeZone: 'Asia/Shanghai' })).toMatch(/2026\/9\/10\s+20:08/)
expect(formatLocalShortDateTime('2026-09-10T14:08:00', { timeZone: 'UTC' })).toMatch(/2026\/9\/10\s+14:08/)
expect(formatLocalShortDateTime('not-a-date')).toBe('未知时间')
expect(formatLocalShortDateTime(null)).toBe('未知时间')
})
it('rejects calendar dates that Date would otherwise normalize', () => {
expect(formatLocalShortDateTime('2026-02-30T12:00:00', { timeZone: 'UTC' })).toBe('未知时间')
expect(formatLocalShortDateTime('2026-02-30T12:00:00Z', { timeZone: 'UTC' })).toBe('未知时间')
expect(formatLocalShortDateTime('2026-04-31T12:00:00+08:00', { timeZone: 'UTC' })).toBe('未知时间')
})
it('localizes known audit actions and entities and hides unknown actions', () => {
expect(['create', 'update', 'complete', 'delete', 'archive', 'restore', 'move', 'import'].map(formatAuditAction)).toEqual(['创建', '更新', '完成', '删除', '归档', '恢复', '移动', '导入'])
expect(['task', 'list', 'folder', 'countdown', 'backup'].map(formatAuditEntity)).toEqual(['任务', '清单', '文件夹', '倒数日', '备份'])
expect(formatAuditAction('launch')).toBe('其他操作')
expect(formatAuditAction(null)).toBe('其他操作')
expect(formatAuditEntity('unknown')).toBe('内容')
})
it('distinguishes tapping the mobile add button from dragging it', () => {
expect(isFabDrag(3, 4)).toBe(false) expect(isFabDrag(3, 4)).toBe(false)
expect(isFabDrag(8, 0)).toBe(true) expect(isFabDrag(8, 0)).toBe(true)
expect(isFabDrag(6, 7)).toBe(true) expect(isFabDrag(6, 7)).toBe(true)
+53
View File
@@ -369,6 +369,59 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
return { writable: true, reason: '' } return { writable: true, reason: '' }
} }
export function formatUserAgent(userAgent?: string | null): string {
const ua = userAgent?.trim() ?? ''
let device = '未知设备'
if (/iPhone/i.test(ua)) device = 'iPhone'
else if (/iPad/i.test(ua) || (/Macintosh/i.test(ua) && /Mobile/i.test(ua))) device = 'iPad'
else if (/Android/i.test(ua) && /Mobile/i.test(ua)) device = 'Android 手机'
else if (/Android/i.test(ua)) device = 'Android 平板'
else if (/Windows/i.test(ua)) device = 'Windows'
else if (/Macintosh|Mac OS X/i.test(ua)) device = 'Mac'
else if (/Linux/i.test(ua)) device = 'Linux'
let browser = '未知浏览器'
if (/Edg(?:e|iOS|A)?\//i.test(ua)) browser = 'Edge'
else if (/Chrome\/|CriOS\//i.test(ua)) browser = 'Chrome'
else if (/Firefox\/|FxiOS\//i.test(ua)) browser = 'Firefox'
else if (/Safari\//i.test(ua) && /Version\//i.test(ua)) browser = 'Safari'
return `${device} · ${browser}`
}
export function formatLocalShortDateTime(value?: string | null, options: { timeZone?: string } = {}): string {
const source = value?.trim()
if (!source) return '未知时间'
const calendar = /^(\d{4})-(\d{2})-(\d{2})T/.exec(source)
if (calendar) {
const [, year, month, day] = calendar
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() + 1 !== Number(month) || probe.getUTCDate() !== Number(day)) return '未知时间'
}
const timezoneLess = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/i.test(source)
const date = new Date(timezoneLess ? `${source}Z` : source)
if (Number.isNaN(date.getTime())) return '未知时间'
try {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short', ...options }).format(date)
} catch {
return '未知时间'
}
}
const AUDIT_ACTIONS: Record<string, string> = {
create: '创建', update: '更新', complete: '完成', delete: '删除', archive: '归档', restore: '恢复', move: '移动', import: '导入',
}
const AUDIT_ENTITIES: Record<string, string> = {
task: '任务', list: '清单', folder: '文件夹', countdown: '倒数日', backup: '备份',
}
export function formatAuditAction(action?: string | null): string {
return action ? AUDIT_ACTIONS[action.toLowerCase()] ?? '其他操作' : '其他操作'
}
export function formatAuditEntity(entityType?: string | null): string {
return entityType ? AUDIT_ENTITIES[entityType.toLowerCase()] ?? '内容' : '内容'
}
export function formatHabitApiError(detail: unknown): string { export function formatHabitApiError(detail: unknown): string {
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
? (detail as Record<string, unknown>).detail ? (detail as Record<string, unknown>).detail
File diff suppressed because one or more lines are too long
+41
View File
@@ -17,6 +17,13 @@ describe('mobile navigation styles', () => {
expect(app).toContain('<span>设置</span>') expect(app).toContain('<span>设置</span>')
}) })
it('marks only exact mobile destinations active and exposes aria-current only there', () => {
for (const view of ['today', 'habits', 'countdowns', 'settings']) {
expect(app).toContain(`:class="{active:activeView==='${view}'}" :aria-current="activeView==='${view}' ? 'page' : undefined"`)
}
expect(app).not.toContain("activeView==='tasks'||activeView==='upcoming'||activeView==='trash'||activeView==='settings'")
})
it('keeps the mobile More sheet visible when it is rendered', () => { it('keeps the mobile More sheet visible when it is rendered', () => {
expect(css).not.toContain('.more-mask{display:none}') expect(css).not.toContain('.more-mask{display:none}')
expect(css).toContain('@media(max-width:930px){.shell') expect(css).toContain('@media(max-width:930px){.shell')
@@ -31,6 +38,40 @@ describe('mobile navigation styles', () => {
}) })
}) })
describe('settings sessions and audit activity', () => {
it('renders readable session metadata, local time, and an accessible revoke action', () => {
expect(mvpPanel).toContain('class="session-copy"')
expect(mvpPanel).toContain('class="session-title"')
expect(mvpPanel).toContain('class="session-meta"')
expect(mvpPanel).toContain('class="session-device"')
expect(mvpPanel).toContain(':datetime="s.last_seen_at ?? s.created_at"')
expect(mvpPanel).toContain(':aria-label="`撤销 ${formatUserAgent(s.user_agent)} 的登录会话`"')
expect(mvpPanel).toContain('class="danger-text session-revoke"')
})
it('does not expose the raw user agent in session DOM attributes', () => {
expect(mvpPanel).not.toContain(':title="s.user_agent || undefined"')
expect(mvpPanel).not.toMatch(/<span class="session-device"[^>]*s\.user_agent[^>]*>/)
expect(mvpPanel).toContain('<span class="session-device">{{ formatUserAgent(s.user_agent) }}</span>')
})
it('renders localized audit copy and semantic local time', () => {
expect(mvpPanel).toContain('class="audit-copy"')
expect(mvpPanel).toContain('class="audit-action"')
expect(mvpPanel).toContain("formatAuditAction(row.action ?? row.event)")
expect(mvpPanel).toContain('formatAuditEntity(row.entity_type)')
expect(mvpPanel).toContain(':datetime="row.created_at ?? row.timestamp"')
})
it('keeps revoke and all existing drag handles at 44px without enlarging icons', () => {
expect(css).toContain('.drag-handle{width:44px;min-width:44px;height:44px;flex:0 0 44px;')
expect(css).toContain('.drag-handle svg{width:17px;height:17px}')
expect(css).not.toContain('.drag-handle{width:36px;flex-basis:36px}')
expect(css).toContain('.session-copy{min-width:0;flex:1;')
expect(css).toContain('.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;')
})
})
describe('solid cream material system', () => { describe('solid cream material system', () => {
it('defines three opaque cream surfaces, warm borders, restrained depth, and the radius scale', () => { it('defines three opaque cream surfaces, warm borders, restrained depth, and the radius scale', () => {
expect(css).toContain('--surface-canvas:#f6f0e5') expect(css).toContain('--surface-canvas:#f6f0e5')