fix: refine mobile navigation and settings
This commit is contained in:
@@ -1254,7 +1254,7 @@ onUnmounted(() => {
|
||||
</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>
|
||||
<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" />
|
||||
<Transition name="task-compose">
|
||||
<div v-if="taskComposeOpen" class="task-compose-mask app-sheet-mask" @click.self="closeTaskCompose">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 { 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 { createCompletionPulse } from './lib/completion-motion'
|
||||
|
||||
@@ -606,8 +606,8 @@ onBeforeUnmount(() => {
|
||||
<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 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 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 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"><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>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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', () => {
|
||||
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: '该习惯已归档' })
|
||||
})
|
||||
|
||||
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(8, 0)).toBe(true)
|
||||
expect(isFabDrag(6, 7)).toBe(true)
|
||||
|
||||
@@ -369,6 +369,59 @@ export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean
|
||||
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 {
|
||||
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
|
||||
? (detail as Record<string, unknown>).detail
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,6 +17,13 @@ describe('mobile navigation styles', () => {
|
||||
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', () => {
|
||||
expect(css).not.toContain('.more-mask{display:none}')
|
||||
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', () => {
|
||||
it('defines three opaque cream surfaces, warm borders, restrained depth, and the radius scale', () => {
|
||||
expect(css).toContain('--surface-canvas:#f6f0e5')
|
||||
|
||||
Reference in New Issue
Block a user