From 84e5fa0af5c811ac02a89a902bbb94a2b8307b5f Mon Sep 17 00:00:00 2001 From: liyy <18435186204@163.com> Date: Mon, 31 Aug 2026 16:50:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/.env.example | 3 + client/package.json | 11 +- client/scripts/generate-api.mjs | 47 ++++ client/src/App.vue | 16 +- client/src/api/client.ts | 86 ++++++- client/src/api/index.ts | 136 ++++++++++- client/src/components/base/AppBadge.vue | 46 ++++ client/src/components/base/AppButton.vue | 90 +++++++ client/src/components/base/AppCard.vue | 76 ++++++ client/src/components/base/AppIcon.vue | 32 +++ client/src/components/base/icons.ts | 113 +++++++++ client/src/components/feedback/AppEmpty.vue | 60 +++++ client/src/components/feedback/AppError.vue | 63 +++++ client/src/components/feedback/AppLoading.vue | 43 ++++ client/src/components/feedback/AppToast.vue | 67 ++++++ client/src/components/feedback/ComingSoon.vue | 15 ++ client/src/composables/useRequest.ts | 37 +++ client/src/composables/useResponsive.ts | 24 +- client/src/composables/useToast.ts | 13 + client/src/layouts/AppNavigation.vue | 9 - client/src/layouts/DesktopLayout.vue | 95 +++++++- client/src/layouts/MobileLayout.vue | 85 ++++++- client/src/main.ts | 6 +- client/src/router/index.ts | 43 +++- client/src/router/nav.ts | 28 +++ client/src/stores/app.ts | 33 ++- client/src/stores/practice.ts | 34 ++- client/src/stores/profile.ts | 26 +- client/src/styles/main.css | 36 ++- client/src/styles/reset.css | 77 ++++++ client/src/styles/tokens.css | 192 +++++++++++++++ client/src/types/view-models.ts | 6 +- client/src/utils/format.ts | 20 ++ client/src/views/dashboard/DashboardView.vue | 225 +++++++++++++++++- client/src/views/mock/MockView.vue | 7 + client/src/views/news/NewsView.vue | 7 + client/src/views/plan/PlanView.vue | 7 + client/src/views/practice/PracticeView.vue | 7 + client/src/views/profile/ProfileView.vue | 7 + client/src/views/questions/QuestionsView.vue | 7 + client/src/views/settings/SettingsView.vue | 7 + client/vite.config.ts | 11 +- docs/checks/task-04.md | 68 ++++++ package.json | 1 + pnpm-lock.yaml | 210 +++++++++++++++- 45 files changed, 2172 insertions(+), 60 deletions(-) create mode 100644 client/.env.example create mode 100644 client/scripts/generate-api.mjs create mode 100644 client/src/components/base/AppBadge.vue create mode 100644 client/src/components/base/AppButton.vue create mode 100644 client/src/components/base/AppCard.vue create mode 100644 client/src/components/base/AppIcon.vue create mode 100644 client/src/components/base/icons.ts create mode 100644 client/src/components/feedback/AppEmpty.vue create mode 100644 client/src/components/feedback/AppError.vue create mode 100644 client/src/components/feedback/AppLoading.vue create mode 100644 client/src/components/feedback/AppToast.vue create mode 100644 client/src/components/feedback/ComingSoon.vue create mode 100644 client/src/composables/useRequest.ts create mode 100644 client/src/composables/useToast.ts delete mode 100644 client/src/layouts/AppNavigation.vue create mode 100644 client/src/router/nav.ts create mode 100644 client/src/styles/reset.css create mode 100644 client/src/styles/tokens.css create mode 100644 client/src/views/mock/MockView.vue create mode 100644 client/src/views/news/NewsView.vue create mode 100644 client/src/views/plan/PlanView.vue create mode 100644 client/src/views/practice/PracticeView.vue create mode 100644 client/src/views/profile/ProfileView.vue create mode 100644 client/src/views/questions/QuestionsView.vue create mode 100644 client/src/views/settings/SettingsView.vue create mode 100644 docs/checks/task-04.md diff --git a/client/.env.example b/client/.env.example new file mode 100644 index 0000000..2267e2f --- /dev/null +++ b/client/.env.example @@ -0,0 +1,3 @@ +# 前端 API 基础地址。留空表示使用相对路径 /api(开发环境经 Vite 代理到后端)。 +# 生产环境若前后端不同源,可设置为后端地址(如 http://127.0.0.1:3000/api)。 +VITE_API_BASE_URL= diff --git a/client/package.json b/client/package.json index a57a1a6..1ed6946 100644 --- a/client/package.json +++ b/client/package.json @@ -3,18 +3,21 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", - "build": "vite build", + "api:generate": "node scripts/generate-api.mjs", + "dev": "npm run api:generate && vite", + "build": "npm run api:generate && vite build", "typecheck": "vue-tsc --noEmit" }, "dependencies": { "@vitejs/plugin-vue": "latest", + "openapi-fetch": "^0.17.0", + "pinia": "latest", "vite": "latest", "vue": "latest", - "vue-router": "latest", - "pinia": "latest" + "vue-router": "latest" }, "devDependencies": { + "openapi-typescript": "^7.13.0", "typescript": "^5.7.3", "vue-tsc": "latest" } diff --git a/client/scripts/generate-api.mjs b/client/scripts/generate-api.mjs new file mode 100644 index 0000000..91b936c --- /dev/null +++ b/client/scripts/generate-api.mjs @@ -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() diff --git a/client/src/App.vue b/client/src/App.vue index cf9a543..5a5fa51 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -1,18 +1,14 @@ diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 07ad93b..2cf39cd 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { - const response = await fetch(`${apiBaseUrl}${path}`, { headers: { 'content-type': 'application/json' }, ...init }) - if (!response.ok) throw new Error(`请求失败:${response.status}`) - return response.json() as Promise +export type ApiPaths = paths + +/** 后端统一错误响应体(需求文档第 6 节格式) */ +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)) + } +} + +/** + * 生成客户端:统一基础 URL、JSON headers、超时与错误解包。 + * 业务代码不直接 new 此实例,统一走 api/index.ts 暴露的领域方法。 + */ +export const apiClient = createClient({ + baseUrl, + headers: { 'Content-Type': 'application/json' }, + fetch: withTimeout(DEFAULT_TIMEOUT) +}) + +/** 从 openapi-fetch 结果解包:error 存在或 fetch 抛错时,抛出统一 ApiError */ +export async function unwrap( + res: PromiseLike<{ data?: T; error?: unknown; response?: Response }> +): Promise { + 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', '无法连接服务,请确认后端已启动') + } } diff --git a/client/src/api/index.ts b/client/src/api/index.ts index cbdc777..74549ec 100644 --- a/client/src/api/index.ts +++ b/client/src/api/index.ts @@ -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 extends { + responses: { 200: { content: { 'application/json': infer D } } } +} + ? D + : never + +type QueryOf

= paths[P][M] extends { + parameters: { query?: infer Q } +} + ? Q + : never + +type BodyOf

= paths[P][M] extends { + requestBody: { content: { 'application/json': infer B } } +} + ? B + : never + +// ---- 常用响应类型 ---- +export type DashboardOverview = SuccessBody +export type DashboardTrends = SuccessBody +export type PracticeModules = SuccessBody +export type Profile = SuccessBody +export type Settings = SuccessBody + +// ---- 数据中枢 ---- +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 })) +} diff --git a/client/src/components/base/AppBadge.vue b/client/src/components/base/AppBadge.vue new file mode 100644 index 0000000..2452fcb --- /dev/null +++ b/client/src/components/base/AppBadge.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/client/src/components/base/AppButton.vue b/client/src/components/base/AppButton.vue new file mode 100644 index 0000000..69ae6c3 --- /dev/null +++ b/client/src/components/base/AppButton.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/client/src/components/base/AppCard.vue b/client/src/components/base/AppCard.vue new file mode 100644 index 0000000..50e0603 --- /dev/null +++ b/client/src/components/base/AppCard.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/client/src/components/base/AppIcon.vue b/client/src/components/base/AppIcon.vue new file mode 100644 index 0000000..5ec8c0d --- /dev/null +++ b/client/src/components/base/AppIcon.vue @@ -0,0 +1,32 @@ + + + diff --git a/client/src/components/base/icons.ts b/client/src/components/base/icons.ts new file mode 100644 index 0000000..d1be3c8 --- /dev/null +++ b/client/src/components/base/icons.ts @@ -0,0 +1,113 @@ +// 线性图标集:统一 stroke 风格(见设计规范「图标」一节),禁止引入图标库依赖。 +// 使用方式: +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 +} + +export const icons: Record = { + 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 } } + ] +} diff --git a/client/src/components/feedback/AppEmpty.vue b/client/src/components/feedback/AppEmpty.vue new file mode 100644 index 0000000..af181e0 --- /dev/null +++ b/client/src/components/feedback/AppEmpty.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/client/src/components/feedback/AppError.vue b/client/src/components/feedback/AppError.vue new file mode 100644 index 0000000..3677197 --- /dev/null +++ b/client/src/components/feedback/AppError.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/client/src/components/feedback/AppLoading.vue b/client/src/components/feedback/AppLoading.vue new file mode 100644 index 0000000..19d9b6c --- /dev/null +++ b/client/src/components/feedback/AppLoading.vue @@ -0,0 +1,43 @@ + + + + + diff --git a/client/src/components/feedback/AppToast.vue b/client/src/components/feedback/AppToast.vue new file mode 100644 index 0000000..bd87c08 --- /dev/null +++ b/client/src/components/feedback/AppToast.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/client/src/components/feedback/ComingSoon.vue b/client/src/components/feedback/ComingSoon.vue new file mode 100644 index 0000000..1d23c45 --- /dev/null +++ b/client/src/components/feedback/ComingSoon.vue @@ -0,0 +1,15 @@ + + + diff --git a/client/src/composables/useRequest.ts b/client/src/composables/useRequest.ts new file mode 100644 index 0000000..d823da3 --- /dev/null +++ b/client/src/composables/useRequest.ts @@ -0,0 +1,37 @@ +import { ref, type Ref, type UnwrapRef } from 'vue' +import { ApiError } from '../api' + +interface UseRequestReturn { + data: Ref | null> + loading: Ref + error: Ref + refresh: () => Promise +} + +/** + * 统一封装异步请求的加载 / 错误 / 数据三态。 + * 页面与 store 复用此组合式函数,避免各自维护 loading/error 样板代码。 + * @param fn 返回 Promise 的请求函数 + * @param immediate 是否在 setup 时立即执行(默认 true) + */ +export function useRequest(fn: () => Promise, immediate = true): UseRequestReturn { + const data = ref(null) as Ref | null> + const loading = ref(false) + const error = ref('') + + const refresh = async () => { + loading.value = true + error.value = '' + try { + data.value = (await fn()) as UnwrapRef + } catch (err) { + error.value = err instanceof ApiError ? err.message : '加载失败,请稍后重试' + } finally { + loading.value = false + } + } + + if (immediate) void refresh() + + return { data, loading, error, refresh } +} diff --git a/client/src/composables/useResponsive.ts b/client/src/composables/useResponsive.ts index 927c8c0..0a881ee 100644 --- a/client/src/composables/useResponsive.ts +++ b/client/src/composables/useResponsive.ts @@ -1,9 +1,25 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' +const MOBILE_BREAKPOINT = 768 + +/** + * 响应式断点:< 768px 判定为移动端,否则桌面端。 + * 使用 window.innerWidth + resize 监听,供 App 根节点切换双端布局。 + */ export function useResponsive() { const width = ref(typeof window === 'undefined' ? 1440 : window.innerWidth) - const update = () => { width.value = window.innerWidth } - onMounted(() => window.addEventListener('resize', update)) - onUnmounted(() => window.removeEventListener('resize', update)) - return { width, isMobile: computed(() => width.value < 768) } + const update = () => { + width.value = window.innerWidth + } + onMounted(() => { + window.addEventListener('resize', update) + }) + onUnmounted(() => { + window.removeEventListener('resize', update) + }) + return { + width, + isMobile: computed(() => width.value < MOBILE_BREAKPOINT), + isDesktop: computed(() => width.value >= MOBILE_BREAKPOINT) + } } diff --git a/client/src/composables/useToast.ts b/client/src/composables/useToast.ts new file mode 100644 index 0000000..43baef6 --- /dev/null +++ b/client/src/composables/useToast.ts @@ -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) + } +} diff --git a/client/src/layouts/AppNavigation.vue b/client/src/layouts/AppNavigation.vue deleted file mode 100644 index b2ce563..0000000 --- a/client/src/layouts/AppNavigation.vue +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/client/src/layouts/DesktopLayout.vue b/client/src/layouts/DesktopLayout.vue index 482383c..7c225be 100644 --- a/client/src/layouts/DesktopLayout.vue +++ b/client/src/layouts/DesktopLayout.vue @@ -1,9 +1,96 @@ + + diff --git a/client/src/layouts/MobileLayout.vue b/client/src/layouts/MobileLayout.vue index 3de89b3..6ba82ef 100644 --- a/client/src/layouts/MobileLayout.vue +++ b/client/src/layouts/MobileLayout.vue @@ -1,8 +1,87 @@ + + diff --git a/client/src/main.ts b/client/src/main.ts index 961d377..1c85907 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -1,6 +1,10 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' import App from './App.vue' +import router from './router' import './styles/main.css' -createApp(App).use(createPinia()).mount('#app') +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 3bd15d8..e1a4f65 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -1,8 +1,35 @@ -export const routes = { - dashboard: '/', - practice: '/practice', - analysis: '/analysis', - plan: '/plan', - news: '/news', - profile: '/profile' -} as const +import { createRouter, createWebHistory } from 'vue-router' +import type { RouteRecordRaw } from 'vue-router' +import DashboardView from '../views/dashboard/DashboardView.vue' +import PracticeView from '../views/practice/PracticeView.vue' +import MockView from '../views/mock/MockView.vue' +import PlanView from '../views/plan/PlanView.vue' +import NewsView from '../views/news/NewsView.vue' +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 diff --git a/client/src/router/nav.ts b/client/src/router/nav.ts new file mode 100644 index 0000000..bdc2e0e --- /dev/null +++ b/client/src/router/nav.ts @@ -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' } +] + +/** 移动底部 TabBar(5 项,见需求文档 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' } +] diff --git a/client/src/stores/app.ts b/client/src/stores/app.ts index dbd630b..5ce044d 100644 --- a/client/src/stores/app.ts +++ b/client/src/stores/app.ts @@ -1,9 +1,36 @@ 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', { - state: () => ({ busy: false, notice: '' }), + state: () => ({ + busyCount: 0, + toasts: [] as Toast[] + }), + getters: { + busy: (state) => state.busyCount > 0 + }, actions: { - setBusy(value: boolean) { this.busy = value }, - notify(message: string) { this.notice = message } + startBusy() { + 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) + } } }) diff --git a/client/src/stores/practice.ts b/client/src/stores/practice.ts index 0fb16d8..dea8a36 100644 --- a/client/src/stores/practice.ts +++ b/client/src/stores/practice.ts @@ -1,5 +1,37 @@ import { defineStore } from 'pinia' +/** + * 刷题会话的跨页面共享状态(仅存会话标识与进度索引, + * 题目内容、作答记录等由接口返回,不在此重复缓存)。 + */ 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 + } + } }) diff --git a/client/src/stores/profile.ts b/client/src/stores/profile.ts index ef65d6f..d94d995 100644 --- a/client/src/stores/profile.ts +++ b/client/src/stores/profile.ts @@ -1,5 +1,29 @@ import { defineStore } from 'pinia' +import { profileApi } from '../api' +import type { Profile } from '../api' 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 + } + } + } }) diff --git a/client/src/styles/main.css b/client/src/styles/main.css index 2cc5b0c..e0b3123 100644 --- a/client/src/styles/main.css +++ b/client/src/styles/main.css @@ -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,再加载基础重置。 + 组件/页面样式在各自 diff --git a/client/src/views/mock/MockView.vue b/client/src/views/mock/MockView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/mock/MockView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/news/NewsView.vue b/client/src/views/news/NewsView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/news/NewsView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/plan/PlanView.vue b/client/src/views/plan/PlanView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/plan/PlanView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/practice/PracticeView.vue b/client/src/views/practice/PracticeView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/practice/PracticeView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/profile/ProfileView.vue b/client/src/views/profile/ProfileView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/profile/ProfileView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/questions/QuestionsView.vue b/client/src/views/questions/QuestionsView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/questions/QuestionsView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/src/views/settings/SettingsView.vue b/client/src/views/settings/SettingsView.vue new file mode 100644 index 0000000..553e1ca --- /dev/null +++ b/client/src/views/settings/SettingsView.vue @@ -0,0 +1,7 @@ + + + diff --git a/client/vite.config.ts b/client/vite.config.ts index 9a3d069..15c7e74 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -3,5 +3,14 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], - server: { port: 5173 } + server: { + port: 5173, + proxy: { + // 开发环境将 /api 代理到后端,前端使用相对路径,避免 CORS 与硬编码地址。 + '/api': { + target: 'http://127.0.0.1:3000', + changeOrigin: true + } + } + } }) diff --git a/docs/checks/task-04.md b/docs/checks/task-04.md new file mode 100644 index 0000000..41452f3 --- /dev/null +++ b/docs/checks/task-04.md @@ -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`,统一 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 空 body,openapi-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 个页面为占位,待任务 05–11 逐个实现。 + +> 微调(2026-08-31):前端类型生成改为从后端运行时端点 `http://localhost:3000/api/openapi.json` 拉取,不再依赖本地 `server/openapi.json` 文件;`dev`/`build` 前置 `api:generate` 需后端已启动。 diff --git a/package.json b/package.json index 9a77536..b842458 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "pnpm --filter @gwy/client dev", "dev:server": "pnpm --filter @gwy/server dev", + "api:generate": "pnpm --filter @gwy/client api:generate", "build": "pnpm -r build", "typecheck": "pnpm -r typecheck" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdceee1..f654bee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@vitejs/plugin-vue': 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)) + openapi-fetch: + specifier: ^0.17.0 + version: 0.17.0 pinia: specifier: latest 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 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: + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) typescript: specifier: ^5.7.3 version: 5.9.3 @@ -63,6 +69,10 @@ importers: 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': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -298,6 +308,16 @@ packages: '@pinojs/redact@0.4.0': 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': resolution: {integrity: sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -592,6 +612,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv-formats@3.0.1: resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -606,6 +630,13 @@ packages: alien-signals@3.2.1: 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: resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} engines: {node: '>=20.19.0'} @@ -621,6 +652,9 @@ packages: avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} @@ -628,14 +662,23 @@ packages: birpc@2.9.0: 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: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -748,6 +791,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} 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: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -755,6 +806,17 @@ packages: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} 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: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} @@ -866,6 +928,10 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -891,9 +957,25 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + openapi-fetch@0.17.0: + resolution: {integrity: sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==} + openapi-types@12.1.3: 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: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -940,6 +1022,10 @@ packages: pkg-types@2.3.1: 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: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -1027,6 +1113,10 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -1048,6 +1138,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -1101,6 +1195,9 @@ packages: webpack: optional: true + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + vite@8.2.2: resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1182,16 +1279,29 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} 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-validator-identifier@7.29.7': {} @@ -1374,6 +1484,29 @@ snapshots: '@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': optional: true @@ -1594,6 +1727,8 @@ snapshots: acorn@8.18.0: {} + agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): optionalDependencies: ajv: 8.20.0 @@ -1607,6 +1742,10 @@ snapshots: alien-signals@3.2.1: {} + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + ast-kit@2.2.0: dependencies: '@babel/parser': 7.29.8 @@ -1625,18 +1764,28 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@1.0.2: {} + balanced-match@4.0.4: {} birpc@2.9.0: {} + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 + change-case@5.4.4: {} + chokidar@5.0.0: dependencies: readdirp: 5.1.1 + colorette@1.4.0: {} + confbox@0.1.8: {} confbox@0.2.4: {} @@ -1647,9 +1796,11 @@ snapshots: csstype@3.2.3: {} - debug@4.4.3: + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 depd@2.0.0: {} @@ -1768,17 +1919,34 @@ snapshots: statuses: 2.0.2 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: {} 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: dependencies: dequal: 2.0.3 json-schema-resolver@3.0.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@10.2.2) fast-uri: 3.1.6 rfdc: 1.4.1 transitivePeerDependencies: @@ -1863,6 +2031,10 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.4 + minipass@7.1.3: {} mlly@1.8.2: @@ -1882,8 +2054,30 @@ snapshots: 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-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-scurry@2.0.2: @@ -1939,6 +2133,8 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + pluralize@8.0.0: {} + postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -2014,6 +2210,8 @@ snapshots: statuses@2.0.2: {} + supports-color@10.2.2: {} + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -2033,6 +2231,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@4.41.0: {} + typescript@5.9.3: {} typescript@7.0.2: @@ -2077,6 +2277,8 @@ snapshots: 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) + 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): dependencies: lightningcss: 1.33.0 @@ -2144,6 +2346,10 @@ snapshots: webpack-virtual-modules@0.6.2: {} + yaml-ast-parser@0.0.43: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + zod@4.5.4: {}