diff --git a/docs/api.md b/docs/api.md
index 85f447d..2a508ee 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -57,10 +57,12 @@ Token 过期或无效时返回:
## 用户认证
+> **注意:** 后端代码实际注册路径为 `/auth/*`(非 `/api/v1/auth/*`),前端 Vite 代理已配置 `/auth` 转发。
+
| 状态 | 方法 | 路径 | 说明 |
|------|------|------|------|
-| [x] | POST | `/api/v1/auth/register` | 用户注册 |
-| [x] | POST | `/api/v1/auth/login` | 用户登录 |
+| [x] | POST | `/auth/register` | 用户注册 |
+| [x] | POST | `/auth/login` | 用户登录 |
| [ ] | GET | `/api/v1/auth/me` | 获取当前用户信息 |
| [ ] | PUT | `/api/v1/auth/password` | 修改密码 |
diff --git a/docs/frontend.md b/docs/frontend.md
index 3c56e22..d8ee65f 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -21,19 +21,24 @@ frontend/src/
├── App.tsx # 根组件 + 路由配置
├── api/ # API 封装层
│ ├── client.ts # fetch 封装:baseURL、统一错误处理、响应解包
-│ ├── project.ts # 工程 CRUD + 风格 GET/PUT
-│ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets
+│ ├── auth.ts # 注册/登录(真实后端调用)
+│ ├── project.ts # 工程 CRUD + 风格 GET/PUT(mock 切换)
+│ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets(mock 切换)
+│ ├── mock.ts # Mock 数据 + 模拟 WebSocket 管线进度
│ └── types.ts # API 请求/响应类型定义(Task、Asset、Style、PipelineProgress 等)
├── stores/ # zustand stores
+│ ├── auth.ts # 用户认证状态(login/register/logout/loadFromStorage)
│ ├── project.ts # 当前工程(id、风格、任务列表)
│ ├── task.ts # 当前任务草稿(用户文本、素材类型、任务风格覆盖、技术参数)
│ └── generation.ts # 生成状态(taskId、进度、阶段、结果、错误)
├── pages/ # 页面级组件
+│ ├── LoginPage.tsx # 登录页
+│ ├── RegisterPage.tsx # 注册页
│ ├── ProjectPage.tsx # 工程首页:工程风格配置 + 任务列表
│ ├── GeneratePage.tsx # 生成页:提示词构建 + 提交
│ └── ResultPage.tsx # 结果页:素材预览 + 下载 + 元数据
├── components/ # 可复用组件
-│ ├── StyleSelector.tsx # 风格选择器
+│ ├── StyleSelector.tsx # 风格选择器(CSS Modules)
│ ├── PromptEditor.tsx # 三段式提示词编辑器
│ ├── GenerateForm.tsx # 生成表单
│ ├── ProgressBar.tsx # 管线进度条(显示当前阶段 + 重试状态)
@@ -43,10 +48,25 @@ frontend/src/
│ └── useProjectStyle.ts # 工程风格加载/保存
├── router/ # 路由定义
│ └── index.tsx
+├── styles/ # 全局样式
+│ └── global.css # 暗色主题 CSS 变量 + reset
└── utils/ # 工具函数
- └── style.ts # 风格合并逻辑(与后端保持一致的 merge 算法)
+ └── style.ts # 风格合并逻辑 + 风格键分类定义
```
+## 实现状态
+
+| 模块 | 状态 | 说明 |
+|------|------|------|
+| API 封装层 | [x] | client.ts 统一 fetch 封装,auth.ts 真实调用,project/generate 使用 mock |
+| Mock 层 | [x] | mock.ts 提供 mock 数据 + 模拟 WebSocket 管线进度 |
+| 认证流程 | [x] | 注册/登录/退出/token 持久化,真实后端对接 |
+| zustand stores | [x] | auth/project/task/generation 四个 store |
+| 页面 | [x] | 5 个页面全部实现(Login/Register/Project/Generate/Result) |
+| 组件 | [x] | StyleSelector/PromptEditor/GenerateForm/ProgressBar/AssetPreview |
+| 路由 | [x] | 含 ProtectedRoute 守卫 |
+| WebSocket | [ ] | 当前使用 mock 模拟,待后端实现后切换为真实 WS |
+
## 路由规划
| 路径 | 页面 | 说明 |
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000..b947077
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+dist/
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 48712b9..b68a12e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,7 +9,9 @@
"version": "0.0.1",
"dependencies": {
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.15.1",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "^18.3.12",
@@ -1206,14 +1208,14 @@
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.29",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
- "dev": true,
+ "devOptional": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
@@ -1326,11 +1328,24 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
- "dev": true,
+ "devOptional": true,
"license": "MIT"
},
"node_modules/debug": {
@@ -1627,6 +1642,44 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-router": {
+ "version": "7.15.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
+ "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.15.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
+ "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.15.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
"node_modules/rollup": {
"version": "4.60.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
@@ -1691,6 +1744,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1844,6 +1903,35 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true,
"license": "ISC"
+ },
+ "node_modules/zustand": {
+ "version": "5.0.13",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
+ "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/frontend/package.json b/frontend/package.json
index 2f12ae3..023aa2d 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -11,7 +11,9 @@
},
"dependencies": {
"react": "^18.3.1",
- "react-dom": "^18.3.1"
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^7.15.1",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@types/react": "^18.3.12",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b262d97..c3ffe69 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,10 +1,15 @@
-function App() {
- return (
-
-
gen2d
-
AI 驱动的 2D 游戏素材生成工具
-
- )
-}
+import { useEffect } from 'react'
+import { RouterProvider } from 'react-router-dom'
+import { router } from './router'
+import { useAuthStore } from './stores/auth'
+import './styles/global.css'
-export default App
+export default function App() {
+ const loadFromStorage = useAuthStore(s => s.loadFromStorage)
+
+ useEffect(() => {
+ loadFromStorage()
+ }, [loadFromStorage])
+
+ return
+}
diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts
new file mode 100644
index 0000000..5949e68
--- /dev/null
+++ b/frontend/src/api/auth.ts
@@ -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 {
+ return post('/auth/register', {
+ username,
+ password,
+ email,
+ } satisfies RegisterRequest)
+}
+
+export async function login(
+ username: string,
+ password: string
+): Promise {
+ const data = await post('/auth/login', {
+ username,
+ password,
+ } satisfies LoginRequest)
+ setToken(data.token)
+ return data
+}
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
new file mode 100644
index 0000000..9ff77c8
--- /dev/null
+++ b/frontend/src/api/client.ts
@@ -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(url: string, options: RequestInit = {}): Promise {
+ const token = getToken()
+ const headers: Record = {
+ 'Content-Type': 'application/json',
+ ...(options.headers as Record),
+ }
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+
+ const res = await fetch(url, {
+ ...options,
+ headers,
+ credentials: 'include',
+ })
+
+ const json: ApiResponse = 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(url: string): Promise {
+ return request(url)
+}
+
+export function post(url: string, body?: unknown): Promise {
+ return request(url, {
+ method: 'POST',
+ body: body ? JSON.stringify(body) : undefined,
+ })
+}
+
+export function put(url: string, body?: unknown): Promise {
+ return request(url, {
+ method: 'PUT',
+ body: body ? JSON.stringify(body) : undefined,
+ })
+}
diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts
new file mode 100644
index 0000000..40670a8
--- /dev/null
+++ b/frontend/src/api/generate.ts
@@ -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 {
+ if (USE_MOCK) return mockSubmitGenerate(projectId, prompt, assetType)
+ throw new Error('Not implemented')
+}
+
+export async function getTask(taskId: string): Promise {
+ if (USE_MOCK) return mockGetTask(taskId)
+ throw new Error('Not implemented')
+}
+
+export async function getAssets(taskId: string): Promise {
+ if (USE_MOCK) return mockGetAssets(taskId)
+ throw new Error('Not implemented')
+}
diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts
new file mode 100644
index 0000000..5731fe9
--- /dev/null
+++ b/frontend/src/api/mock.ts
@@ -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 {
+ await randomDelay()
+ return { ...MOCK_PROJECT }
+}
+
+export async function mockGetStyle(
+ _projectId: string
+): Promise> {
+ await randomDelay()
+ return { ...MOCK_PROJECT.style.kvPairs }
+}
+
+export async function mockSaveStyle(
+ _projectId: string,
+ _kvPairs: Record
+): Promise {
+ await randomDelay()
+}
+
+export async function mockGetTasks(_projectId: string): Promise {
+ await randomDelay()
+ return MOCK_TASKS.map(t => ({ ...t }))
+}
+
+export async function mockGetTask(taskId: string): Promise {
+ 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 {
+ await randomDelay()
+ return MOCK_ASSETS.map(a => ({ ...a }))
+}
+
+export async function mockSubmitGenerate(
+ _projectId: string,
+ prompt: string,
+ assetType: string
+): Promise {
+ 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 = {
+ 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)
+ }
+}
diff --git a/frontend/src/api/project.ts b/frontend/src/api/project.ts
new file mode 100644
index 0000000..3aa3447
--- /dev/null
+++ b/frontend/src/api/project.ts
@@ -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 {
+ if (USE_MOCK) return mockGetProject(projectId)
+ // TODO: 真实 API 调用
+ throw new Error('Not implemented')
+}
+
+export async function getStyle(
+ projectId: string
+): Promise> {
+ if (USE_MOCK) return mockGetStyle(projectId)
+ throw new Error('Not implemented')
+}
+
+export async function saveStyle(
+ projectId: string,
+ kvPairs: Record
+): Promise {
+ if (USE_MOCK) return mockSaveStyle(projectId, kvPairs)
+ throw new Error('Not implemented')
+}
+
+export async function getTasks(projectId: string): Promise {
+ if (USE_MOCK) return mockGetTasks(projectId)
+ throw new Error('Not implemented')
+}
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
new file mode 100644
index 0000000..b9eaa96
--- /dev/null
+++ b/frontend/src/api/types.ts
@@ -0,0 +1,117 @@
+// 统一响应信封
+export interface ApiResponse {
+ 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 }
+ createdAt: string
+}
+
+export interface CreateProjectRequest {
+ name: string
+}
+
+// 风格
+export interface UpdateStyleRequest {
+ kvPairs: Record
+}
+
+// 任务
+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
+ 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
+}
diff --git a/frontend/src/components/AssetPreview.tsx b/frontend/src/components/AssetPreview.tsx
new file mode 100644
index 0000000..5686d7a
--- /dev/null
+++ b/frontend/src/components/AssetPreview.tsx
@@ -0,0 +1,77 @@
+import type { Asset } from '../api/types'
+
+interface AssetPreviewProps {
+ assets: Asset[]
+}
+
+export default function AssetPreview({ assets }: AssetPreviewProps) {
+ if (assets.length === 0) {
+ return (
+
+ 暂无素材
+
+ )
+ }
+
+ return (
+
+ {assets.map(asset => (
+
+ {/* 图片预览 */}
+
+

+ {/* spritesheet 网格叠加 */}
+ {asset.metadata.frameWidth && asset.metadata.frameHeight && (
+
+ )}
+
+
+ {/* 元数据 */}
+
+ 尺寸: {asset.width} × {asset.height}
+ {asset.metadata.frameWidth && (
+ 帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight}
+ )}
+ {asset.metadata.frameCount && 帧数: {asset.metadata.frameCount}}
+ {asset.metadata.directions && 方向: {asset.metadata.directions}}
+ 格式: {asset.format}
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/components/GenerateForm.tsx b/frontend/src/components/GenerateForm.tsx
new file mode 100644
index 0000000..e3c2ec0
--- /dev/null
+++ b/frontend/src/components/GenerateForm.tsx
@@ -0,0 +1,124 @@
+import type { AssetType } from '../api/types'
+import { useProjectStore } from '../stores/project'
+import { useTaskStore } from '../stores/task'
+import { mergeStyles } from '../utils/style'
+import StyleSelector from './StyleSelector'
+import PromptEditor from './PromptEditor'
+
+const ASSET_TYPES: { value: AssetType; label: string }[] = [
+ { value: 'sprite', label: '精灵' },
+ { value: 'background', label: '背景' },
+ { value: 'ui', label: 'UI' },
+ { value: 'animation', label: '动画' },
+]
+
+interface GenerateFormProps {
+ onSubmit: () => void
+ submitting: boolean
+}
+
+export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps) {
+ const { style: projectStyle } = useProjectStore()
+ const {
+ prompt,
+ assetType,
+ taskStyle,
+ params,
+ setPrompt,
+ setAssetType,
+ toggleTaskStyle,
+ setParams,
+ } = useTaskStore()
+
+ const mergedStyle = mergeStyles(projectStyle, taskStyle)
+
+ return (
+
+ {/* 素材类型 */}
+
+
+ 素材类型
+
+
+ {ASSET_TYPES.map(t => (
+
+ ))}
+
+
+
+ {/* 任务风格覆盖 */}
+
+
+ 风格覆盖(可选)
+
+
+
+
+ {/* 提示词 */}
+
+
+ {/* 参数 */}
+
+
+ 参数
+
+
+
+
+
+
+
+ {/* 提交 */}
+
+
+ )
+}
diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx
new file mode 100644
index 0000000..60c1d1a
--- /dev/null
+++ b/frontend/src/components/ProgressBar.tsx
@@ -0,0 +1,135 @@
+import type { PipelineStage } from '../api/types'
+import { STAGE_LABELS } from '../api/mock'
+
+const STAGES: PipelineStage[] = [
+ 'prompt_builder',
+ 'asset_generator',
+ 'quality_supervisor',
+ 'format_adapter',
+]
+
+interface ProgressBarProps {
+ stage: PipelineStage | null
+ progress: number
+ status: string
+ retryCount: number
+ rejectReason: string | null
+}
+
+export default function ProgressBar({
+ stage,
+ progress,
+ status,
+ retryCount,
+ rejectReason,
+}: ProgressBarProps) {
+ const currentIndex = stage ? STAGES.indexOf(stage) : -1
+
+ return (
+
+ {/* 阶段步进器 */}
+
+ {STAGES.map((s, i) => {
+ const isActive = i === currentIndex
+ const isCompleted =
+ i < currentIndex || (i === currentIndex && progress === 100 && status !== 'failed')
+ const isFailed = isActive && status === 'failed'
+
+ return (
+
+ {/* 步骤头 */}
+
+
+ {isCompleted && !isActive ? '✓' : isFailed ? '✗' : i + 1}
+
+ {STAGE_LABELS[s]}
+
+
+ {/* 进度条 */}
+
+
+ )
+ })}
+
+
+ {/* 重试信息 */}
+ {retryCount > 0 && (
+
+ ⚠ 质检重试中(第 {retryCount} 次)
+ {rejectReason && — {rejectReason}}
+
+ )}
+
+ {/* 错误信息 */}
+ {status === 'failed' && (
+
+ ✗ 生成失败
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx
new file mode 100644
index 0000000..bfbf7fb
--- /dev/null
+++ b/frontend/src/components/PromptEditor.tsx
@@ -0,0 +1,67 @@
+import { STYLE_CATEGORIES } from '../utils/style'
+
+interface PromptEditorProps {
+ prompt: string
+ onPromptChange: (text: string) => void
+ style: Record
+ params: {
+ resolution: number
+ format: string
+ }
+}
+
+function styleToDescription(kvPairs: Record): string {
+ return STYLE_CATEGORIES.map(cat => {
+ const value = kvPairs[cat.key]
+ if (!value) return null
+ const option = cat.options.find(o => o.value === value)
+ return option ? `${cat.label}: ${option.label}` : null
+ })
+ .filter(Boolean)
+ .join(', ')
+}
+
+export default function PromptEditor({
+ prompt,
+ onPromptChange,
+ style,
+ params,
+}: PromptEditorProps) {
+ const styleDesc = styleToDescription(style)
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/StyleSelector.module.css b/frontend/src/components/StyleSelector.module.css
new file mode 100644
index 0000000..c14b278
--- /dev/null
+++ b/frontend/src/components/StyleSelector.module.css
@@ -0,0 +1,63 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ gap: 20px;
+}
+
+.category {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.label {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.options {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.pill {
+ padding: 6px 16px;
+ border-radius: 20px;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-primary);
+ font-size: 13px;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.pill:hover {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.pillActive {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+
+.pillActive:hover {
+ background: var(--accent-hover);
+ border-color: var(--accent-hover);
+ color: #fff;
+}
+
+/* compact 模式 */
+.compact .category {
+ flex-direction: row;
+ align-items: center;
+ gap: 12px;
+}
+
+.compact .label {
+ min-width: 60px;
+ flex-shrink: 0;
+}
diff --git a/frontend/src/components/StyleSelector.tsx b/frontend/src/components/StyleSelector.tsx
new file mode 100644
index 0000000..9f6ef47
--- /dev/null
+++ b/frontend/src/components/StyleSelector.tsx
@@ -0,0 +1,38 @@
+import { STYLE_CATEGORIES } from '../utils/style'
+import styles from './StyleSelector.module.css'
+
+interface StyleSelectorProps {
+ value: Record
+ onChange: (key: string, value: string) => void
+ compact?: boolean
+}
+
+export default function StyleSelector({
+ value,
+ onChange,
+ compact,
+}: StyleSelectorProps) {
+ return (
+
+ {STYLE_CATEGORIES.map(cat => (
+
+
{cat.label}
+
+ {cat.options.map(opt => (
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/frontend/src/hooks/useGenerate.ts b/frontend/src/hooks/useGenerate.ts
new file mode 100644
index 0000000..49220c7
--- /dev/null
+++ b/frontend/src/hooks/useGenerate.ts
@@ -0,0 +1,17 @@
+import { useGenerationStore } from '../stores/generation'
+
+export function useGenerate() {
+ const store = useGenerationStore()
+ return {
+ submit: store.submit,
+ taskId: store.taskId,
+ stage: store.stage,
+ progress: store.progress,
+ status: store.status,
+ retryCount: store.retryCount,
+ rejectReason: store.rejectReason,
+ assets: store.assets,
+ error: store.error,
+ reset: store.reset,
+ }
+}
diff --git a/frontend/src/hooks/useProjectStyle.ts b/frontend/src/hooks/useProjectStyle.ts
new file mode 100644
index 0000000..74287d1
--- /dev/null
+++ b/frontend/src/hooks/useProjectStyle.ts
@@ -0,0 +1,14 @@
+import { useEffect } from 'react'
+import { useProjectStore } from '../stores/project'
+
+export function useProjectStyle(projectId: string) {
+ const { style, loading, loadProject, loadStyle, updateStyle, saveStyle } =
+ useProjectStore()
+
+ useEffect(() => {
+ loadProject(projectId)
+ loadStyle(projectId)
+ }, [projectId, loadProject, loadStyle])
+
+ return { style, loading, updateStyle, saveStyle }
+}
diff --git a/frontend/src/pages/GeneratePage.tsx b/frontend/src/pages/GeneratePage.tsx
new file mode 100644
index 0000000..0fc55ce
--- /dev/null
+++ b/frontend/src/pages/GeneratePage.tsx
@@ -0,0 +1,111 @@
+import { useEffect } from 'react'
+import { Link, useNavigate, useParams } from 'react-router-dom'
+import { useTaskStore } from '../stores/task'
+import { useGenerationStore } from '../stores/generation'
+import GenerateForm from '../components/GenerateForm'
+import ProgressBar from '../components/ProgressBar'
+
+export default function GeneratePage() {
+ const { projectId = 'proj-default' } = useParams()
+ const navigate = useNavigate()
+ const { prompt, assetType, reset: resetTask } = useTaskStore()
+ const {
+ status,
+ stage,
+ progress,
+ retryCount,
+ rejectReason,
+ taskId,
+ submit,
+ reset: resetGeneration,
+ } = useGenerationStore()
+
+ // 组件卸载时重置生成状态
+ useEffect(() => {
+ return () => resetGeneration()
+ }, [resetGeneration])
+
+ // 完成后自动跳转
+ useEffect(() => {
+ if (status === 'completed' && taskId) {
+ const timer = setTimeout(() => {
+ navigate(`/projects/${projectId}/tasks/${taskId}`)
+ }, 1500)
+ return () => clearTimeout(timer)
+ }
+ }, [status, taskId, projectId, navigate])
+
+ const handleSubmit = async () => {
+ await submit(projectId, prompt, assetType)
+ }
+
+ const handleReset = () => {
+ resetGeneration()
+ resetTask()
+ }
+
+ return (
+
+ {/* 顶栏 */}
+
+
新建生成
+
+ 返回工程
+
+
+
+ {/* 生成表单 */}
+ {status === 'idle' || status === 'submitting' ? (
+
+ ) : (
+
+ {/* 进度条 */}
+
+
+ {/* 状态提示 */}
+ {status === 'running' && (
+
+ 管线执行中,请稍候...
+
+ )}
+
+ {status === 'completed' && (
+
+ ✓ 生成完成,正在跳转到结果页...
+
+ )}
+
+ {status === 'failed' && (
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..52de3a4
--- /dev/null
+++ b/frontend/src/pages/LoginPage.tsx
@@ -0,0 +1,82 @@
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../stores/auth'
+
+export default function LoginPage() {
+ const navigate = useNavigate()
+ const { login, loading, error, clearError } = useAuthStore()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ try {
+ await login(username, password)
+ navigate('/')
+ } catch {
+ // error 已在 store 中设置
+ }
+ }
+
+ return (
+
+
+
gen2d
+
+ AI 驱动的 2D 游戏素材生成工具
+
+
+
+
+
+ 没有账号?{' '}
+ 去注册
+
+
+
+ )
+}
diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx
new file mode 100644
index 0000000..8dbefc8
--- /dev/null
+++ b/frontend/src/pages/ProjectPage.tsx
@@ -0,0 +1,174 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { useProjectStore } from '../stores/project'
+import { useAuthStore } from '../stores/auth'
+import { getTasks } from '../api/project'
+import type { Task } from '../api/types'
+import StyleSelector from '../components/StyleSelector'
+
+const STATUS_LABELS: Record = {
+ pending: { label: '等待中', color: 'var(--text-muted)' },
+ running: { label: '运行中', color: 'var(--warning)' },
+ completed: { label: '已完成', color: 'var(--success)' },
+ failed: { label: '失败', color: 'var(--error)' },
+}
+
+export default function ProjectPage() {
+ const { projectId = 'proj-default' } = useParams()
+ const { user, logout } = useAuthStore()
+ const {
+ name,
+ style,
+ loading,
+ loadProject,
+ loadStyle,
+ updateStyle,
+ saveStyle,
+ } = useProjectStore()
+ const [tasks, setTasks] = useState([])
+ const [saving, setSaving] = useState(false)
+
+ useEffect(() => {
+ loadProject(projectId)
+ loadStyle(projectId)
+ getTasks(projectId).then(setTasks)
+ }, [projectId, loadProject, loadStyle])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await saveStyle()
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+ {/* 顶栏 */}
+
+
{loading ? '加载中...' : name}
+
+
+ {user?.username}
+
+
+
+
+
+ {/* 工程风格 */}
+
+
+
工程风格
+
+
+
+
+
+ {/* 任务列表 */}
+
+
+
历史任务
+
+ 新建生成
+
+
+
+ {tasks.length === 0 ? (
+
+ 暂无任务
+
+ ) : (
+
+
+
+ | 提示词 |
+ 类型 |
+ 状态 |
+ 创建时间 |
+ 操作 |
+
+
+
+ {tasks.map(task => {
+ const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
+ return (
+
+ | {task.prompt} |
+ {task.assetType} |
+ {statusInfo.label} |
+
+ {new Date(task.createdAt).toLocaleString('zh-CN')}
+ |
+
+ {task.status === 'completed' && (
+
+ 查看结果
+
+ )}
+ |
+
+ )
+ })}
+
+
+ )}
+
+
+ )
+}
+
+const thStyle: React.CSSProperties = {
+ textAlign: 'left',
+ padding: '10px 12px',
+ fontSize: 13,
+ color: 'var(--text-secondary)',
+ fontWeight: 600,
+}
+
+const tdStyle: React.CSSProperties = {
+ padding: '10px 12px',
+ fontSize: 13,
+}
diff --git a/frontend/src/pages/RegisterPage.tsx b/frontend/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..a6a009c
--- /dev/null
+++ b/frontend/src/pages/RegisterPage.tsx
@@ -0,0 +1,93 @@
+import { useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { useAuthStore } from '../stores/auth'
+
+export default function RegisterPage() {
+ const navigate = useNavigate()
+ const { register, loading, error, clearError } = useAuthStore()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [email, setEmail] = useState('')
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault()
+ try {
+ await register(username, password, email || undefined)
+ navigate('/login')
+ } catch {
+ // error 已在 store 中设置
+ }
+ }
+
+ return (
+
+
+
gen2d
+
+ 创建新账号
+
+
+
+
+
+ 已有账号?{' '}
+ 去登录
+
+
+
+ )
+}
diff --git a/frontend/src/pages/ResultPage.tsx b/frontend/src/pages/ResultPage.tsx
new file mode 100644
index 0000000..4a0bd62
--- /dev/null
+++ b/frontend/src/pages/ResultPage.tsx
@@ -0,0 +1,133 @@
+import { useEffect, useState } from 'react'
+import { Link, useParams } from 'react-router-dom'
+import { getTask, getAssets } from '../api/generate'
+import type { Asset, Task } from '../api/types'
+import AssetPreview from '../components/AssetPreview'
+
+export default function ResultPage() {
+ const { projectId = 'proj-default', taskId } = useParams()
+ const [task, setTask] = useState(null)
+ const [assets, setAssets] = useState([])
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ if (!taskId) return
+ setLoading(true)
+ Promise.all([getTask(taskId), getAssets(taskId)])
+ .then(([t, a]) => {
+ setTask(t)
+ setAssets(a)
+ })
+ .finally(() => setLoading(false))
+ }, [taskId])
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (!task) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* 顶栏 */}
+
+
生成结果
+
+ 返回工程
+
+
+
+ {/* 任务信息 */}
+
+ 任务信息
+
+ 提示词
+ {task.prompt}
+ 素材类型
+ {task.assetType}
+ 状态
+
+ {task.status === 'completed' ? '已完成' : '失败'}
+
+ 创建时间
+ {new Date(task.createdAt).toLocaleString('zh-CN')}
+ {task.retryCount != null && task.retryCount > 0 && (
+ <>
+ 重试次数
+ {task.retryCount}
+ >
+ )}
+ {task.error && (
+ <>
+ 错误
+ {task.error}
+ >
+ )}
+
+
+
+ {/* 素材预览 */}
+
+
+
素材预览
+ {assets.length > 0 && (
+
+ )}
+
+
+
+
+ )
+}
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
new file mode 100644
index 0000000..f29091d
--- /dev/null
+++ b/frontend/src/router/index.tsx
@@ -0,0 +1,45 @@
+import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom'
+import { useAuthStore } from '../stores/auth'
+import LoginPage from '../pages/LoginPage'
+import RegisterPage from '../pages/RegisterPage'
+import ProjectPage from '../pages/ProjectPage'
+import GeneratePage from '../pages/GeneratePage'
+import ResultPage from '../pages/ResultPage'
+
+function ProtectedRoute() {
+ const isAuthenticated = useAuthStore(s => s.isAuthenticated)
+ if (!isAuthenticated) return
+ return
+}
+
+export const router = createBrowserRouter([
+ {
+ path: '/login',
+ element: ,
+ },
+ {
+ path: '/register',
+ element: ,
+ },
+ {
+ element: ,
+ children: [
+ {
+ path: '/',
+ element: ,
+ },
+ {
+ path: '/projects/:projectId',
+ element: ,
+ },
+ {
+ path: '/projects/:projectId/generate',
+ element: ,
+ },
+ {
+ path: '/projects/:projectId/tasks/:taskId',
+ element: ,
+ },
+ ],
+ },
+])
diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts
new file mode 100644
index 0000000..6a34804
--- /dev/null
+++ b/frontend/src/stores/auth.ts
@@ -0,0 +1,69 @@
+import { create } from 'zustand'
+import * as authApi from '../api/auth'
+import { clearToken, getToken } from '../api/client'
+
+interface User {
+ id: number
+ username: string
+ email: string
+}
+
+interface AuthState {
+ user: User | null
+ isAuthenticated: boolean
+ loading: boolean
+ error: string | null
+ login: (username: string, password: string) => Promise
+ register: (username: string, password: string, email?: string) => Promise
+ logout: () => void
+ loadFromStorage: () => void
+ clearError: () => void
+}
+
+export const useAuthStore = create((set) => ({
+ user: null,
+ isAuthenticated: false,
+ loading: false,
+ error: null,
+
+ login: async (username, password) => {
+ set({ loading: true, error: null })
+ try {
+ const data = await authApi.login(username, password)
+ set({
+ user: { id: data.user.id, username: data.user.username, email: data.user.email },
+ isAuthenticated: true,
+ loading: false,
+ })
+ } catch (err) {
+ set({ loading: false, error: (err as Error).message })
+ throw err
+ }
+ },
+
+ register: async (username, password, email) => {
+ set({ loading: true, error: null })
+ try {
+ await authApi.register(username, password, email)
+ set({ loading: false })
+ } catch (err) {
+ set({ loading: false, error: (err as Error).message })
+ throw err
+ }
+ },
+
+ logout: () => {
+ clearToken()
+ set({ user: null, isAuthenticated: false })
+ },
+
+ loadFromStorage: () => {
+ const token = getToken()
+ if (token) {
+ // token 存在视为已登录,用户信息从 token 解析或后续 /auth/me 获取
+ set({ isAuthenticated: true })
+ }
+ },
+
+ clearError: () => set({ error: null }),
+}))
diff --git a/frontend/src/stores/generation.ts b/frontend/src/stores/generation.ts
new file mode 100644
index 0000000..b904755
--- /dev/null
+++ b/frontend/src/stores/generation.ts
@@ -0,0 +1,82 @@
+import { create } from 'zustand'
+import type { Asset, PipelineProgress, PipelineStage } from '../api/types'
+import { submitGenerate } from '../api/generate'
+import { createMockWebSocket } from '../api/mock'
+
+type Status = 'idle' | 'submitting' | 'running' | 'completed' | 'failed'
+
+interface GenerationState {
+ taskId: string | null
+ stage: PipelineStage | null
+ progress: number
+ status: Status
+ retryCount: number
+ rejectReason: string | null
+ assets: Asset[]
+ error: string | null
+ submit: (projectId: string, prompt: string, assetType: string) => Promise
+ handleProgress: (msg: PipelineProgress) => void
+ reset: () => void
+}
+
+let cleanupWs: (() => void) | null = null
+
+export const useGenerationStore = create((set, get) => ({
+ taskId: null,
+ stage: null,
+ progress: 0,
+ status: 'idle',
+ retryCount: 0,
+ rejectReason: null,
+ assets: [],
+ error: null,
+
+ submit: async (projectId, prompt, assetType) => {
+ set({ status: 'submitting', error: null })
+ try {
+ const taskId = await submitGenerate(projectId, prompt, assetType)
+ set({ taskId, status: 'running', progress: 0 })
+
+ // 启动 mock WebSocket
+ cleanupWs = createMockWebSocket(
+ taskId,
+ (msg) => get().handleProgress(msg),
+ (assets) => {
+ set({ status: 'completed', assets, progress: 100 })
+ },
+ (error) => {
+ set({ status: 'failed', error })
+ }
+ )
+ } catch (err) {
+ set({ status: 'failed', error: (err as Error).message })
+ }
+ },
+
+ handleProgress: (msg) => {
+ set({
+ stage: msg.stage,
+ progress: msg.progress,
+ retryCount: msg.retryCount ?? get().retryCount,
+ rejectReason: msg.rejectReason ?? null,
+ })
+ if (msg.result?.assets) {
+ set({ assets: msg.result.assets })
+ }
+ },
+
+ reset: () => {
+ cleanupWs?.()
+ cleanupWs = null
+ set({
+ taskId: null,
+ stage: null,
+ progress: 0,
+ status: 'idle',
+ retryCount: 0,
+ rejectReason: null,
+ assets: [],
+ error: null,
+ })
+ },
+}))
diff --git a/frontend/src/stores/project.ts b/frontend/src/stores/project.ts
new file mode 100644
index 0000000..f3097e0
--- /dev/null
+++ b/frontend/src/stores/project.ts
@@ -0,0 +1,49 @@
+import { create } from 'zustand'
+import * as projectApi from '../api/project'
+
+interface ProjectState {
+ projectId: string
+ name: string
+ style: Record
+ loading: boolean
+ loadProject: (projectId: string) => Promise
+ loadStyle: (projectId: string) => Promise
+ updateStyle: (key: string, value: string) => void
+ saveStyle: () => Promise
+}
+
+export const useProjectStore = create((set, get) => ({
+ projectId: '',
+ name: '',
+ style: {},
+ loading: false,
+
+ loadProject: async (projectId) => {
+ set({ loading: true })
+ try {
+ const project = await projectApi.getProject(projectId)
+ set({
+ projectId: project.id,
+ name: project.name,
+ style: project.style.kvPairs,
+ loading: false,
+ })
+ } catch {
+ set({ loading: false })
+ }
+ },
+
+ loadStyle: async (projectId) => {
+ const kvPairs = await projectApi.getStyle(projectId)
+ set({ style: kvPairs })
+ },
+
+ updateStyle: (key, value) => {
+ set(state => ({ style: { ...state.style, [key]: value } }))
+ },
+
+ saveStyle: async () => {
+ const { projectId, style } = get()
+ await projectApi.saveStyle(projectId, style)
+ },
+}))
diff --git a/frontend/src/stores/task.ts b/frontend/src/stores/task.ts
new file mode 100644
index 0000000..c9d60fe
--- /dev/null
+++ b/frontend/src/stores/task.ts
@@ -0,0 +1,58 @@
+import { create } from 'zustand'
+import type { AssetType } from '../api/types'
+
+interface TaskParams {
+ resolution: number
+ frames?: { directions: number; framesPerDirection: number }
+ format: 'spritesheet' | 'individual'
+}
+
+interface TaskState {
+ prompt: string
+ assetType: AssetType
+ taskStyle: Record
+ params: TaskParams
+ setPrompt: (text: string) => void
+ setAssetType: (type: AssetType) => void
+ toggleTaskStyle: (key: string, value: string) => void
+ setParams: (params: Partial) => void
+ reset: () => void
+}
+
+const DEFAULT_PARAMS: TaskParams = {
+ resolution: 256,
+ format: 'spritesheet',
+}
+
+export const useTaskStore = create((set) => ({
+ prompt: '',
+ assetType: 'sprite',
+ taskStyle: {},
+ params: { ...DEFAULT_PARAMS },
+
+ setPrompt: (text) => set({ prompt: text }),
+
+ setAssetType: (type) => set({ assetType: type }),
+
+ toggleTaskStyle: (key, value) =>
+ set(state => {
+ const next = { ...state.taskStyle }
+ if (next[key] === value) {
+ delete next[key]
+ } else {
+ next[key] = value
+ }
+ return { taskStyle: next }
+ }),
+
+ setParams: (params) =>
+ set(state => ({ params: { ...state.params, ...params } })),
+
+ reset: () =>
+ set({
+ prompt: '',
+ assetType: 'sprite',
+ taskStyle: {},
+ params: { ...DEFAULT_PARAMS },
+ }),
+}))
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
new file mode 100644
index 0000000..dddaa4c
--- /dev/null
+++ b/frontend/src/styles/global.css
@@ -0,0 +1,117 @@
+:root {
+ --bg-primary: #1a1a2e;
+ --bg-secondary: #16213e;
+ --bg-card: #0f3460;
+ --bg-input: #1a1a3e;
+ --text-primary: #e0e0e0;
+ --text-secondary: #a0a0a0;
+ --text-muted: #666;
+ --accent: #e94560;
+ --accent-hover: #ff6b81;
+ --accent-dim: rgba(233, 69, 96, 0.15);
+ --success: #4ade80;
+ --warning: #fbbf24;
+ --error: #ef4444;
+ --border: #2a2a4a;
+ --radius: 8px;
+ --radius-lg: 12px;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
+ 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg-primary);
+ color: var(--text-primary);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+}
+
+a:hover {
+ color: var(--accent-hover);
+}
+
+input,
+textarea,
+select,
+button {
+ font-family: inherit;
+ font-size: inherit;
+ color: inherit;
+}
+
+input,
+textarea,
+select {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 10px 14px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+ border-color: var(--accent);
+}
+
+button {
+ cursor: pointer;
+ border: none;
+ border-radius: var(--radius);
+ padding: 10px 20px;
+ font-weight: 600;
+ transition: all 0.2s;
+}
+
+button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: var(--accent);
+ color: #fff;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+
+.btn-secondary {
+ background: transparent;
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+}
+
+.btn-secondary:hover:not(:disabled) {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.card {
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ padding: 24px;
+}
+
+.container {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 0 20px;
+}
diff --git a/frontend/src/utils/style.ts b/frontend/src/utils/style.ts
new file mode 100644
index 0000000..07520aa
--- /dev/null
+++ b/frontend/src/utils/style.ts
@@ -0,0 +1,88 @@
+/**
+ * 合并工程风格与任务风格覆盖(任务覆盖优先,与后端 PromptBuilder 逻辑一致)
+ */
+export function mergeStyles(
+ projectStyle: Record,
+ taskOverrides: Record
+): Record {
+ return { ...projectStyle, ...taskOverrides }
+}
+
+/**
+ * 风格键分类定义
+ */
+export interface StyleCategory {
+ key: string
+ label: string
+ options: { value: string; label: string }[]
+}
+
+export const STYLE_CATEGORIES: StyleCategory[] = [
+ {
+ key: 'artStyle',
+ label: '美术风格',
+ options: [
+ { value: 'pixel', label: '像素' },
+ { value: 'cartoon', label: '卡通' },
+ { value: 'hand-drawn', label: '手绘' },
+ { value: 'vector', label: '矢量' },
+ { value: 'flat', label: '扁平' },
+ ],
+ },
+ {
+ key: 'palette',
+ label: '色调',
+ options: [
+ { value: 'warm', label: '暖色' },
+ { value: 'cool', label: '冷色' },
+ { value: 'neutral', label: '中性' },
+ { value: 'vibrant', label: '鲜艳' },
+ { value: 'muted', label: '柔和' },
+ { value: 'monochrome', label: '单色' },
+ ],
+ },
+ {
+ key: 'lineWeight',
+ label: '线条',
+ options: [
+ { value: 'none', label: '无' },
+ { value: 'thin', label: '细线' },
+ { value: 'medium', label: '中等' },
+ { value: 'thick', label: '粗线' },
+ ],
+ },
+ {
+ key: 'scene',
+ label: '场景',
+ options: [
+ { value: 'forest', label: '森林' },
+ { value: 'dungeon', label: '地牢' },
+ { value: 'city', label: '城市' },
+ { value: 'space', label: '太空' },
+ { value: 'underwater', label: '水下' },
+ { value: 'desert', label: '沙漠' },
+ ],
+ },
+ {
+ key: 'lighting',
+ label: '光照',
+ options: [
+ { value: 'bright', label: '明亮' },
+ { value: 'dim', label: '昏暗' },
+ { value: 'dramatic', label: '戏剧' },
+ { value: 'ambient', label: '环境光' },
+ { value: 'neon', label: '霓虹' },
+ ],
+ },
+ {
+ key: 'mood',
+ label: '情绪',
+ options: [
+ { value: 'cheerful', label: '欢快' },
+ { value: 'dark', label: '黑暗' },
+ { value: 'mysterious', label: '神秘' },
+ { value: 'epic', label: '史诗' },
+ { value: 'calm', label: '平静' },
+ ],
+ },
+]
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 3e35b47..7f74e10 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -10,6 +10,10 @@ export default defineConfig({
target: 'http://localhost:8080',
changeOrigin: true,
},
+ '/auth': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
},
},
})