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:
+4
-2
@@ -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` | 修改密码 |
|
||||
|
||||
|
||||
+24
-4
@@ -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 |
|
||||
|
||||
## 路由规划
|
||||
|
||||
| 路径 | 页面 | 说明 |
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
Generated
+92
-4
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+14
-9
@@ -1,10 +1,15 @@
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<h1>gen2d</h1>
|
||||
<p>AI 驱动的 2D 游戏素材生成工具</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
|
||||
暂无素材
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{assets.map(asset => (
|
||||
<div
|
||||
key={asset.id}
|
||||
className="card"
|
||||
style={{ padding: 16 }}
|
||||
>
|
||||
{/* 图片预览 */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.id}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 400,
|
||||
borderRadius: 'var(--radius)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
/>
|
||||
{/* spritesheet 网格叠加 */}
|
||||
{asset.metadata.frameWidth && asset.metadata.frameHeight && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(233,69,96,0.3) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(233,69,96,0.3) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: `${asset.metadata.frameWidth}px ${asset.metadata.frameHeight}px`,
|
||||
borderRadius: 'var(--radius)',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 元数据 */}
|
||||
<div style={{ display: 'flex', gap: 24, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<span>尺寸: {asset.width} × {asset.height}</span>
|
||||
{asset.metadata.frameWidth && (
|
||||
<span>帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight}</span>
|
||||
)}
|
||||
{asset.metadata.frameCount && <span>帧数: {asset.metadata.frameCount}</span>}
|
||||
{asset.metadata.directions && <span>方向: {asset.metadata.directions}</span>}
|
||||
<span>格式: {asset.format}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{/* 素材类型 */}
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
素材类型
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{ASSET_TYPES.map(t => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
className={assetType === t.value ? 'btn-primary' : 'btn-secondary'}
|
||||
onClick={() => setAssetType(t.value)}
|
||||
style={{ padding: '8px 20px' }}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 任务风格覆盖 */}
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
风格覆盖(可选)
|
||||
</h3>
|
||||
<StyleSelector value={taskStyle} onChange={toggleTaskStyle} compact />
|
||||
</div>
|
||||
|
||||
{/* 提示词 */}
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
提示词
|
||||
</h3>
|
||||
<PromptEditor
|
||||
prompt={prompt}
|
||||
onPromptChange={setPrompt}
|
||||
style={mergedStyle}
|
||||
params={{ resolution: params.resolution, format: params.format }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 参数 */}
|
||||
<div>
|
||||
<h3 style={{ marginBottom: 12, fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
参数
|
||||
</h3>
|
||||
<div style={{ display: 'flex', gap: 24, alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>分辨率</span>
|
||||
<input
|
||||
type="number"
|
||||
value={params.resolution}
|
||||
onChange={e => setParams({ resolution: Number(e.target.value) })}
|
||||
min={32}
|
||||
max={1024}
|
||||
step={32}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-muted)' }}>px</span>
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>格式</span>
|
||||
<select
|
||||
value={params.format}
|
||||
onChange={e =>
|
||||
setParams({ format: e.target.value as 'spritesheet' | 'individual' })
|
||||
}
|
||||
>
|
||||
<option value="spritesheet">Spritesheet</option>
|
||||
<option value="individual">单帧</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 提交 */}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={onSubmit}
|
||||
disabled={submitting || !prompt.trim()}
|
||||
style={{ alignSelf: 'flex-start', padding: '12px 32px', fontSize: 16 }}
|
||||
>
|
||||
{submitting ? '提交中...' : '开始生成'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: 20,
|
||||
background: 'var(--bg-secondary)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* 阶段步进器 */}
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{STAGES.map((s, i) => {
|
||||
const isActive = i === currentIndex
|
||||
const isCompleted =
|
||||
i < currentIndex || (i === currentIndex && progress === 100 && status !== 'failed')
|
||||
const isFailed = isActive && status === 'failed'
|
||||
|
||||
return (
|
||||
<div key={s} style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* 步骤头 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 13,
|
||||
color: isActive
|
||||
? 'var(--accent)'
|
||||
: isCompleted
|
||||
? 'var(--success)'
|
||||
: 'var(--text-muted)',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
background: isActive
|
||||
? 'var(--accent)'
|
||||
: isCompleted
|
||||
? 'var(--success)'
|
||||
: 'var(--border)',
|
||||
color: isActive || isCompleted ? '#fff' : 'var(--text-muted)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isCompleted && !isActive ? '✓' : isFailed ? '✗' : i + 1}
|
||||
</span>
|
||||
<span>{STAGE_LABELS[s]}</span>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div
|
||||
style={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
background: 'var(--border)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
borderRadius: 2,
|
||||
background: isFailed
|
||||
? 'var(--error)'
|
||||
: isActive
|
||||
? 'var(--accent)'
|
||||
: isCompleted
|
||||
? 'var(--success)'
|
||||
: 'transparent',
|
||||
width: isCompleted && !isActive ? '100%' : isActive ? `${progress}%` : '0%',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 重试信息 */}
|
||||
{retryCount > 0 && (
|
||||
<div style={{ fontSize: 13, color: 'var(--warning)' }}>
|
||||
⚠ 质检重试中(第 {retryCount} 次)
|
||||
{rejectReason && <span> — {rejectReason}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{status === 'failed' && (
|
||||
<div style={{ fontSize: 13, color: 'var(--error)' }}>
|
||||
✗ 生成失败
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { STYLE_CATEGORIES } from '../utils/style'
|
||||
|
||||
interface PromptEditorProps {
|
||||
prompt: string
|
||||
onPromptChange: (text: string) => void
|
||||
style: Record<string, string>
|
||||
params: {
|
||||
resolution: number
|
||||
format: string
|
||||
}
|
||||
}
|
||||
|
||||
function styleToDescription(kvPairs: Record<string, string>): 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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={e => onPromptChange(e.target.value)}
|
||||
placeholder="描述你想要生成的游戏素材..."
|
||||
rows={4}
|
||||
style={{ resize: 'vertical', width: '100%' }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--bg-input)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 'var(--radius)',
|
||||
padding: 12,
|
||||
fontSize: 13,
|
||||
color: 'var(--text-secondary)',
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--accent)' }}>[风格]</strong>{' '}
|
||||
{styleDesc || '未设置'}
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--accent)' }}>[主体]</strong>{' '}
|
||||
{prompt || '未输入'}
|
||||
</div>
|
||||
<div>
|
||||
<strong style={{ color: 'var(--accent)' }}>[参数]</strong> 分辨率=
|
||||
{params.resolution}px, 格式={params.format}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { STYLE_CATEGORIES } from '../utils/style'
|
||||
import styles from './StyleSelector.module.css'
|
||||
|
||||
interface StyleSelectorProps {
|
||||
value: Record<string, string>
|
||||
onChange: (key: string, value: string) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function StyleSelector({
|
||||
value,
|
||||
onChange,
|
||||
compact,
|
||||
}: StyleSelectorProps) {
|
||||
return (
|
||||
<div className={`${styles.container} ${compact ? styles.compact : ''}`}>
|
||||
{STYLE_CATEGORIES.map(cat => (
|
||||
<div key={cat.key} className={styles.category}>
|
||||
<span className={styles.label}>{cat.label}</span>
|
||||
<div className={styles.options}>
|
||||
{cat.options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`${styles.pill} ${
|
||||
value[cat.key] === opt.value ? styles.pillActive : ''
|
||||
}`}
|
||||
onClick={() => onChange(cat.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||
{/* 顶栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 24 }}>新建生成</h1>
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="btn-secondary"
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 13,
|
||||
borderRadius: 'var(--radius)',
|
||||
}}
|
||||
>
|
||||
返回工程
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 生成表单 */}
|
||||
{status === 'idle' || status === 'submitting' ? (
|
||||
<GenerateForm onSubmit={handleSubmit} submitting={status === 'submitting'} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{/* 进度条 */}
|
||||
<ProgressBar
|
||||
stage={stage}
|
||||
progress={progress}
|
||||
status={status}
|
||||
retryCount={retryCount}
|
||||
rejectReason={rejectReason}
|
||||
/>
|
||||
|
||||
{/* 状态提示 */}
|
||||
{status === 'running' && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||
管线执行中,请稍候...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'completed' && (
|
||||
<p style={{ textAlign: 'center', color: 'var(--success)' }}>
|
||||
✓ 生成完成,正在跳转到结果页...
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status === 'failed' && (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--error)', marginBottom: 16 }}>生成失败</p>
|
||||
<button className="btn-primary" onClick={handleReset}>
|
||||
重新开始
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ width: 380, padding: 40 }}>
|
||||
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 32,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
AI 驱动的 2D 游戏素材生成工具
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
required
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: 'var(--error)', fontSize: 13 }}>{error}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
没有账号?{' '}
|
||||
<Link to="/register">去注册</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<string, { label: string; color: string }> = {
|
||||
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<Task[]>([])
|
||||
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 (
|
||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||
{/* 顶栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 24 }}>{loading ? '加载中...' : name}</h1>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
{user?.username}
|
||||
</span>
|
||||
<button className="btn-secondary" onClick={logout} style={{ padding: '6px 16px', fontSize: 13 }}>
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工程风格 */}
|
||||
<section className="card" style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 18 }}>工程风格</h2>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||
>
|
||||
{saving ? '保存中...' : '保存风格'}
|
||||
</button>
|
||||
</div>
|
||||
<StyleSelector value={style} onChange={updateStyle} />
|
||||
</section>
|
||||
|
||||
{/* 任务列表 */}
|
||||
<section className="card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 18 }}>历史任务</h2>
|
||||
<Link
|
||||
to={`/projects/${projectId}/generate`}
|
||||
className="btn-primary"
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 13,
|
||||
borderRadius: 'var(--radius)',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
新建生成
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 32 }}>
|
||||
暂无任务
|
||||
</p>
|
||||
) : (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<th style={thStyle}>提示词</th>
|
||||
<th style={thStyle}>类型</th>
|
||||
<th style={thStyle}>状态</th>
|
||||
<th style={thStyle}>创建时间</th>
|
||||
<th style={thStyle}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map(task => {
|
||||
const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending
|
||||
return (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<td style={tdStyle}>{task.prompt}</td>
|
||||
<td style={tdStyle}>{task.assetType}</td>
|
||||
<td style={{ ...tdStyle, color: statusInfo.color }}>{statusInfo.label}</td>
|
||||
<td style={tdStyle}>
|
||||
{new Date(task.createdAt).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
{task.status === 'completed' && (
|
||||
<Link
|
||||
to={`/projects/${projectId}/tasks/${task.id}`}
|
||||
style={{ fontSize: 13 }}
|
||||
>
|
||||
查看结果
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="card" style={{ width: 380, padding: 40 }}>
|
||||
<h1 style={{ textAlign: 'center', marginBottom: 8, fontSize: 28 }}>gen2d</h1>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: 'var(--text-secondary)',
|
||||
marginBottom: 32,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
创建新账号
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="用户名(3-32 字符)"
|
||||
value={username}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱(可选)"
|
||||
value={email}
|
||||
onChange={e => {
|
||||
setEmail(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码(至少 6 位)"
|
||||
value={password}
|
||||
onChange={e => {
|
||||
setPassword(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: 'var(--error)', fontSize: 13 }}>{error}</div>
|
||||
)}
|
||||
|
||||
<button type="submit" className="btn-primary" disabled={loading} style={{ width: '100%' }}>
|
||||
{loading ? '注册中...' : '注册'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p style={{ textAlign: 'center', marginTop: 20, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
已有账号?{' '}
|
||||
<Link to="/login">去登录</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<Task | null>(null)
|
||||
const [assets, setAssets] = useState<Asset[]>([])
|
||||
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 (
|
||||
<div className="container" style={{ paddingTop: 40, textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--text-secondary)' }}>加载中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: 40, textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--error)' }}>任务不存在</p>
|
||||
<Link to={`/projects/${projectId}`} style={{ marginTop: 16, display: 'inline-block' }}>
|
||||
返回工程
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container" style={{ paddingTop: 24, paddingBottom: 40 }}>
|
||||
{/* 顶栏 */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: 24 }}>生成结果</h1>
|
||||
<Link
|
||||
to={`/projects/${projectId}`}
|
||||
className="btn-secondary"
|
||||
style={{
|
||||
padding: '8px 20px',
|
||||
fontSize: 13,
|
||||
borderRadius: 'var(--radius)',
|
||||
}}
|
||||
>
|
||||
返回工程
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 任务信息 */}
|
||||
<section className="card" style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 16, marginBottom: 16 }}>任务信息</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '120px 1fr',
|
||||
gap: '8px 16px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>提示词</span>
|
||||
<span>{task.prompt}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>素材类型</span>
|
||||
<span>{task.assetType}</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>状态</span>
|
||||
<span
|
||||
style={{
|
||||
color: task.status === 'completed' ? 'var(--success)' : 'var(--error)',
|
||||
}}
|
||||
>
|
||||
{task.status === 'completed' ? '已完成' : '失败'}
|
||||
</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>创建时间</span>
|
||||
<span>{new Date(task.createdAt).toLocaleString('zh-CN')}</span>
|
||||
{task.retryCount != null && task.retryCount > 0 && (
|
||||
<>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>重试次数</span>
|
||||
<span>{task.retryCount}</span>
|
||||
</>
|
||||
)}
|
||||
{task.error && (
|
||||
<>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>错误</span>
|
||||
<span style={{ color: 'var(--error)' }}>{task.error}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 素材预览 */}
|
||||
<section className="card" style={{ marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ fontSize: 16 }}>素材预览</h2>
|
||||
{assets.length > 0 && (
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={() => alert('下载功能即将上线')}
|
||||
style={{ padding: '8px 20px', fontSize: 13 }}
|
||||
>
|
||||
下载素材
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<AssetPreview assets={assets} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <Navigate to="/login" replace />
|
||||
return <Outlet />
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: <RegisterPage />,
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute />,
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <Navigate to="/projects/proj-default" replace />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
element: <ProjectPage />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/generate',
|
||||
element: <GeneratePage />,
|
||||
},
|
||||
{
|
||||
path: '/projects/:projectId/tasks/:taskId',
|
||||
element: <ResultPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -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<void>
|
||||
register: (username: string, password: string, email?: string) => Promise<void>
|
||||
logout: () => void
|
||||
loadFromStorage: () => void
|
||||
clearError: () => void
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((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 }),
|
||||
}))
|
||||
@@ -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<void>
|
||||
handleProgress: (msg: PipelineProgress) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
let cleanupWs: (() => void) | null = null
|
||||
|
||||
export const useGenerationStore = create<GenerationState>((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,
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,49 @@
|
||||
import { create } from 'zustand'
|
||||
import * as projectApi from '../api/project'
|
||||
|
||||
interface ProjectState {
|
||||
projectId: string
|
||||
name: string
|
||||
style: Record<string, string>
|
||||
loading: boolean
|
||||
loadProject: (projectId: string) => Promise<void>
|
||||
loadStyle: (projectId: string) => Promise<void>
|
||||
updateStyle: (key: string, value: string) => void
|
||||
saveStyle: () => Promise<void>
|
||||
}
|
||||
|
||||
export const useProjectStore = create<ProjectState>((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)
|
||||
},
|
||||
}))
|
||||
@@ -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<string, string>
|
||||
params: TaskParams
|
||||
setPrompt: (text: string) => void
|
||||
setAssetType: (type: AssetType) => void
|
||||
toggleTaskStyle: (key: string, value: string) => void
|
||||
setParams: (params: Partial<TaskParams>) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const DEFAULT_PARAMS: TaskParams = {
|
||||
resolution: 256,
|
||||
format: 'spritesheet',
|
||||
}
|
||||
|
||||
export const useTaskStore = create<TaskState>((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 },
|
||||
}),
|
||||
}))
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 合并工程风格与任务风格覆盖(任务覆盖优先,与后端 PromptBuilder 逻辑一致)
|
||||
*/
|
||||
export function mergeStyles(
|
||||
projectStyle: Record<string, string>,
|
||||
taskOverrides: Record<string, string>
|
||||
): Record<string, string> {
|
||||
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: '平静' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -10,6 +10,10 @@ export default defineConfig({
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/auth': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user