feat(web): 接入 ESLint 与 vitest,TrendChart hover 读数

- ESLint(flat config,vue essential + ts recommended)接入并清零:
  webauthn.ts 标注为刻意的动态类型边界(文件级豁免),theme-init.js
  修掉未用的 catch 变量
- vitest + jsdom 接入,build 前置跑测试;覆盖 format 工具、auth store
  (token 只存内存、bootstrap 恢复、遗留键清理)、client.ts 401 拦截器
  single-flight(自定义 axios adapter 模拟 401→refresh→重放)
- TrendChart:hover 命中区盖满槽位(窄条也可悬停),读数显示在头部行,
  标签行联动高亮
- auth store bootstrap 顺手清理旧版 localStorage 遗留的 access token 键
This commit is contained in:
Sakurasan committed 2026-10-30 02:04:02 +08:00
1 parent 263bfb7845
commit 854d0f5d8c
10 files changed
+1713 -46

No files matched your search

+35 -6
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
const props = withDefaults(
defineProps<{
@@ -19,19 +19,31 @@ const chart = computed(() => {
w: Math.min(Math.max(bw * 0.5, 2), bw),
h: (p.value / max) * 100,
label: p.label,
value: props.format(p.value),
value: p.value,
text: props.format(p.value),
}))
return { bars, maxLabel: props.format(max) }
})
// 标签行是 flex 均分:点数一多每个标签挤成竖排还与条形错位,超过阈值整行隐藏
const showLabels = computed(() => props.points.length > 0 && props.points.length <= 32)
// hover 读数显示在头部行(代替静态 max),避免在 SVG 里做浮层定位
const hovered = ref<number | null>(null)
const head = computed(() => {
const i = hovered.value
if (i != null && chart.value.bars[i]) {
const b = chart.value.bars[i]
return { label: `${b.label}: `, value: b.text }
}
return { label: 'max ', value: chart.value.maxLabel }
})
</script>
<template>
<div class="w-full">
<div class="mb-1 flex items-baseline justify-between">
<span class="mono-num text-xs text-muted">max {{ chart.maxLabel }}</span>
<span class="mono-num text-xs text-muted">{{ head.label }}{{ head.value }}</span>
</div>
<svg
:viewBox="`0 0 100 ${height}`"
@@ -40,22 +52,39 @@ const showLabels = computed(() => props.points.length > 0 && props.points.length
preserveAspectRatio="none"
role="img"
:aria-label="`用量趋势,共 ${points.length} 天`"
@mouseleave="hovered = null"
>
<!-- 基线 -->
<line x1="0" :y1="height - 18" x2="100" :y2="height - 18" stroke="var(--edge)" stroke-width="0.6" />
<g v-for="b in chart.bars" :key="b.label">
<g v-for="(b, i) in chart.bars" :key="b.label">
<!-- 透明命中区盖满整个槽位:窄条也容易悬停 -->
<rect
:x="b.x"
y="0"
:width="100 / points.length"
:height="height"
class="fill-transparent"
@mouseenter="hovered = i"
/>
<rect
:x="b.x"
:y="height - 18 - b.h"
:width="b.w"
:height="b.h"
rx="0.8"
class="fill-accent/80 hover:fill-accent"
:class="hovered === i ? 'fill-accent' : 'fill-accent/80'"
/>
<title v-if="hovered === i">{{ b.label }}: {{ b.text }}</title>
</g>
</svg>
<div v-if="showLabels" class="mt-1 flex justify-between font-mono text-[10px] text-muted">
<span v-for="b in chart.bars" :key="'l-' + b.label" class="truncate">{{ b.label }}</span>
<span
v-for="(b, i) in chart.bars"
:key="'l-' + b.label"
class="truncate"
:class="hovered === i ? 'text-ink' : ''"
@mouseenter="hovered = i"
>{{ b.label }}</span>
</div>
</div>
</template>
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { fmtCost, fmtMoney, fmtNum, fmtTime } from './format'
describe('fmtCost', () => {
it('零值为 $0', () => expect(fmtCost(0)).toBe('$0'))
it('极小值不出现科学计数法', () => {
expect(fmtCost(5e-7)).toBe('<$0.000001')
expect(fmtCost(5e-3)).toMatch(/^\$0\.005/)
})
it('小值定点去尾零', () => expect(fmtCost(0.005)).toBe('$0.005'))
it('常规值千分位', () => expect(fmtCost(12.5)).toBe('$12.50'))
})
describe('fmtMoney', () => {
it('千分位与两位小数', () => expect(fmtMoney(1234.5)).toBe('$1,234.50'))
})
describe('fmtNum', () => {
it('千分位', () => expect(fmtNum(1234567)).toBe('1,234,567'))
})
describe('fmtTime', () => {
it('空值与非法值返回 -', () => {
expect(fmtTime(null)).toBe('-')
expect(fmtTime('not-a-date')).toBe('-')
})
it('合法时间可解析', () => expect(fmtTime('2026-01-01T00:00:00Z')).not.toBe('-'))
})
+3
View File
@@ -1,3 +1,6 @@
// WebAuthn 结构体在浏览器 API / 后端 JSON 之间搬运,字段形状由规范定义且随
// 凭据类型变化,这里是刻意的动态类型边界,不做静态收窄。
/* eslint-disable @typescript-eslint/no-explicit-any */
// WebAuthn(passkey)浏览器侧工具:选项编解码 + 凭据序列化。
// go-webauthn 返回的 challenge/id 为 base64url 字符串,浏览器需要 ArrayBuffer;
// 回调的 credential 需要把 ArrayBuffer 字段转回 base64url。
+106
View File
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { AxiosError } from 'axios'
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { http } from '@/api/client'
import { useAuthStore } from './auth'
describe('auth store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
})
it('setSession 存 token 且不落 localStorage(XSS 面收敛)', () => {
const s = useAuthStore()
s.setSession({ access_token: 'tok', user: { id: 1, role: 'user' } as never })
expect(s.accessToken).toBe('tok')
expect(localStorage.getItem('ot_access')).toBeNull()
})
it('bootstrap 用 refresh cookie 恢复会话并清遗留键', async () => {
localStorage.setItem('ot_access', 'legacy')
http.defaults.adapter = mockAdapter({
'/api/v1/auth/refresh': { status: 200, data: { data: { access_token: 'boot' } } },
'/api/v1/auth/me': { status: 200, data: { data: { user: { id: 1, role: 'user' } } } },
})
const s = useAuthStore()
await s.bootstrap()
expect(s.accessToken).toBe('boot')
expect(s.ready).toBe(true)
expect(localStorage.getItem('ot_access')).toBeNull()
})
it('clear 清空会话', () => {
const s = useAuthStore()
s.setSession({ access_token: 'tok', user: { id: 1, role: 'user' } as never })
s.clear()
expect(s.accessToken).toBe('')
expect(s.user).toBeNull()
})
})
// ---------------------------------------------------------------------------
// client.ts 401 拦截器的 single-flight 验证:用自定义 adapter 模拟上游,
// 两个请求同时 401 时共享同一次 /auth/refresh,重放都成功。
type MockRoute = { status: number; data: unknown }
// 自定义 adapter 需要自己模拟 axios 的 validateStatus:非 2xx 以 AxiosError 拒绝,
// 并带上 response/config,401 拦截器才能从错误对象里取到它们。
function respond(config: InternalAxiosRequestConfig, route: MockRoute): Promise<AxiosResponse> {
const resp: AxiosResponse = { status: route.status, data: route.data, statusText: '', headers: {}, config }
if (route.status >= 200 && route.status < 300) return Promise.resolve(resp)
return Promise.reject(new AxiosError('mock status ' + route.status, 'ERR_MOCK', config, null, resp))
}
function mockAdapter(routes: Record<string, MockRoute>, calls?: string[]) {
return async (config: InternalAxiosRequestConfig) => {
const url = String(config.url)
const full = config.baseURL && !url.startsWith('http') ? config.baseURL + url : url
calls?.push(full)
const route = routes[full]
if (!route) throw new Error('unexpected request: ' + full)
return respond(config, route)
}
}
describe('client 401 interceptor single-flight', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('并发 401 共享一次 refresh,各自重放成功', async () => {
const calls: string[] = []
// 路由表可变:401 → refresh 完成后重放的请求返回 200
const routes: Record<string, MockRoute> = {
'/api/v1/thing-a': { status: 401, data: {} },
'/api/v1/thing-b': { status: 401, data: {} },
'/api/v1/auth/refresh': { status: 200, data: { data: { access_token: 'new-tok' } } },
}
http.defaults.adapter = async (config) => {
const url = String(config.url)
const full = config.baseURL ? config.baseURL + url : url
calls.push(full)
// refresh 拿到新 token 后重放的请求会带上 Authorization 头,据此返回成功响应
const replayed = Boolean(config.headers?.Authorization)
if (replayed && (full === '/api/v1/thing-a' || full === '/api/v1/thing-b')) {
return respond(config, { status: 200, data: { ok: true } })
}
const route = routes[full]
if (!route) throw new Error('unexpected request: ' + full)
return respond(config, route)
}
const auth = useAuthStore()
const [a, b] = await Promise.all([
http.get('/thing-a').catch(() => 'rejected-a'),
http.get('/thing-b').catch(() => 'rejected-b'),
])
expect(a).not.toBe('rejected-a')
expect(b).not.toBe('rejected-b')
expect(auth.accessToken).toBe('new-tok')
const refreshCalls = calls.filter((c) => c.startsWith('/api/v1/auth/refresh'))
expect(refreshCalls.length).toBe(1)
})
})
+5
View File
@@ -6,6 +6,10 @@ import type { User } from '@/types'
// 避免重复打 /auth/refresh
let bootstrapInflight: Promise<void> | null = null
// 历史遗留:旧版本把 access token 持久化在 localStorage(XSS 可窃取面)。
// token 现只存内存,启动时顺手清掉旧键。
const LEGACY_TOKEN_KEY = 'ot_access'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null as User | null,
@@ -20,6 +24,7 @@ export const useAuthStore = defineStore('auth', {
},
actions: {
async bootstrap() {
localStorage.removeItem(LEGACY_TOKEN_KEY)
if (this.ready) return
if (!bootstrapInflight) {
bootstrapInflight = (async () => {