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:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user