= 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 @@
+
+
+
+
+
+
{{ title }}
+
{{ description }}
+
+
+
+
+
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 @@
+
+
+
+
+
+
{{ title }}
+
{{ message }}
+
重试
+
+
+
+
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: {}