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

gen2d

-

AI 驱动的 2D 游戏素材生成工具

-
- ) -} +import { useEffect } from 'react' +import { RouterProvider } from 'react-router-dom' +import { router } from './router' +import { useAuthStore } from './stores/auth' +import './styles/global.css' -export default App +export default function App() { + const loadFromStorage = useAuthStore(s => s.loadFromStorage) + + useEffect(() => { + loadFromStorage() + }, [loadFromStorage]) + + return +} diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..5949e68 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -0,0 +1,31 @@ +import { post, setToken } from './client' +import type { + LoginRequest, + LoginResponse, + RegisterRequest, + RegisterResponse, +} from './types' + +export async function register( + username: string, + password: string, + email?: string +): Promise { + return post('/auth/register', { + username, + password, + email, + } satisfies RegisterRequest) +} + +export async function login( + username: string, + password: string +): Promise { + const data = await post('/auth/login', { + username, + password, + } satisfies LoginRequest) + setToken(data.token) + return data +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..9ff77c8 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,70 @@ +import type { ApiResponse } from './types' + +const TOKEN_KEY = 'gen2d_token' + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken(): void { + localStorage.removeItem(TOKEN_KEY) +} + +export class ApiError extends Error { + code: number + constructor(code: number, message: string) { + super(message) + this.code = code + } +} + +async function request(url: string, options: RequestInit = {}): Promise { + const token = getToken() + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + + const res = await fetch(url, { + ...options, + headers, + credentials: 'include', + }) + + const json: ApiResponse = await res.json() + + if (json.code !== 0) { + if (json.code === 401) { + clearToken() + window.location.href = '/login' + } + throw new ApiError(json.code, json.message) + } + + return json.data +} + +export function get(url: string): Promise { + return request(url) +} + +export function post(url: string, body?: unknown): Promise { + return request(url, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }) +} + +export function put(url: string, body?: unknown): Promise { + return request(url, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }) +} diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts new file mode 100644 index 0000000..40670a8 --- /dev/null +++ b/frontend/src/api/generate.ts @@ -0,0 +1,23 @@ +import type { Asset, Task } from './types' +import { mockGetAssets, mockGetTask, mockSubmitGenerate } from './mock' + +const USE_MOCK = true + +export async function submitGenerate( + projectId: string, + prompt: string, + assetType: string +): Promise { + if (USE_MOCK) return mockSubmitGenerate(projectId, prompt, assetType) + throw new Error('Not implemented') +} + +export async function getTask(taskId: string): Promise { + if (USE_MOCK) return mockGetTask(taskId) + throw new Error('Not implemented') +} + +export async function getAssets(taskId: string): Promise { + if (USE_MOCK) return mockGetAssets(taskId) + throw new Error('Not implemented') +} diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts new file mode 100644 index 0000000..5731fe9 --- /dev/null +++ b/frontend/src/api/mock.ts @@ -0,0 +1,217 @@ +import type { Asset, PipelineProgress, PipelineStage, Project, Task } from './types' + +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) +const randomDelay = () => delay(300 + Math.random() * 500) + +// Mock 数据 +const MOCK_PROJECT: Project = { + id: 'proj-default', + name: '我的工程', + style: { + kvPairs: { + artStyle: 'pixel', + palette: 'warm', + lineWeight: 'thin', + scene: 'forest', + lighting: 'bright', + mood: 'cheerful', + }, + }, + createdAt: '2026-05-20T10:00:00Z', +} + +const MOCK_TASKS: Task[] = [ + { + id: 'task-001', + projectId: 'proj-default', + prompt: '一个拿剑的小人', + assetType: 'sprite', + status: 'completed', + progress: 100, + createdAt: '2026-05-24T09:00:00Z', + updatedAt: '2026-05-24T09:02:00Z', + }, + { + id: 'task-002', + projectId: 'proj-default', + prompt: '森林背景', + assetType: 'background', + status: 'failed', + error: '生成超时', + createdAt: '2026-05-24T08:00:00Z', + updatedAt: '2026-05-24T08:10:00Z', + }, + { + id: 'task-003', + projectId: 'proj-default', + prompt: '魔法药水瓶', + assetType: 'ui', + status: 'completed', + progress: 100, + createdAt: '2026-05-23T15:00:00Z', + updatedAt: '2026-05-23T15:03:00Z', + }, +] + +const MOCK_ASSETS: Asset[] = [ + { + id: 'asset-001', + url: 'https://placehold.co/256x256/e94560/1a1a2e?text=Sprite', + format: 'png', + width: 256, + height: 256, + metadata: { + frameWidth: 64, + frameHeight: 64, + frameCount: 16, + directions: 4, + }, + }, +] + +// Mock API 函数 +export async function mockGetProject(_id: string): Promise { + await randomDelay() + return { ...MOCK_PROJECT } +} + +export async function mockGetStyle( + _projectId: string +): Promise> { + await randomDelay() + return { ...MOCK_PROJECT.style.kvPairs } +} + +export async function mockSaveStyle( + _projectId: string, + _kvPairs: Record +): Promise { + await randomDelay() +} + +export async function mockGetTasks(_projectId: string): Promise { + await randomDelay() + return MOCK_TASKS.map(t => ({ ...t })) +} + +export async function mockGetTask(taskId: string): Promise { + await randomDelay() + const task = MOCK_TASKS.find(t => t.id === taskId) + if (!task) throw new Error('任务不存在') + return { ...task } +} + +export async function mockGetAssets(_taskId: string): Promise { + await randomDelay() + return MOCK_ASSETS.map(a => ({ ...a })) +} + +export async function mockSubmitGenerate( + _projectId: string, + prompt: string, + assetType: string +): Promise { + await delay(500) + const taskId = `task-${Date.now()}` + MOCK_TASKS.unshift({ + id: taskId, + projectId: _projectId, + prompt, + assetType, + status: 'pending', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + return taskId +} + +// 模拟 WebSocket 管线进度 +const STAGES: PipelineStage[] = [ + 'prompt_builder', + 'asset_generator', + 'quality_supervisor', + 'format_adapter', +] + +const STAGE_LABELS: Record = { + prompt_builder: '提示词构建', + asset_generator: '素材生成', + quality_supervisor: '质量检查', + format_adapter: '格式转换', +} + +export { STAGE_LABELS } + +export function createMockWebSocket( + _taskId: string, + onProgress: (msg: PipelineProgress) => void, + onComplete: (assets: Asset[]) => void, + _onError?: (error: string) => void +): () => void { + let stageIndex = 0 + let tick = 0 + let retryCount = 0 + const maxRetries = 1 + let cancelled = false + + const interval = setInterval(() => { + if (cancelled) return + + const stage = STAGES[stageIndex] + const stageProgress = (tick % 3) * 50 // 0, 50, 100 + + // 质检阶段有概率重试 + if ( + stage === 'quality_supervisor' && + stageProgress === 100 && + retryCount < maxRetries && + Math.random() < 0.3 + ) { + onProgress({ + stage: 'quality_supervisor', + status: 'failed', + progress: 100, + retryCount: retryCount + 1, + rejectReason: '风格一致性不足', + }) + retryCount++ + stageIndex = 0 + tick = 0 + return + } + + const isLastTickOfStage = stageProgress === 100 + const isLastStage = stageIndex === STAGES.length - 1 + + if (isLastTickOfStage && isLastStage) { + // 管线完成 + onProgress({ + stage: 'format_adapter', + status: 'completed', + progress: 100, + result: { assets: MOCK_ASSETS }, + }) + onComplete(MOCK_ASSETS) + clearInterval(interval) + return + } + + onProgress({ + stage, + status: isLastTickOfStage ? 'completed' : 'running', + progress: stageProgress, + retryCount: retryCount > 0 ? retryCount : undefined, + }) + + tick++ + if (isLastTickOfStage) { + stageIndex++ + tick = 0 + } + }, 1000) + + return () => { + cancelled = true + clearInterval(interval) + } +} diff --git a/frontend/src/api/project.ts b/frontend/src/api/project.ts new file mode 100644 index 0000000..3aa3447 --- /dev/null +++ b/frontend/src/api/project.ts @@ -0,0 +1,35 @@ +import type { Project, Task } from './types' +import { + mockGetProject, + mockGetStyle, + mockGetTasks, + mockSaveStyle, +} from './mock' + +const USE_MOCK = true + +export async function getProject(projectId: string): Promise { + if (USE_MOCK) return mockGetProject(projectId) + // TODO: 真实 API 调用 + throw new Error('Not implemented') +} + +export async function getStyle( + projectId: string +): Promise> { + if (USE_MOCK) return mockGetStyle(projectId) + throw new Error('Not implemented') +} + +export async function saveStyle( + projectId: string, + kvPairs: Record +): Promise { + if (USE_MOCK) return mockSaveStyle(projectId, kvPairs) + throw new Error('Not implemented') +} + +export async function getTasks(projectId: string): Promise { + if (USE_MOCK) return mockGetTasks(projectId) + throw new Error('Not implemented') +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts new file mode 100644 index 0000000..b9eaa96 --- /dev/null +++ b/frontend/src/api/types.ts @@ -0,0 +1,117 @@ +// 统一响应信封 +export interface ApiResponse { + code: number + message: string + data: T +} + +// 认证 +export interface LoginRequest { + username: string + password: string +} + +export interface RegisterRequest { + username: string + password: string + email?: string +} + +export interface LoginResponse { + token: string + expiresIn: number + user: { + id: number + username: string + email: string + createdAt: string + updatedAt: string + } +} + +export interface RegisterResponse { + id: number + username: string +} + +// 素材类型 +export type AssetType = 'sprite' | 'background' | 'ui' | 'animation' + +// 管线阶段 +export type PipelineStage = + | 'prompt_builder' + | 'asset_generator' + | 'quality_supervisor' + | 'format_adapter' + +// 工程 +export interface Project { + id: string + name: string + style: { kvPairs: Record } + createdAt: string +} + +export interface CreateProjectRequest { + name: string +} + +// 风格 +export interface UpdateStyleRequest { + kvPairs: Record +} + +// 任务 +export interface Task { + id: string + projectId: string + prompt: string + assetType: string + status: 'pending' | 'running' | 'completed' | 'failed' + stage?: PipelineStage + progress?: number + retryCount?: number + error?: string | null + createdAt: string + updatedAt: string +} + +// 素材 +export interface Asset { + id: string + url: string + format: string + width: number + height: number + metadata: { + frameWidth?: number + frameHeight?: number + frameCount?: number + directions?: number + } +} + +// 生成请求 +export interface GenerateRequest { + projectId: string + prompt: string + assetType: AssetType + taskStyle?: Record + params?: { + resolution?: number + frames?: { directions?: number; framesPerDirection?: number } + format?: 'spritesheet' | 'individual' + } +} + +// WebSocket 管线进度 +export interface PipelineProgress { + stage: PipelineStage + status: 'running' | 'completed' | 'failed' + progress: number + message?: string + retryCount?: number + rejectReason?: string + result?: { assets: Asset[] } + error?: string +} diff --git a/frontend/src/components/AssetPreview.tsx b/frontend/src/components/AssetPreview.tsx new file mode 100644 index 0000000..5686d7a --- /dev/null +++ b/frontend/src/components/AssetPreview.tsx @@ -0,0 +1,77 @@ +import type { Asset } from '../api/types' + +interface AssetPreviewProps { + assets: Asset[] +} + +export default function AssetPreview({ assets }: AssetPreviewProps) { + if (assets.length === 0) { + return ( +
+ 暂无素材 +
+ ) + } + + return ( +
+ {assets.map(asset => ( +
+ {/* 图片预览 */} +
+ {asset.id} + {/* spritesheet 网格叠加 */} + {asset.metadata.frameWidth && asset.metadata.frameHeight && ( +
+ )} +
+ + {/* 元数据 */} +
+ 尺寸: {asset.width} × {asset.height} + {asset.metadata.frameWidth && ( + 帧大小: {asset.metadata.frameWidth} × {asset.metadata.frameHeight} + )} + {asset.metadata.frameCount && 帧数: {asset.metadata.frameCount}} + {asset.metadata.directions && 方向: {asset.metadata.directions}} + 格式: {asset.format} +
+
+ ))} +
+ ) +} diff --git a/frontend/src/components/GenerateForm.tsx b/frontend/src/components/GenerateForm.tsx new file mode 100644 index 0000000..e3c2ec0 --- /dev/null +++ b/frontend/src/components/GenerateForm.tsx @@ -0,0 +1,124 @@ +import type { AssetType } from '../api/types' +import { useProjectStore } from '../stores/project' +import { useTaskStore } from '../stores/task' +import { mergeStyles } from '../utils/style' +import StyleSelector from './StyleSelector' +import PromptEditor from './PromptEditor' + +const ASSET_TYPES: { value: AssetType; label: string }[] = [ + { value: 'sprite', label: '精灵' }, + { value: 'background', label: '背景' }, + { value: 'ui', label: 'UI' }, + { value: 'animation', label: '动画' }, +] + +interface GenerateFormProps { + onSubmit: () => void + submitting: boolean +} + +export default function GenerateForm({ onSubmit, submitting }: GenerateFormProps) { + const { style: projectStyle } = useProjectStore() + const { + prompt, + assetType, + taskStyle, + params, + setPrompt, + setAssetType, + toggleTaskStyle, + setParams, + } = useTaskStore() + + const mergedStyle = mergeStyles(projectStyle, taskStyle) + + return ( +
+ {/* 素材类型 */} +
+

+ 素材类型 +

+
+ {ASSET_TYPES.map(t => ( + + ))} +
+
+ + {/* 任务风格覆盖 */} +
+

+ 风格覆盖(可选) +

+ +
+ + {/* 提示词 */} +
+

+ 提示词 +

+ +
+ + {/* 参数 */} +
+

+ 参数 +

+
+ + +
+
+ + {/* 提交 */} + +
+ ) +} diff --git a/frontend/src/components/ProgressBar.tsx b/frontend/src/components/ProgressBar.tsx new file mode 100644 index 0000000..60c1d1a --- /dev/null +++ b/frontend/src/components/ProgressBar.tsx @@ -0,0 +1,135 @@ +import type { PipelineStage } from '../api/types' +import { STAGE_LABELS } from '../api/mock' + +const STAGES: PipelineStage[] = [ + 'prompt_builder', + 'asset_generator', + 'quality_supervisor', + 'format_adapter', +] + +interface ProgressBarProps { + stage: PipelineStage | null + progress: number + status: string + retryCount: number + rejectReason: string | null +} + +export default function ProgressBar({ + stage, + progress, + status, + retryCount, + rejectReason, +}: ProgressBarProps) { + const currentIndex = stage ? STAGES.indexOf(stage) : -1 + + return ( +
+ {/* 阶段步进器 */} +
+ {STAGES.map((s, i) => { + const isActive = i === currentIndex + const isCompleted = + i < currentIndex || (i === currentIndex && progress === 100 && status !== 'failed') + const isFailed = isActive && status === 'failed' + + return ( +
+ {/* 步骤头 */} +
+ + {isCompleted && !isActive ? '✓' : isFailed ? '✗' : i + 1} + + {STAGE_LABELS[s]} +
+ + {/* 进度条 */} +
+
+
+
+ ) + })} +
+ + {/* 重试信息 */} + {retryCount > 0 && ( +
+ ⚠ 质检重试中(第 {retryCount} 次) + {rejectReason && — {rejectReason}} +
+ )} + + {/* 错误信息 */} + {status === 'failed' && ( +
+ ✗ 生成失败 +
+ )} +
+ ) +} diff --git a/frontend/src/components/PromptEditor.tsx b/frontend/src/components/PromptEditor.tsx new file mode 100644 index 0000000..bfbf7fb --- /dev/null +++ b/frontend/src/components/PromptEditor.tsx @@ -0,0 +1,67 @@ +import { STYLE_CATEGORIES } from '../utils/style' + +interface PromptEditorProps { + prompt: string + onPromptChange: (text: string) => void + style: Record + params: { + resolution: number + format: string + } +} + +function styleToDescription(kvPairs: Record): string { + return STYLE_CATEGORIES.map(cat => { + const value = kvPairs[cat.key] + if (!value) return null + const option = cat.options.find(o => o.value === value) + return option ? `${cat.label}: ${option.label}` : null + }) + .filter(Boolean) + .join(', ') +} + +export default function PromptEditor({ + prompt, + onPromptChange, + style, + params, +}: PromptEditorProps) { + const styleDesc = styleToDescription(style) + + return ( +
+