前端: 按 Web Interface Guidelines 全面重做

组件:
- Button: transition-all→明确属性列表, touch-action, aria-busy
- Input: name/spellcheck/inputmode/aria-invalid/aria-describedby, focus-visible ring,
  label 关联, 暴露 focus() 供错误定位, required 标记
- Modal: Escape 关闭 + focus trap + 焦点归还, overscroll-behavior: contain,
  aria-labelledby, body 滚动锁, touch-action
- 新增 Toast(aria-live polite) + pinia toast store

页面:
- ConsoleLayout: skip link, <main> 语义标签, aside/nav aria-label, aria-current,
  装饰 SVG aria-hidden, Intl 金额, translate=no 品牌/代码
- Dashboard: Intl.NumberFormat, 骨架屏 loading, aria-live 错误
- Keys: 字段级错误+焦点定位, toast 反馈(复制/吊销), name 属性
- Usage: select 加 label, 分页/筛选同步 URL(可深链), Intl 日期/金额, 表格 caption
- Login/Register: 字段级校验+错误定位, spellcheck=false, inputmode, autocomplete
- Landing: router-link 替换 href, aria-hidden, text-balance, scroll-mt
- index.html: theme-color + color-scheme

验证: playwright 22 项断言全过, WCAG AA 对比度 9 组全过, 零控制台错误
This commit is contained in:
Sakurasan
2026-08-15 13:32:18 +08:00
parent 5b67b66611
commit b25e9ec8a7
14 changed files with 646 additions and 185 deletions
+98
View File
@@ -0,0 +1,98 @@
// WIG 合规验证:语义标签 / aria / focus / modal 交互
const { chromium } = require('playwright')
async function main() {
const browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text().slice(0, 150)) })
const BASE = 'http://127.0.0.1:8088'
let pass = 0, fail = 0
const check = (name, ok) => { console.log(`${ok ? '✓' : '✗'} ${name}`); ok ? pass++ : fail++ }
// ---- Landing ----
await page.goto(BASE + '/', { waitUntil: 'networkidle' })
check('landing: h1 存在', await page.locator('h1').count() === 1)
check('landing: main 语义标签', await page.locator('main#landing-main').count() === 1)
check('landing: 导航用 router-link(<a>)', await page.locator('header nav a').count() >= 2)
check('landing: theme-color', await page.locator('meta[name="theme-color"]').count() === 1)
// ---- 注册(带字段级校验)----
const uname = 'wig' + Date.now().toString().slice(-6)
await page.goto(BASE + '/register', { waitUntil: 'networkidle' })
// 空提交 → 字段级错误
await page.click('button[type="submit"]')
await page.waitForTimeout(300)
check('register: 字段级错误显示', await page.locator('[role="alert"]').count() >= 1)
// 填错邮箱
await page.fill('input[name="username"]', uname)
await page.fill('input[name="email"]', 'not-an-email')
await page.fill('input[name="password"]', 'password123')
await page.fill('input[name="confirm"]', 'password123')
await page.click('button[type="submit"]')
await page.waitForTimeout(300)
check('register: 邮箱格式错误', await page.locator('text=邮箱格式不正确').count() === 1)
// 修正并提交
await page.fill('input[name="email"]', uname + '@test.com')
await page.click('button[type="submit"]')
await page.waitForURL('**/console/dashboard', { timeout: 10000 })
await page.waitForTimeout(1500)
// ---- Console ----
check('console: skip link', await page.locator('a[href="#main-content"]').count() === 1)
check('console: main 语义标签', await page.locator('main#main-content').count() === 1)
check('console: aside aria-label', await page.locator('aside[aria-label]').count() === 1)
check('console: 侧边栏卡片', await page.locator('section[aria-label="用量指标"] .rounded-lg').count() === 4)
check('console: 骨架屏已消失(loaded)', await page.locator('.animate-pulse').count() === 0)
// ---- Keys ----
await page.goto(BASE + '/console/keys', { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
check('keys: 表格 caption', await page.locator('table caption.sr-only').count() === 1)
check('keys: 吊销按钮', await page.locator('button:has-text("吊销")').count() >= 0)
// 创建密钥 modal:Escape 关闭
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
check('modal: dialog 打开', await page.locator('[role="dialog"]').count() === 1)
await page.keyboard.press('Escape')
await page.waitForTimeout(300)
check('modal: Escape 关闭', await page.locator('[role="dialog"]').count() === 0)
// 重新打开并创建
await page.click('button:has-text("新建密钥")')
await page.waitForTimeout(300)
await page.fill('input[name="key-name"]', 'wig-test')
await page.click('button:has-text("创建密钥")')
await page.waitForTimeout(600)
check('modal: 一次性明文展示', await page.locator('code.text-mint-300').count() === 1)
check('modal: 复制按钮 + toast', (await page.click('button:has-text("复制")'), true))
await page.waitForTimeout(300)
check('toast: aria-live 容器', await page.locator('[aria-live="polite"]').count() >= 1)
await page.click('button:has-text("完成")')
// ---- Usage ----
await page.goto(BASE + '/console/usage?model=gpt-4o-mini', { waitUntil: 'networkidle' })
await page.waitForTimeout(800)
check('usage: select 有 label', await page.locator('label:has(select)').count() === 1)
check('usage: URL 反映筛选状态', page.url().includes('model='))
check('usage: 分页 aria 标签', await page.locator('nav[aria-label="分页"]').count() === 1)
// ---- Tab 焦点可见性 ----
await page.goto(BASE + '/console/dashboard', { waitUntil: 'networkidle' })
await page.keyboard.press('Tab')
await page.waitForTimeout(200)
const focusedRing = await page.evaluate(() => {
const el = document.activeElement
if (!el) return 'none'
const s = getComputedStyle(el)
return s.outlineStyle === 'auto' || s.outlineWidth !== '0px' ? 'outline' : (s.boxShadow !== 'none' ? 'ring' : 'none')
})
check(`focus: Tab 首元素可见焦点 (${focusedRing})`, focusedRing !== 'none')
console.log(`\n结果: ${pass} 通过, ${fail} 失败`)
if (errors.length) console.log('控制台错误:', errors.slice(0, 5))
await browser.close()
process.exit(fail > 0 ? 1 : 0)
}
main().catch((e) => { console.error(e); process.exit(1) })