feat: 增加前端骨架

This commit is contained in:
liyy 2026-08-31 16:50:38 +08:00
parent 4b2c80bef1
commit 84e5fa0af5
45 changed files with 2172 additions and 60 deletions

3
client/.env.example Normal file
View File

@ -0,0 +1,3 @@
# 前端 API 基础地址。留空表示使用相对路径 /api开发环境经 Vite 代理到后端)。
# 生产环境若前后端不同源,可设置为后端地址(如 http://127.0.0.1:3000/api
VITE_API_BASE_URL=

View File

@ -3,18 +3,21 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "api:generate": "node scripts/generate-api.mjs",
"build": "vite build", "dev": "npm run api:generate && vite",
"build": "npm run api:generate && vite build",
"typecheck": "vue-tsc --noEmit" "typecheck": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@vitejs/plugin-vue": "latest", "@vitejs/plugin-vue": "latest",
"openapi-fetch": "^0.17.0",
"pinia": "latest",
"vite": "latest", "vite": "latest",
"vue": "latest", "vue": "latest",
"vue-router": "latest", "vue-router": "latest"
"pinia": "latest"
}, },
"devDependencies": { "devDependencies": {
"openapi-typescript": "^7.13.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vue-tsc": "latest" "vue-tsc": "latest"
} }

View File

@ -0,0 +1,47 @@
import openapiTS, { astToString } from 'openapi-typescript'
import { writeFile, mkdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const output = join(__dirname, '..', 'src', 'api', 'generated', 'schema.d.ts')
// 接口契约唯一来源:后端运行时提供的 OpenAPI 文档端点。
// 生成前需先启动后端pnpm dev:server
const openapiUrl = process.env.OPENAPI_URL ?? 'http://localhost:3000/api/openapi.json'
const HEADER = `// 本文件由 openapi-typescript 自动生成,请勿手工修改。
// 重新生成pnpm --filter @gwy/client api:generate需后端服务运行中
// 来源:${openapiUrl}
`
async function main() {
let spec
try {
const res = await fetch(openapiUrl)
if (!res.ok) {
console.error(`[api:generate] 获取 OpenAPI 文档失败HTTP ${res.status} ${openapiUrl}`)
console.error('请确认后端服务已启动pnpm dev:server。')
process.exit(1)
}
spec = await res.json()
} catch (err) {
console.error(`[api:generate] 无法获取 OpenAPI 文档:${openapiUrl}`)
console.error(err instanceof Error ? err.message : err)
console.error('请确认后端服务已启动pnpm dev:server。')
process.exit(1)
}
try {
const nodes = await openapiTS(spec, {})
const ast = astToString(nodes)
await mkdir(dirname(output), { recursive: true })
await writeFile(output, HEADER + ast, 'utf8')
console.log(`[api:generate] 已生成 ${output}(来源:${openapiUrl}`)
} catch (err) {
console.error('[api:generate] 类型生成失败:', err instanceof Error ? err.message : err)
process.exit(1)
}
}
await main()

View File

@ -1,18 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { useResponsive } from './composables/useResponsive'
import DesktopLayout from './layouts/DesktopLayout.vue' import DesktopLayout from './layouts/DesktopLayout.vue'
import MobileLayout from './layouts/MobileLayout.vue' import MobileLayout from './layouts/MobileLayout.vue'
import DashboardView from './views/dashboard/DashboardView.vue' import AppToast from './components/feedback/AppToast.vue'
const nav = ['数据中枢', '刷题中心', '模考分析', '备考计划', '要闻', '我的'] const { isMobile } = useResponsive()
const active = ref(0)
const title = computed(() => nav[active.value])
</script> </script>
<template> <template>
<div class="app-shell"> <DesktopLayout v-if="!isMobile"><RouterView /></DesktopLayout>
<DesktopLayout :items="nav" :active="active" @select="active = $event" /> <MobileLayout v-else><RouterView /></MobileLayout>
<main class="content"><header><div><h1>{{ title }}</h1><p>个人备考工作台 · 页面骨架</p></div><button class="primary">开始使用</button></header><DashboardView /></main> <AppToast />
<MobileLayout :items="nav" :active="active" @select="active = $event" />
</div>
</template> </template>

View File

@ -1,7 +1,83 @@
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api' import createClient from 'openapi-fetch'
import type { paths } from './generated/schema'
export async function request<T>(path: string, init?: RequestInit): Promise<T> { export type ApiPaths = paths
const response = await fetch(`${apiBaseUrl}${path}`, { headers: { 'content-type': 'application/json' }, ...init })
if (!response.ok) throw new Error(`请求失败:${response.status}`) /** 后端统一错误响应体(需求文档第 6 节格式) */
return response.json() as Promise<T> export interface ApiErrorDetail {
path?: string
message?: string
}
/** 业务 / 网络 / 超时错误的统一异常 */
export class ApiError extends Error {
readonly code: string
readonly details: ApiErrorDetail[]
constructor(code: string, message: string, details: ApiErrorDetail[] = []) {
super(message)
this.name = 'ApiError'
this.code = code
this.details = details
}
}
/** 基础地址:默认相对路径(开发环境经 Vite 代理),可经 VITE_API_BASE_URL 覆盖 */
const baseUrl = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/+$/, '')
const DEFAULT_TIMEOUT = 15_000
/** 给 fetch 统一附加超时信号 */
function withTimeout(timeoutMs: number): typeof fetch {
return (input, init) => {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
const userSignal = init?.signal
if (userSignal) {
userSignal.addEventListener('abort', () => controller.abort(), { once: true })
}
return fetch(input, { ...init, signal: controller.signal }).finally(() => clearTimeout(timer))
}
}
/**
* URLJSON headers
* new api/index.ts
*/
export const apiClient = createClient<paths>({
baseUrl,
headers: { 'Content-Type': 'application/json' },
fetch: withTimeout(DEFAULT_TIMEOUT)
})
/** 从 openapi-fetch 结果解包error 存在或 fetch 抛错时,抛出统一 ApiError */
export async function unwrap<T>(
res: PromiseLike<{ data?: T; error?: unknown; response?: Response }>
): Promise<T> {
try {
const { data, error, response } = (await res) as {
data?: T
error?: unknown
response?: Response
}
if (response && !response.ok) {
const body = error as { error?: { code?: string; message?: string; details?: ApiErrorDetail[] } }
const fallback =
response.status === 502 || response.status === 503 || response.status === 504
? '无法连接服务,请确认后端已启动'
: '请求失败,请稍后重试'
throw new ApiError(
body?.error?.code ?? 'REQUEST_ERROR',
body?.error?.message ?? fallback,
body?.error?.details ?? []
)
}
return data as T
} catch (err) {
if (err instanceof ApiError) throw err
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError('TIMEOUT', '请求超时,请稍后重试')
}
throw new ApiError('NETWORK_ERROR', '无法连接服务,请确认后端已启动')
}
} }

View File

@ -1 +1,135 @@
export { apiBaseUrl, request } from './client' import { apiClient, unwrap } from './client'
import type { paths } from './generated/schema'
export type { ApiPaths, ApiErrorDetail } from './client'
export { ApiError } from './client'
export type { paths }
// ---- 从生成的 paths 推导请求/响应类型的工具类型(避免页面重复定义接口类型)----
type Methods = 'get' | 'post' | 'patch' | 'delete' | 'put'
type SuccessBody<Op> = Op extends {
responses: { 200: { content: { 'application/json': infer D } } }
}
? D
: never
type QueryOf<P extends keyof paths, M extends Methods> = paths[P][M] extends {
parameters: { query?: infer Q }
}
? Q
: never
type BodyOf<P extends keyof paths, M extends Methods> = paths[P][M] extends {
requestBody: { content: { 'application/json': infer B } }
}
? B
: never
// ---- 常用响应类型 ----
export type DashboardOverview = SuccessBody<paths['/api/dashboard/overview']['get']>
export type DashboardTrends = SuccessBody<paths['/api/dashboard/trends']['get']>
export type PracticeModules = SuccessBody<paths['/api/practice/modules']['get']>
export type Profile = SuccessBody<paths['/api/profile']['get']>
export type Settings = SuccessBody<paths['/api/settings']['get']>
// ---- 数据中枢 ----
export const dashboardApi = {
overview: () => unwrap(apiClient.GET('/api/dashboard/overview')),
trends: (days?: number) =>
unwrap(apiClient.GET('/api/dashboard/trends', { params: { query: { days } } }))
}
// ---- 刷题 ----
export const practiceApi = {
modules: () => unwrap(apiClient.GET('/api/practice/modules')),
start: (body: BodyOf<'/api/practice/start', 'post'>) =>
unwrap(apiClient.POST('/api/practice/start', { body })),
question: (sessionId: string) =>
unwrap(apiClient.GET('/api/practice/{sessionId}/question', { params: { path: { sessionId } } })),
answer: (sessionId: string, body: BodyOf<'/api/practice/{sessionId}/answer', 'post'>) =>
unwrap(
apiClient.POST('/api/practice/{sessionId}/answer', {
params: { path: { sessionId } },
body
})
),
finish: (sessionId: string) =>
unwrap(apiClient.POST('/api/practice/{sessionId}/finish', { params: { path: { sessionId } } }))
}
// ---- 错题复习 ----
export const reviewApi = {
list: (query?: QueryOf<'/api/review/list', 'get'>) =>
unwrap(apiClient.GET('/api/review/list', { params: { query } })),
submit: (id: string, answer: string) =>
unwrap(
apiClient.POST('/api/review/{id}/submit', {
params: { path: { id } },
body: { answer } as BodyOf<'/api/review/{id}/submit', 'post'>
})
),
markMastered: (id: string) =>
unwrap(apiClient.POST('/api/review/{id}/mark-mastered', { params: { path: { id } } }))
}
// ---- 备考计划 ----
export const plansApi = {
today: (date?: string) => unwrap(apiClient.GET('/api/plans/today', { params: { query: { date } } })),
week: (start?: string) => unwrap(apiClient.GET('/api/plans/week', { params: { query: { start } } })),
createTask: (body: BodyOf<'/api/plans/tasks', 'post'>) =>
unwrap(apiClient.POST('/api/plans/tasks', { body })),
toggleTask: (id: string) =>
unwrap(apiClient.PATCH('/api/plans/tasks/{id}/toggle', { params: { path: { id } } }))
}
// ---- 模考 ----
export const mockApi = {
analysis: () => unwrap(apiClient.GET('/api/mock-exams/analysis')),
record: (body: BodyOf<'/api/mock-exams/record', 'post'>) =>
unwrap(apiClient.POST('/api/mock-exams/record', { body }))
}
// ---- 要闻 ----
export const newsApi = {
list: (query?: QueryOf<'/api/news/list', 'get'>) =>
unwrap(apiClient.GET('/api/news/list', { params: { query } })),
detail: (id: string) => unwrap(apiClient.GET('/api/news/{id}', { params: { path: { id } } })),
importJson: (body: BodyOf<'/api/news/import/json', 'post'>) =>
unwrap(apiClient.POST('/api/news/import/json', { body })),
importRss: (body: BodyOf<'/api/news/import/rss', 'post'>) =>
unwrap(apiClient.POST('/api/news/import/rss', { body })),
importApi: (body: BodyOf<'/api/news/import/api', 'post'>) =>
unwrap(apiClient.POST('/api/news/import/api', { body })),
importUrl: (body: BodyOf<'/api/news/import/url', 'post'>) =>
unwrap(apiClient.POST('/api/news/import/url', { body }))
}
// ---- 题库管理 ----
export const questionsApi = {
list: (query?: QueryOf<'/api/questions/list', 'get'>) =>
unwrap(apiClient.GET('/api/questions/list', { params: { query } })),
importQuestions: (body: BodyOf<'/api/questions/import', 'post'>) =>
unwrap(apiClient.POST('/api/questions/import', { body })),
exportQuestions: () => unwrap(apiClient.GET('/api/questions/export')),
remove: (id: string) => unwrap(apiClient.DELETE('/api/questions/{id}', { params: { path: { id } } }))
}
// ---- AI 讲解 ----
export const aiApi = {
explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) =>
unwrap(apiClient.POST('/api/ai/explain-question', { body }))
}
// ---- 个人档案 / 设置 ----
export const profileApi = {
get: () => unwrap(apiClient.GET('/api/profile')),
patch: (body: BodyOf<'/api/profile', 'patch'>) =>
unwrap(apiClient.PATCH('/api/profile', { body }))
}
export const settingsApi = {
get: () => unwrap(apiClient.GET('/api/settings')),
patch: (body: BodyOf<'/api/settings', 'patch'>) =>
unwrap(apiClient.PATCH('/api/settings', { body }))
}

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'success' | 'warning' | 'danger' | 'soft' | 'primary'
}>(),
{ variant: 'soft' }
)
</script>
<template>
<span class="badge" :class="`badge--${variant}`"><slot /></span>
</template>
<style scoped>
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px var(--space-2);
border-radius: var(--radius-sm);
font-size: var(--fs-label);
font-weight: var(--fw-label-bold);
line-height: var(--lh-label);
white-space: nowrap;
}
.badge--success {
color: var(--success);
background: var(--success-soft);
}
.badge--warning {
color: var(--warning);
background: var(--warning-soft);
}
.badge--danger {
color: var(--danger);
background: var(--danger-soft);
}
.badge--soft {
color: var(--text-secondary);
background: var(--bg-soft);
}
.badge--primary {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 10%, #fff);
}
</style>

View File

@ -0,0 +1,90 @@
<script setup lang="ts">
import AppIcon from './AppIcon.vue'
import type { IconName } from './icons'
withDefaults(
defineProps<{
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
size?: 'md' | 'lg'
icon?: IconName
type?: 'button' | 'submit'
disabled?: boolean
block?: boolean
}>(),
{ variant: 'primary', size: 'md', type: 'button', disabled: false, block: false }
)
</script>
<template>
<button
:type="type"
:disabled="disabled"
class="btn"
:class="[`btn--${variant}`, `btn--${size}`, { 'btn--block': block }]"
>
<AppIcon v-if="icon" :name="icon" :size="size === 'lg' ? 18 : 16" />
<span><slot /></span>
</button>
</template>
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-btn);
font-size: var(--fs-button);
font-weight: var(--fw-button);
line-height: 1;
border: 1px solid transparent;
transition: background var(--motion-hover-fast) var(--ease-default),
opacity var(--motion-hover-fast) var(--ease-default);
white-space: nowrap;
}
.btn--md {
height: var(--btn-height);
padding: 0 var(--space-4);
}
.btn--lg {
height: var(--btn-height-pill);
padding: 0 var(--space-5);
}
.btn--block {
width: 100%;
}
.btn--primary {
background: var(--primary);
color: var(--on-accent);
}
.btn--primary:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 90%, #000);
}
.btn--secondary {
background: transparent;
color: var(--primary);
border-color: var(--primary);
}
.btn--secondary:hover:not(:disabled) {
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.btn--ghost {
background: transparent;
color: var(--text-secondary);
}
.btn--ghost:hover:not(:disabled) {
background: var(--bg-soft);
color: var(--text-primary);
}
.btn--danger {
background: var(--danger);
color: var(--on-accent);
}
.btn--danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 90%, #000);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@ -0,0 +1,76 @@
<script setup lang="ts">
import AppIcon from './AppIcon.vue'
import type { IconName } from './icons'
withDefaults(
defineProps<{
title?: string
icon?: IconName
padded?: boolean
interactive?: boolean
}>(),
{ padded: true, interactive: false }
)
</script>
<template>
<section class="card" :class="{ 'card--flat': !padded, 'card--interactive': interactive }">
<header v-if="title || $slots.header" class="card__head">
<div v-if="title" class="card__title">
<AppIcon v-if="icon" :name="icon" :size="18" />
<h3>{{ title }}</h3>
</div>
<div v-if="$slots.header" class="card__action"><slot name="header" /></div>
</header>
<slot />
</section>
</template>
<style scoped>
.card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--card-padding-desktop);
}
.card--flat {
padding: 0;
overflow: hidden;
}
.card--interactive {
cursor: pointer;
transition: box-shadow var(--motion-card-hover) var(--ease-default);
}
.card--interactive:hover {
box-shadow: var(--shadow-level-1);
}
.card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-4);
}
.card__title {
display: flex;
align-items: center;
gap: var(--space-2);
color: var(--primary);
}
.card__title h3 {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
line-height: var(--lh-card-title);
color: var(--text-primary);
}
.card__action {
display: flex;
align-items: center;
gap: var(--space-2);
}
@media (max-width: 767px) {
.card {
padding: var(--card-padding-mobile);
}
}
</style>

View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import { icons } from './icons'
import type { IconName } from './icons'
const props = withDefaults(
defineProps<{
name: IconName
size?: number | string
strokeWidth?: number
}>(),
{ size: 20, strokeWidth: 2 }
)
const nodes = computed(() => icons[props.name] ?? [])
</script>
<template>
<svg
:width="size"
:height="size"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<component :is="node.tag" v-for="(node, i) in nodes" :key="i" v-bind="node.attrs" />
</svg>
</template>

View File

@ -0,0 +1,113 @@
// 线性图标集:统一 stroke 风格(见设计规范「图标」一节),禁止引入图标库依赖。
// 使用方式:<AppIcon name="home" :size="20" />
export type IconName =
| 'home'
| 'dashboard'
| 'pen'
| 'chart'
| 'calendar'
| 'news'
| 'user'
| 'book'
| 'settings'
| 'inbox'
| 'alert'
| 'check'
| 'chevron-right'
| 'clock'
| 'flame'
| 'target'
interface IconNode {
tag: 'path' | 'circle' | 'rect' | 'line' | 'polyline'
attrs: Record<string, string | number>
}
export const icons: Record<IconName, IconNode[]> = {
home: [
{ tag: 'path', attrs: { d: 'M3 10.5 12 3l9 7.5' } },
{ tag: 'path', attrs: { d: 'M5 9.5V21h14V9.5' } },
{ tag: 'path', attrs: { d: 'M10 21v-6h4v6' } }
],
dashboard: [
{ tag: 'rect', attrs: { x: 3, y: 3, width: 7, height: 7, rx: 2 } },
{ tag: 'rect', attrs: { x: 14, y: 3, width: 7, height: 7, rx: 2 } },
{ tag: 'rect', attrs: { x: 3, y: 14, width: 7, height: 7, rx: 2 } },
{ tag: 'rect', attrs: { x: 14, y: 14, width: 7, height: 7, rx: 2 } }
],
pen: [
{ tag: 'path', attrs: { d: 'M12 20h9' } },
{ tag: 'path', attrs: { d: 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z' } }
],
chart: [
{ tag: 'path', attrs: { d: 'M3 3v18h18' } },
{ tag: 'path', attrs: { d: 'M18 17V9' } },
{ tag: 'path', attrs: { d: 'M13 17V5' } },
{ tag: 'path', attrs: { d: 'M8 17v-3' } }
],
calendar: [
{ tag: 'rect', attrs: { x: 3, y: 4, width: 18, height: 18, rx: 2 } },
{ tag: 'line', attrs: { x1: 16, y1: 2, x2: 16, y2: 6 } },
{ tag: 'line', attrs: { x1: 8, y1: 2, x2: 8, y2: 6 } },
{ tag: 'line', attrs: { x1: 3, y1: 10, x2: 21, y2: 10 } }
],
news: [
{ tag: 'rect', attrs: { x: 3, y: 5, width: 18, height: 15, rx: 2 } },
{ tag: 'line', attrs: { x1: 3, y1: 10, x2: 21, y2: 10 } },
{ tag: 'line', attrs: { x1: 7, y1: 7, x2: 11, y2: 7 } },
{ tag: 'line', attrs: { x1: 7, y1: 14, x2: 15, y2: 14 } },
{ tag: 'line', attrs: { x1: 7, y1: 17, x2: 11, y2: 17 } }
],
user: [
{ tag: 'circle', attrs: { cx: 12, cy: 8, r: 4 } },
{ tag: 'path', attrs: { d: 'M4 21a8 8 0 0 1 16 0' } }
],
book: [
{ tag: 'path', attrs: { d: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20' } },
{ tag: 'path', attrs: { d: 'M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z' } }
],
settings: [
{ tag: 'line', attrs: { x1: 21, y1: 4, x2: 14, y2: 4 } },
{ tag: 'line', attrs: { x1: 10, y1: 4, x2: 3, y2: 4 } },
{ tag: 'line', attrs: { x1: 21, y1: 12, x2: 12, y2: 12 } },
{ tag: 'line', attrs: { x1: 8, y1: 12, x2: 3, y2: 12 } },
{ tag: 'line', attrs: { x1: 21, y1: 20, x2: 16, y2: 20 } },
{ tag: 'line', attrs: { x1: 12, y1: 20, x2: 3, y2: 20 } },
{ tag: 'line', attrs: { x1: 14, y1: 2, x2: 14, y2: 6 } },
{ tag: 'line', attrs: { x1: 8, y1: 10, x2: 8, y2: 14 } },
{ tag: 'line', attrs: { x1: 16, y1: 18, x2: 16, y2: 22 } }
],
inbox: [
{ tag: 'path', attrs: { d: 'M22 12h-6l-2 3h-4l-2-3H2' } },
{
tag: 'path',
attrs: {
d: 'M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z'
}
}
],
alert: [
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
{ tag: 'line', attrs: { x1: 12, y1: 8, x2: 12, y2: 12 } },
{ tag: 'line', attrs: { x1: 12, y1: 16, x2: 12.01, y2: 16 } }
],
check: [{ tag: 'path', attrs: { d: 'M20 6 9 17l-5-5' } }],
'chevron-right': [{ tag: 'path', attrs: { d: 'M9 18l6-6-6-6' } }],
clock: [
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
{ tag: 'path', attrs: { d: 'M12 6v6l4 2' } }
],
flame: [
{
tag: 'path',
attrs: {
d: 'M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z'
}
}
],
target: [
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 6 } },
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 2 } }
]
}

View File

@ -0,0 +1,60 @@
<script setup lang="ts">
import AppIcon from '../base/AppIcon.vue'
import type { IconName } from '../base/icons'
withDefaults(
defineProps<{
title?: string
description?: string
icon?: IconName
}>(),
{ title: '暂无数据', description: '', icon: 'inbox' }
)
</script>
<template>
<div class="empty">
<div class="empty__icon"><AppIcon :name="icon" :size="32" /></div>
<p class="empty__title">{{ title }}</p>
<p v-if="description" class="empty__desc">{{ description }}</p>
<div v-if="$slots.default" class="empty__action"><slot /></div>
</div>
</template>
<style scoped>
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: var(--space-8) var(--space-4);
color: var(--text-muted);
}
.empty__icon {
display: grid;
place-items: center;
width: 64px;
height: 64px;
border-radius: var(--radius-xl);
background: var(--bg-soft);
color: var(--text-muted);
margin-bottom: var(--space-4);
}
.empty__title {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-secondary);
}
.empty__desc {
margin: var(--space-2) 0 0;
max-width: 320px;
font-size: var(--fs-subtitle);
color: var(--text-muted);
line-height: var(--lh-subtitle);
}
.empty__action {
margin-top: var(--space-4);
}
</style>

View File

@ -0,0 +1,63 @@
<script setup lang="ts">
import AppIcon from '../base/AppIcon.vue'
import AppButton from '../base/AppButton.vue'
withDefaults(
defineProps<{
title?: string
message?: string
retry?: boolean
fullscreen?: boolean
}>(),
{ title: '加载失败', message: '', retry: false, fullscreen: false }
)
const emit = defineEmits<{ retry: [] }>()
</script>
<template>
<div class="error" :class="{ 'error--full': fullscreen }">
<div class="error__icon"><AppIcon name="alert" :size="28" /></div>
<p class="error__title">{{ title }}</p>
<p v-if="message" class="error__msg">{{ message }}</p>
<AppButton v-if="retry" variant="secondary" size="md" @click="emit('retry')">重试</AppButton>
</div>
</template>
<style scoped>
.error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-2);
text-align: center;
padding: var(--space-8) var(--space-4);
}
.error--full {
min-height: 40vh;
}
.error__icon {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: var(--radius-xl);
background: var(--danger-soft);
color: var(--danger);
margin-bottom: var(--space-2);
}
.error__title {
margin: 0;
font-size: var(--fs-card-title);
font-weight: var(--fw-card-title);
color: var(--text-primary);
}
.error__msg {
margin: 0 0 var(--space-2);
max-width: 360px;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
line-height: var(--lh-subtitle);
}
</style>

View File

@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(defineProps<{ text?: string; fullscreen?: boolean }>(), { fullscreen: false })
</script>
<template>
<div class="loading" :class="{ 'loading--full': fullscreen }">
<span class="loading__spinner" />
<p v-if="text" class="loading__text">{{ text }}</p>
</div>
</template>
<style scoped>
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8);
color: var(--text-secondary);
}
.loading--full {
min-height: 40vh;
}
.loading__spinner {
width: 26px;
height: 26px;
border-radius: 50%;
border: 3px solid var(--bg-soft);
border-top-color: var(--primary);
animation: app-spin 0.8s linear infinite;
}
.loading__text {
margin: 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
@keyframes app-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,67 @@
<script setup lang="ts">
import { useAppStore } from '../../stores/app'
import AppIcon from '../base/AppIcon.vue'
const app = useAppStore()
const iconOf = (type: string) => (type === 'success' ? 'check' : type === 'error' ? 'alert' : 'inbox')
</script>
<template>
<Teleport to="body">
<div class="toast-layer" aria-live="polite">
<TransitionGroup name="toast">
<div v-for="t in app.toasts" :key="t.id" class="toast" :class="`toast--${t.type}`">
<AppIcon :name="iconOf(t.type)" :size="16" />
<span>{{ t.message }}</span>
</div>
</TransitionGroup>
</div>
</Teleport>
</template>
<style scoped>
.toast-layer {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 999;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-2);
max-width: 80vw;
padding: 10px var(--space-4);
border-radius: var(--radius-md);
background: var(--bg-card);
color: var(--text-primary);
font-size: var(--fs-subtitle);
box-shadow: var(--shadow-level-3);
border: 1px solid var(--border-subtle);
}
.toast--success {
color: var(--success);
}
.toast--error {
color: var(--danger);
}
.toast--info {
color: var(--text-primary);
}
.toast-enter-active,
.toast-leave-active {
transition: all var(--motion-switch) var(--ease-default);
}
.toast-enter-from,
.toast-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import AppCard from '../base/AppCard.vue'
import AppEmpty from './AppEmpty.vue'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '页面'))
</script>
<template>
<AppCard>
<AppEmpty :title="`「${title}」建设中`" description="该页面将在后续任务中实现,当前已完成导航与路由占位。" />
</AppCard>
</template>

View File

@ -0,0 +1,37 @@
import { ref, type Ref, type UnwrapRef } from 'vue'
import { ApiError } from '../api'
interface UseRequestReturn<T> {
data: Ref<UnwrapRef<T> | null>
loading: Ref<boolean>
error: Ref<string>
refresh: () => Promise<void>
}
/**
* / /
* store loading/error
* @param fn Promise
* @param immediate setup true
*/
export function useRequest<T>(fn: () => Promise<T>, immediate = true): UseRequestReturn<T> {
const data = ref<T | null>(null) as Ref<UnwrapRef<T> | null>
const loading = ref(false)
const error = ref('')
const refresh = async () => {
loading.value = true
error.value = ''
try {
data.value = (await fn()) as UnwrapRef<T>
} catch (err) {
error.value = err instanceof ApiError ? err.message : '加载失败,请稍后重试'
} finally {
loading.value = false
}
}
if (immediate) void refresh()
return { data, loading, error, refresh }
}

View File

@ -1,9 +1,25 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
const MOBILE_BREAKPOINT = 768
/**
* < 768px
* 使 window.innerWidth + resize App
*/
export function useResponsive() { export function useResponsive() {
const width = ref(typeof window === 'undefined' ? 1440 : window.innerWidth) const width = ref(typeof window === 'undefined' ? 1440 : window.innerWidth)
const update = () => { width.value = window.innerWidth } const update = () => {
onMounted(() => window.addEventListener('resize', update)) width.value = window.innerWidth
onUnmounted(() => window.removeEventListener('resize', update)) }
return { width, isMobile: computed(() => width.value < 768) } onMounted(() => {
window.addEventListener('resize', update)
})
onUnmounted(() => {
window.removeEventListener('resize', update)
})
return {
width,
isMobile: computed(() => width.value < MOBILE_BREAKPOINT),
isDesktop: computed(() => width.value >= MOBILE_BREAKPOINT)
}
} }

View File

@ -0,0 +1,13 @@
import { useAppStore } from '../stores/app'
import type { ToastType } from '../stores/app'
/** 全局轻提示入口success / error / info 三种语义 */
export function useToast() {
const app = useAppStore()
return {
success: (message: string) => app.toast(message, 'success'),
error: (message: string) => app.toast(message, 'error'),
info: (message: string) => app.toast(message, 'info'),
toast: (message: string, type: ToastType = 'info') => app.toast(message, type)
}
}

View File

@ -1,9 +0,0 @@
<script setup lang="ts">
defineProps<{ items: string[]; active: number }>()
const emit = defineEmits<{ select: [index: number] }>()
</script>
<template>
<div class="brand"><span></span><strong>备考中枢</strong></div>
<button v-for="(item, index) in items" :key="item" :class="['nav-item', { active: active === index }]" @click="emit('select', index)">{{ item }}</button>
</template>

View File

@ -1,9 +1,96 @@
<script setup lang="ts"> <script setup lang="ts">
import AppNavigation from './AppNavigation.vue' import { useRoute } from 'vue-router'
defineProps<{ items: string[]; active: number }>() import AppIcon from '../components/base/AppIcon.vue'
const emit = defineEmits<{ select: [index: number] }>() import { desktopNav } from '../router/nav'
const route = useRoute()
</script> </script>
<template> <template>
<aside class="sidebar"><AppNavigation :items="items" :active="active" @select="emit('select', $event)" /><div class="sidebar-spacer" /><button class="nav-item">题库管理</button><button class="nav-item">设置</button></aside> <div class="layout-desktop">
<aside class="sidebar">
<div class="brand">
<span class="brand__mark"><AppIcon name="dashboard" :size="18" /></span>
<strong>备考通</strong>
</div>
<nav class="nav">
<RouterLink
v-for="item in desktopNav"
:key="item.to"
:to="item.to"
class="nav-item"
:class="{ active: route.path === item.to }"
>
<AppIcon :name="item.icon" :size="18" />
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</aside>
<main class="content"><slot /></main>
</div>
</template> </template>
<style scoped>
.layout-desktop {
min-height: 100vh;
}
.sidebar {
position: fixed;
inset: 0 auto 0 0;
width: var(--sidebar-width-desktop);
padding: var(--space-4) var(--space-3);
background: var(--bg-card);
border-right: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.brand {
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
height: 40px;
font-size: 16px;
color: var(--text-primary);
}
.brand__mark {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
background: var(--gradient-hero-deep);
color: var(--on-accent);
}
.nav {
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
height: 40px;
padding: 0 var(--space-3);
border-radius: var(--radius-btn);
color: var(--text-secondary);
font-size: 13px;
transition: background var(--motion-hover-fast) var(--ease-default);
}
.nav-item:hover {
background: var(--bg-soft);
color: var(--text-primary);
}
.nav-item.active {
background: var(--primary);
color: var(--on-accent);
font-weight: var(--fw-card-title);
}
.content {
margin-left: var(--sidebar-width-desktop);
padding: var(--padding-desktop);
max-width: calc(var(--sidebar-width-desktop) + var(--content-max-width));
}
</style>

View File

@ -1,8 +1,87 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ items: string[]; active: number }>() import { computed } from 'vue'
const emit = defineEmits<{ select: [index: number] }>() import { useRoute } from 'vue-router'
import AppIcon from '../components/base/AppIcon.vue'
import { mobileNav } from '../router/nav'
const route = useRoute()
const title = computed(() => String(route.meta.title ?? '备考通'))
</script> </script>
<template> <template>
<nav class="mobile-tabs"><button v-for="(item, index) in items" :key="item" :class="{ active: active === index }" @click="emit('select', index)"><span>{{ ['⌂','▣','▥','▤','▤','●'][index] }}</span>{{ item }}</button></nav> <div class="layout-mobile">
<header class="topbar">
<h1>{{ title }}</h1>
</header>
<main class="content"><slot /></main>
<nav class="tabbar">
<RouterLink
v-for="item in mobileNav"
:key="item.to"
:to="item.to"
class="tab"
:class="{ active: route.path === item.to }"
>
<AppIcon :name="item.icon" :size="20" />
<span>{{ item.label }}</span>
</RouterLink>
</nav>
</div>
</template> </template>
<style scoped>
.layout-mobile {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
height: 56px;
padding: 0 var(--padding-mobile);
background: var(--bg-canvas);
}
.topbar h1 {
margin: 0;
font-size: 20px;
font-weight: var(--fw-h1);
color: var(--text-primary);
}
.content {
padding: 0 var(--padding-mobile) var(--tabbar-height-mobile);
}
.tabbar {
position: fixed;
z-index: 20;
left: var(--space-4);
right: var(--space-4);
bottom: var(--space-4);
height: 72px;
display: flex;
align-items: center;
justify-content: space-around;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-pill);
box-shadow: var(--shadow-level-2);
padding: 6px;
}
.tab {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
flex: 1;
padding: 8px 6px;
border-radius: var(--radius-pill);
color: var(--text-muted);
font-size: 11px;
}
.tab.active {
background: var(--primary);
color: var(--on-accent);
font-weight: var(--fw-card-title);
}
</style>

View File

@ -1,6 +1,10 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router'
import './styles/main.css' import './styles/main.css'
createApp(App).use(createPinia()).mount('#app') const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@ -1,8 +1,35 @@
export const routes = { import { createRouter, createWebHistory } from 'vue-router'
dashboard: '/', import type { RouteRecordRaw } from 'vue-router'
practice: '/practice', import DashboardView from '../views/dashboard/DashboardView.vue'
analysis: '/analysis', import PracticeView from '../views/practice/PracticeView.vue'
plan: '/plan', import MockView from '../views/mock/MockView.vue'
news: '/news', import PlanView from '../views/plan/PlanView.vue'
profile: '/profile' import NewsView from '../views/news/NewsView.vue'
} as const import ProfileView from '../views/profile/ProfileView.vue'
import QuestionsView from '../views/questions/QuestionsView.vue'
import SettingsView from '../views/settings/SettingsView.vue'
export const routes: RouteRecordRaw[] = [
{ path: '/', name: 'dashboard', component: DashboardView, meta: { title: '数据中枢' } },
{ path: '/practice', name: 'practice', component: PracticeView, meta: { title: '刷题中心' } },
{ path: '/mock', name: 'mock', component: MockView, meta: { title: '模考分析' } },
{ path: '/plan', name: 'plan', component: PlanView, meta: { title: '备考计划' } },
{ path: '/news', name: 'news', component: NewsView, meta: { title: '要闻' } },
{ path: '/profile', name: 'profile', component: ProfileView, meta: { title: '我的' } },
{ path: '/questions', name: 'questions', component: QuestionsView, meta: { title: '题库管理' } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: '设置' } },
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior: () => ({ top: 0 })
})
router.afterEach((to) => {
const title = to.meta.title as string | undefined
document.title = title ? `${title} · 备考通` : '备考通'
})
export default router

28
client/src/router/nav.ts Normal file
View File

@ -0,0 +1,28 @@
import type { IconName } from '../components/base/icons'
export interface NavItem {
label: string
to: string
icon: IconName
}
/** 桌面左侧导航8 项,见需求文档 3.1 */
export const desktopNav: NavItem[] = [
{ label: '数据中枢', to: '/', icon: 'dashboard' },
{ label: '刷题中心', to: '/practice', icon: 'pen' },
{ label: '模考分析', to: '/mock', icon: 'chart' },
{ label: '备考计划', to: '/plan', icon: 'calendar' },
{ label: '要闻', to: '/news', icon: 'news' },
{ label: '我的', to: '/profile', icon: 'user' },
{ label: '题库管理', to: '/questions', icon: 'book' },
{ label: '设置', to: '/settings', icon: 'settings' }
]
/** 移动底部 TabBar5 项,见需求文档 3.2 */
export const mobileNav: NavItem[] = [
{ label: '首页', to: '/', icon: 'home' },
{ label: '刷题', to: '/practice', icon: 'pen' },
{ label: '要闻', to: '/news', icon: 'news' },
{ label: '分析', to: '/mock', icon: 'chart' },
{ label: '我的', to: '/profile', icon: 'user' }
]

View File

@ -1,9 +1,36 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
export type ToastType = 'info' | 'success' | 'error'
export interface Toast {
id: number
message: string
type: ToastType
}
let toastSeq = 0
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', {
state: () => ({ busy: false, notice: '' }), state: () => ({
busyCount: 0,
toasts: [] as Toast[]
}),
getters: {
busy: (state) => state.busyCount > 0
},
actions: { actions: {
setBusy(value: boolean) { this.busy = value }, startBusy() {
notify(message: string) { this.notice = message } this.busyCount += 1
},
stopBusy() {
this.busyCount = Math.max(0, this.busyCount - 1)
},
toast(message: string, type: ToastType = 'info') {
const id = ++toastSeq
this.toasts.push({ id, message, type })
setTimeout(() => this.dismissToast(id), 3200)
},
dismissToast(id: number) {
this.toasts = this.toasts.filter((t) => t.id !== id)
}
} }
}) })

View File

@ -1,5 +1,37 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
/**
*
*
*/
export const usePracticeStore = defineStore('practice', { export const usePracticeStore = defineStore('practice', {
state: () => ({ sessionId: null as string | null, currentIndex: 0 }) state: () => ({
sessionId: null as string | null,
module: '' as string,
durationMinutes: 0,
currentIndex: 0,
total: 0
}),
getters: {
inSession: (state) => state.sessionId !== null
},
actions: {
start(session: { sessionId: string; module: string; durationMinutes: number; total: number }) {
this.sessionId = session.sessionId
this.module = session.module
this.durationMinutes = session.durationMinutes
this.currentIndex = 0
this.total = session.total
},
next() {
if (this.currentIndex < this.total - 1) this.currentIndex += 1
},
reset() {
this.sessionId = null
this.module = ''
this.durationMinutes = 0
this.currentIndex = 0
this.total = 0
}
}
}) })

View File

@ -1,5 +1,29 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { profileApi } from '../api'
import type { Profile } from '../api'
export const useProfileStore = defineStore('profile', { export const useProfileStore = defineStore('profile', {
state: () => ({ nickname: '备考人', targetScore: 72, examDate: '' }) state: () => ({
profile: null as Profile | null,
loading: false,
error: ''
}),
getters: {
nickname: (state) => state.profile?.nickname ?? '备考人',
targetScore: (state) => state.profile?.targetScore ?? 0,
examDate: (state) => state.profile?.examDate ?? ''
},
actions: {
async fetch() {
this.loading = true
this.error = ''
try {
this.profile = await profileApi.get()
} catch (err) {
this.error = err instanceof Error ? err.message : '加载失败'
} finally {
this.loading = false
}
}
}
}) })

View File

@ -1 +1,35 @@
:root{font-family:Arial,"PingFang SC",sans-serif;color:#1a1f2e;background:#f7f6f2;font-synthesis:none}*{box-sizing:border-box}body{margin:0}.app-shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:220px;padding:16px 10px;background:#fff;border-right:1px solid #e8e6e0;display:flex;flex-direction:column;gap:7px}.brand{height:36px;display:flex;align-items:center;gap:9px}.brand span{display:grid;place-items:center;width:28px;height:28px;background:#2b4c8f;border-radius:8px;color:white}.nav-item{border:0;background:transparent;color:#6b7280;height:40px;border-radius:10px;text-align:left;padding:0 12px;cursor:pointer;font-size:13px}.nav-item.active{background:#2b4c8f;color:#fff}.sidebar-spacer{flex:1}.content{margin-left:220px;padding:32px;max-width:1250px}header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:20px}h1{font-size:24px;margin:0 0 5px}h2{margin:0 0 8px}p{font-size:13px;color:#6b7280;margin:0;line-height:1.5}.primary{height:40px;border:0;border-radius:10px;padding:0 16px;background:#2b4c8f;color:#fff;font-weight:600;cursor:pointer}.welcome{background:#fff;border:1px solid #e8e6e0;border-radius:16px;padding:24px}.mobile-tabs{display:none}@media(max-width:767px){.sidebar{display:none}.content{margin:0;padding:28px 24px 110px}.content header{margin-bottom:24px}.content h1{font-size:22px}.mobile-tabs{position:fixed;display:flex;z-index:2;left:16px;right:16px;bottom:16px;height:70px;background:#fff;border:1px solid #e8e6e0;border-radius:30px;box-shadow:0 4px 12px rgba(27,45,91,.08);justify-content:space-around;padding:8px}.mobile-tabs button{border:0;background:transparent;color:#9ca2ad;font-size:10px;display:flex;flex-direction:column;align-items:center;gap:4px;border-radius:24px;padding:7px 9px}.mobile-tabs button.active{background:#2b4c8f;color:#fff}.mobile-tabs span{font-size:17px}.primary{height:36px;padding:0 11px;font-size:12px}} /* 全局样式入口先加载设计 Token再加载基础重置
组件/页面样式在各自 <style scoped> 引用 var(--token) 而非硬编码颜色 */
@import './tokens.css';
@import './reset.css';
:root {
color-scheme: light;
}
#app {
min-height: 100vh;
}
/* 统一焦点可见性,保持键盘可访问 */
:focus-visible {
outline: 2px solid var(--primary-bright);
outline-offset: 2px;
}
/* 简洁滚动条WebKit */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-subtle);
border-radius: var(--radius-pill);
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
background-clip: content-box;
}

View File

@ -0,0 +1,77 @@
/* 基础重置:统一盒模型、边距、字体与滚动行为。颜色等一律引用 tokens禁止硬编码。 */
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
font-family: var(--font-sans);
font-size: var(--fs-body);
line-height: var(--lh-body);
color: var(--text-primary);
background: var(--bg-canvas);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1,
h2,
h3,
h4,
p,
figure {
margin: 0;
}
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
}
a {
color: inherit;
text-decoration: none;
}
button {
font-family: inherit;
cursor: pointer;
background: none;
border: none;
padding: 0;
color: inherit;
}
input,
textarea,
select {
font-family: inherit;
font-size: inherit;
color: inherit;
}
img,
svg {
display: block;
max-width: 100%;
}
/* 数值强调(统计数字)统一使用数值字体 */
.num {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
}

View File

@ -0,0 +1,192 @@
/**
* 备考通 · Design Tokens (CSS 变量)
* =====================================================================
* 版本v1.0.0 · 2026-08-26
* 来源设计规范-备考通-20260826.mdArdot 文件 718570750880145 真实变量校准
* 说明本文件与 design-tokens.json 数值完全一致是前端唯一颜色/排印/
* 圆角/阴影/间距来源前端必须引用这些变量禁止硬编码颜色值
*
* 使用方式前端在 `styles/tokens.css` import 本文件或复制 :root
* 组件内一律 `var(--token-name)` 引用
* =====================================================================
*/
:root {
/* ------------------------------------------------------------------
* 1. 颜色 Color
* ------------------------------------------------------------------ */
/* 中性底色 Neutral */
--bg-canvas: #F7F6F2; /* 画布/页面背景(浅米白) */
--bg-card: #FFFFFF; /* 卡片/面板背景 */
--bg-soft: #EFEFF5; /* 图标底、次级填充、标签底、进度条轨道 */
--border-subtle: #E8E6E0; /* 卡片描边、分隔线(中性浅暖) */
/* 品牌主色 Brand深蓝系 */
--primary: #2B4C8F; /* 主按钮、主色、选中态高亮底、进度填充 */
--primary-deep: #1B2D5B; /* 渐变深端、页面主标题强调、深蓝 hero 底 */
--primary-bright: #3A6BD8; /* 渐变亮端、hero 卡片、active 图标 */
/* 文字色 Text */
--text-primary: #1A1F2E; /* 主标题、正文主色 */
--text-secondary: #6B7280; /* 副标题、描述文字、图标默认色 */
--text-muted: #9CA2AD; /* 弱提示、占位、次要标记 */
/* 语义色 Semantic状态反馈 */
--success: #1B9266; /* 正向趋势、正确率、达标 */
--success-soft: #E0F5EB; /* 正向标签底、打卡徽章底 */
--warning: #DC9A1E; /* 提醒、中等状态 */
--warning-soft: #FEF0D1; /* 提醒标签底 */
--danger: #D14C55; /* 负向、错误、待复习错题数 */
--danger-soft: #FCE5E7; /* 危险标签底、错误提示底 */
/* 前景/辅助 Foreground */
--on-accent: #FFFFFF; /* 主色/深蓝底上的前景色(按钮文字、深蓝 hero 文字) */
--white: #FFFFFF; /* 纯白(唯一允许的裸色值) */
/* 渐变 Gradients仅 hero 大卡 / 置顶卡使用) */
--gradient-hero-deep: linear-gradient(135deg, #1B2D5B 0%, #3A6BD8 100%); /* 首页 hero 卡 / 要闻置顶卡 */
--gradient-hero-primary: linear-gradient(180deg, #2B4C8F 0%, #1B2D5B 100%); /* 移动首页深蓝 hero */
/* ------------------------------------------------------------------
* 2. 字体 Font
* ------------------------------------------------------------------ */
--font-sans: 'Sarasa Gothic SC', 'PingFang SC', sans-serif; /* 中文正文/标题/标签 */
--font-num: 'Inter', 'SF Pro', sans-serif; /* 统计数字/时间/单价/代码标识 */
/* ------------------------------------------------------------------
* 3. 排印 Typography
* role = 用途界定部分 token 的可变字段如趋势色由语义色覆盖
* ------------------------------------------------------------------ */
--fs-h1: 24px; --fw-h1: 700; --lh-h1: 1.2; --ff-h1: var(--font-sans); /* H1 页面标题 */
--fs-data-num: 22px; --fw-data-num: 700; --lh-data-num: 1.2; --ff-data-num: var(--font-num); /* 数据大字Inter Bold */
--fs-card-title:15px; --fw-card-title: 600; --lh-card-title: 1.4; --ff-card-title: var(--font-sans); /* 卡标题 */
--fs-label: 12px; --fw-label: 400; --lh-label: 1.4; --ff-label: var(--font-sans); /* 标签 */
--fs-label-bold:12px; --fw-label-bold: 600; --lh-label-bold: 1.4; --ff-label-bold: var(--font-sans); /* 强标签/徽章 */
--fs-subtitle: 13px; --fw-subtitle: 400; --lh-subtitle: 1.5; --ff-subtitle: var(--font-sans); /* 副标题 */
--fs-button: 13px; --fw-button: 600; --lh-button: 1.4; --ff-button: var(--font-sans); /* 按钮文字 */
--fs-trend: 11px; --fw-trend: 500; --lh-trend: 1.4; --ff-trend: var(--font-sans); /* 趋势指数 */
--fs-body: 14px; --fw-body: 400; --lh-body: 1.5; --ff-body: var(--font-sans); /* 正文/题干 */
/* ------------------------------------------------------------------
* 4. 圆角 Radius
* ------------------------------------------------------------------ */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px; /* 标准卡片(默认) */
--radius-xl: 24px; /* Hero 渐变大卡 */
--radius-pill: 999px; /* 头像、胶囊按钮、天数徽章 */
--radius-btn: 10px; /* 主/次按钮圆角 */
/* ------------------------------------------------------------------
* 5. 阴影 Shadow
* 默认以白底 + 细描边(border-subtle)表达卡片不用重阴影
* ------------------------------------------------------------------ */
--shadow-level-0: none;
--shadow-level-1: 0 1px 2px rgba(0, 0, 0, 0.04); /* 悬浮态轻投影 */
--shadow-level-2: 0 4px 12px rgba(27, 45, 91, 0.08); /* 弹层、浮起卡片、TabBar */
--shadow-level-3: 0 8px 24px rgba(27, 45, 91, 0.14); /* 模态、下拉菜单 */
/* ------------------------------------------------------------------
* 6. 间距 Spacing4px 网格
* ------------------------------------------------------------------ */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
/* ------------------------------------------------------------------
* 7. 布局 Layout
* ------------------------------------------------------------------ */
--sidebar-width-desktop: 220px;
--content-max-width: 1220px;
--padding-desktop: 32px; /* 桌面左右 padding */
--padding-mobile: 24px; /* 移动左右 padding */
--card-padding-desktop: 24px;
--card-padding-mobile: 16px;
--card-gap-desktop: 20px;
--card-gap-mobile: 12px;
--tabbar-height-mobile: 120px; /* 移动 TabBar 高度 */
--btn-height: 40px;
--btn-height-pill: 44px;
/* ------------------------------------------------------------------
* 8. 断点 Breakpoint
* ------------------------------------------------------------------ */
--bp-mobile: 390px;
--bp-desktop: 1440px;
/* ------------------------------------------------------------------
* 9. 动效 Motion
* ------------------------------------------------------------------ */
--motion-hover-fast: 120ms; /* 按钮 hover 背景过渡 */
--motion-card-hover: 160ms; /* 卡片 hover 投影过渡 */
--motion-switch: 250ms; /* 切换/展开 */
--motion-tab-fade: 200ms; /* 移动 Tab 切换淡入 */
--ease-default: ease;
}
/* =====================================================================
* 10. 组件级预设预设Typographic & Component recipe
* 前端不直接硬编码上述按此处 recipe 组装组件
* ===================================================================== */
/* Hero 数据卡(首页 5 要素卡 / 深蓝大卡) */
.hub-card-hero {
color: var(--on-accent);
background: var(--gradient-hero-deep);
border-radius: var(--radius-lg);
padding: var(--card-padding-desktop);
}
/* 标准白卡(默认卡片) */
.hub-card {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--card-padding-desktop);
}
/* 主按钮 */
.hub-btn-primary {
background: var(--primary);
color: var(--on-accent);
border-radius: var(--radius-btn);
height: var(--btn-height);
font: var(--fw-button) var(--fs-button) / var(--lh-button) var(--ff-button);
transition: background var(--motion-hover-fast) var(--ease-default);
}
.hub-btn-primary:hover { background: color-mix(in srgb, var(--primary) 92%, #000); }
/* 次按钮(透明 + primary 描边) */
.hub-btn-secondary {
background: transparent;
color: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-btn);
height: var(--btn-height);
font: var(--fw-button) var(--fs-button) / var(--lh-button) var(--ff-button);
}
/* 语义标签 / 徽章(主色 + 浅底成对) */
.hub-badge {
font: var(--fw-label-bold) var(--fs-label) / var(--lh-label) var(--ff-label);
border-radius: var(--radius-sm);
padding: 2px var(--space-2);
}
.hub-badge--success { color: var(--success); background: var(--success-soft); }
.hub-badge--warning { color: var(--warning); background: var(--warning-soft); }
.hub-badge--danger { color: var(--danger); background: var(--danger-soft); }
.hub-badge--soft { color: var(--text-secondary); background: var(--bg-soft); }

View File

@ -1 +1,5 @@
export type NavigationItem = { label: string; index: number } /**
* View Models
*
* api/generated api/index
*/

View File

@ -1 +1,21 @@
/**
*
*
*/
export const formatPercent = (value: number) => `${Math.round(value)}%` export const formatPercent = (value: number) => `${Math.round(value)}%`
/** 秒 → 「X 分 Y 秒」或「Y 秒」 */
export function formatDuration(seconds: number): string {
const s = Math.max(0, Math.round(seconds))
if (s < 60) return `${s}`
const m = Math.floor(s / 60)
const rest = s % 60
return rest === 0 ? `${m} 分钟` : `${m}${rest}`
}
/** YYYY-MM-DD → 「M月D日」 */
export function formatDateShort(iso: string): string {
const [, m, d] = iso.split('-')
return `${Number(m)}${Number(d)}`
}

View File

@ -1,3 +1,226 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { dashboardApi } from '../../api'
import { useRequest } from '../../composables/useRequest'
import { useProfileStore } from '../../stores/profile'
import AppCard from '../../components/base/AppCard.vue'
import AppLoading from '../../components/feedback/AppLoading.vue'
import AppError from '../../components/feedback/AppError.vue'
import AppEmpty from '../../components/feedback/AppEmpty.vue'
import AppIcon from '../../components/base/AppIcon.vue'
import type { IconName } from '../../components/base/icons'
const profile = useProfileStore()
const { data, loading, error, refresh } = useRequest(() => dashboardApi.overview())
onMounted(() => {
if (!profile.profile) profile.fetch()
})
const isEmpty = computed(() => {
const d = data.value
return !!d && d.totalQuestions === 0 && d.totalAnswered === 0
})
interface StatItem {
key: string
label: string
value: number
unit: string
icon: IconName
tone?: 'danger' | 'success'
}
const stats = computed<StatItem[]>(() => {
const d = data.value
if (!d) return []
return [
{ key: 'questions', label: '题库题量', value: d.totalQuestions, unit: '题', icon: 'book' },
{ key: 'review', label: '待复习错题', value: d.pendingReview, unit: '题', icon: 'alert', tone: d.pendingReview > 0 ? 'danger' : undefined },
{ key: 'tasks', label: '今日任务', value: d.todayTasksDone, unit: `/${d.todayTasksTotal}`, icon: 'check' },
{ key: 'minutes', label: '今日学习', value: d.todayMinutes, unit: '分钟', icon: 'clock' },
{ key: 'streak', label: '连续打卡', value: d.streakDays, unit: '天', icon: 'flame' }
]
})
</script>
<template> <template>
<section class="welcome"><h2>备考通已就绪</h2><p>前端工程架构已启动后续任务将逐页还原原型并接入真实数据</p></section> <div class="dashboard">
<header class="page-head">
<div>
<h1>数据中枢</h1>
<p>{{ profile.nickname }}欢迎回来 · 今日也要稳步推进</p>
</div>
</header>
<AppLoading v-if="loading" fullscreen />
<AppError
v-else-if="error"
fullscreen
title="数据加载失败"
:message="error"
retry
@retry="refresh"
/>
<AppEmpty
v-else-if="isEmpty"
title="还没有学习数据"
description="完成第一次刷题或导入题库后,这里会展示你的备考概览。"
/>
<div v-else-if="data" class="dashboard__body">
<section class="hero">
<div class="hero__item">
<span class="hero__label">学习天数</span>
<strong class="num">{{ data.studyDays }}<em></em></strong>
</div>
<div class="hero__item">
<span class="hero__label">累计答题</span>
<strong class="num">{{ data.totalAnswered }}<em></em></strong>
</div>
<div class="hero__item">
<span class="hero__label">正确率</span>
<strong class="num">{{ data.accuracy }}<em>%</em></strong>
</div>
</section>
<div class="stat-grid">
<AppCard v-for="s in stats" :key="s.key" class="stat">
<div class="stat__icon" :class="{ 'stat__icon--danger': s.tone === 'danger', 'stat__icon--success': s.tone === 'success' }">
<AppIcon :name="s.icon" :size="18" />
</div>
<div class="stat__meta">
<span class="stat__label">{{ s.label }}</span>
<strong class="num">{{ s.value }}<em>{{ s.unit }}</em></strong>
</div>
</AppCard>
</div>
</div>
</div>
</template> </template>
<style scoped>
.dashboard {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.page-head h1 {
font-size: var(--fs-h1);
font-weight: var(--fw-h1);
line-height: var(--lh-h1);
color: var(--text-primary);
}
.page-head p {
margin: var(--space-1) 0 0;
font-size: var(--fs-subtitle);
color: var(--text-secondary);
}
.dashboard__body {
display: flex;
flex-direction: column;
gap: var(--space-5);
}
.hero {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--space-4);
background: var(--gradient-hero-deep);
color: var(--on-accent);
border-radius: var(--radius-xl);
padding: var(--card-padding-desktop);
}
.hero__item {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.hero__label {
font-size: var(--fs-label);
opacity: 0.85;
}
.hero__item strong {
font-size: 30px;
font-weight: 700;
line-height: 1.1;
}
.hero__item em {
font-style: normal;
font-size: 14px;
font-weight: 400;
margin-left: 4px;
opacity: 0.85;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--card-gap-desktop);
}
.stat {
display: flex;
align-items: center;
gap: var(--space-3);
}
.stat__icon {
display: grid;
place-items: center;
width: 40px;
height: 40px;
flex: none;
border-radius: var(--radius-md);
background: var(--bg-soft);
color: var(--primary);
}
.stat__icon--danger {
background: var(--danger-soft);
color: var(--danger);
}
.stat__icon--success {
background: var(--success-soft);
color: var(--success);
}
.stat__meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat__label {
font-size: var(--fs-label);
color: var(--text-secondary);
}
.stat__meta strong {
font-size: var(--fs-data-num);
font-weight: 700;
line-height: var(--lh-data-num);
color: var(--text-primary);
}
.stat__meta em {
font-style: normal;
font-size: var(--fs-label);
font-weight: 400;
color: var(--text-muted);
margin-left: 2px;
}
@media (max-width: 1023px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 767px) {
.hero {
grid-template-columns: 1fr;
gap: var(--space-4);
padding: var(--card-padding-mobile);
}
.hero__item strong {
font-size: 26px;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--card-gap-mobile);
}
}
</style>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import ComingSoon from '../../components/feedback/ComingSoon.vue'
</script>
<template>
<ComingSoon />
</template>

View File

@ -3,5 +3,14 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { port: 5173 } server: {
port: 5173,
proxy: {
// 开发环境将 /api 代理到后端,前端使用相对路径,避免 CORS 与硬编码地址。
'/api': {
target: 'http://127.0.0.1:3000',
changeOrigin: true
}
}
}
}) })

68
docs/checks/task-04.md Normal file
View File

@ -0,0 +1,68 @@
# 任务 04 检查记录:前端 API 自动生成与基础视觉壳层
日期2026-08-31
## 交付内容
### OpenAPI 代码生成管线
- `client/scripts/generate-api.mjs`:从后端运行时端点 `http://localhost:3000/api/openapi.json`(可用 `OPENAPI_URL` 覆盖)拉取并生成类型;获取失败/生成失败时 exit 1 并提示先启动后端。
- `client/package.json`:新增 `api:generate`,并接入 `dev``build` 前置(`npm run api:generate && vite`OpenAPI 无法获取或生成失败时命令直接失败。
- 根 `package.json``pnpm api:generate` = client `api:generate`(需后端服务运行中)。
- `client/src/api/generated/schema.d.ts`:由 openapi-typescript 生成,已在 `.gitignore` 中忽略、禁止手工修改。
### 请求客户端与领域 API
- `client/src/api/client.ts``openapi-fetch``createClient<paths>`,统一 baseUrl默认相对路径 `/api`,开发环境经 Vite 代理、JSON headers、15s 超时AbortController与错误解包`ApiError` 统一异常。
- `client/src/api/index.ts`按域暴露类型化方法dashboard/practice/review/plans/mock/news/questions/ai/profile/settings请求/响应类型由 `BodyOf`/`QueryOf`/`SuccessBody` 从生成的 `paths` 推导,业务代码不写 URL 与 `fetch`
### 设计 Token 与基础组件
- `client/src/styles/tokens.css`(承接 `deliverables/design/design-tokens.css`)、`reset.css``main.css`
- `components/base/`AppIcon内联线性 SVG 图标集替代依赖、AppButton、AppCard、AppBadge。
- `components/feedback/`AppLoading、AppEmpty、AppError、AppToast、ComingSoon。
### 路由、Store 与双端布局
- `router/nav.ts`PC 8 项 / Mobile 5 项导航配置)、`router/index.ts`8 条路由 + 兜底重定向 + 文档标题)。
- `stores/app.ts`busy + toast`profile.ts`(档案加载)、`practice.ts`(会话状态)。
- `layouts/DesktopLayout.vue`220px 侧栏)、`layouts/MobileLayout.vue`(顶部栏 + 底部 TabBar
- `App.vue``<768px` 断点切换双端布局;`main.ts` 接入 Pinia + Router。
- 首页 `DashboardView.vue` 通过 `dashboardApi.overview()` 加载真实数据,覆盖加载/空/错误/成功四态;其余 7 个导航页为 ComingSoon 占位。
## 过程中发现并修复的问题
1. **后端 OpenAPI 参数污染**`routes.ts``emptyObjectSchema = { type: 'object' }` 占位导致 `@fastify/swagger` 把 "type" 关键字误当参数名,每个路由生成虚假 `type`query/path参数会污染前端生成类型。改为「无 schema 时省略 params/querystring 键」,重新导出后 31 个路径参数干净0 个虚假参数)。
2. **openapi-typescript v7 API 变化**:返回 `ts.Node[]` 而非字符串,需 `astToString` 转换。
3. **Vite 依赖优化缓存**`client/node_modules/.vite` 过期导致 dev 启动触发 bulk-delete 守卫报错,清除缓存后恢复。
4. **空响应体错误处理**:后端宕机时 Vite 代理返回 502 空 bodyopenapi-fetch 返回 `error: undefined`,导致 `unwrap` 误判成功并返回 undefined触发 `isEmpty` 读取 undefined 报错。改为在 `unwrap` 中按 `response.ok` 判定并映射 502/503/504 为「无法连接服务」。
## 命令检查
- `pnpm typecheck`通过client + server
- `pnpm build`通过client 构建前自动从 `http://localhost:3000/api/openapi.json` 执行 `api:generate`(后端运行中)。
- `pnpm --filter @gwy/client api:generate`:后端运行中生成成功;后端未启动时 exit 1 并提示「请确认后端服务已启动pnpm dev:server」。
## 浏览器检查agent-browser实际渲染 DOM 验证)
| 场景 | 结果 |
|---|---|
| 桌面 1440 视口 | 侧栏 8 项导航齐全;首页标题「数据中枢」+ 问候语profile 真实加载) |
| 首页数据 | hero 卡「学习天数 92 / 累计答题 0 / 正确率 0%」+ 5 张统计卡(题库题量 3、待复习 0、今日任务 0/0、今日学习 0、连续打卡 0均来自 `/api/dashboard/overview` |
| 移动 390 视口 | 顶部栏标题 + 底部 TabBar 5 项(首页/刷题/要闻/分析/我的) |
| SPA 导航 | 点击「刷题中心」→ 路由切到 `/practice`TabBar/标题同步变化,占位页「建设中」正常 |
| 错误态 | 停后端 → 显示「数据加载失败 / 无法连接服务,请确认后端已启动」+「重试」按钮 |
| 重试恢复 | 重启后端点击「重试」→ 数据恢复渲染 |
| 空态 | 清空题库 → 显示「还没有学习数据」空状态,恢复数据后正常 |
截图:`deliverables/checks/task-04-desktop.png``task-04-mobile.png`
## 完成标准核对
- ✅ 前端业务代码不直接写 URL 和 `fetch`(统一走 `api/index.ts` 类型化方法 + `openapi-fetch`)。
- ✅ 修改后端 Schema 能重新生成类型(`pnpm api:generate` 从运行时 API 拉取生成,需后端运行)。
- ✅ 首页路由能通过真实接口加载overview + profile 经 Vite 代理到后端)。
- ✅ 页面可在桌面和移动断点切换768px 断点,双端布局实测)。
- ✅ 统一加载、空数据、错误提示组件AppLoading/AppEmpty/AppError均实测
## 当前结论
任务 04 完成:前端 API 自动生成管线、类型化请求客户端、路由与 Pinia 基础 store、设计 Token、PC 侧栏与 Mobile TabBar、以及四态反馈组件全部落地首页已通过真实接口加载。图标采用内联线性 SVG 替代 lucide 依赖(`lucide-vue-next` 已改名弃用为 `@lucide/vue` 且安装受阻,为避免大体积依赖与不稳定安装而自建图标集)。剩余 7 个页面为占位,待任务 0511 逐个实现。
> 微调2026-08-31前端类型生成改为从后端运行时端点 `http://localhost:3000/api/openapi.json` 拉取,不再依赖本地 `server/openapi.json` 文件;`dev`/`build` 前置 `api:generate` 需后端已启动。

View File

@ -5,6 +5,7 @@
"scripts": { "scripts": {
"dev": "pnpm --filter @gwy/client dev", "dev": "pnpm --filter @gwy/client dev",
"dev:server": "pnpm --filter @gwy/server dev", "dev:server": "pnpm --filter @gwy/server dev",
"api:generate": "pnpm --filter @gwy/client api:generate",
"build": "pnpm -r build", "build": "pnpm -r build",
"typecheck": "pnpm -r typecheck" "typecheck": "pnpm -r typecheck"
} }

210
pnpm-lock.yaml generated
View File

@ -13,6 +13,9 @@ importers:
'@vitejs/plugin-vue': '@vitejs/plugin-vue':
specifier: latest specifier: latest
version: 6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) version: 6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))
openapi-fetch:
specifier: ^0.17.0
version: 0.17.0
pinia: pinia:
specifier: latest specifier: latest
version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3))
@ -26,6 +29,9 @@ importers:
specifier: latest specifier: latest
version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))
devDependencies: devDependencies:
openapi-typescript:
specifier: ^7.13.0
version: 7.13.0(typescript@5.9.3)
typescript: typescript:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
@ -63,6 +69,10 @@ importers:
packages: packages:
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.29.7': '@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -298,6 +308,16 @@ packages:
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
'@redocly/config@0.22.0':
resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==}
'@redocly/openapi-core@1.34.19':
resolution: {integrity: sha512-o/0VgsBXgwcY1lyeqcVtSGdTQAPnVggo0fbFVPlxl5XVDKUcVH0OLRqt3CbkwByT5FU305E0iE0O7MzThjDblw==}
engines: {node: '>=18.17.0', npm: '>=9.5.0'}
'@rolldown/binding-android-arm-eabi@1.2.6': '@rolldown/binding-android-arm-eabi@1.2.6':
resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -592,6 +612,10 @@ packages:
engines: {node: '>=0.4.0'} engines: {node: '>=0.4.0'}
hasBin: true hasBin: true
agent-base@7.1.4:
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
engines: {node: '>= 14'}
ajv-formats@3.0.1: ajv-formats@3.0.1:
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
peerDependencies: peerDependencies:
@ -606,6 +630,13 @@ packages:
alien-signals@3.2.1: alien-signals@3.2.1:
resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
ast-kit@2.2.0: ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@ -621,6 +652,9 @@ packages:
avvio@9.3.0: avvio@9.3.0:
resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@4.0.4: balanced-match@4.0.4:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
@ -628,14 +662,23 @@ packages:
birpc@2.9.0: birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
brace-expansion@5.0.9: brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
change-case@5.4.4:
resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==}
chokidar@5.0.0: chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
confbox@0.1.8: confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
@ -748,6 +791,14 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
inherits@2.0.4: inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -755,6 +806,17 @@ packages:
resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
js-levenshtein@1.1.6:
resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==}
engines: {node: '>=0.10.0'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
@ -866,6 +928,10 @@ packages:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
minimatch@5.1.9:
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
engines: {node: '>=10'}
minipass@7.1.3: minipass@7.1.3:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
@ -891,9 +957,25 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
openapi-fetch@0.17.0:
resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==}
openapi-types@12.1.3: openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
openapi-typescript-helpers@0.1.0:
resolution: {integrity: sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==}
openapi-typescript@7.13.0:
resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==}
hasBin: true
peerDependencies:
typescript: ^5.x
parse-json@8.3.0:
resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==}
engines: {node: '>=18'}
path-browserify@1.0.1: path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@ -940,6 +1022,10 @@ packages:
pkg-types@2.3.1: pkg-types@2.3.1:
resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==}
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss@8.5.26: postcss@8.5.26:
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@ -1027,6 +1113,10 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
supports-color@10.2.2:
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
engines: {node: '>=18'}
thread-stream@4.2.0: thread-stream@4.2.0:
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
@ -1048,6 +1138,10 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
type-fest@4.41.0:
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
engines: {node: '>=16'}
typescript@5.9.3: typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
@ -1101,6 +1195,9 @@ packages:
webpack: webpack:
optional: true optional: true
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
vite@8.2.2: vite@8.2.2:
resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@ -1182,16 +1279,29 @@ packages:
webpack-virtual-modules@0.6.2: webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
yaml-ast-parser@0.0.43:
resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==}
yaml@2.9.0: yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
zod@4.5.4: zod@4.5.4:
resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
snapshots: snapshots:
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/helper-string-parser@7.29.7': {} '@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {}
@ -1374,6 +1484,29 @@ snapshots:
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
uri-js-replace: 1.0.1
'@redocly/config@0.22.0': {}
'@redocly/openapi-core@1.34.19(supports-color@10.2.2)':
dependencies:
'@redocly/ajv': 8.11.2
'@redocly/config': 0.22.0
colorette: 1.4.0
https-proxy-agent: 7.0.6(supports-color@10.2.2)
js-levenshtein: 1.1.6
js-yaml: 4.3.1
minimatch: 5.1.9
pluralize: 8.0.0
yaml-ast-parser: 0.0.43
transitivePeerDependencies:
- supports-color
'@rolldown/binding-android-arm-eabi@1.2.6': '@rolldown/binding-android-arm-eabi@1.2.6':
optional: true optional: true
@ -1594,6 +1727,8 @@ snapshots:
acorn@8.18.0: {} acorn@8.18.0: {}
agent-base@7.1.4: {}
ajv-formats@3.0.1(ajv@8.20.0): ajv-formats@3.0.1(ajv@8.20.0):
optionalDependencies: optionalDependencies:
ajv: 8.20.0 ajv: 8.20.0
@ -1607,6 +1742,10 @@ snapshots:
alien-signals@3.2.1: {} alien-signals@3.2.1: {}
ansi-colors@4.1.3: {}
argparse@2.0.1: {}
ast-kit@2.2.0: ast-kit@2.2.0:
dependencies: dependencies:
'@babel/parser': 7.29.8 '@babel/parser': 7.29.8
@ -1625,18 +1764,28 @@ snapshots:
'@fastify/error': 4.2.0 '@fastify/error': 4.2.0
fastq: 1.20.1 fastq: 1.20.1
balanced-match@1.0.2: {}
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
birpc@2.9.0: {} birpc@2.9.0: {}
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
brace-expansion@5.0.9: brace-expansion@5.0.9:
dependencies: dependencies:
balanced-match: 4.0.4 balanced-match: 4.0.4
change-case@5.4.4: {}
chokidar@5.0.0: chokidar@5.0.0:
dependencies: dependencies:
readdirp: 5.1.1 readdirp: 5.1.1
colorette@1.4.0: {}
confbox@0.1.8: {} confbox@0.1.8: {}
confbox@0.2.4: {} confbox@0.2.4: {}
@ -1647,9 +1796,11 @@ snapshots:
csstype@3.2.3: {} csstype@3.2.3: {}
debug@4.4.3: debug@4.4.3(supports-color@10.2.2):
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
optionalDependencies:
supports-color: 10.2.2
depd@2.0.0: {} depd@2.0.0: {}
@ -1768,17 +1919,34 @@ snapshots:
statuses: 2.0.2 statuses: 2.0.2
toidentifier: 1.0.1 toidentifier: 1.0.1
https-proxy-agent@7.0.6(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
index-to-position@1.2.0: {}
inherits@2.0.4: {} inherits@2.0.4: {}
ipaddr.js@2.5.0: {} ipaddr.js@2.5.0: {}
js-levenshtein@1.1.6: {}
js-tokens@4.0.0: {}
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
json-schema-ref-resolver@3.0.0: json-schema-ref-resolver@3.0.0:
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
json-schema-resolver@3.0.0: json-schema-resolver@3.0.0:
dependencies: dependencies:
debug: 4.4.3 debug: 4.4.3(supports-color@10.2.2)
fast-uri: 3.1.6 fast-uri: 3.1.6
rfdc: 1.4.1 rfdc: 1.4.1
transitivePeerDependencies: transitivePeerDependencies:
@ -1863,6 +2031,10 @@ snapshots:
dependencies: dependencies:
brace-expansion: 5.0.9 brace-expansion: 5.0.9
minimatch@5.1.9:
dependencies:
brace-expansion: 2.1.4
minipass@7.1.3: {} minipass@7.1.3: {}
mlly@1.8.2: mlly@1.8.2:
@ -1882,8 +2054,30 @@ snapshots:
on-exit-leak-free@2.1.2: {} on-exit-leak-free@2.1.2: {}
openapi-fetch@0.17.0:
dependencies:
openapi-typescript-helpers: 0.1.0
openapi-types@12.1.3: {} openapi-types@12.1.3: {}
openapi-typescript-helpers@0.1.0: {}
openapi-typescript@7.13.0(typescript@5.9.3):
dependencies:
'@redocly/openapi-core': 1.34.19(supports-color@10.2.2)
ansi-colors: 4.1.3
change-case: 5.4.4
parse-json: 8.3.0
supports-color: 10.2.2
typescript: 5.9.3
yargs-parser: 21.1.1
parse-json@8.3.0:
dependencies:
'@babel/code-frame': 7.29.7
index-to-position: 1.2.0
type-fest: 4.41.0
path-browserify@1.0.1: {} path-browserify@1.0.1: {}
path-scurry@2.0.2: path-scurry@2.0.2:
@ -1939,6 +2133,8 @@ snapshots:
exsolve: 1.1.1 exsolve: 1.1.1
pathe: 2.0.3 pathe: 2.0.3
pluralize@8.0.0: {}
postcss@8.5.26: postcss@8.5.26:
dependencies: dependencies:
nanoid: 3.3.18 nanoid: 3.3.18
@ -2014,6 +2210,8 @@ snapshots:
statuses@2.0.2: {} statuses@2.0.2: {}
supports-color@10.2.2: {}
thread-stream@4.2.0: thread-stream@4.2.0:
dependencies: dependencies:
real-require: 1.0.0 real-require: 1.0.0
@ -2033,6 +2231,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
type-fest@4.41.0: {}
typescript@5.9.3: {} typescript@5.9.3: {}
typescript@7.0.2: typescript@7.0.2:
@ -2077,6 +2277,8 @@ snapshots:
rolldown: 1.2.6 rolldown: 1.2.6
vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0) vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0)
uri-js-replace@1.0.1: {}
vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0): vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
@ -2144,6 +2346,10 @@ snapshots:
webpack-virtual-modules@0.6.2: {} webpack-virtual-modules@0.6.2: {}
yaml-ast-parser@0.0.43: {}
yaml@2.9.0: {} yaml@2.9.0: {}
yargs-parser@21.1.1: {}
zod@4.5.4: {} zod@4.5.4: {}