fix: resolve ui audit bugs
ci / docker (push) Successful in 3m30s

This commit is contained in:
2026-09-07 09:08:22 +08:00
parent 6f7694c23e
commit ec90e9155e
7 changed files with 119 additions and 16 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2, Ellipsis,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { defaultView, isTaskView, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { defaultView, isTaskView, nextTotalAfterLocalTaskAdd, quickTaskFields, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
import CountdownPanel from './CountdownPanel.vue'
@@ -247,7 +247,7 @@ async function addTask() {
try {
const fields = quickTaskFields(activeView.value, activeList.value, inboxId)
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({ title: title.value.trim(), ...fields }) })
tasks.value.push(task); title.value = ''; selectTask(task); mobileDetail.value = true; toast('任务已添加')
tasks.value.push(task); totalTasks.value = nextTotalAfterLocalTaskAdd(totalTasks.value); title.value = ''; selectTask(task); mobileDetail.value = true; toast('任务已添加')
} catch (reason) { fail(reason) }
}
async function loadTodayView() {
+15
View File
@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync('src/CountdownPanel.vue', 'utf8')
describe('countdown modal accessibility', () => {
it('names the dialog and supports focus and Escape close', () => {
expect(source).toContain('aria-labelledby="countdown-dialog-title"')
expect(source).toContain('id="countdown-dialog-title"')
expect(source).toContain('@keydown.esc="closeDialog"')
expect(source).toContain('ref="titleInput"')
expect(source).toContain('titleInput.value?.focus()')
expect(source).toContain(':inert="open"')
})
})
+38 -9
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { Archive, ArchiveRestore, CalendarHeart, Pencil, Pin, Plus, Trash2, X } from 'lucide-vue-next'
import { csrfHeader } from './lib/csrf'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey } from './lib/mvp-utils'
import { calendarModeLabel, countdownDayText, countdownKindLabel, dateKey, formatApiErrorDetail } from './lib/mvp-utils'
type Countdown = {
id: string; title: string; event_date: string; display_date: string; kind: 'countdown'|'anniversary'|'birthday'
@@ -18,6 +18,28 @@ const editingId = ref<string|null>(null), error = ref('')
const currentYear = new Date().getFullYear()
const freshForm = (): Form => ({ title:'', event_date:dateKey(new Date()), kind:'countdown', repeat_rule:'none', icon:'📅', calendar_mode:'solar', lunar_year:currentYear, lunar_month:1, lunar_day:1, leap_month:false, ignore_year:false })
const form = ref<Form>(freshForm())
const titleInput = ref<HTMLInputElement | null>(null)
let previousFocus: HTMLElement | null = null
function focusDialog() {
previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
void nextTick(() => titleInput.value?.focus())
}
function closeDialog() {
open.value = false
void nextTick(() => previousFocus?.focus())
}
function trapDialogFocus(event: KeyboardEvent) {
if (event.key !== 'Tab') return
const dialog = event.currentTarget as HTMLElement
const controls = Array.from(dialog.querySelectorAll<HTMLElement>('button,input,select,textarea,[tabindex]:not([tabindex="-1"])'))
.filter((item) => !item.hasAttribute('disabled'))
if (!controls.length) return
const first = controls[0]
const last = controls[controls.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
function primaryDate(item: Countdown) {
// 农历项主信息用农历;否则公历。忽略年份/每年重复时副行给出换算公历日期。
@@ -42,7 +64,10 @@ async function request(path:string, options:RequestInit={}) {
if (options.body) headers['Content-Type'] = 'application/json'
Object.assign(headers, csrfHeader(options.method))
const response = await fetch('/api/v1' + path, { credentials:'include', ...options, headers })
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.detail || '请求失败') }
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(formatApiErrorDetail((body as { detail?: unknown }).detail ?? body))
}
return response.status === 204 ? null : response.json()
}
async function safe(work:()=>Promise<void>) { busy.value=true; error.value=''; try { await work() } catch(reason) { error.value=reason instanceof Error ? reason.message : '请求失败' } finally { busy.value=false } }
@@ -52,6 +77,7 @@ function edit(item:Countdown) {
editingId.value=item.id
form.value={ title:item.title, event_date:item.event_date, kind:item.kind, repeat_rule:item.repeat_rule, icon:item.icon, calendar_mode:item.calendar_mode, lunar_year:item.lunar_year || Number(item.event_date.slice(0,4)), lunar_month:Math.abs(item.lunar_month || 1), lunar_day:item.lunar_day || 1, leap_month:(item.lunar_month || 0)<0, ignore_year:item.ignore_year }
open.value=true
focusDialog()
}
async function save() {
if (!form.value.title.trim()) return
@@ -60,7 +86,7 @@ async function save() {
if (form.value.calendar_mode==='lunar') { payload.lunar_month=form.value.leap_month ? -form.value.lunar_month : form.value.lunar_month; payload.lunar_day=form.value.lunar_day }
const path=editingId.value ? `/countdowns/${editingId.value}` : '/countdowns'
await request(path,{ method:editingId.value?'PATCH':'POST', body:JSON.stringify(payload) })
open.value=false; await load(); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
closeDialog(); await load(); emit('notice',editingId.value?'倒数日已更新':'倒数日已添加')
})
}
async function pin(item:Countdown){await safe(async()=>{await request(`/countdowns/${item.id}/pin`,{method:'POST'});await load();emit('notice','已置顶')})}
@@ -70,12 +96,14 @@ async function purge(item:Countdown){if(!confirm(`永久删除“${item.title}
function formatDate(value:string){const [y,m,d]=value.split('-');return `${y}${Number(m)}${Number(d)}`}
function formatDateShort(value:string){const [y,m,d]=value.split('-');return `${y}/${Number(m)}/${Number(d)}`}
function repeatLabel(value:Countdown['repeat_rule']){return({none:'不重复',weekly:'每周',monthly:'每月',yearly:'每年'})[value]}
function onAdd(){editingId.value=null;form.value=freshForm();open.value=true}
function onAdd(){editingId.value=null;form.value=freshForm();open.value=true;focusDialog()}
onMounted(load)
onBeforeUnmount(() => { previousFocus = null })
</script>
<template>
<section class="countdown-view" :class="{ loading:busy }">
<div class="countdown-content" :inert="open" :aria-hidden="open ? 'true' : undefined">
<header class="countdown-hero"><div><small>记住值得期待与纪念的日子</small><h2>倒数日</h2></div><button class="countdown-add" @click="onAdd"><Plus/>添加日子</button></header>
<p v-if="error" class="inline-error">{{error}}</p>
<div class="countdown-grid">
@@ -90,9 +118,10 @@ onMounted(load)
<button v-if="archived.length" class="archived-toggle" @click="showArchived=!showArchived"><ArchiveRestore/>已归档{{archived.length}}</button>
<div v-if="showArchived" class="archived-countdowns"><article v-for="item in archived" :key="item.id"><span>{{item.icon}}</span><b>{{item.title}}</b><small>{{formatDateShort(item.display_date)}}<template v-if="item.lunar_text"> · {{item.lunar_text}}</template><template v-if="item.calendar_mode==='lunar'"> · 农历</template></small><button @click="restore(item)"><ArchiveRestore/>恢复</button><button class="danger-text" @click="purge(item)"><Trash2/>永久删除</button></article></div>
<button class="fab countdown-fab mobile-only" aria-label="添加倒数日" @click="add"><Plus/></button>
<div v-if="open" class="countdown-modal-mask" @click.self="open=false"><form class="countdown-modal" role="dialog" aria-modal="true" @submit.prevent="save">
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3>{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="open=false"><X/></button></header>
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
</div>
<div v-if="open" class="countdown-modal-mask" @click.self="closeDialog"><form class="countdown-modal" role="dialog" aria-modal="true" aria-labelledby="countdown-dialog-title" @submit.prevent="save" @keydown.esc="closeDialog" @keydown="trapDialogFocus">
<header><div><small>{{editingId?'调整重要日子':'记下重要日子'}}</small><h3 id="countdown-dialog-title">{{editingId?'编辑倒数日':'新建倒数日'}}</h3></div><button type="button" aria-label="关闭" @click="closeDialog"><X/></button></header>
<label>图标与名称<div class="countdown-title-fields"><input v-model="form.icon" maxlength="8" aria-label="图标"><input ref="titleInput" v-model="form.title" maxlength="200" required placeholder="例如:去北海道旅行" autofocus></div></label>
<label>历法<select v-model="form.calendar_mode"><option value="solar">{{calendarModeLabel('solar')}}</option><option value="lunar">{{calendarModeLabel('lunar')}}</option></select></label>
<label v-if="form.calendar_mode==='solar'">日期<input v-model="form.event_date" type="date" required></label>
<div v-else class="countdown-title-fields">
@@ -104,7 +133,7 @@ onMounted(load)
<label><input v-model="form.ignore_year" type="checkbox"> 忽略年份<span v-if="form.calendar_mode==='lunar'">每年按农历计算</span></label>
<label>类型<select v-model="form.kind"><option value="countdown">倒数日</option><option value="anniversary">纪念日</option><option value="birthday">生日</option></select></label>
<label>重复<select v-model="form.repeat_rule"><option value="none">不重复</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option></select></label>
<footer><button type="button" class="secondary" @click="open=false">取消</button><button class="primary-small">保存</button></footer>
<footer><button type="button" class="secondary" @click="closeDialog">取消</button><button class="primary-small">保存</button></footer>
</form></div>
</section>
</template>
+28
View File
@@ -93,3 +93,31 @@ export function calendarModeLabel(mode: string) {
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
}
export function nextTotalAfterLocalTaskAdd(total: number) {
return Math.max(0, Number(total) || 0) + 1
}
export function formatApiErrorDetail(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail.map((item) => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
const location = Array.isArray(record.loc)
? record.loc.filter((part) => part !== 'body').join('.')
: ''
const message = typeof record.msg === 'string' ? record.msg : JSON.stringify(record)
return location ? `${location}${message}` : message
}
return String(item)
})
return messages.filter(Boolean).join('') || '请求参数有误'
}
if (detail && typeof detail === 'object') {
const record = detail as Record<string, unknown>
if ('detail' in record) return formatApiErrorDetail(record.detail)
return JSON.stringify(record)
}
return '请求失败'
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { formatApiErrorDetail, nextTotalAfterLocalTaskAdd } from './mvp-utils'
describe('UI bugfix helpers', () => {
it('formats FastAPI validation arrays into readable messages', () => {
expect(formatApiErrorDetail([
{ loc: ['body', 'lunar_day'], msg: 'Input should be less than or equal to 30' },
{ loc: ['body', 'title'], msg: 'String should have at least 1 character' },
])).toBe('lunar_dayInput should be less than or equal to 30titleString should have at least 1 character')
})
it('formats nested API detail objects instead of [object Object]', () => {
expect(formatApiErrorDetail({ detail: [{ loc: ['body', 'event_date'], msg: 'Input should be a valid date' }] })).toBe('event_dateInput should be a valid date')
})
it('increments the visible total after a local quick-add insert', () => {
expect(nextTotalAfterLocalTaskAdd(0)).toBe(1)
expect(nextTotalAfterLocalTaskAdd(49)).toBe(50)
})
})
File diff suppressed because one or more lines are too long
+11
View File
@@ -17,3 +17,14 @@ describe('task and habit row decoration', () => {
expect(css).toContain('.habit-row{border-left:0;')
})
})
describe('mobile touch targets', () => {
it('keeps primary mobile controls at least 44px high', () => {
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toContain('.mini-icon,.row-actions button{min-width:44px;min-height:44px;')
expect(css).toContain('.bottom button{min-height:44px;')
expect(css).toContain('.habit-create button{min-height:44px;')
expect(css).toContain('.countdown-add{min-height:44px;')
expect(css).toContain('.countdown-modal header button{min-width:44px;min-height:44px;')
})
})