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
+35 -2
View File
@@ -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)
+53
View File
@@ -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