feat: 增加前端骨架
This commit is contained in:
parent
4b2c80bef1
commit
84e5fa0af5
3
client/.env.example
Normal file
3
client/.env.example
Normal file
@ -0,0 +1,3 @@
|
||||
# 前端 API 基础地址。留空表示使用相对路径 /api(开发环境经 Vite 代理到后端)。
|
||||
# 生产环境若前后端不同源,可设置为后端地址(如 http://127.0.0.1:3000/api)。
|
||||
VITE_API_BASE_URL=
|
||||
@ -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"
|
||||
}
|
||||
|
||||
47
client/scripts/generate-api.mjs
Normal file
47
client/scripts/generate-api.mjs
Normal file
@ -0,0 +1,47 @@
|
||||
import openapiTS, { astToString } from 'openapi-typescript'
|
||||
import { writeFile, mkdir } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const output = join(__dirname, '..', 'src', 'api', 'generated', 'schema.d.ts')
|
||||
|
||||
// 接口契约唯一来源:后端运行时提供的 OpenAPI 文档端点。
|
||||
// 生成前需先启动后端(pnpm dev:server)。
|
||||
const openapiUrl = process.env.OPENAPI_URL ?? 'http://localhost:3000/api/openapi.json'
|
||||
|
||||
const HEADER = `// 本文件由 openapi-typescript 自动生成,请勿手工修改。
|
||||
// 重新生成:pnpm --filter @gwy/client api:generate(需后端服务运行中)
|
||||
// 来源:${openapiUrl}
|
||||
`
|
||||
|
||||
async function main() {
|
||||
let spec
|
||||
try {
|
||||
const res = await fetch(openapiUrl)
|
||||
if (!res.ok) {
|
||||
console.error(`[api:generate] 获取 OpenAPI 文档失败:HTTP ${res.status} ${openapiUrl}`)
|
||||
console.error('请确认后端服务已启动(pnpm dev:server)。')
|
||||
process.exit(1)
|
||||
}
|
||||
spec = await res.json()
|
||||
} catch (err) {
|
||||
console.error(`[api:generate] 无法获取 OpenAPI 文档:${openapiUrl}`)
|
||||
console.error(err instanceof Error ? err.message : err)
|
||||
console.error('请确认后端服务已启动(pnpm dev:server)。')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
const nodes = await openapiTS(spec, {})
|
||||
const ast = astToString(nodes)
|
||||
await mkdir(dirname(output), { recursive: true })
|
||||
await writeFile(output, HEADER + ast, 'utf8')
|
||||
console.log(`[api:generate] 已生成 ${output}(来源:${openapiUrl})`)
|
||||
} catch (err) {
|
||||
console.error('[api:generate] 类型生成失败:', err instanceof Error ? err.message : err)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@ -1,18 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useResponsive } from './composables/useResponsive'
|
||||
import DesktopLayout from './layouts/DesktopLayout.vue'
|
||||
import MobileLayout from './layouts/MobileLayout.vue'
|
||||
import DashboardView from './views/dashboard/DashboardView.vue'
|
||||
import AppToast from './components/feedback/AppToast.vue'
|
||||
|
||||
const nav = ['数据中枢', '刷题中心', '模考分析', '备考计划', '要闻', '我的']
|
||||
const active = ref(0)
|
||||
const title = computed(() => nav[active.value])
|
||||
const { isMobile } = useResponsive()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<DesktopLayout :items="nav" :active="active" @select="active = $event" />
|
||||
<main class="content"><header><div><h1>{{ title }}</h1><p>个人备考工作台 · 页面骨架</p></div><button class="primary">开始使用</button></header><DashboardView /></main>
|
||||
<MobileLayout :items="nav" :active="active" @select="active = $event" />
|
||||
</div>
|
||||
<DesktopLayout v-if="!isMobile"><RouterView /></DesktopLayout>
|
||||
<MobileLayout v-else><RouterView /></MobileLayout>
|
||||
<AppToast />
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,83 @@
|
||||
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api'
|
||||
import createClient from 'openapi-fetch'
|
||||
import type { paths } from './generated/schema'
|
||||
|
||||
export async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<T>
|
||||
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<paths>({
|
||||
baseUrl,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
fetch: withTimeout(DEFAULT_TIMEOUT)
|
||||
})
|
||||
|
||||
/** 从 openapi-fetch 结果解包:error 存在或 fetch 抛错时,抛出统一 ApiError */
|
||||
export async function unwrap<T>(
|
||||
res: PromiseLike<{ data?: T; error?: unknown; response?: Response }>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const { data, error, response } = (await res) as {
|
||||
data?: T
|
||||
error?: unknown
|
||||
response?: Response
|
||||
}
|
||||
if (response && !response.ok) {
|
||||
const body = error as { error?: { code?: string; message?: string; details?: ApiErrorDetail[] } }
|
||||
const fallback =
|
||||
response.status === 502 || response.status === 503 || response.status === 504
|
||||
? '无法连接服务,请确认后端已启动'
|
||||
: '请求失败,请稍后重试'
|
||||
throw new ApiError(
|
||||
body?.error?.code ?? 'REQUEST_ERROR',
|
||||
body?.error?.message ?? fallback,
|
||||
body?.error?.details ?? []
|
||||
)
|
||||
}
|
||||
return data as T
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new ApiError('TIMEOUT', '请求超时,请稍后重试')
|
||||
}
|
||||
throw new ApiError('NETWORK_ERROR', '无法连接服务,请确认后端已启动')
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1,135 @@
|
||||
export { apiBaseUrl, request } from './client'
|
||||
import { apiClient, unwrap } from './client'
|
||||
import type { paths } from './generated/schema'
|
||||
|
||||
export type { ApiPaths, ApiErrorDetail } from './client'
|
||||
export { ApiError } from './client'
|
||||
export type { paths }
|
||||
|
||||
// ---- 从生成的 paths 推导请求/响应类型的工具类型(避免页面重复定义接口类型)----
|
||||
type Methods = 'get' | 'post' | 'patch' | 'delete' | 'put'
|
||||
|
||||
type SuccessBody<Op> = Op extends {
|
||||
responses: { 200: { content: { 'application/json': infer D } } }
|
||||
}
|
||||
? D
|
||||
: never
|
||||
|
||||
type QueryOf<P extends keyof paths, M extends Methods> = paths[P][M] extends {
|
||||
parameters: { query?: infer Q }
|
||||
}
|
||||
? Q
|
||||
: never
|
||||
|
||||
type BodyOf<P extends keyof paths, M extends Methods> = paths[P][M] extends {
|
||||
requestBody: { content: { 'application/json': infer B } }
|
||||
}
|
||||
? B
|
||||
: never
|
||||
|
||||
// ---- 常用响应类型 ----
|
||||
export type DashboardOverview = SuccessBody<paths['/api/dashboard/overview']['get']>
|
||||
export type DashboardTrends = SuccessBody<paths['/api/dashboard/trends']['get']>
|
||||
export type PracticeModules = SuccessBody<paths['/api/practice/modules']['get']>
|
||||
export type Profile = SuccessBody<paths['/api/profile']['get']>
|
||||
export type Settings = SuccessBody<paths['/api/settings']['get']>
|
||||
|
||||
// ---- 数据中枢 ----
|
||||
export const dashboardApi = {
|
||||
overview: () => unwrap(apiClient.GET('/api/dashboard/overview')),
|
||||
trends: (days?: number) =>
|
||||
unwrap(apiClient.GET('/api/dashboard/trends', { params: { query: { days } } }))
|
||||
}
|
||||
|
||||
// ---- 刷题 ----
|
||||
export const practiceApi = {
|
||||
modules: () => unwrap(apiClient.GET('/api/practice/modules')),
|
||||
start: (body: BodyOf<'/api/practice/start', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/practice/start', { body })),
|
||||
question: (sessionId: string) =>
|
||||
unwrap(apiClient.GET('/api/practice/{sessionId}/question', { params: { path: { sessionId } } })),
|
||||
answer: (sessionId: string, body: BodyOf<'/api/practice/{sessionId}/answer', 'post'>) =>
|
||||
unwrap(
|
||||
apiClient.POST('/api/practice/{sessionId}/answer', {
|
||||
params: { path: { sessionId } },
|
||||
body
|
||||
})
|
||||
),
|
||||
finish: (sessionId: string) =>
|
||||
unwrap(apiClient.POST('/api/practice/{sessionId}/finish', { params: { path: { sessionId } } }))
|
||||
}
|
||||
|
||||
// ---- 错题复习 ----
|
||||
export const reviewApi = {
|
||||
list: (query?: QueryOf<'/api/review/list', 'get'>) =>
|
||||
unwrap(apiClient.GET('/api/review/list', { params: { query } })),
|
||||
submit: (id: string, answer: string) =>
|
||||
unwrap(
|
||||
apiClient.POST('/api/review/{id}/submit', {
|
||||
params: { path: { id } },
|
||||
body: { answer } as BodyOf<'/api/review/{id}/submit', 'post'>
|
||||
})
|
||||
),
|
||||
markMastered: (id: string) =>
|
||||
unwrap(apiClient.POST('/api/review/{id}/mark-mastered', { params: { path: { id } } }))
|
||||
}
|
||||
|
||||
// ---- 备考计划 ----
|
||||
export const plansApi = {
|
||||
today: (date?: string) => unwrap(apiClient.GET('/api/plans/today', { params: { query: { date } } })),
|
||||
week: (start?: string) => unwrap(apiClient.GET('/api/plans/week', { params: { query: { start } } })),
|
||||
createTask: (body: BodyOf<'/api/plans/tasks', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/plans/tasks', { body })),
|
||||
toggleTask: (id: string) =>
|
||||
unwrap(apiClient.PATCH('/api/plans/tasks/{id}/toggle', { params: { path: { id } } }))
|
||||
}
|
||||
|
||||
// ---- 模考 ----
|
||||
export const mockApi = {
|
||||
analysis: () => unwrap(apiClient.GET('/api/mock-exams/analysis')),
|
||||
record: (body: BodyOf<'/api/mock-exams/record', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/mock-exams/record', { body }))
|
||||
}
|
||||
|
||||
// ---- 要闻 ----
|
||||
export const newsApi = {
|
||||
list: (query?: QueryOf<'/api/news/list', 'get'>) =>
|
||||
unwrap(apiClient.GET('/api/news/list', { params: { query } })),
|
||||
detail: (id: string) => unwrap(apiClient.GET('/api/news/{id}', { params: { path: { id } } })),
|
||||
importJson: (body: BodyOf<'/api/news/import/json', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/news/import/json', { body })),
|
||||
importRss: (body: BodyOf<'/api/news/import/rss', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/news/import/rss', { body })),
|
||||
importApi: (body: BodyOf<'/api/news/import/api', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/news/import/api', { body })),
|
||||
importUrl: (body: BodyOf<'/api/news/import/url', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/news/import/url', { body }))
|
||||
}
|
||||
|
||||
// ---- 题库管理 ----
|
||||
export const questionsApi = {
|
||||
list: (query?: QueryOf<'/api/questions/list', 'get'>) =>
|
||||
unwrap(apiClient.GET('/api/questions/list', { params: { query } })),
|
||||
importQuestions: (body: BodyOf<'/api/questions/import', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/questions/import', { body })),
|
||||
exportQuestions: () => unwrap(apiClient.GET('/api/questions/export')),
|
||||
remove: (id: string) => unwrap(apiClient.DELETE('/api/questions/{id}', { params: { path: { id } } }))
|
||||
}
|
||||
|
||||
// ---- AI 讲解 ----
|
||||
export const aiApi = {
|
||||
explain: (body: BodyOf<'/api/ai/explain-question', 'post'>) =>
|
||||
unwrap(apiClient.POST('/api/ai/explain-question', { body }))
|
||||
}
|
||||
|
||||
// ---- 个人档案 / 设置 ----
|
||||
export const profileApi = {
|
||||
get: () => unwrap(apiClient.GET('/api/profile')),
|
||||
patch: (body: BodyOf<'/api/profile', 'patch'>) =>
|
||||
unwrap(apiClient.PATCH('/api/profile', { body }))
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => unwrap(apiClient.GET('/api/settings')),
|
||||
patch: (body: BodyOf<'/api/settings', 'patch'>) =>
|
||||
unwrap(apiClient.PATCH('/api/settings', { body }))
|
||||
}
|
||||
|
||||
46
client/src/components/base/AppBadge.vue
Normal file
46
client/src/components/base/AppBadge.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'success' | 'warning' | 'danger' | 'soft' | 'primary'
|
||||
}>(),
|
||||
{ variant: 'soft' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="badge" :class="`badge--${variant}`"><slot /></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--fs-label);
|
||||
font-weight: var(--fw-label-bold);
|
||||
line-height: var(--lh-label);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge--success {
|
||||
color: var(--success);
|
||||
background: var(--success-soft);
|
||||
}
|
||||
.badge--warning {
|
||||
color: var(--warning);
|
||||
background: var(--warning-soft);
|
||||
}
|
||||
.badge--danger {
|
||||
color: var(--danger);
|
||||
background: var(--danger-soft);
|
||||
}
|
||||
.badge--soft {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.badge--primary {
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, #fff);
|
||||
}
|
||||
</style>
|
||||
90
client/src/components/base/AppButton.vue
Normal file
90
client/src/components/base/AppButton.vue
Normal file
@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import type { IconName } from './icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||
size?: 'md' | 'lg'
|
||||
icon?: IconName
|
||||
type?: 'button' | 'submit'
|
||||
disabled?: boolean
|
||||
block?: boolean
|
||||
}>(),
|
||||
{ variant: 'primary', size: 'md', type: 'button', disabled: false, block: false }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled"
|
||||
class="btn"
|
||||
:class="[`btn--${variant}`, `btn--${size}`, { 'btn--block': block }]"
|
||||
>
|
||||
<AppIcon v-if="icon" :name="icon" :size="size === 'lg' ? 18 : 16" />
|
||||
<span><slot /></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: var(--fs-button);
|
||||
font-weight: var(--fw-button);
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--motion-hover-fast) var(--ease-default),
|
||||
opacity var(--motion-hover-fast) var(--ease-default);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn--md {
|
||||
height: var(--btn-height);
|
||||
padding: 0 var(--space-4);
|
||||
}
|
||||
.btn--lg {
|
||||
height: var(--btn-height-pill);
|
||||
padding: 0 var(--space-5);
|
||||
}
|
||||
.btn--block {
|
||||
width: 100%;
|
||||
}
|
||||
.btn--primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 90%, #000);
|
||||
}
|
||||
.btn--secondary {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
.btn--secondary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
}
|
||||
.btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.btn--ghost:hover:not(:disabled) {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.btn--danger {
|
||||
background: var(--danger);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--danger) 90%, #000);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
76
client/src/components/base/AppCard.vue
Normal file
76
client/src/components/base/AppCard.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import type { IconName } from './icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
icon?: IconName
|
||||
padded?: boolean
|
||||
interactive?: boolean
|
||||
}>(),
|
||||
{ padded: true, interactive: false }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card" :class="{ 'card--flat': !padded, 'card--interactive': interactive }">
|
||||
<header v-if="title || $slots.header" class="card__head">
|
||||
<div v-if="title" class="card__title">
|
||||
<AppIcon v-if="icon" :name="icon" :size="18" />
|
||||
<h3>{{ title }}</h3>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="card__action"><slot name="header" /></div>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--card-padding-desktop);
|
||||
}
|
||||
.card--flat {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card--interactive {
|
||||
cursor: pointer;
|
||||
transition: box-shadow var(--motion-card-hover) var(--ease-default);
|
||||
}
|
||||
.card--interactive:hover {
|
||||
box-shadow: var(--shadow-level-1);
|
||||
}
|
||||
.card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--primary);
|
||||
}
|
||||
.card__title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-card-title);
|
||||
font-weight: var(--fw-card-title);
|
||||
line-height: var(--lh-card-title);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.card__action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.card {
|
||||
padding: var(--card-padding-mobile);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
client/src/components/base/AppIcon.vue
Normal file
32
client/src/components/base/AppIcon.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { icons } from './icons'
|
||||
import type { IconName } from './icons'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
name: IconName
|
||||
size?: number | string
|
||||
strokeWidth?: number
|
||||
}>(),
|
||||
{ size: 20, strokeWidth: 2 }
|
||||
)
|
||||
|
||||
const nodes = computed(() => icons[props.name] ?? [])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size"
|
||||
:height="size"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component :is="node.tag" v-for="(node, i) in nodes" :key="i" v-bind="node.attrs" />
|
||||
</svg>
|
||||
</template>
|
||||
113
client/src/components/base/icons.ts
Normal file
113
client/src/components/base/icons.ts
Normal file
@ -0,0 +1,113 @@
|
||||
// 线性图标集:统一 stroke 风格(见设计规范「图标」一节),禁止引入图标库依赖。
|
||||
// 使用方式:<AppIcon name="home" :size="20" />
|
||||
export type IconName =
|
||||
| 'home'
|
||||
| 'dashboard'
|
||||
| 'pen'
|
||||
| 'chart'
|
||||
| 'calendar'
|
||||
| 'news'
|
||||
| 'user'
|
||||
| 'book'
|
||||
| 'settings'
|
||||
| 'inbox'
|
||||
| 'alert'
|
||||
| 'check'
|
||||
| 'chevron-right'
|
||||
| 'clock'
|
||||
| 'flame'
|
||||
| 'target'
|
||||
|
||||
interface IconNode {
|
||||
tag: 'path' | 'circle' | 'rect' | 'line' | 'polyline'
|
||||
attrs: Record<string, string | number>
|
||||
}
|
||||
|
||||
export const icons: Record<IconName, IconNode[]> = {
|
||||
home: [
|
||||
{ tag: 'path', attrs: { d: 'M3 10.5 12 3l9 7.5' } },
|
||||
{ tag: 'path', attrs: { d: 'M5 9.5V21h14V9.5' } },
|
||||
{ tag: 'path', attrs: { d: 'M10 21v-6h4v6' } }
|
||||
],
|
||||
dashboard: [
|
||||
{ tag: 'rect', attrs: { x: 3, y: 3, width: 7, height: 7, rx: 2 } },
|
||||
{ tag: 'rect', attrs: { x: 14, y: 3, width: 7, height: 7, rx: 2 } },
|
||||
{ tag: 'rect', attrs: { x: 3, y: 14, width: 7, height: 7, rx: 2 } },
|
||||
{ tag: 'rect', attrs: { x: 14, y: 14, width: 7, height: 7, rx: 2 } }
|
||||
],
|
||||
pen: [
|
||||
{ tag: 'path', attrs: { d: 'M12 20h9' } },
|
||||
{ tag: 'path', attrs: { d: 'M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z' } }
|
||||
],
|
||||
chart: [
|
||||
{ tag: 'path', attrs: { d: 'M3 3v18h18' } },
|
||||
{ tag: 'path', attrs: { d: 'M18 17V9' } },
|
||||
{ tag: 'path', attrs: { d: 'M13 17V5' } },
|
||||
{ tag: 'path', attrs: { d: 'M8 17v-3' } }
|
||||
],
|
||||
calendar: [
|
||||
{ tag: 'rect', attrs: { x: 3, y: 4, width: 18, height: 18, rx: 2 } },
|
||||
{ tag: 'line', attrs: { x1: 16, y1: 2, x2: 16, y2: 6 } },
|
||||
{ tag: 'line', attrs: { x1: 8, y1: 2, x2: 8, y2: 6 } },
|
||||
{ tag: 'line', attrs: { x1: 3, y1: 10, x2: 21, y2: 10 } }
|
||||
],
|
||||
news: [
|
||||
{ tag: 'rect', attrs: { x: 3, y: 5, width: 18, height: 15, rx: 2 } },
|
||||
{ tag: 'line', attrs: { x1: 3, y1: 10, x2: 21, y2: 10 } },
|
||||
{ tag: 'line', attrs: { x1: 7, y1: 7, x2: 11, y2: 7 } },
|
||||
{ tag: 'line', attrs: { x1: 7, y1: 14, x2: 15, y2: 14 } },
|
||||
{ tag: 'line', attrs: { x1: 7, y1: 17, x2: 11, y2: 17 } }
|
||||
],
|
||||
user: [
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 8, r: 4 } },
|
||||
{ tag: 'path', attrs: { d: 'M4 21a8 8 0 0 1 16 0' } }
|
||||
],
|
||||
book: [
|
||||
{ tag: 'path', attrs: { d: 'M4 19.5A2.5 2.5 0 0 1 6.5 17H20' } },
|
||||
{ tag: 'path', attrs: { d: 'M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z' } }
|
||||
],
|
||||
settings: [
|
||||
{ tag: 'line', attrs: { x1: 21, y1: 4, x2: 14, y2: 4 } },
|
||||
{ tag: 'line', attrs: { x1: 10, y1: 4, x2: 3, y2: 4 } },
|
||||
{ tag: 'line', attrs: { x1: 21, y1: 12, x2: 12, y2: 12 } },
|
||||
{ tag: 'line', attrs: { x1: 8, y1: 12, x2: 3, y2: 12 } },
|
||||
{ tag: 'line', attrs: { x1: 21, y1: 20, x2: 16, y2: 20 } },
|
||||
{ tag: 'line', attrs: { x1: 12, y1: 20, x2: 3, y2: 20 } },
|
||||
{ tag: 'line', attrs: { x1: 14, y1: 2, x2: 14, y2: 6 } },
|
||||
{ tag: 'line', attrs: { x1: 8, y1: 10, x2: 8, y2: 14 } },
|
||||
{ tag: 'line', attrs: { x1: 16, y1: 18, x2: 16, y2: 22 } }
|
||||
],
|
||||
inbox: [
|
||||
{ tag: 'path', attrs: { d: 'M22 12h-6l-2 3h-4l-2-3H2' } },
|
||||
{
|
||||
tag: 'path',
|
||||
attrs: {
|
||||
d: 'M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z'
|
||||
}
|
||||
}
|
||||
],
|
||||
alert: [
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
|
||||
{ tag: 'line', attrs: { x1: 12, y1: 8, x2: 12, y2: 12 } },
|
||||
{ tag: 'line', attrs: { x1: 12, y1: 16, x2: 12.01, y2: 16 } }
|
||||
],
|
||||
check: [{ tag: 'path', attrs: { d: 'M20 6 9 17l-5-5' } }],
|
||||
'chevron-right': [{ tag: 'path', attrs: { d: 'M9 18l6-6-6-6' } }],
|
||||
clock: [
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
|
||||
{ tag: 'path', attrs: { d: 'M12 6v6l4 2' } }
|
||||
],
|
||||
flame: [
|
||||
{
|
||||
tag: 'path',
|
||||
attrs: {
|
||||
d: 'M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z'
|
||||
}
|
||||
}
|
||||
],
|
||||
target: [
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 10 } },
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 6 } },
|
||||
{ tag: 'circle', attrs: { cx: 12, cy: 12, r: 2 } }
|
||||
]
|
||||
}
|
||||
60
client/src/components/feedback/AppEmpty.vue
Normal file
60
client/src/components/feedback/AppEmpty.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '../base/AppIcon.vue'
|
||||
import type { IconName } from '../base/icons'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: IconName
|
||||
}>(),
|
||||
{ title: '暂无数据', description: '', icon: 'inbox' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty">
|
||||
<div class="empty__icon"><AppIcon :name="icon" :size="32" /></div>
|
||||
<p class="empty__title">{{ title }}</p>
|
||||
<p v-if="description" class="empty__desc">{{ description }}</p>
|
||||
<div v-if="$slots.default" class="empty__action"><slot /></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.empty__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
.empty__title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-card-title);
|
||||
font-weight: var(--fw-card-title);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.empty__desc {
|
||||
margin: var(--space-2) 0 0;
|
||||
max-width: 320px;
|
||||
font-size: var(--fs-subtitle);
|
||||
color: var(--text-muted);
|
||||
line-height: var(--lh-subtitle);
|
||||
}
|
||||
.empty__action {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
</style>
|
||||
63
client/src/components/feedback/AppError.vue
Normal file
63
client/src/components/feedback/AppError.vue
Normal file
@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '../base/AppIcon.vue'
|
||||
import AppButton from '../base/AppButton.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string
|
||||
message?: string
|
||||
retry?: boolean
|
||||
fullscreen?: boolean
|
||||
}>(),
|
||||
{ title: '加载失败', message: '', retry: false, fullscreen: false }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="error" :class="{ 'error--full': fullscreen }">
|
||||
<div class="error__icon"><AppIcon name="alert" :size="28" /></div>
|
||||
<p class="error__title">{{ title }}</p>
|
||||
<p v-if="message" class="error__msg">{{ message }}</p>
|
||||
<AppButton v-if="retry" variant="secondary" size="md" @click="emit('retry')">重试</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
text-align: center;
|
||||
padding: var(--space-8) var(--space-4);
|
||||
}
|
||||
.error--full {
|
||||
min-height: 40vh;
|
||||
}
|
||||
.error__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.error__title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-card-title);
|
||||
font-weight: var(--fw-card-title);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.error__msg {
|
||||
margin: 0 0 var(--space-2);
|
||||
max-width: 360px;
|
||||
font-size: var(--fs-subtitle);
|
||||
color: var(--text-secondary);
|
||||
line-height: var(--lh-subtitle);
|
||||
}
|
||||
</style>
|
||||
43
client/src/components/feedback/AppLoading.vue
Normal file
43
client/src/components/feedback/AppLoading.vue
Normal file
@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ text?: string; fullscreen?: boolean }>(), { fullscreen: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="loading" :class="{ 'loading--full': fullscreen }">
|
||||
<span class="loading__spinner" />
|
||||
<p v-if="text" class="loading__text">{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.loading--full {
|
||||
min-height: 40vh;
|
||||
}
|
||||
.loading__spinner {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid var(--bg-soft);
|
||||
border-top-color: var(--primary);
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
}
|
||||
.loading__text {
|
||||
margin: 0;
|
||||
font-size: var(--fs-subtitle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
@keyframes app-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
67
client/src/components/feedback/AppToast.vue
Normal file
67
client/src/components/feedback/AppToast.vue
Normal file
@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { useAppStore } from '../../stores/app'
|
||||
import AppIcon from '../base/AppIcon.vue'
|
||||
|
||||
const app = useAppStore()
|
||||
|
||||
const iconOf = (type: string) => (type === 'success' ? 'check' : type === 'error' ? 'alert' : 'inbox')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="toast-layer" aria-live="polite">
|
||||
<TransitionGroup name="toast">
|
||||
<div v-for="t in app.toasts" :key="t.id" class="toast" :class="`toast--${t.type}`">
|
||||
<AppIcon :name="iconOf(t.type)" :size="16" />
|
||||
<span>{{ t.message }}</span>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-layer {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: 80vw;
|
||||
padding: 10px var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--fs-subtitle);
|
||||
box-shadow: var(--shadow-level-3);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
.toast--success {
|
||||
color: var(--success);
|
||||
}
|
||||
.toast--error {
|
||||
color: var(--danger);
|
||||
}
|
||||
.toast--info {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all var(--motion-switch) var(--ease-default);
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
15
client/src/components/feedback/ComingSoon.vue
Normal file
15
client/src/components/feedback/ComingSoon.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppCard from '../base/AppCard.vue'
|
||||
import AppEmpty from './AppEmpty.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => String(route.meta.title ?? '页面'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard>
|
||||
<AppEmpty :title="`「${title}」建设中`" description="该页面将在后续任务中实现,当前已完成导航与路由占位。" />
|
||||
</AppCard>
|
||||
</template>
|
||||
37
client/src/composables/useRequest.ts
Normal file
37
client/src/composables/useRequest.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { ref, type Ref, type UnwrapRef } from 'vue'
|
||||
import { ApiError } from '../api'
|
||||
|
||||
interface UseRequestReturn<T> {
|
||||
data: Ref<UnwrapRef<T> | null>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string>
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一封装异步请求的加载 / 错误 / 数据三态。
|
||||
* 页面与 store 复用此组合式函数,避免各自维护 loading/error 样板代码。
|
||||
* @param fn 返回 Promise 的请求函数
|
||||
* @param immediate 是否在 setup 时立即执行(默认 true)
|
||||
*/
|
||||
export function useRequest<T>(fn: () => Promise<T>, immediate = true): UseRequestReturn<T> {
|
||||
const data = ref<T | null>(null) as Ref<UnwrapRef<T> | null>
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const refresh = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
data.value = (await fn()) as UnwrapRef<T>
|
||||
} catch (err) {
|
||||
error.value = err instanceof ApiError ? err.message : '加载失败,请稍后重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
if (immediate) void refresh()
|
||||
|
||||
return { data, loading, error, refresh }
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
13
client/src/composables/useToast.ts
Normal file
13
client/src/composables/useToast.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { useAppStore } from '../stores/app'
|
||||
import type { ToastType } from '../stores/app'
|
||||
|
||||
/** 全局轻提示入口:success / error / info 三种语义 */
|
||||
export function useToast() {
|
||||
const app = useAppStore()
|
||||
return {
|
||||
success: (message: string) => app.toast(message, 'success'),
|
||||
error: (message: string) => app.toast(message, 'error'),
|
||||
info: (message: string) => app.toast(message, 'info'),
|
||||
toast: (message: string, type: ToastType = 'info') => app.toast(message, type)
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ items: string[]; active: number }>()
|
||||
const emit = defineEmits<{ select: [index: number] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="brand"><span>✦</span><strong>备考中枢</strong></div>
|
||||
<button v-for="(item, index) in items" :key="item" :class="['nav-item', { active: active === index }]" @click="emit('select', index)">{{ item }}</button>
|
||||
</template>
|
||||
@ -1,9 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import AppNavigation from './AppNavigation.vue'
|
||||
defineProps<{ items: string[]; active: number }>()
|
||||
const emit = defineEmits<{ select: [index: number] }>()
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppIcon from '../components/base/AppIcon.vue'
|
||||
import { desktopNav } from '../router/nav'
|
||||
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar"><AppNavigation :items="items" :active="active" @select="emit('select', $event)" /><div class="sidebar-spacer" /><button class="nav-item">题库管理</button><button class="nav-item">设置</button></aside>
|
||||
<div class="layout-desktop">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand__mark"><AppIcon name="dashboard" :size="18" /></span>
|
||||
<strong>备考通</strong>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="item in desktopNav"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: route.path === item.to }"
|
||||
>
|
||||
<AppIcon :name="item.icon" :size="18" />
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
<main class="content"><slot /></main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-desktop {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--sidebar-width-desktop);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-2);
|
||||
height: 40px;
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.brand__mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--gradient-hero-deep);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
transition: background var(--motion-hover-fast) var(--ease-default);
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: var(--bg-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: var(--primary);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-card-title);
|
||||
}
|
||||
.content {
|
||||
margin-left: var(--sidebar-width-desktop);
|
||||
padding: var(--padding-desktop);
|
||||
max-width: calc(var(--sidebar-width-desktop) + var(--content-max-width));
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,8 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ items: string[]; active: number }>()
|
||||
const emit = defineEmits<{ select: [index: number] }>()
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AppIcon from '../components/base/AppIcon.vue'
|
||||
import { mobileNav } from '../router/nav'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => String(route.meta.title ?? '备考通'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="mobile-tabs"><button v-for="(item, index) in items" :key="item" :class="{ active: active === index }" @click="emit('select', index)"><span>{{ ['⌂','▣','▥','▤','▤','●'][index] }}</span>{{ item }}</button></nav>
|
||||
<div class="layout-mobile">
|
||||
<header class="topbar">
|
||||
<h1>{{ title }}</h1>
|
||||
</header>
|
||||
<main class="content"><slot /></main>
|
||||
<nav class="tabbar">
|
||||
<RouterLink
|
||||
v-for="item in mobileNav"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="tab"
|
||||
:class="{ active: route.path === item.to }"
|
||||
>
|
||||
<AppIcon :name="item.icon" :size="20" />
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-mobile {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 56px;
|
||||
padding: 0 var(--padding-mobile);
|
||||
background: var(--bg-canvas);
|
||||
}
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: var(--fw-h1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.content {
|
||||
padding: 0 var(--padding-mobile) var(--tabbar-height-mobile);
|
||||
}
|
||||
.tabbar {
|
||||
position: fixed;
|
||||
z-index: 20;
|
||||
left: var(--space-4);
|
||||
right: var(--space-4);
|
||||
bottom: var(--space-4);
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-pill);
|
||||
box-shadow: var(--shadow-level-2);
|
||||
padding: 6px;
|
||||
}
|
||||
.tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex: 1;
|
||||
padding: 8px 6px;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--primary);
|
||||
color: var(--on-accent);
|
||||
font-weight: var(--fw-card-title);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
28
client/src/router/nav.ts
Normal file
28
client/src/router/nav.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import type { IconName } from '../components/base/icons'
|
||||
|
||||
export interface NavItem {
|
||||
label: string
|
||||
to: string
|
||||
icon: IconName
|
||||
}
|
||||
|
||||
/** 桌面左侧导航(8 项,见需求文档 3.1) */
|
||||
export const desktopNav: NavItem[] = [
|
||||
{ label: '数据中枢', to: '/', icon: 'dashboard' },
|
||||
{ label: '刷题中心', to: '/practice', icon: 'pen' },
|
||||
{ label: '模考分析', to: '/mock', icon: 'chart' },
|
||||
{ label: '备考计划', to: '/plan', icon: 'calendar' },
|
||||
{ label: '要闻', to: '/news', icon: 'news' },
|
||||
{ label: '我的', to: '/profile', icon: 'user' },
|
||||
{ label: '题库管理', to: '/questions', icon: 'book' },
|
||||
{ label: '设置', to: '/settings', icon: 'settings' }
|
||||
]
|
||||
|
||||
/** 移动底部 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' }
|
||||
]
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -1 +1,35 @@
|
||||
:root{font-family:Arial,"PingFang SC",sans-serif;color:#1a1f2e;background:#f7f6f2;font-synthesis:none}*{box-sizing:border-box}body{margin:0}.app-shell{min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:220px;padding:16px 10px;background:#fff;border-right:1px solid #e8e6e0;display:flex;flex-direction:column;gap:7px}.brand{height:36px;display:flex;align-items:center;gap:9px}.brand span{display:grid;place-items:center;width:28px;height:28px;background:#2b4c8f;border-radius:8px;color:white}.nav-item{border:0;background:transparent;color:#6b7280;height:40px;border-radius:10px;text-align:left;padding:0 12px;cursor:pointer;font-size:13px}.nav-item.active{background:#2b4c8f;color:#fff}.sidebar-spacer{flex:1}.content{margin-left:220px;padding:32px;max-width:1250px}header{display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:20px}h1{font-size:24px;margin:0 0 5px}h2{margin:0 0 8px}p{font-size:13px;color:#6b7280;margin:0;line-height:1.5}.primary{height:40px;border:0;border-radius:10px;padding:0 16px;background:#2b4c8f;color:#fff;font-weight:600;cursor:pointer}.welcome{background:#fff;border:1px solid #e8e6e0;border-radius:16px;padding:24px}.mobile-tabs{display:none}@media(max-width:767px){.sidebar{display:none}.content{margin:0;padding:28px 24px 110px}.content header{margin-bottom:24px}.content h1{font-size:22px}.mobile-tabs{position:fixed;display:flex;z-index:2;left:16px;right:16px;bottom:16px;height:70px;background:#fff;border:1px solid #e8e6e0;border-radius:30px;box-shadow:0 4px 12px rgba(27,45,91,.08);justify-content:space-around;padding:8px}.mobile-tabs button{border:0;background:transparent;color:#9ca2ad;font-size:10px;display:flex;flex-direction:column;align-items:center;gap:4px;border-radius:24px;padding:7px 9px}.mobile-tabs button.active{background:#2b4c8f;color:#fff}.mobile-tabs span{font-size:17px}.primary{height:36px;padding:0 11px;font-size:12px}}
|
||||
/* 全局样式入口:先加载设计 Token,再加载基础重置。
|
||||
组件/页面样式在各自 <style scoped> 中,引用 var(--token) 而非硬编码颜色。 */
|
||||
|
||||
@import './tokens.css';
|
||||
@import './reset.css';
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 统一焦点可见性,保持键盘可访问 */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--primary-bright);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* 简洁滚动条(WebKit) */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-subtle);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
77
client/src/styles/reset.css
Normal file
77
client/src/styles/reset.css
Normal file
@ -0,0 +1,77 @@
|
||||
/* 基础重置:统一盒模型、边距、字体与滚动行为。颜色等一律引用 tokens,禁止硬编码。 */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--fs-body);
|
||||
line-height: var(--lh-body);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-canvas);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p,
|
||||
figure {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 数值强调(统计数字)统一使用数值字体 */
|
||||
.num {
|
||||
font-family: var(--font-num);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
192
client/src/styles/tokens.css
Normal file
192
client/src/styles/tokens.css
Normal file
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 备考通 · Design Tokens (CSS 变量)
|
||||
* =====================================================================
|
||||
* 版本:v1.0.0 · 2026-08-26
|
||||
* 来源:设计规范-备考通-20260826.md(Ardot 文件 718570750880145 真实变量校准)
|
||||
* 说明:本文件与 design-tokens.json 数值完全一致,是前端唯一颜色/排印/
|
||||
* 圆角/阴影/间距来源。前端必须引用这些变量,禁止硬编码颜色值。
|
||||
*
|
||||
* 使用方式:前端在 `styles/tokens.css` 中 import 本文件或复制 :root 节,
|
||||
* 组件内一律 `var(--token-name)` 引用。
|
||||
* =====================================================================
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ------------------------------------------------------------------
|
||||
* 1. 颜色 Color
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/* 中性底色 Neutral */
|
||||
--bg-canvas: #F7F6F2; /* 画布/页面背景(浅米白) */
|
||||
--bg-card: #FFFFFF; /* 卡片/面板背景 */
|
||||
--bg-soft: #EFEFF5; /* 图标底、次级填充、标签底、进度条轨道 */
|
||||
--border-subtle: #E8E6E0; /* 卡片描边、分隔线(中性浅暖) */
|
||||
|
||||
/* 品牌主色 Brand(深蓝系) */
|
||||
--primary: #2B4C8F; /* 主按钮、主色、选中态高亮底、进度填充 */
|
||||
--primary-deep: #1B2D5B; /* 渐变深端、页面主标题强调、深蓝 hero 底 */
|
||||
--primary-bright: #3A6BD8; /* 渐变亮端、hero 卡片、active 图标 */
|
||||
|
||||
/* 文字色 Text */
|
||||
--text-primary: #1A1F2E; /* 主标题、正文主色 */
|
||||
--text-secondary: #6B7280; /* 副标题、描述文字、图标默认色 */
|
||||
--text-muted: #9CA2AD; /* 弱提示、占位、次要标记 */
|
||||
|
||||
/* 语义色 Semantic(状态反馈) */
|
||||
--success: #1B9266; /* 正向趋势、正确率、达标 */
|
||||
--success-soft: #E0F5EB; /* 正向标签底、打卡徽章底 */
|
||||
--warning: #DC9A1E; /* 提醒、中等状态 */
|
||||
--warning-soft: #FEF0D1; /* 提醒标签底 */
|
||||
--danger: #D14C55; /* 负向、错误、待复习错题数 */
|
||||
--danger-soft: #FCE5E7; /* 危险标签底、错误提示底 */
|
||||
|
||||
/* 前景/辅助 Foreground */
|
||||
--on-accent: #FFFFFF; /* 主色/深蓝底上的前景色(按钮文字、深蓝 hero 文字) */
|
||||
--white: #FFFFFF; /* 纯白(唯一允许的裸色值) */
|
||||
|
||||
/* 渐变 Gradients(仅 hero 大卡 / 置顶卡使用) */
|
||||
--gradient-hero-deep: linear-gradient(135deg, #1B2D5B 0%, #3A6BD8 100%); /* 首页 hero 卡 / 要闻置顶卡 */
|
||||
--gradient-hero-primary: linear-gradient(180deg, #2B4C8F 0%, #1B2D5B 100%); /* 移动首页深蓝 hero */
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 2. 字体 Font
|
||||
* ------------------------------------------------------------------ */
|
||||
--font-sans: 'Sarasa Gothic SC', 'PingFang SC', sans-serif; /* 中文正文/标题/标签 */
|
||||
--font-num: 'Inter', 'SF Pro', sans-serif; /* 统计数字/时间/单价/代码标识 */
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 3. 排印 Typography
|
||||
* role = 用途界定;部分 token 的可变字段(如趋势色)由语义色覆盖
|
||||
* ------------------------------------------------------------------ */
|
||||
--fs-h1: 24px; --fw-h1: 700; --lh-h1: 1.2; --ff-h1: var(--font-sans); /* H1 页面标题 */
|
||||
--fs-data-num: 22px; --fw-data-num: 700; --lh-data-num: 1.2; --ff-data-num: var(--font-num); /* 数据大字(Inter Bold) */
|
||||
--fs-card-title:15px; --fw-card-title: 600; --lh-card-title: 1.4; --ff-card-title: var(--font-sans); /* 卡标题 */
|
||||
--fs-label: 12px; --fw-label: 400; --lh-label: 1.4; --ff-label: var(--font-sans); /* 标签 */
|
||||
--fs-label-bold:12px; --fw-label-bold: 600; --lh-label-bold: 1.4; --ff-label-bold: var(--font-sans); /* 强标签/徽章 */
|
||||
--fs-subtitle: 13px; --fw-subtitle: 400; --lh-subtitle: 1.5; --ff-subtitle: var(--font-sans); /* 副标题 */
|
||||
--fs-button: 13px; --fw-button: 600; --lh-button: 1.4; --ff-button: var(--font-sans); /* 按钮文字 */
|
||||
--fs-trend: 11px; --fw-trend: 500; --lh-trend: 1.4; --ff-trend: var(--font-sans); /* 趋势指数 */
|
||||
--fs-body: 14px; --fw-body: 400; --lh-body: 1.5; --ff-body: var(--font-sans); /* 正文/题干 */
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 4. 圆角 Radius
|
||||
* ------------------------------------------------------------------ */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px; /* 标准卡片(默认) */
|
||||
--radius-xl: 24px; /* Hero 渐变大卡 */
|
||||
--radius-pill: 999px; /* 头像、胶囊按钮、天数徽章 */
|
||||
--radius-btn: 10px; /* 主/次按钮圆角 */
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 5. 阴影 Shadow
|
||||
* 默认以「白底 + 细描边(border-subtle)」表达卡片,不用重阴影。
|
||||
* ------------------------------------------------------------------ */
|
||||
--shadow-level-0: none;
|
||||
--shadow-level-1: 0 1px 2px rgba(0, 0, 0, 0.04); /* 悬浮态轻投影 */
|
||||
--shadow-level-2: 0 4px 12px rgba(27, 45, 91, 0.08); /* 弹层、浮起卡片、TabBar */
|
||||
--shadow-level-3: 0 8px 24px rgba(27, 45, 91, 0.14); /* 模态、下拉菜单 */
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 6. 间距 Spacing(4px 网格)
|
||||
* ------------------------------------------------------------------ */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 7. 布局 Layout
|
||||
* ------------------------------------------------------------------ */
|
||||
--sidebar-width-desktop: 220px;
|
||||
--content-max-width: 1220px;
|
||||
--padding-desktop: 32px; /* 桌面左右 padding */
|
||||
--padding-mobile: 24px; /* 移动左右 padding */
|
||||
--card-padding-desktop: 24px;
|
||||
--card-padding-mobile: 16px;
|
||||
--card-gap-desktop: 20px;
|
||||
--card-gap-mobile: 12px;
|
||||
--tabbar-height-mobile: 120px; /* 移动 TabBar 高度 */
|
||||
--btn-height: 40px;
|
||||
--btn-height-pill: 44px;
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 8. 断点 Breakpoint
|
||||
* ------------------------------------------------------------------ */
|
||||
--bp-mobile: 390px;
|
||||
--bp-desktop: 1440px;
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* 9. 动效 Motion
|
||||
* ------------------------------------------------------------------ */
|
||||
--motion-hover-fast: 120ms; /* 按钮 hover 背景过渡 */
|
||||
--motion-card-hover: 160ms; /* 卡片 hover 投影过渡 */
|
||||
--motion-switch: 250ms; /* 切换/展开 */
|
||||
--motion-tab-fade: 200ms; /* 移动 Tab 切换淡入 */
|
||||
--ease-default: ease;
|
||||
}
|
||||
|
||||
|
||||
/* =====================================================================
|
||||
* 10. 组件级预设预设(Typographic & Component recipe)
|
||||
* —— 前端不直接硬编码上述,按此处 recipe 组装组件。
|
||||
* ===================================================================== */
|
||||
|
||||
/* Hero 数据卡(首页 5 要素卡 / 深蓝大卡) */
|
||||
.hub-card-hero {
|
||||
color: var(--on-accent);
|
||||
background: var(--gradient-hero-deep);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--card-padding-desktop);
|
||||
}
|
||||
|
||||
/* 标准白卡(默认卡片) */
|
||||
.hub-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--card-padding-desktop);
|
||||
}
|
||||
|
||||
/* 主按钮 */
|
||||
.hub-btn-primary {
|
||||
background: var(--primary);
|
||||
color: var(--on-accent);
|
||||
border-radius: var(--radius-btn);
|
||||
height: var(--btn-height);
|
||||
font: var(--fw-button) var(--fs-button) / var(--lh-button) var(--ff-button);
|
||||
transition: background var(--motion-hover-fast) var(--ease-default);
|
||||
}
|
||||
.hub-btn-primary:hover { background: color-mix(in srgb, var(--primary) 92%, #000); }
|
||||
|
||||
/* 次按钮(透明 + primary 描边) */
|
||||
.hub-btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: var(--radius-btn);
|
||||
height: var(--btn-height);
|
||||
font: var(--fw-button) var(--fs-button) / var(--lh-button) var(--ff-button);
|
||||
}
|
||||
|
||||
/* 语义标签 / 徽章(主色 + 浅底成对) */
|
||||
.hub-badge {
|
||||
font: var(--fw-label-bold) var(--fs-label) / var(--lh-label) var(--ff-label);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px var(--space-2);
|
||||
}
|
||||
.hub-badge--success { color: var(--success); background: var(--success-soft); }
|
||||
.hub-badge--warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.hub-badge--danger { color: var(--danger); background: var(--danger-soft); }
|
||||
.hub-badge--soft { color: var(--text-secondary); background: var(--bg-soft); }
|
||||
@ -1 +1,5 @@
|
||||
export type NavigationItem = { label: string; index: number }
|
||||
/**
|
||||
* 页面组合类型(View Models)。
|
||||
* 仅存放「页面组合」所需的类型,不复制接口类型——接口类型一律来自
|
||||
* api/generated 与 api/index 的派生类型。页面逐步实现时按需补充。
|
||||
*/
|
||||
|
||||
@ -1 +1,21 @@
|
||||
/**
|
||||
* 前端格式化工具:数字、百分比、日期、时长。
|
||||
* 业务代码统一走这里,避免散落重复的格式化逻辑。
|
||||
*/
|
||||
|
||||
export const formatPercent = (value: number) => `${Math.round(value)}%`
|
||||
|
||||
/** 秒 → 「X 分 Y 秒」或「Y 秒」 */
|
||||
export function formatDuration(seconds: number): string {
|
||||
const s = Math.max(0, Math.round(seconds))
|
||||
if (s < 60) return `${s} 秒`
|
||||
const m = Math.floor(s / 60)
|
||||
const rest = s % 60
|
||||
return rest === 0 ? `${m} 分钟` : `${m} 分 ${rest} 秒`
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD → 「M月D日」 */
|
||||
export function formatDateShort(iso: string): string {
|
||||
const [, m, d] = iso.split('-')
|
||||
return `${Number(m)}月${Number(d)}日`
|
||||
}
|
||||
|
||||
@ -1,3 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { dashboardApi } from '../../api'
|
||||
import { useRequest } from '../../composables/useRequest'
|
||||
import { useProfileStore } from '../../stores/profile'
|
||||
import AppCard from '../../components/base/AppCard.vue'
|
||||
import AppLoading from '../../components/feedback/AppLoading.vue'
|
||||
import AppError from '../../components/feedback/AppError.vue'
|
||||
import AppEmpty from '../../components/feedback/AppEmpty.vue'
|
||||
import AppIcon from '../../components/base/AppIcon.vue'
|
||||
import type { IconName } from '../../components/base/icons'
|
||||
|
||||
const profile = useProfileStore()
|
||||
const { data, loading, error, refresh } = useRequest(() => dashboardApi.overview())
|
||||
|
||||
onMounted(() => {
|
||||
if (!profile.profile) profile.fetch()
|
||||
})
|
||||
|
||||
const isEmpty = computed(() => {
|
||||
const d = data.value
|
||||
return !!d && d.totalQuestions === 0 && d.totalAnswered === 0
|
||||
})
|
||||
|
||||
interface StatItem {
|
||||
key: string
|
||||
label: string
|
||||
value: number
|
||||
unit: string
|
||||
icon: IconName
|
||||
tone?: 'danger' | 'success'
|
||||
}
|
||||
|
||||
const stats = computed<StatItem[]>(() => {
|
||||
const d = data.value
|
||||
if (!d) return []
|
||||
return [
|
||||
{ key: 'questions', label: '题库题量', value: d.totalQuestions, unit: '题', icon: 'book' },
|
||||
{ key: 'review', label: '待复习错题', value: d.pendingReview, unit: '题', icon: 'alert', tone: d.pendingReview > 0 ? 'danger' : undefined },
|
||||
{ key: 'tasks', label: '今日任务', value: d.todayTasksDone, unit: `/${d.todayTasksTotal}`, icon: 'check' },
|
||||
{ key: 'minutes', label: '今日学习', value: d.todayMinutes, unit: '分钟', icon: 'clock' },
|
||||
{ key: 'streak', label: '连续打卡', value: d.streakDays, unit: '天', icon: 'flame' }
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="welcome"><h2>备考通已就绪</h2><p>前端工程架构已启动,后续任务将逐页还原原型并接入真实数据。</p></section>
|
||||
<div class="dashboard">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h1>数据中枢</h1>
|
||||
<p>{{ profile.nickname }},欢迎回来 · 今日也要稳步推进</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<AppLoading v-if="loading" fullscreen />
|
||||
|
||||
<AppError
|
||||
v-else-if="error"
|
||||
fullscreen
|
||||
title="数据加载失败"
|
||||
:message="error"
|
||||
retry
|
||||
@retry="refresh"
|
||||
/>
|
||||
|
||||
<AppEmpty
|
||||
v-else-if="isEmpty"
|
||||
title="还没有学习数据"
|
||||
description="完成第一次刷题或导入题库后,这里会展示你的备考概览。"
|
||||
/>
|
||||
|
||||
<div v-else-if="data" class="dashboard__body">
|
||||
<section class="hero">
|
||||
<div class="hero__item">
|
||||
<span class="hero__label">学习天数</span>
|
||||
<strong class="num">{{ data.studyDays }}<em>天</em></strong>
|
||||
</div>
|
||||
<div class="hero__item">
|
||||
<span class="hero__label">累计答题</span>
|
||||
<strong class="num">{{ data.totalAnswered }}<em>题</em></strong>
|
||||
</div>
|
||||
<div class="hero__item">
|
||||
<span class="hero__label">正确率</span>
|
||||
<strong class="num">{{ data.accuracy }}<em>%</em></strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="stat-grid">
|
||||
<AppCard v-for="s in stats" :key="s.key" class="stat">
|
||||
<div class="stat__icon" :class="{ 'stat__icon--danger': s.tone === 'danger', 'stat__icon--success': s.tone === 'success' }">
|
||||
<AppIcon :name="s.icon" :size="18" />
|
||||
</div>
|
||||
<div class="stat__meta">
|
||||
<span class="stat__label">{{ s.label }}</span>
|
||||
<strong class="num">{{ s.value }}<em>{{ s.unit }}</em></strong>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.page-head h1 {
|
||||
font-size: var(--fs-h1);
|
||||
font-weight: var(--fw-h1);
|
||||
line-height: var(--lh-h1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.page-head p {
|
||||
margin: var(--space-1) 0 0;
|
||||
font-size: var(--fs-subtitle);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dashboard__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-4);
|
||||
background: var(--gradient-hero-deep);
|
||||
color: var(--on-accent);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--card-padding-desktop);
|
||||
}
|
||||
.hero__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.hero__label {
|
||||
font-size: var(--fs-label);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.hero__item strong {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
.hero__item em {
|
||||
font-style: normal;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-left: 4px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--card-gap-desktop);
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.stat__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-soft);
|
||||
color: var(--primary);
|
||||
}
|
||||
.stat__icon--danger {
|
||||
background: var(--danger-soft);
|
||||
color: var(--danger);
|
||||
}
|
||||
.stat__icon--success {
|
||||
background: var(--success-soft);
|
||||
color: var(--success);
|
||||
}
|
||||
.stat__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat__label {
|
||||
font-size: var(--fs-label);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.stat__meta strong {
|
||||
font-size: var(--fs-data-num);
|
||||
font-weight: 700;
|
||||
line-height: var(--lh-data-num);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.stat__meta em {
|
||||
font-style: normal;
|
||||
font-size: var(--fs-label);
|
||||
font-weight: 400;
|
||||
color: var(--text-muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.hero {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
padding: var(--card-padding-mobile);
|
||||
}
|
||||
.hero__item strong {
|
||||
font-size: 26px;
|
||||
}
|
||||
.stat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--card-gap-mobile);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
7
client/src/views/mock/MockView.vue
Normal file
7
client/src/views/mock/MockView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/news/NewsView.vue
Normal file
7
client/src/views/news/NewsView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/plan/PlanView.vue
Normal file
7
client/src/views/plan/PlanView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/practice/PracticeView.vue
Normal file
7
client/src/views/practice/PracticeView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/profile/ProfileView.vue
Normal file
7
client/src/views/profile/ProfileView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/questions/QuestionsView.vue
Normal file
7
client/src/views/questions/QuestionsView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
7
client/src/views/settings/SettingsView.vue
Normal file
7
client/src/views/settings/SettingsView.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import ComingSoon from '../../components/feedback/ComingSoon.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ComingSoon />
|
||||
</template>
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
68
docs/checks/task-04.md
Normal file
68
docs/checks/task-04.md
Normal file
@ -0,0 +1,68 @@
|
||||
# 任务 04 检查记录:前端 API 自动生成与基础视觉壳层
|
||||
|
||||
日期:2026-08-31
|
||||
|
||||
## 交付内容
|
||||
|
||||
### OpenAPI 代码生成管线
|
||||
- `client/scripts/generate-api.mjs`:从后端运行时端点 `http://localhost:3000/api/openapi.json`(可用 `OPENAPI_URL` 覆盖)拉取并生成类型;获取失败/生成失败时 exit 1 并提示先启动后端。
|
||||
- `client/package.json`:新增 `api:generate`,并接入 `dev`、`build` 前置(`npm run api:generate && vite`),OpenAPI 无法获取或生成失败时命令直接失败。
|
||||
- 根 `package.json`:`pnpm api:generate` = client `api:generate`(需后端服务运行中)。
|
||||
- `client/src/api/generated/schema.d.ts`:由 openapi-typescript 生成,已在 `.gitignore` 中忽略、禁止手工修改。
|
||||
|
||||
### 请求客户端与领域 API
|
||||
- `client/src/api/client.ts`:`openapi-fetch` 的 `createClient<paths>`,统一 baseUrl(默认相对路径 `/api`,开发环境经 Vite 代理)、JSON headers、15s 超时(AbortController)与错误解包;`ApiError` 统一异常。
|
||||
- `client/src/api/index.ts`:按域暴露类型化方法(dashboard/practice/review/plans/mock/news/questions/ai/profile/settings),请求/响应类型由 `BodyOf`/`QueryOf`/`SuccessBody` 从生成的 `paths` 推导,业务代码不写 URL 与 `fetch`。
|
||||
|
||||
### 设计 Token 与基础组件
|
||||
- `client/src/styles/tokens.css`(承接 `deliverables/design/design-tokens.css`)、`reset.css`、`main.css`。
|
||||
- `components/base/`:AppIcon(内联线性 SVG 图标集,替代依赖)、AppButton、AppCard、AppBadge。
|
||||
- `components/feedback/`:AppLoading、AppEmpty、AppError、AppToast、ComingSoon。
|
||||
|
||||
### 路由、Store 与双端布局
|
||||
- `router/nav.ts`(PC 8 项 / Mobile 5 项导航配置)、`router/index.ts`(8 条路由 + 兜底重定向 + 文档标题)。
|
||||
- `stores/app.ts`(busy + toast)、`profile.ts`(档案加载)、`practice.ts`(会话状态)。
|
||||
- `layouts/DesktopLayout.vue`(220px 侧栏)、`layouts/MobileLayout.vue`(顶部栏 + 底部 TabBar)。
|
||||
- `App.vue` 按 `<768px` 断点切换双端布局;`main.ts` 接入 Pinia + Router。
|
||||
- 首页 `DashboardView.vue` 通过 `dashboardApi.overview()` 加载真实数据,覆盖加载/空/错误/成功四态;其余 7 个导航页为 ComingSoon 占位。
|
||||
|
||||
## 过程中发现并修复的问题
|
||||
|
||||
1. **后端 OpenAPI 参数污染**:`routes.ts` 用 `emptyObjectSchema = { type: 'object' }` 占位导致 `@fastify/swagger` 把 "type" 关键字误当参数名,每个路由生成虚假 `type`(query/path)参数,会污染前端生成类型。改为「无 schema 时省略 params/querystring 键」,重新导出后 31 个路径参数干净(0 个虚假参数)。
|
||||
2. **openapi-typescript v7 API 变化**:返回 `ts.Node[]` 而非字符串,需 `astToString` 转换。
|
||||
3. **Vite 依赖优化缓存**:`client/node_modules/.vite` 过期导致 dev 启动触发 bulk-delete 守卫报错,清除缓存后恢复。
|
||||
4. **空响应体错误处理**:后端宕机时 Vite 代理返回 502 空 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` 需后端已启动。
|
||||
@ -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"
|
||||
}
|
||||
|
||||
210
pnpm-lock.yaml
generated
210
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user