feat(frontend): 构建前端 MVP,实现完整 UI 框架与 mock 数据层

- 安装 zustand + react-router-dom 依赖
- 修复 Vite 代理配置,添加 /auth 路由转发
- 实现 API 封装层(client.ts 统一 fetch、auth.ts 真实调用)
- 实现 mock 数据层(mock.ts 模拟 WebSocket 管线进度)
- 实现 4 个 zustand stores(auth/project/task/generation)
- 实现 5 个组件(StyleSelector/PromptEditor/GenerateForm/ProgressBar/AssetPreview)
- 实现 5 个页面(Login/Register/Project/Generate/Result)
- 实现路由含 ProtectedRoute 守卫
- 添加暗色主题全局样式
- 更新前端文档与 API 文档
This commit is contained in:
2026-05-24 14:18:39 +08:00
parent 302b9ba88e
commit a2736f722c
33 changed files with 2272 additions and 20 deletions
+31
View File
@@ -0,0 +1,31 @@
import { post, setToken } from './client'
import type {
LoginRequest,
LoginResponse,
RegisterRequest,
RegisterResponse,
} from './types'
export async function register(
username: string,
password: string,
email?: string
): Promise<RegisterResponse> {
return post<RegisterResponse>('/auth/register', {
username,
password,
email,
} satisfies RegisterRequest)
}
export async function login(
username: string,
password: string
): Promise<LoginResponse> {
const data = await post<LoginResponse>('/auth/login', {
username,
password,
} satisfies LoginRequest)
setToken(data.token)
return data
}
+70
View File
@@ -0,0 +1,70 @@
import type { ApiResponse } from './types'
const TOKEN_KEY = 'gen2d_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export class ApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(url, {
...options,
headers,
credentials: 'include',
})
const json: ApiResponse<T> = await res.json()
if (json.code !== 0) {
if (json.code === 401) {
clearToken()
window.location.href = '/login'
}
throw new ApiError(json.code, json.message)
}
return json.data
}
export function get<T>(url: string): Promise<T> {
return request<T>(url)
}
export function post<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
})
}
export function put<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
})
}
+23
View File
@@ -0,0 +1,23 @@
import type { Asset, Task } from './types'
import { mockGetAssets, mockGetTask, mockSubmitGenerate } from './mock'
const USE_MOCK = true
export async function submitGenerate(
projectId: string,
prompt: string,
assetType: string
): Promise<string> {
if (USE_MOCK) return mockSubmitGenerate(projectId, prompt, assetType)
throw new Error('Not implemented')
}
export async function getTask(taskId: string): Promise<Task> {
if (USE_MOCK) return mockGetTask(taskId)
throw new Error('Not implemented')
}
export async function getAssets(taskId: string): Promise<Asset[]> {
if (USE_MOCK) return mockGetAssets(taskId)
throw new Error('Not implemented')
}
+217
View File
@@ -0,0 +1,217 @@
import type { Asset, PipelineProgress, PipelineStage, Project, Task } from './types'
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const randomDelay = () => delay(300 + Math.random() * 500)
// Mock 数据
const MOCK_PROJECT: Project = {
id: 'proj-default',
name: '我的工程',
style: {
kvPairs: {
artStyle: 'pixel',
palette: 'warm',
lineWeight: 'thin',
scene: 'forest',
lighting: 'bright',
mood: 'cheerful',
},
},
createdAt: '2026-05-20T10:00:00Z',
}
const MOCK_TASKS: Task[] = [
{
id: 'task-001',
projectId: 'proj-default',
prompt: '一个拿剑的小人',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-24T09:00:00Z',
updatedAt: '2026-05-24T09:02:00Z',
},
{
id: 'task-002',
projectId: 'proj-default',
prompt: '森林背景',
assetType: 'background',
status: 'failed',
error: '生成超时',
createdAt: '2026-05-24T08:00:00Z',
updatedAt: '2026-05-24T08:10:00Z',
},
{
id: 'task-003',
projectId: 'proj-default',
prompt: '魔法药水瓶',
assetType: 'ui',
status: 'completed',
progress: 100,
createdAt: '2026-05-23T15:00:00Z',
updatedAt: '2026-05-23T15:03:00Z',
},
]
const MOCK_ASSETS: Asset[] = [
{
id: 'asset-001',
url: 'https://placehold.co/256x256/e94560/1a1a2e?text=Sprite',
format: 'png',
width: 256,
height: 256,
metadata: {
frameWidth: 64,
frameHeight: 64,
frameCount: 16,
directions: 4,
},
},
]
// Mock API 函数
export async function mockGetProject(_id: string): Promise<Project> {
await randomDelay()
return { ...MOCK_PROJECT }
}
export async function mockGetStyle(
_projectId: string
): Promise<Record<string, string>> {
await randomDelay()
return { ...MOCK_PROJECT.style.kvPairs }
}
export async function mockSaveStyle(
_projectId: string,
_kvPairs: Record<string, string>
): Promise<void> {
await randomDelay()
}
export async function mockGetTasks(_projectId: string): Promise<Task[]> {
await randomDelay()
return MOCK_TASKS.map(t => ({ ...t }))
}
export async function mockGetTask(taskId: string): Promise<Task> {
await randomDelay()
const task = MOCK_TASKS.find(t => t.id === taskId)
if (!task) throw new Error('任务不存在')
return { ...task }
}
export async function mockGetAssets(_taskId: string): Promise<Asset[]> {
await randomDelay()
return MOCK_ASSETS.map(a => ({ ...a }))
}
export async function mockSubmitGenerate(
_projectId: string,
prompt: string,
assetType: string
): Promise<string> {
await delay(500)
const taskId = `task-${Date.now()}`
MOCK_TASKS.unshift({
id: taskId,
projectId: _projectId,
prompt,
assetType,
status: 'pending',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
return taskId
}
// 模拟 WebSocket 管线进度
const STAGES: PipelineStage[] = [
'prompt_builder',
'asset_generator',
'quality_supervisor',
'format_adapter',
]
const STAGE_LABELS: Record<PipelineStage, string> = {
prompt_builder: '提示词构建',
asset_generator: '素材生成',
quality_supervisor: '质量检查',
format_adapter: '格式转换',
}
export { STAGE_LABELS }
export function createMockWebSocket(
_taskId: string,
onProgress: (msg: PipelineProgress) => void,
onComplete: (assets: Asset[]) => void,
_onError?: (error: string) => void
): () => void {
let stageIndex = 0
let tick = 0
let retryCount = 0
const maxRetries = 1
let cancelled = false
const interval = setInterval(() => {
if (cancelled) return
const stage = STAGES[stageIndex]
const stageProgress = (tick % 3) * 50 // 0, 50, 100
// 质检阶段有概率重试
if (
stage === 'quality_supervisor' &&
stageProgress === 100 &&
retryCount < maxRetries &&
Math.random() < 0.3
) {
onProgress({
stage: 'quality_supervisor',
status: 'failed',
progress: 100,
retryCount: retryCount + 1,
rejectReason: '风格一致性不足',
})
retryCount++
stageIndex = 0
tick = 0
return
}
const isLastTickOfStage = stageProgress === 100
const isLastStage = stageIndex === STAGES.length - 1
if (isLastTickOfStage && isLastStage) {
// 管线完成
onProgress({
stage: 'format_adapter',
status: 'completed',
progress: 100,
result: { assets: MOCK_ASSETS },
})
onComplete(MOCK_ASSETS)
clearInterval(interval)
return
}
onProgress({
stage,
status: isLastTickOfStage ? 'completed' : 'running',
progress: stageProgress,
retryCount: retryCount > 0 ? retryCount : undefined,
})
tick++
if (isLastTickOfStage) {
stageIndex++
tick = 0
}
}, 1000)
return () => {
cancelled = true
clearInterval(interval)
}
}
+35
View File
@@ -0,0 +1,35 @@
import type { Project, Task } from './types'
import {
mockGetProject,
mockGetStyle,
mockGetTasks,
mockSaveStyle,
} from './mock'
const USE_MOCK = true
export async function getProject(projectId: string): Promise<Project> {
if (USE_MOCK) return mockGetProject(projectId)
// TODO: 真实 API 调用
throw new Error('Not implemented')
}
export async function getStyle(
projectId: string
): Promise<Record<string, string>> {
if (USE_MOCK) return mockGetStyle(projectId)
throw new Error('Not implemented')
}
export async function saveStyle(
projectId: string,
kvPairs: Record<string, string>
): Promise<void> {
if (USE_MOCK) return mockSaveStyle(projectId, kvPairs)
throw new Error('Not implemented')
}
export async function getTasks(projectId: string): Promise<Task[]> {
if (USE_MOCK) return mockGetTasks(projectId)
throw new Error('Not implemented')
}
+117
View File
@@ -0,0 +1,117 @@
// 统一响应信封
export interface ApiResponse<T> {
code: number
message: string
data: T
}
// 认证
export interface LoginRequest {
username: string
password: string
}
export interface RegisterRequest {
username: string
password: string
email?: string
}
export interface LoginResponse {
token: string
expiresIn: number
user: {
id: number
username: string
email: string
createdAt: string
updatedAt: string
}
}
export interface RegisterResponse {
id: number
username: string
}
// 素材类型
export type AssetType = 'sprite' | 'background' | 'ui' | 'animation'
// 管线阶段
export type PipelineStage =
| 'prompt_builder'
| 'asset_generator'
| 'quality_supervisor'
| 'format_adapter'
// 工程
export interface Project {
id: string
name: string
style: { kvPairs: Record<string, string> }
createdAt: string
}
export interface CreateProjectRequest {
name: string
}
// 风格
export interface UpdateStyleRequest {
kvPairs: Record<string, string>
}
// 任务
export interface Task {
id: string
projectId: string
prompt: string
assetType: string
status: 'pending' | 'running' | 'completed' | 'failed'
stage?: PipelineStage
progress?: number
retryCount?: number
error?: string | null
createdAt: string
updatedAt: string
}
// 素材
export interface Asset {
id: string
url: string
format: string
width: number
height: number
metadata: {
frameWidth?: number
frameHeight?: number
frameCount?: number
directions?: number
}
}
// 生成请求
export interface GenerateRequest {
projectId: string
prompt: string
assetType: AssetType
taskStyle?: Record<string, string>
params?: {
resolution?: number
frames?: { directions?: number; framesPerDirection?: number }
format?: 'spritesheet' | 'individual'
}
}
// WebSocket 管线进度
export interface PipelineProgress {
stage: PipelineStage
status: 'running' | 'completed' | 'failed'
progress: number
message?: string
retryCount?: number
rejectReason?: string
result?: { assets: Asset[] }
error?: string
}