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:
1 parent
263bfb7845
commit
854d0f5d8c
10 files changed
+1713
-46
No files matched your search
@@ -0,0 +1,16 @@
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
// essential:只查真实缺陷(缺 key、重复属性、v-if 同框等),格式类规则交给编辑器
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
{
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
},
|
||||
},
|
||||
)
|
||||
+9
-2
@@ -5,8 +5,10 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
"build": "vue-tsc -b && vitest run --silent && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.5",
|
||||
@@ -21,9 +23,14 @@
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint-plugin-vue": "^10.12.0",
|
||||
"jsdom": "^30.1.3",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^5.0.5",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
Generated
+1494
-37
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,7 @@
|
||||
var m = localStorage.getItem('ot_theme') || 'system'
|
||||
var dark = m === 'dark' || (m === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||
} catch (e) {
|
||||
} catch {
|
||||
document.documentElement.setAttribute('data-theme', 'dark')
|
||||
}
|
||||
})()
|
||||
@@ -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>
|
||||
@@ -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('-'))
|
||||
})
|
||||
@@ -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。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { mergeConfig } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
// 复用 vite 的 @ 别名与插件,跑 src 下的单元测试
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
}) as typeof viteConfig,
|
||||
)
|
||||
Reference in new issue
Block a user