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

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