refactor: 前端重构为项目+任务双核心模型

- 新增 HomePage 项目画廊页,支持新建/删除工程
- 新增 ProjectDetailPage 三栏工作台(配置+生成+任务列表)
- 新增 CustomTagsEditor 支持自定义风格标签(custom: 前缀存入 kvPairs)
- 重构 project store 为 draft/commit 模式,支持手动保存/取消
- 新增 projectList store 管理工程列表
- 扩展 mock.ts 为多工程数据结构
- 扩展 api/project.ts 新增项目 CRUD API(mock-gated)
- 简化 ResultPage,仅展示素材预览
- 删除 GeneratePage、ProjectPage 及未使用的 hooks
- 更新 docs/ 文档(frontend.md、api.md、style-keys.md、_index.md)
This commit is contained in:
2026-05-25 16:15:31 +08:00
parent 2bc0ec2eaa
commit a603f5548c
26 changed files with 1397 additions and 644 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ Go + Gin + Eino / Vite + React + TypeScript + zustand / SQLite / 七牛云对象
## 文档索引 ## 文档索引
- [后端工程](backend.md) — 分层结构、管线设计(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter)、风格模型 - [后端工程](backend.md) — 分层结构、管线设计(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter)、风格模型
- [前端工程](frontend.md) — 组件树、状态管理、路由、WebSocket 通信 - [前端工程](frontend.md) — 组件树、状态管理、路由、三栏工作台、自定义标签
- [异步任务](async-tasks.md) — 任务队列、状态机、并发控制、失败重试 - [异步任务](async-tasks.md) — 任务队列、状态机、并发控制、失败重试
- [数据存储](database.md) — SQLite 选型、表结构、七牛云对象存储 - [数据存储](database.md) — SQLite 选型、表结构、七牛云对象存储
- [API 设计](api.md) — 接口列表、请求/响应示例、实现状态 - [API 设计](api.md) — 接口列表、请求/响应示例、实现状态
+15 -6
View File
@@ -217,9 +217,10 @@ POST /api/v1/prompt/optimize
| 状态 | 方法 | 路径 | 说明 | | 状态 | 方法 | 路径 | 说明 |
|------|------|------|------| |------|------|------|------|
| [ ] | GET | `/api/v1/projects` | 获取当前用户的工程列表 | | [ ] | GET | `/api/v1/projects` | 获取当前用户的工程列表(前端已对接 mock) |
| [ ] | POST | `/api/v1/projects` | 创建工程 | | [ ] | POST | `/api/v1/projects` | 创建工程(前端已对接 mock) |
| [ ] | GET | `/api/v1/projects/:projectId` | 获取工程信息 | | [ ] | GET | `/api/v1/projects/:projectId` | 获取工程信息 |
| [ ] | PUT | `/api/v1/projects/:projectId` | 更新工程信息(如名称) |
| [ ] | DELETE | `/api/v1/projects/:projectId` | 删除工程(级联删除任务和素材) | | [ ] | DELETE | `/api/v1/projects/:projectId` | 删除工程(级联删除任务和素材) |
| [ ] | GET | `/api/v1/projects/:projectId/tasks` | 获取工程下的任务列表 | | [ ] | GET | `/api/v1/projects/:projectId/tasks` | 获取工程下的任务列表 |
@@ -244,7 +245,9 @@ POST /api/v1/prompt/optimize
{ {
"id": "proj_abc123", "id": "proj_abc123",
"name": "我的像素游戏", "name": "我的像素游戏",
"createdAt": "2026-05-24T10:00:00Z" "createdAt": "2026-05-24T10:00:00Z",
"taskCount": 5,
"lastActivityAt": "2026-05-24T10:05:00Z"
} }
] ]
} }
@@ -255,7 +258,11 @@ POST /api/v1/prompt/optimize
```json ```json
{ {
"name": "我的像素游戏" "name": "我的像素游戏",
"style": {
"artStyle": "pixel",
"palette": "warm"
}
} }
``` ```
@@ -268,8 +275,10 @@ POST /api/v1/prompt/optimize
"data": { "data": {
"id": "proj_abc123", "id": "proj_abc123",
"name": "我的像素游戏", "name": "我的像素游戏",
"style": { "kvPairs": {} }, "style": { "kvPairs": { "artStyle": "pixel", "palette": "warm" } },
"createdAt": "2026-05-24T10:00:00Z" "createdAt": "2026-05-24T10:00:00Z",
"taskCount": 0,
"lastActivityAt": "2026-05-24T10:00:00Z"
} }
} }
``` ```
+196 -175
View File
@@ -10,7 +10,7 @@ Vite 6 + React 18 + TypeScript + zustand + react-router-dom
| UI 框架 | React 18 | 函数组件 + Hooks | | UI 框架 | React 18 | 函数组件 + Hooks |
| 状态管理 | zustand | 轻量,支持 devtools 中间件 | | 状态管理 | zustand | 轻量,支持 devtools 中间件 |
| 路由 | react-router-dom v6 | SPA 模式 | | 路由 | react-router-dom v6 | SPA 模式 |
| 实时通信 | 原生 WebSocket | 连接后端 `/api/v1/tasks/:taskId/ws`(Cookie 认证) | | 实时通信 | HTTP 轮询 | 每 1.5s 轮询任务状态(WebSocket 待后端实现) |
| HTTP 请求 | fetch + 封装层 | 统一错误处理、响应解包 | | HTTP 请求 | fetch + 封装层 | 统一错误处理、响应解包 |
## 目录结构 ## 目录结构
@@ -18,40 +18,51 @@ Vite 6 + React 18 + TypeScript + zustand + react-router-dom
``` ```
frontend/src/ frontend/src/
├── main.tsx # 入口 ├── main.tsx # 入口
├── App.tsx # 根组件 + 路由配置 ├── App.tsx # 根组件
├── api/ # API 封装层 ├── api/ # API 封装层
│ ├── client.ts # fetch 封装:baseURL、统一错误处理、响应解包 │ ├── client.ts # fetch 封装:统一错误处理、响应解包(get/post/put/del)
│ ├── auth.ts # 注册/登录(真实后端调用) │ ├── auth.ts # 注册/登录(真实后端调用)
│ ├── project.ts # 工程 CRUD + 风格 GET/PUT(mock 切换) │ ├── project.ts # 工程 CRUD + 风格 + 任务列表(mock 切换)
│ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets(mock 切换) │ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets
│ ├── mock.ts # Mock 数据 + 模拟 WebSocket 管线进度 │ ├── prompt.ts # 提示词优化 + extractTags
│ └── types.ts # API 请求/响应类型定义(Task、Asset、Style、PipelineProgress 等) │ ├── mock.ts # 多工程 Mock 数据 + 模拟 WebSocket 管线进度
│ └── types.ts # API 类型定义
├── stores/ # zustand stores ├── stores/ # zustand stores
│ ├── auth.ts # 用户认证状态(login/register/logout/loadFromStorage) │ ├── auth.ts # 用户认证状态
│ ├── project.ts # 当前工程(id、风格、任务列表) │ ├── project.ts # 当前工程详情(含 draft/commit 风格编辑)
│ ├── task.ts # 当前任务草稿(用户文本、素材类型、任务风格覆盖、技术参数) │ ├── projectList.ts # 工程列表(画廊页)
│ └── generation.ts # 生成状态(taskId、进度、阶段、结果、错误) │ ├── task.ts # 任务草稿(生成表单状态)
│ ├── generation.ts # 生成状态(提交、轮询、结果)
│ ├── theme.ts # 主题切换(light/dark)
│ └── toast.ts # Toast 通知
├── pages/ # 页面级组件 ├── pages/ # 页面级组件
│ ├── LoginPage.tsx # 登录页 │ ├── LoginPage.tsx # 登录页
│ ├── RegisterPage.tsx # 注册页 │ ├── RegisterPage.tsx # 注册页
│ ├── ProjectPage.tsx # 工程首页:工程风格配置 + 任务列表 │ ├── HomePage.tsx # 项目画廊(工程列表 + 新建弹窗)
│ ├── GeneratePage.tsx # 生成页:提示词构建 + 提交 │ ├── ProjectDetailPage.tsx # 三栏工作台(配置 + 生成 + 任务列表)
│ └── ResultPage.tsx # 结果页:素材预览 + 下载 + 元数据 │ └── ResultPage.tsx # 结果页(素材预览 + 下载)
├── components/ # 可复用组件 ├── components/ # 可复用组件
│ ├── StyleSelector.tsx # 风格选择器(CSS Modules) │ ├── Layout.tsx # 导航栏 + Outlet + ToastContainer
│ ├── StyleSelector.tsx # 预设风格选择器(pill 按钮)
│ ├── CustomTagsEditor.tsx # 自定义标签编辑器(可添加/删除)
│ ├── PromptEditor.tsx # 三段式提示词编辑器 │ ├── PromptEditor.tsx # 三段式提示词编辑器
│ ├── GenerateForm.tsx # 生成表单 │ ├── GenerateForm.tsx # 生成表单(素材类型 + 风格覆盖 + 提示词 + 参数)
│ ├── ProgressBar.tsx # 管线进度条(显示当前阶段 + 重试状态) │ ├── ProgressBar.tsx # 管线进度条
│ └── AssetPreview.tsx # 素材预览(spritesheet 预览、单帧预览) │ ├── AssetPreview.tsx # 素材预览
├── hooks/ # 自定义 Hooks │ ├── ProjectCard.tsx # 画廊卡片
│ ├── useGenerate.ts # 提交生成任务 + WebSocket 订阅进度 │ ├── CreateProjectModal.tsx # 新建工程弹窗
│ └── useProjectStyle.ts # 工程风格加载/保存 │ ├── ProjectConfigPanel.tsx # 左栏:工程配置编辑面板
│ ├── TaskListPanel.tsx # 右栏:任务列表面板
│ ├── StatusBadge.tsx # 状态徽标组件
│ ├── EmptyState.tsx # 空状态占位
│ ├── Skeleton.tsx # 加载骨架屏
│ └── Toast.tsx # Toast 通知容器
├── router/ # 路由定义 ├── router/ # 路由定义
│ └── index.tsx │ └── index.tsx
├── styles/ # 全局样式 ├── styles/ # 全局样式
│ └── global.css # 暗色主题 CSS 变量 + reset │ └── global.css # 暗色/亮色主题 CSS 变量 + reset
└── utils/ # 工具函数 └── utils/ # 工具函数
└── style.ts # 风格合并逻辑 + 风格键分类定义 └── style.ts # 风格合并、预设分类、自定义标签工具
``` ```
## 实现状态 ## 实现状态
@@ -59,167 +70,219 @@ frontend/src/
| 模块 | 状态 | 说明 | | 模块 | 状态 | 说明 |
|------|------|------| |------|------|------|
| API 封装层 | [x] | client.ts 统一 fetch 封装,auth.ts 真实调用,project/generate 使用 mock | | API 封装层 | [x] | client.ts 统一 fetch 封装,auth.ts 真实调用,project/generate 使用 mock |
| Mock 层 | [x] | mock.ts 提供 mock 数据 + 模拟 WebSocket 管线进度 | | Mock 层 | [x] | mock.ts 提供多工程 mock 数据 + 模拟 WebSocket 管线进度 |
| 认证流程 | [x] | 注册/登录/退出/token 持久化,真实后端对接 | | 认证流程 | [x] | 注册/登录/退出/token 持久化,真实后端对接 |
| zustand stores | [x] | auth/project/task/generation 四个 store | | zustand stores | [x] | auth/project/projectList/task/generation/theme/toast 七个 store |
| 页面 | [x] | 5 个页面全部实现(Login/Register/Project/Generate/Result) | | 页面 | [x] | 5 个页面(Login/Register/Home/ProjectDetail/Result) |
| 组件 | [x] | StyleSelector/PromptEditor/GenerateForm/ProgressBar/AssetPreview | | 组件 | [x] | StyleSelector/CustomTagsEditor/PromptEditor/GenerateForm/ProgressBar/AssetPreview/ProjectCard/CreateProjectModal/ProjectConfigPanel/TaskListPanel/StatusBadge 等 |
| 路由 | [x] | 含 ProtectedRoute 守卫 | | 路由 | [x] | 含 ProtectedRoute 守卫 |
| WebSocket | [ ] | 当前使用 mock 模拟,待后端实现后切换为真实 WS | | 自定义标签 | [x] | `custom:` 前缀存入 kvPairs,零后端改动 |
## 路由规划 ## 路由规划
| 路径 | 页面 | 说明 | | 路径 | 页面 | 说明 |
|------|------|------| |------|------|------|
| `/` | — | 重定向到默认工程 | | `/` | HomePage | 项目画廊,展示所有工程 |
| `/projects/:projectId` | ProjectPage | 工程首页,配置工程风格,查看历史任务 | | `/projects/:projectId` | ProjectDetailPage | 三栏工作台(配置 + 生成 + 任务列表) |
| `/projects/:projectId/generate` | GeneratePage | 提示词构建 + 提交生成 | | `/projects/:projectId/tasks/:taskId` | ResultPage | 素材预览 + 下载 |
| `/projects/:projectId/tasks/:taskId` | ResultPage | 任务结果页,预览素材、下载 |
## 组件树 ## 组件树
```mermaid ```mermaid
graph TD graph TD
App --> Router App --> Router
Router --> ProjectPage Router --> HomePage
Router --> GeneratePage Router --> ProjectDetailPage
Router --> ResultPage Router --> ResultPage
ProjectPage --> StyleSelector["StyleSelector(工程风格编辑)"] HomePage --> ProjectCard["ProjectCard(工程卡片)"]
ProjectPage --> TaskList["TaskList(历史任务列表)"] HomePage --> CreateProjectModal["CreateProjectModal(新建弹窗)"]
CreateProjectModal --> StyleSelector
CreateProjectModal --> CustomTagsEditor
GeneratePage --> GenerateForm ProjectDetailPage --> ProjectConfigPanel["ProjectConfigPanel(左栏配置)"]
GenerateForm --> AssetTypePicker["AssetTypePicker"] ProjectDetailPage --> GenerateForm["GenerateForm(中间上半)"]
GenerateForm --> StyleSelector2["StyleSelector(任务风格覆盖)"] ProjectDetailPage --> ProgressBar["ProgressBar(中间下半)"]
GenerateForm --> PromptEditor["PromptEditor(三段式预览)"] ProjectDetailPage --> TaskListPanel["TaskListPanel(右栏任务)"]
GenerateForm --> ParamsForm["ParamsForm(分辨率、帧数)"]
GeneratePage --> ProgressBar["ProgressBar(管线进度)"]
ResultPage --> AssetPreview["AssetPreview(素材预览)"] ProjectConfigPanel --> StyleSelector2["StyleSelector"]
ResultPage --> MetadataPanel["MetadataPanel(元数据)"] ProjectConfigPanel --> CustomTagsEditor2["CustomTagsEditor"]
ResultPage --> DownloadButton["DownloadButton"] TaskListPanel --> StatusBadge["StatusBadge"]
GenerateForm --> PromptEditor["PromptEditor"]
GenerateForm --> StyleSelector3["StyleSelector(任务覆盖)"]
ResultPage --> AssetPreview["AssetPreview"]
``` ```
## 三栏工作台布局
ProjectDetailPage 采用 CSS Grid 三栏布局:
```
grid-template-columns: 260px 1fr 320px
```
| 区域 | 宽度 | 内容 |
|------|------|------|
| 左栏 | 260px | 工程名(可编辑)、预设风格选择器、自定义标签编辑器、保存/取消按钮 |
| 中间 | flex-1 | 上半:生成表单;下半:当前任务进度条(提交后显示) |
| 右栏 | 320px | 任务列表(所有状态),15 秒自动刷新 |
## 核心交互流程 ## 核心交互流程
### 项目管理流程
```mermaid
sequenceDiagram
actor User
participant HP as HomePage
participant API as 后端 API
User->>HP: 进入首页
HP->>API: GET /api/v1/projects
API-->>HP: 工程列表
HP->>HP: 画廊展示 ProjectCard
User->>HP: 点击「新建工程」
HP->>HP: 弹出 CreateProjectModal
User->>HP: 输入名称、选择风格、添加自定义标签
HP->>API: POST /api/v1/projects
API-->>HP: 新工程
HP->>HP: 跳转到 ProjectDetailPage
```
### 生成流程 ### 生成流程
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
actor User actor User
participant GP as GeneratePage participant PDP as ProjectDetailPage
participant API as 后端 API participant API as 后端 API
participant WS as WebSocket
User->>GP: 填写 prompt、选择素材类型 User->>PDP: 填写 prompt、选择素材类型
User->>GP: StyleSelector 点选任务风格覆盖 User->>PDP: 点击提交
GP->>GP: PromptEditor 实时预览三段式提示词 PDP->>API: POST /api/v1/generate
User->>GP: 点击提交 API-->>PDP: 返回 taskId
GP->>API: POST /api/v1/generate PDP->>PDP: ProgressBar 显示进度
API-->>GP: 返回 taskId
GP->>WS: ws://host/api/v1/tasks/:taskId/ws(Cookie 自动携带) loop 轮询任务状态(1.5s)
loop 管线执行中 PDP->>API: GET /api/v1/tasks/:taskId
WS-->>GP: PipelineProgress(stage + progress) API-->>PDP: 任务状态 + 进度
GP->>GP: ProgressBar 更新阶段和进度 PDP->>PDP: 更新 ProgressBar
end end
WS-->>GP: format_adapter completed + assets
GP->>GP: 跳转 ResultPage API-->>PDP: status = completed
PDP->>API: GET /api/v1/tasks/:taskId/assets
API-->>PDP: 素材列表
PDP->>PDP: 显示「查看结果」按钮
``` ```
管线阶段对应 Eino Graph 节点,ProgressBar 展示: ### 风格编辑流程(手动保存)
| 阶段 | 说明 | ProgressBar 展示 |
|------|------|-----------------|
| `prompt_optimizer` | 调用 PromptAgent/LLM 优化提示词,合并风格 | 第 1 步 |
| `asset_generator` | 调用 AI 推理 API 出图 | 第 2 步 |
| `quality_supervisor` | 视觉模型质检 | 第 3 步(可能回退到第 1 步) |
| `format_adapter` | 格式转换、spritesheet 打包 | 第 4 步 |
质检重试时,ProgressBar 显示回退动画和重试次数。
### 风格编辑流程
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
actor User actor User
participant SP as StyleSelector participant PCP as ProjectConfigPanel
participant Store as project store
participant API as 后端 API participant API as 后端 API
User->>SP: 进入 ProjectPage User->>PCP: 进入工程页
SP->>API: GET /api/v1/projects/:id/style PCP->>Store: startEditing() — 复制 style 到 draftStyle
API-->>SP: 返回 kvPairs User->>PCP: 修改预设风格 / 添加自定义标签
SP->>SP: 按分类展示风格键值对 PCP->>Store: updateDraft(key, value) — 更新 draftStyle
User->>SP: 点选/修改风格 Store->>Store: hasUnsavedChanges = true
SP->>SP: 实时展示当前配置
User->>SP: 点击保存 alt 点击保存
SP->>API: PUT /api/v1/projects/:id/style User->>PCP: 点击「保存」
API-->>SP: 保存成功 PCP->>Store: commitDraft()
Store->>API: PUT /api/v1/projects/:id/style
Store->>API: PUT /api/v1/projects/:id(如名称变更)
Store->>Store: style = draftStyle, hasUnsavedChanges = false
else 点击取消
User->>PCP: 点击「取消」
PCP->>Store: discardDraft()
Store->>Store: draftStyle = style, hasUnsavedChanges = false
end
``` ```
## 错误处理 ## 自定义标签
### API 请求错误 在预设风格分类之外,用户可添加自定义标签。标签存储在工程风格的 `kvPairs` 中,使用 `custom:` 前缀区分:
- `api/client.ts` 统一拦截 `code !== 0` 的响应,抛出业务异常 ```json
- 401:跳转到登录页(Token 已过期或无效,Cookie 会被服务端清除) {
- 403:提示无权访问,不自动跳转 "artStyle": "pixel",
- 429:提示请求过于频繁,稍后重试 "palette": "warm",
- 500:展示通用错误提示 "custom:cel-shading": "true",
"custom:glow-effects": "true"
}
```
### WebSocket 断连 工具函数(`utils/style.ts`):
- `isCustomKey(key)` — 判断是否为自定义标签
- `getCustomTags(style)` — 提取自定义标签列表
- `addCustomTag(style, tag)` — 添加标签
- `removeCustomTag(style, tag)` — 删除标签
- 连接断开后自动重连(指数退避,最大间隔 10 秒) `extractTags()` 函数在提取标签时自动包含自定义标签值。
- 重连失败超过 3 次后,降级为轮询模式(`GET /api/v1/tasks/:taskId`,间隔 2-3 秒)
- 连接恢复后自动切回 WebSocket
### 加载状态
- 页面级加载(如 ProjectPage 进入时):展示骨架屏或 Loading 指示器
- 操作级提交(如保存风格、提交生成):按钮置为 loading 态,防止重复提交
## 状态管理 ## 状态管理
### zustand stores ### zustand stores
**project store** — 当前工程 **projectList store** — 工程列表(HomePage)
```typescript ```typescript
interface ProjectStore { interface ProjectListState {
projectId: string; projects: Project[];
name: string;
style: Record<string, string>; // kvPairs
loading: boolean; loading: boolean;
loadProject: (projectId: string) => Promise<void>; loadProjects: () => Promise<void>;
loadStyle: (projectId: string) => Promise<void>; createProject: (name: string, style?: Record<string, string>) => Promise<Project>;
updateStyle: (kvPairs: Record<string, string>) => void; deleteProject: (projectId: string) => Promise<void>;
saveStyle: () => Promise<void>;
} }
``` ```
**task store** — 任务草稿(对应后端 `PipelineInput`) **project store** — 当前工程详情(ProjectDetailPage)
```typescript ```typescript
interface TaskStore { interface ProjectState {
projectId: string;
name: string;
style: Record<string, string>;
draftStyle: Record<string, string>;
draftName: string;
hasUnsavedChanges: boolean;
loading: boolean;
loadProject: (projectId: string) => Promise<void>;
loadStyle: (projectId: string) => Promise<void>;
startEditing: () => void;
updateDraft: (key: string, value: string) => void;
setDraftStyle: (style: Record<string, string>) => void;
setDraftName: (name: string) => void;
discardDraft: () => void;
commitDraft: () => Promise<void>;
}
```
**task store** — 任务草稿
```typescript
interface TaskState {
prompt: string; prompt: string;
assetType: 'sprite' | 'background' | 'ui' | 'animation'; assetType: 'sprite' | 'background' | 'ui' | 'animation';
taskStyle: Record<string, string>; // 仅覆盖的键 taskStyle: Record<string, string>;
params: { params: TaskParams;
resolution: number; enableAI: boolean;
frames?: { directions: number; framesPerDirection: number }; optimizedPrompt: string | null;
format: 'spritesheet' | 'individual'; // ... setters, runOptimize, reset
};
setPrompt: (text: string) => void;
setAssetType: (type: TaskStore['assetType']) => void;
toggleTaskStyle: (key: string, value: string) => void;
setParams: (params: Partial<TaskStore['params']>) => void;
reset: () => void;
} }
``` ```
**generation store** — 生成状态 **generation store** — 生成状态
```typescript ```typescript
interface GenerationStore { interface GenerationState {
taskId: string | null; taskId: string | null;
stage: PipelineStage | null; stage: PipelineStage | null;
progress: number; progress: number;
@@ -228,57 +291,26 @@ interface GenerationStore {
rejectReason: string | null; rejectReason: string | null;
assets: Asset[]; assets: Asset[];
error: string | null; error: string | null;
submit: (projectId: string, task: TaskStore) => Promise<void>; submit: (req: GenerateRequest) => Promise<void>;
reset: () => void; reset: () => void;
} }
type PipelineStage =
| 'prompt_optimizer'
| 'asset_generator'
| 'quality_supervisor'
| 'format_adapter';
``` ```
## API 封装 ## API 类型定义(api/types.ts)
`api/client.ts` 统一封装:
- baseURL 从环境变量读取,开发模式默认 `/api/v1`
- 所有 fetch 请求设置 `credentials: 'include'`,自动携带 Cookie
- 所有响应按 `{ code, message, data }` 解包,`code !== 0` 时抛错
- 统一 401/403/500 错误处理
- WebSocket 连接封装为 `createTaskSocket(taskId)` 返回可订阅对象(浏览器自动携带 Cookie)
### API 类型定义(api/types.ts)
```typescript ```typescript
// 请求
interface CreateProjectRequest {
name: string;
}
interface GenerateRequest {
projectId: string;
prompt: string;
assetType: 'sprite' | 'background' | 'ui' | 'animation';
taskStyle?: Record<string, string>;
params?: {
resolution?: number;
frames?: { directions?: number; framesPerDirection?: number };
format?: 'spritesheet' | 'individual';
};
}
interface UpdateStyleRequest {
kvPairs: Record<string, string>;
}
// 响应
interface Project { interface Project {
id: string; id: string;
name: string; name: string;
style: { kvPairs: Record<string, string> }; style: { kvPairs: Record<string, string> };
createdAt: string; createdAt: string;
taskCount?: number;
lastActivityAt?: string;
}
interface CreateProjectRequest {
name: string;
style?: Record<string, string>;
} }
interface Task { interface Task {
@@ -286,7 +318,7 @@ interface Task {
projectId: string; projectId: string;
prompt: string; prompt: string;
assetType: string; assetType: string;
status: 'pending' | 'running' | 'completed' | 'failed'; status: 'pending' | 'submitted' | 'running' | 'completed' | 'failed';
stage?: PipelineStage; stage?: PipelineStage;
progress?: number; progress?: number;
retryCount?: number; retryCount?: number;
@@ -297,6 +329,7 @@ interface Task {
interface Asset { interface Asset {
id: string; id: string;
key: string;
url: string; url: string;
format: string; format: string;
width: number; width: number;
@@ -308,18 +341,6 @@ interface Asset {
directions?: number; directions?: number;
}; };
} }
// WebSocket 消息
interface PipelineProgress {
stage: PipelineStage;
status: 'running' | 'completed' | 'failed';
progress: number;
message?: string;
retryCount?: number;
rejectReason?: string;
result?: { assets: Asset[] };
error?: string;
}
``` ```
## 预设风格键分类 ## 预设风格键分类
@@ -327,5 +348,5 @@ interface PipelineProgress {
前端 StyleSelector 以分类标签组织,完整键值表见 [预设风格键](style-keys.md)。 前端 StyleSelector 以分类标签组织,完整键值表见 [预设风格键](style-keys.md)。
StyleSelector 两种使用场景: StyleSelector 两种使用场景:
1. **工程风格**(ProjectPage):全量编辑,保存到后端 1. **工程风格**(ProjectConfigPanel / CreateProjectModal):全量编辑,保存到后端
2. **任务覆盖**(GeneratePage):基于工程风格展示,高亮已覆盖的键,仅记录差异 2. **任务覆盖**(GenerateForm):基于工程风格展示,仅记录差异
+15
View File
@@ -12,3 +12,18 @@
| 情绪 | `mood` | cheerful, dark, mysterious, epic, calm | | 情绪 | `mood` | cheerful, dark, mysterious, epic, calm |
(具体键值后续可扩展,这里是初始集合) (具体键值后续可扩展,这里是初始集合)
## 自定义标签
除预设分类外,用户可添加自定义标签。标签存储在 `kvPairs` 中,使用 `custom:` 前缀区分:
```json
{
"artStyle": "pixel",
"palette": "warm",
"custom:cel-shading": "true",
"custom:glow-effects": "true"
}
```
前端 `extractTags()` 函数在提取标签时自动包含自定义标签值(去掉前缀),送入提示词优化管线。
+4
View File
@@ -64,3 +64,7 @@ export function put<T>(url: string, body?: unknown): Promise<T> {
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}) })
} }
export function del<T>(url: string): Promise<T> {
return request<T>(url, { method: 'DELETE' })
}
+172 -58
View File
@@ -3,60 +3,114 @@ import type { Asset, PipelineProgress, PipelineStage, Project, Task } from './ty
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const randomDelay = () => delay(300 + Math.random() * 500) const randomDelay = () => delay(300 + Math.random() * 500)
// Mock 数据 // Mock 工程数据
const MOCK_PROJECT: Project = { const MOCK_PROJECTS: Project[] = [
id: 'proj-default', {
name: '我的工程', id: 'proj-pixel-forest',
style: { name: '像素森林',
kvPairs: { style: {
artStyle: 'pixel', kvPairs: {
palette: 'warm', artStyle: 'pixel',
lineWeight: 'thin', palette: 'warm',
scene: 'forest', lineWeight: 'thin',
lighting: 'bright', scene: 'forest',
mood: 'cheerful', lighting: 'bright',
mood: 'cheerful',
},
}, },
}, createdAt: '2026-05-20T10:00:00Z',
createdAt: '2026-05-20T10:00:00Z', taskCount: 3,
} lastActivityAt: '2026-05-24T09:02: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', id: 'proj-dark-dungeon',
projectId: 'proj-default', name: '暗黑地牢',
prompt: '森林背景', style: {
assetType: 'background', kvPairs: {
status: 'failed', artStyle: 'hand-drawn',
error: '生成超时', palette: 'muted',
createdAt: '2026-05-24T08:00:00Z', lineWeight: 'thick',
updatedAt: '2026-05-24T08:10:00Z', scene: 'dungeon',
lighting: 'dim',
mood: 'dark',
},
},
createdAt: '2026-05-18T14:00:00Z',
taskCount: 1,
lastActivityAt: '2026-05-22T16:30:00Z',
}, },
{ {
id: 'task-003', id: 'proj-neon-city',
projectId: 'proj-default', name: '霓虹都市',
prompt: '魔法药水瓶', style: {
assetType: 'ui', kvPairs: {
status: 'completed', artStyle: 'vector',
progress: 100, palette: 'vibrant',
createdAt: '2026-05-23T15:00:00Z', lineWeight: 'medium',
updatedAt: '2026-05-23T15:03:00Z', scene: 'city',
lighting: 'neon',
mood: 'epic',
},
},
createdAt: '2026-05-15T08:00:00Z',
taskCount: 0,
lastActivityAt: '2026-05-15T08:00:00Z',
}, },
] ]
// Mock 任务数据(按工程分组)
const MOCK_TASKS: Record<string, Task[]> = {
'proj-pixel-forest': [
{
id: 'task-001',
projectId: 'proj-pixel-forest',
prompt: '一个拿剑的小人',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-24T09:00:00Z',
updatedAt: '2026-05-24T09:02:00Z',
},
{
id: 'task-002',
projectId: 'proj-pixel-forest',
prompt: '森林背景',
assetType: 'background',
status: 'failed',
error: '生成超时',
createdAt: '2026-05-24T08:00:00Z',
updatedAt: '2026-05-24T08:10:00Z',
},
{
id: 'task-003',
projectId: 'proj-pixel-forest',
prompt: '魔法药水瓶',
assetType: 'ui',
status: 'completed',
progress: 100,
createdAt: '2026-05-23T15:00:00Z',
updatedAt: '2026-05-23T15:03:00Z',
},
],
'proj-dark-dungeon': [
{
id: 'task-004',
projectId: 'proj-dark-dungeon',
prompt: '骷髅战士',
assetType: 'sprite',
status: 'completed',
progress: 100,
createdAt: '2026-05-22T16:28:00Z',
updatedAt: '2026-05-22T16:30:00Z',
},
],
'proj-neon-city': [],
}
const MOCK_ASSETS: Asset[] = [ const MOCK_ASSETS: Asset[] = [
{ {
id: 'asset-001', id: 'asset-001',
key: 'generation/proj-001/task-001/0.png', key: 'generation/proj-pixel-forest/task-001/0.png',
url: 'https://placehold.co/256x256/e94560/1a1a2e?text=Sprite', url: 'https://placehold.co/256x256/e94560/1a1a2e?text=Sprite',
format: 'png', format: 'png',
width: 256, width: 256,
@@ -70,36 +124,89 @@ const MOCK_ASSETS: Asset[] = [
}, },
] ]
// Mock API 函数 // Mock API 函数 — 工程
export async function mockGetProject(_id: string): Promise<Project> { export async function mockListProjects(): Promise<Project[]> {
await randomDelay() await randomDelay()
return { ...MOCK_PROJECT } return MOCK_PROJECTS.map(p => ({ ...p }))
} }
export async function mockGetProject(projectId: string): Promise<Project> {
await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
return { ...project }
}
export async function mockCreateProject(
name: string,
style?: Record<string, string>
): Promise<Project> {
await delay(500)
const id = `proj-${Date.now()}`
const now = new Date().toISOString()
const project: Project = {
id,
name,
style: { kvPairs: style || {} },
createdAt: now,
taskCount: 0,
lastActivityAt: now,
}
MOCK_PROJECTS.unshift(project)
MOCK_TASKS[id] = []
return { ...project }
}
export async function mockDeleteProject(projectId: string): Promise<void> {
await randomDelay()
const index = MOCK_PROJECTS.findIndex(p => p.id === projectId)
if (index !== -1) MOCK_PROJECTS.splice(index, 1)
delete MOCK_TASKS[projectId]
}
export async function mockUpdateProject(
projectId: string,
data: { name?: string }
): Promise<Project> {
await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
if (data.name !== undefined) project.name = data.name
return { ...project }
}
// Mock API 函数 — 风格
export async function mockGetStyle( export async function mockGetStyle(
_projectId: string projectId: string
): Promise<Record<string, string>> { ): Promise<Record<string, string>> {
await randomDelay() await randomDelay()
return { ...MOCK_PROJECT.style.kvPairs } const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (!project) throw new Error('工程不存在')
return { ...project.style.kvPairs }
} }
export async function mockSaveStyle( export async function mockSaveStyle(
_projectId: string, projectId: string,
_kvPairs: Record<string, string> kvPairs: Record<string, string>
): Promise<void> { ): Promise<void> {
await randomDelay() await randomDelay()
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (project) project.style.kvPairs = { ...kvPairs }
} }
export async function mockGetTasks(_projectId: string): Promise<Task[]> { // Mock API 函数 — 任务
export async function mockGetTasks(projectId: string): Promise<Task[]> {
await randomDelay() await randomDelay()
return MOCK_TASKS.map(t => ({ ...t })) return (MOCK_TASKS[projectId] || []).map(t => ({ ...t }))
} }
export async function mockGetTask(taskId: string): Promise<Task> { export async function mockGetTask(taskId: string): Promise<Task> {
await randomDelay() await randomDelay()
const task = MOCK_TASKS.find(t => t.id === taskId) for (const tasks of Object.values(MOCK_TASKS)) {
if (!task) throw new Error('任务不存在') const task = tasks.find(t => t.id === taskId)
return { ...task } if (task) return { ...task }
}
throw new Error('任务不存在')
} }
export async function mockGetAssets(_taskId: string): Promise<Asset[]> { export async function mockGetAssets(_taskId: string): Promise<Asset[]> {
@@ -108,21 +215,28 @@ export async function mockGetAssets(_taskId: string): Promise<Asset[]> {
} }
export async function mockSubmitGenerate( export async function mockSubmitGenerate(
_projectId: string, projectId: string,
prompt: string, prompt: string,
assetType: string assetType: string
): Promise<string> { ): Promise<string> {
await delay(500) await delay(500)
const taskId = `task-${Date.now()}` const taskId = `task-${Date.now()}`
MOCK_TASKS.unshift({ if (!MOCK_TASKS[projectId]) MOCK_TASKS[projectId] = []
MOCK_TASKS[projectId].unshift({
id: taskId, id: taskId,
projectId: _projectId, projectId,
prompt, prompt,
assetType, assetType,
status: 'pending', status: 'pending',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
// 更新工程的 lastActivityAt 和 taskCount
const project = MOCK_PROJECTS.find(p => p.id === projectId)
if (project) {
project.lastActivityAt = new Date().toISOString()
project.taskCount = (project.taskCount || 0) + 1
}
return taskId return taskId
} }
+31 -2
View File
@@ -1,16 +1,45 @@
import type { Project, Task } from './types' import type { Project, Task } from './types'
import { import {
mockListProjects,
mockGetProject, mockGetProject,
mockCreateProject,
mockDeleteProject,
mockUpdateProject,
mockGetStyle, mockGetStyle,
mockGetTasks,
mockSaveStyle, mockSaveStyle,
mockGetTasks,
} from './mock' } from './mock'
const USE_MOCK = true const USE_MOCK = true
export async function listProjects(): Promise<Project[]> {
if (USE_MOCK) return mockListProjects()
throw new Error('Not implemented')
}
export async function getProject(projectId: string): Promise<Project> { export async function getProject(projectId: string): Promise<Project> {
if (USE_MOCK) return mockGetProject(projectId) if (USE_MOCK) return mockGetProject(projectId)
// TODO: 真实 API 调用 throw new Error('Not implemented')
}
export async function createProject(
name: string,
style?: Record<string, string>
): Promise<Project> {
if (USE_MOCK) return mockCreateProject(name, style)
throw new Error('Not implemented')
}
export async function deleteProject(projectId: string): Promise<void> {
if (USE_MOCK) return mockDeleteProject(projectId)
throw new Error('Not implemented')
}
export async function updateProject(
projectId: string,
data: { name?: string }
): Promise<Project> {
if (USE_MOCK) return mockUpdateProject(projectId, data)
throw new Error('Not implemented') throw new Error('Not implemented')
} }
+5 -3
View File
@@ -1,5 +1,5 @@
import { post } from './client' import { post } from './client'
import { STYLE_CATEGORIES } from '../utils/style' import { STYLE_CATEGORIES, getCustomTags } from '../utils/style'
interface OptimizePromptParams { interface OptimizePromptParams {
tags: string[] tags: string[]
@@ -14,15 +14,17 @@ interface OptimizePromptResponse {
} }
/** /**
* 从风格键值对中提取标签的中文名称作为 tags * 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签
*/ */
export function extractTags(style: Record<string, string>): string[] { export function extractTags(style: Record<string, string>): string[] {
return STYLE_CATEGORIES.flatMap(cat => { const presetTags = STYLE_CATEGORIES.flatMap(cat => {
const value = style[cat.key] const value = style[cat.key]
if (!value) return [] if (!value) return []
const option = cat.options.find(o => o.value === value) const option = cat.options.find(o => o.value === value)
return option ? [option.label] : [] return option ? [option.label] : []
}) })
const customTags = getCustomTags(style)
return [...presetTags, ...customTags]
} }
/** /**
+3
View File
@@ -50,10 +50,13 @@ export interface Project {
name: string name: string
style: { kvPairs: Record<string, string> } style: { kvPairs: Record<string, string> }
createdAt: string createdAt: string
taskCount?: number
lastActivityAt?: string
} }
export interface CreateProjectRequest { export interface CreateProjectRequest {
name: string name: string
style?: Record<string, string>
} }
// 风格 // 风格
@@ -0,0 +1,115 @@
import { useState } from 'react'
import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
interface CreateProjectModalProps {
open: boolean
onClose: () => void
onSubmit: (name: string, style: Record<string, string>) => Promise<void>
}
export default function CreateProjectModal({
open,
onClose,
onSubmit,
}: CreateProjectModalProps) {
const [name, setName] = useState('')
const [style, setStyle] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
if (!open) return null
const handleStyleChange = (key: string, value: string) => {
setStyle(prev =>
prev[key] === value
? (() => {
const { [key]: _, ...rest } = prev
return rest
})()
: { ...prev, [key]: value }
)
}
const handleSubmit = async () => {
if (!name.trim()) return
setSubmitting(true)
try {
await onSubmit(name.trim(), style)
setName('')
setStyle({})
onClose()
} finally {
setSubmitting(false)
}
}
return (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={onClose}
>
<div
style={{
position: 'absolute',
inset: 0,
background: 'rgba(0, 0, 0, 0.5)',
}}
/>
<div
className="card"
style={{
position: 'relative',
width: '100%',
maxWidth: 520,
maxHeight: '80vh',
overflow: 'auto',
zIndex: 1,
}}
onClick={e => e.stopPropagation()}
>
<h2 style={{ fontSize: 18, marginBottom: 20 }}>新建工程</h2>
<label style={{ display: 'block', marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 6, color: 'var(--text-secondary)' }}>
工程名称
</span>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="输入工程名称"
style={{ width: '100%' }}
/>
</label>
<div style={{ marginBottom: 16 }}>
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
风格配置
</span>
<StyleSelector value={style} onChange={handleStyleChange} compact />
<CustomTagsEditor style={style} onChange={setStyle} />
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 12 }}>
<button className="btn-secondary" onClick={onClose} disabled={submitting}>
取消
</button>
<button
className="btn-primary"
onClick={handleSubmit}
disabled={!name.trim() || submitting}
>
{submitting ? '创建中...' : '创建'}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,87 @@
import { useState } from 'react'
import { getCustomTags, addCustomTag, removeCustomTag } from '../utils/style'
interface CustomTagsEditorProps {
style: Record<string, string>
onChange: (style: Record<string, string>) => void
}
export default function CustomTagsEditor({ style, onChange }: CustomTagsEditorProps) {
const [input, setInput] = useState('')
const tags = getCustomTags(style)
const handleAdd = () => {
const tag = input.trim()
if (!tag) return
if (tags.includes(tag)) return
onChange(addCustomTag(style, tag))
setInput('')
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
handleAdd()
}
}
return (
<div style={{ marginTop: 12 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
自定义标签
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
{tags.map(tag => (
<span
key={tag}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
padding: '3px 10px',
borderRadius: 999,
background: 'var(--bg-input)',
border: '1px solid var(--border)',
}}
>
{tag}
<button
type="button"
style={{
background: 'transparent',
border: 'none',
color: 'var(--text-muted)',
fontSize: 14,
padding: 0,
lineHeight: 1,
cursor: 'pointer',
}}
onClick={() => onChange(removeCustomTag(style, tag))}
>
x
</button>
</span>
))}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入标签后按 Enter 添加"
style={{ flex: 1, padding: '6px 10px', fontSize: 13 }}
/>
<button
type="button"
className="btn-secondary"
style={{ padding: '6px 14px', fontSize: 13 }}
onClick={handleAdd}
>
添加
</button>
</div>
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useNavigate } from 'react-router-dom'
import type { Project } from '../api/types'
import { STYLE_CATEGORIES } from '../utils/style'
interface ProjectCardProps {
project: Project
onDelete?: (projectId: string) => void
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes} 分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours} 小时前`
const days = Math.floor(hours / 24)
return `${days} 天前`
}
export default function ProjectCard({ project, onDelete }: ProjectCardProps) {
const navigate = useNavigate()
const presetTags = STYLE_CATEGORIES.flatMap(cat => {
const value = project.style.kvPairs[cat.key]
if (!value) return []
const option = cat.options.find(o => o.value === value)
return option ? [option.label] : []
}).slice(0, 4)
return (
<div
className="card"
style={{ cursor: 'pointer', position: 'relative' }}
onClick={() => navigate(`/projects/${project.id}`)}
>
{onDelete && (
<button
style={{
position: 'absolute',
top: 12,
right: 12,
background: 'transparent',
border: 'none',
color: 'var(--text-muted)',
fontSize: 16,
padding: '4px 8px',
borderRadius: 'var(--radius)',
}}
onClick={e => {
e.stopPropagation()
onDelete(project.id)
}}
>
x
</button>
)}
<h3 style={{ fontSize: 16, marginBottom: 12 }}>{project.name}</h3>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{presetTags.map(tag => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 999,
background: 'var(--accent-dim)',
color: 'var(--accent)',
}}
>
{tag}
</span>
))}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: 12,
color: 'var(--text-muted)',
}}
>
<span>{project.taskCount || 0} 个任务</span>
{project.lastActivityAt && (
<span>{formatRelativeTime(project.lastActivityAt)}</span>
)}
</div>
</div>
)
}
@@ -0,0 +1,109 @@
import { useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useToastStore } from '../stores/toast'
import StyleSelector from './StyleSelector'
import CustomTagsEditor from './CustomTagsEditor'
import Skeleton from './Skeleton'
export default function ProjectConfigPanel() {
const { projectId = '' } = useParams()
const {
draftName,
draftStyle,
hasUnsavedChanges,
loading,
loadProject,
loadStyle,
startEditing,
updateDraft,
setDraftStyle,
setDraftName,
discardDraft,
commitDraft,
} = useProjectStore()
const addToast = useToastStore(s => s.addToast)
useEffect(() => {
if (projectId) {
loadProject(projectId)
loadStyle(projectId)
startEditing()
}
}, [projectId, loadProject, loadStyle, startEditing])
const handleSave = async () => {
try {
await commitDraft()
addToast({ type: 'success', message: '配置已保存' })
} catch {
addToast({ type: 'error', message: '保存失败' })
}
}
if (loading) {
return (
<div style={{ padding: 16 }}>
<Skeleton variant="text" lines={6} />
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border)' }}>
<input
type="text"
value={draftName}
onChange={e => setDraftName(e.target.value)}
style={{
width: '100%',
fontSize: 16,
fontWeight: 600,
padding: '4px 0',
border: 'none',
background: 'transparent',
outline: 'none',
borderBottom: '2px solid transparent',
}}
onFocus={e => (e.target.style.borderBottomColor = 'var(--accent)')}
onBlur={e => (e.target.style.borderBottomColor = 'transparent')}
/>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '12px 16px' }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8, color: 'var(--text-secondary)' }}>
工程风格
</div>
<StyleSelector value={draftStyle} onChange={updateDraft} compact />
<CustomTagsEditor style={draftStyle} onChange={setDraftStyle} />
</div>
<div
style={{
padding: 12,
borderTop: '1px solid var(--border)',
display: 'flex',
gap: 8,
}}
>
<button
className="btn-primary"
onClick={handleSave}
disabled={!hasUnsavedChanges}
style={{ flex: 1, padding: '8px 0', fontSize: 13 }}
>
保存
</button>
<button
className="btn-secondary"
onClick={discardDraft}
disabled={!hasUnsavedChanges}
style={{ flex: 1, padding: '8px 0', fontSize: 13 }}
>
取消
</button>
</div>
</div>
)
}
+31
View File
@@ -0,0 +1,31 @@
interface StatusBadgeProps {
status: string
}
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
pending: { label: '等待中', color: 'var(--text-muted)', bg: 'var(--bg-input)' },
submitted: { label: '已提交', color: 'var(--text-secondary)', bg: 'var(--bg-input)' },
running: { label: '运行中', color: 'var(--warning)', bg: 'rgba(230, 119, 0, 0.12)' },
completed: { label: '已完成', color: 'var(--success)', bg: 'rgba(43, 138, 62, 0.12)' },
failed: { label: '失败', color: 'var(--error)', bg: 'rgba(201, 42, 42, 0.12)' },
}
export default function StatusBadge({ status }: StatusBadgeProps) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.pending
return (
<span
style={{
display: 'inline-block',
fontSize: 11,
fontWeight: 600,
padding: '2px 8px',
borderRadius: 999,
color: config.color,
background: config.bg,
}}
>
{config.label}
</span>
)
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams } from 'react-router-dom'
import { getTasks } from '../api/project'
import type { Task } from '../api/types'
import StatusBadge from './StatusBadge'
import Skeleton from './Skeleton'
const ASSET_TYPE_LABELS: Record<string, string> = {
sprite: '精灵',
background: '背景',
ui: 'UI',
animation: '动画',
}
function formatRelativeTime(dateStr: string): string {
const now = Date.now()
const then = new Date(dateStr).getTime()
const diff = now - then
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}小时前`
const days = Math.floor(hours / 24)
return `${days}天前`
}
export default function TaskListPanel() {
const { projectId = '' } = useParams()
const [tasks, setTasks] = useState<Task[]>([])
const [loading, setLoading] = useState(true)
const fetchTasks = useCallback(async () => {
if (!projectId) return
try {
const data = await getTasks(projectId)
setTasks(data)
} finally {
setLoading(false)
}
}, [projectId])
useEffect(() => {
fetchTasks()
const timer = setInterval(fetchTasks, 15000)
return () => clearInterval(timer)
}, [fetchTasks])
if (loading) {
return (
<div style={{ padding: 16 }}>
<Skeleton variant="text" lines={4} />
</div>
)
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<h3 style={{ fontSize: 14, fontWeight: 600, padding: '16px 16px 12px', borderBottom: '1px solid var(--border)' }}>
任务列表 ({tasks.length})
</h3>
<div style={{ flex: 1, overflow: 'auto' }}>
{tasks.length === 0 ? (
<p style={{ padding: 16, fontSize: 13, color: 'var(--text-muted)', textAlign: 'center' }}>
暂无任务
</p>
) : (
tasks.map(task => (
<a
key={task.id}
href={task.status === 'completed' ? `/projects/${projectId}/tasks/${task.id}` : undefined}
style={{
display: 'block',
padding: '12px 16px',
borderBottom: '1px solid var(--border)',
textDecoration: 'none',
color: 'inherit',
cursor: task.status === 'completed' ? 'pointer' : 'default',
transition: 'background 0.15s',
}}
onMouseEnter={e => {
if (task.status === 'completed')
(e.currentTarget as HTMLElement).style.background = 'var(--accent-dim)'
}}
onMouseLeave={e => {
(e.currentTarget as HTMLElement).style.background = ''
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{ASSET_TYPE_LABELS[task.assetType] || task.assetType}
</span>
<StatusBadge status={task.status} />
</div>
<p
style={{
fontSize: 13,
lineHeight: 1.4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{task.prompt}
</p>
<span style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 4, display: 'block' }}>
{formatRelativeTime(task.createdAt)}
</span>
</a>
))
)}
</div>
</div>
)
}
-14
View File
@@ -1,14 +0,0 @@
import { useGenerationStore } from '../stores/generation'
export function useGenerate() {
const store = useGenerationStore()
return {
submit: store.submit,
taskId: store.taskId,
progress: store.progress,
status: store.status,
assets: store.assets,
error: store.error,
reset: store.reset,
}
}
-14
View File
@@ -1,14 +0,0 @@
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 }
}
-132
View File
@@ -1,132 +0,0 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useTaskStore } from '../stores/task'
import { useProjectStore } from '../stores/project'
import { useGenerationStore } from '../stores/generation'
import { useToastStore } from '../stores/toast'
import { extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
import type { GenerateRequest } from '../api/types'
import GenerateForm from '../components/GenerateForm'
import ProgressBar from '../components/ProgressBar'
export default function GeneratePage() {
const { projectId = 'proj-default' } = useParams()
const navigate = useNavigate()
const addToast = useToastStore(s => s.addToast)
const taskStore = useTaskStore()
const { style: projectStyle, loadProject } = useProjectStore()
const {
status,
stage,
progress,
taskId,
statusText,
retryCount,
rejectReason,
submit,
reset: resetGeneration,
} = useGenerationStore()
// 加载工程风格
useEffect(() => {
loadProject(projectId)
}, [projectId, loadProject])
// 组件卸载时重置生成状态
useEffect(() => {
return () => resetGeneration()
}, [resetGeneration])
// 失败时显示 toast
useEffect(() => {
if (status === 'failed') {
const errText = useGenerationStore.getState().error || '未知错误'
addToast({ type: 'error', message: `素材生成失败:${errText}` })
}
}, [status, addToast])
const handleSubmit = async (finalPrompt: string) => {
const { taskStyle, params, enableAI, optimizedPrompt } = taskStore
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
const req: GenerateRequest = {
projectId,
prompt: enableAI && optimizedPrompt ? optimizedPrompt : finalPrompt,
assetType: taskStore.assetType,
tags,
projectStyle,
taskStyle,
resolution: params.resolution,
directions: params.frames?.directions,
framesPerDir: params.frames?.framesPerDirection,
format: params.format,
}
await submit(req)
}
const handleViewResult = () => {
if (taskId) navigate(`/projects/${projectId}/tasks/${taskId}`)
}
const handleReset = () => {
resetGeneration()
taskStore.reset()
}
return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>新建生成</h1>
{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)' }}>
{statusText || '管线执行中,请稍候...'}
</p>
)}
{status === 'completed' && (
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--success)', marginBottom: 16 }}>
生成完成
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button className="btn-primary" onClick={handleViewResult}>
查看结果
</button>
<button className="btn-secondary" onClick={handleReset}>
继续生成
</button>
</div>
</div>
)}
{status === 'failed' && (
<div style={{ textAlign: 'center' }}>
<p style={{ color: 'var(--error)', marginBottom: 16 }}>
{useGenerationStore.getState().error || '生成失败'}
</p>
<button className="btn-primary" onClick={handleReset}>
重新开始
</button>
</div>
)}
</div>
)}
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from 'react'
import { useProjectListStore } from '../stores/projectList'
import { useToastStore } from '../stores/toast'
import ProjectCard from '../components/ProjectCard'
import CreateProjectModal from '../components/CreateProjectModal'
import EmptyState from '../components/EmptyState'
import Skeleton from '../components/Skeleton'
export default function HomePage() {
const { projects, loading, loadProjects, createProject, deleteProject } =
useProjectListStore()
const addToast = useToastStore(s => s.addToast)
const [modalOpen, setModalOpen] = useState(false)
useEffect(() => {
loadProjects()
}, [loadProjects])
const handleCreate = async (name: string, style: Record<string, string>) => {
await createProject(name, style)
addToast({ type: 'success', message: '工程创建成功' })
}
const handleDelete = async (projectId: string) => {
if (!confirm('确定删除该工程?所有任务和素材将被永久删除。')) return
await deleteProject(projectId)
addToast({ type: 'success', message: '工程已删除' })
}
if (loading) {
return (
<div className="container page-enter" style={{ paddingTop: 32 }}>
<Skeleton variant="card" />
<div style={{ height: 16 }} />
<Skeleton variant="card" />
</div>
)
}
return (
<div className="container page-enter" style={{ paddingTop: 32, paddingBottom: 32 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<h1 style={{ fontSize: 22 }}>我的工程</h1>
<button className="btn-primary" onClick={() => setModalOpen(true)}>
新建工程
</button>
</div>
{projects.length === 0 ? (
<EmptyState
title="还没有工程"
description="创建你的第一个工程,开始生成游戏素材"
action={{ label: '新建工程', onClick: () => setModalOpen(true) }}
/>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))',
gap: 16,
}}
>
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onDelete={handleDelete}
/>
))}
</div>
)}
<CreateProjectModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSubmit={handleCreate}
/>
</div>
)
}
+173
View File
@@ -0,0 +1,173 @@
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useTaskStore } from '../stores/task'
import { useGenerationStore } from '../stores/generation'
import { useToastStore } from '../stores/toast'
import { extractTags } from '../api/prompt'
import { mergeStyles } from '../utils/style'
import type { GenerateRequest } from '../api/types'
import ProjectConfigPanel from '../components/ProjectConfigPanel'
import GenerateForm from '../components/GenerateForm'
import ProgressBar from '../components/ProgressBar'
import TaskListPanel from '../components/TaskListPanel'
export default function ProjectDetailPage() {
const { projectId = '' } = useParams()
const navigate = useNavigate()
const addToast = useToastStore(s => s.addToast)
const { style: projectStyle } = useProjectStore()
const taskStore = useTaskStore()
const {
status,
stage,
progress,
taskId,
statusText,
retryCount,
rejectReason,
submit,
reset: resetGeneration,
} = useGenerationStore()
// 重置生成状态(切换工程时)
useEffect(() => {
resetGeneration()
taskStore.reset()
}, [projectId]) // eslint-disable-line react-hooks/exhaustive-deps
// 失败时 toast
useEffect(() => {
if (status === 'failed') {
const errText = useGenerationStore.getState().error || '未知错误'
addToast({ type: 'error', message: `素材生成失败:${errText}` })
}
}, [status, addToast])
const handleSubmit = async (finalPrompt: string) => {
const { taskStyle, params, enableAI, optimizedPrompt } = taskStore
const mergedStyle = mergeStyles(projectStyle, taskStyle)
const tags = extractTags(mergedStyle)
const req: GenerateRequest = {
projectId,
prompt: enableAI && optimizedPrompt ? optimizedPrompt : finalPrompt,
assetType: taskStore.assetType,
tags,
projectStyle,
taskStyle,
resolution: params.resolution,
directions: params.frames?.directions,
framesPerDir: params.frames?.framesPerDirection,
format: params.format,
}
await submit(req)
}
const handleViewResult = () => {
if (taskId) navigate(`/projects/${projectId}/tasks/${taskId}`)
}
const handleReset = () => {
resetGeneration()
taskStore.reset()
}
return (
<div
style={{
display: 'grid',
gridTemplateColumns: '260px 1fr 320px',
height: 'calc(100vh - 56px)',
overflow: 'hidden',
}}
>
{/* 左栏:工程配置 */}
<aside
style={{
borderRight: '1px solid var(--border)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<ProjectConfigPanel />
</aside>
{/* 中间:生成表单 + 进度 */}
<main
className="page-enter"
style={{ overflow: 'auto', padding: '24px 32px' }}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 32, maxWidth: 720 }}>
{/* 上半:生成表单 */}
<section>
<h2 style={{ fontSize: 18, marginBottom: 20 }}>新建生成</h2>
<GenerateForm onSubmit={handleSubmit} submitting={status === 'submitting'} />
</section>
{/* 下半:当前任务进度 */}
{status !== 'idle' && (
<section className="card">
<h2 style={{ fontSize: 16, marginBottom: 16 }}>生成进度</h2>
<ProgressBar
stage={stage}
progress={progress}
status={status}
retryCount={retryCount}
rejectReason={rejectReason}
/>
{status === 'running' && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 12 }}>
{statusText || '管线执行中,请稍候...'}
</p>
)}
{status === 'completed' && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<p style={{ color: 'var(--success)', marginBottom: 12 }}>
生成完成
</p>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center' }}>
<button className="btn-primary" onClick={handleViewResult}>
查看结果
</button>
<button className="btn-secondary" onClick={handleReset}>
继续生成
</button>
</div>
</div>
)}
{status === 'failed' && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<p style={{ color: 'var(--error)', marginBottom: 12 }}>
{useGenerationStore.getState().error || '生成失败'}
</p>
<button className="btn-primary" onClick={handleReset}>
重新开始
</button>
</div>
)}
</section>
)}
</div>
</main>
{/* 右栏:任务列表 */}
<aside
style={{
borderLeft: '1px solid var(--border)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}}
>
<TaskListPanel />
</aside>
</div>
)
}
-165
View File
@@ -1,165 +0,0 @@
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { useProjectStore } from '../stores/project'
import { useToastStore } from '../stores/toast'
import { getTasks } from '../api/project'
import type { Task } from '../api/types'
import StyleSelector from '../components/StyleSelector'
import Skeleton from '../components/Skeleton'
import EmptyState from '../components/EmptyState'
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 navigate = useNavigate()
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 addToast = useToastStore(s => s.addToast)
const handleSave = async () => {
setSaving(true)
try {
await saveStyle()
addToast({ type: 'success', message: '风格已保存' })
} catch {
addToast({ type: 'error', message: '保存失败,请重试' })
} finally {
setSaving(false)
}
}
return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>{loading ? <Skeleton variant="text" lines={1} /> : name}</h1>
{/* 工程风格 */}
<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 ? (
<EmptyState
title="暂无任务"
description="点击「新建生成」开始创建你的第一个游戏素材"
action={{ label: '新建生成', onClick: () => navigate(`/projects/${projectId}/generate`) }}
/>
) : (
<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} className="table-row-hover" 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,
}
+23 -58
View File
@@ -6,7 +6,7 @@ import AssetPreview from '../components/AssetPreview'
import Skeleton from '../components/Skeleton' import Skeleton from '../components/Skeleton'
export default function ResultPage() { export default function ResultPage() {
const { projectId = 'proj-default', taskId } = useParams() const { projectId = '', taskId } = useParams()
const [task, setTask] = useState<Task | null>(null) const [task, setTask] = useState<Task | null>(null)
const [assets, setAssets] = useState<Asset[]>([]) const [assets, setAssets] = useState<Asset[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -33,12 +33,10 @@ export default function ResultPage() {
setPolling(false) setPolling(false)
setLoading(false) setLoading(false)
} else if (!pollRef.current) { } else if (!pollRef.current) {
// 开始轮询
setPolling(true) setPolling(true)
pollRef.current = setInterval(fetchTask, 2000) pollRef.current = setInterval(fetchTask, 2000)
} }
} catch { } catch {
// 出错也停止加载态
setLoading(false) setLoading(false)
} }
} }
@@ -53,10 +51,10 @@ export default function ResultPage() {
if (loading || polling) { if (loading || polling) {
return ( return (
<div className="container page-enter" style={{ paddingTop: 40 }}> <div className="container page-enter" style={{ paddingTop: 40 }}>
<div className="card" style={{ marginBottom: 24 }}> <div className="card">
<Skeleton variant="text" lines={1} /> <Skeleton variant="text" lines={1} />
<div style={{ marginTop: 16 }}> <div style={{ marginTop: 16 }}>
<Skeleton variant="text" lines={4} /> <Skeleton variant="card" />
</div> </div>
{task && ( {task && (
<p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 16 }}> <p style={{ textAlign: 'center', color: 'var(--text-secondary)', marginTop: 16 }}>
@@ -66,9 +64,6 @@ export default function ResultPage() {
</p> </p>
)} )}
</div> </div>
<div className="card">
<Skeleton variant="card" />
</div>
</div> </div>
) )
} }
@@ -86,58 +81,28 @@ export default function ResultPage() {
return ( return (
<div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}> <div className="container page-enter" style={{ paddingTop: 24, paddingBottom: 40 }}>
<h1 style={{ fontSize: 24, marginBottom: 32 }}>生成结果</h1> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
<section className="card" style={{ marginBottom: 24 }}> <Link
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}> to={`/projects/${projectId}`}
<h2 style={{ fontSize: 16, marginBottom: 0 }}>任务信息</h2> style={{ fontSize: 13, color: 'var(--text-secondary)' }}
<div style={{ display: 'flex', gap: 12 }}> >
{assets.length > 0 && ( &larr; 返回工程
<button </Link>
className="btn-primary" <h1 style={{ fontSize: 22 }}>生成结果</h1>
onClick={() => downloadAssets(assets)}
style={{ padding: '8px 20px', fontSize: 13 }}
>
下载全部素材
</button>
)}
<Link
to={`/projects/${projectId}/generate`}
className="btn-secondary"
style={{ padding: '8px 20px', fontSize: 13, borderRadius: 'var(--radius)', display: 'inline-block' }}
>
继续生成
</Link>
</div>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr', gap: '8px 16px', fontSize: 13 }}> {assets.length > 0 && (
<span style={{ color: 'var(--text-secondary)' }}>提示词</span> <button
<span>{task.prompt}</span> className="btn-primary"
<span style={{ color: 'var(--text-secondary)' }}>素材类型</span> onClick={() => downloadAssets(assets)}
<span>{task.assetType}</span> style={{ padding: '8px 20px', fontSize: 13 }}
<span style={{ color: 'var(--text-secondary)' }}>状态</span> >
<span style={{ color: task.status === 'completed' ? 'var(--success)' : 'var(--error)' }}> 下载全部素材
{task.status === 'completed' ? '已完成' : '失败'} </button>
</span> )}
<span style={{ color: 'var(--text-secondary)' }}>创建时间</span> </div>
<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 }}> <section className="card">
<h2 style={{ fontSize: 16, marginBottom: 16 }}>素材预览</h2>
<AssetPreview assets={assets} /> <AssetPreview assets={assets} />
</section> </section>
</div> </div>
+3 -8
View File
@@ -3,8 +3,7 @@ import { useAuthStore } from '../stores/auth'
import Layout from '../components/Layout' import Layout from '../components/Layout'
import LoginPage from '../pages/LoginPage' import LoginPage from '../pages/LoginPage'
import RegisterPage from '../pages/RegisterPage' import RegisterPage from '../pages/RegisterPage'
import ProjectPage from '../pages/ProjectPage' import HomePage from '../pages/HomePage'
import GeneratePage from '../pages/GeneratePage'
import ResultPage from '../pages/ResultPage' import ResultPage from '../pages/ResultPage'
function ProtectedRoute() { function ProtectedRoute() {
@@ -30,15 +29,11 @@ export const router = createBrowserRouter([
children: [ children: [
{ {
path: '/', path: '/',
element: <Navigate to="/projects/proj-default" replace />, element: <HomePage />,
}, },
{ {
path: '/projects/:projectId', path: '/projects/:projectId',
element: <ProjectPage />, lazy: () => import('../pages/ProjectDetailPage').then(m => ({ Component: m.default })),
},
{
path: '/projects/:projectId/generate',
element: <GeneratePage />,
}, },
{ {
path: '/projects/:projectId/tasks/:taskId', path: '/projects/:projectId/tasks/:taskId',
+63 -8
View File
@@ -5,17 +5,28 @@ interface ProjectState {
projectId: string projectId: string
name: string name: string
style: Record<string, string> style: Record<string, string>
draftStyle: Record<string, string>
draftName: string
hasUnsavedChanges: boolean
loading: boolean loading: boolean
loadProject: (projectId: string) => Promise<void> loadProject: (projectId: string) => Promise<void>
loadStyle: (projectId: string) => Promise<void> loadStyle: (projectId: string) => Promise<void>
updateStyle: (key: string, value: string) => void startEditing: () => void
saveStyle: () => Promise<void> updateDraft: (key: string, value: string) => void
setDraftStyle: (style: Record<string, string>) => void
setDraftName: (name: string) => void
discardDraft: () => void
commitDraft: () => Promise<void>
updateName: (name: string) => Promise<void>
} }
export const useProjectStore = create<ProjectState>((set, get) => ({ export const useProjectStore = create<ProjectState>((set, get) => ({
projectId: '', projectId: '',
name: '', name: '',
style: {}, style: {},
draftStyle: {},
draftName: '',
hasUnsavedChanges: false,
loading: false, loading: false,
loadProject: async (projectId) => { loadProject: async (projectId) => {
@@ -25,7 +36,10 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
set({ set({
projectId: project.id, projectId: project.id,
name: project.name, name: project.name,
draftName: project.name,
style: project.style.kvPairs, style: project.style.kvPairs,
draftStyle: { ...project.style.kvPairs },
hasUnsavedChanges: false,
loading: false, loading: false,
}) })
} catch { } catch {
@@ -35,15 +49,56 @@ export const useProjectStore = create<ProjectState>((set, get) => ({
loadStyle: async (projectId) => { loadStyle: async (projectId) => {
const kvPairs = await projectApi.getStyle(projectId) const kvPairs = await projectApi.getStyle(projectId)
set({ style: kvPairs }) set({ style: kvPairs, draftStyle: { ...kvPairs }, hasUnsavedChanges: false })
}, },
updateStyle: (key, value) => { startEditing: () => {
set(state => ({ style: { ...state.style, [key]: value } })) const { style, name } = get()
set({ draftStyle: { ...style }, draftName: name, hasUnsavedChanges: false })
}, },
saveStyle: async () => { updateDraft: (key, value) => {
const { projectId, style } = get() set(state => {
await projectApi.saveStyle(projectId, style) const next = { ...state.draftStyle }
if (next[key] === value) {
delete next[key]
} else {
next[key] = value
}
return {
draftStyle: next,
hasUnsavedChanges: true,
}
})
},
setDraftStyle: (style) => set({ draftStyle: style, hasUnsavedChanges: true }),
setDraftName: (name) => set({ draftName: name, hasUnsavedChanges: true }),
discardDraft: () => {
const { style, name } = get()
set({ draftStyle: { ...style }, draftName: name, hasUnsavedChanges: false })
},
commitDraft: async () => {
const { projectId, draftStyle, draftName, name } = get()
// save style
await projectApi.saveStyle(projectId, draftStyle)
// save name if changed
if (draftName !== name) {
await projectApi.updateProject(projectId, { name: draftName })
}
set({
style: { ...draftStyle },
name: draftName,
hasUnsavedChanges: false,
})
},
updateName: async (name) => {
const { projectId } = get()
await projectApi.updateProject(projectId, { name })
set({ name, draftName: name })
}, },
})) }))
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand'
import type { Project } from '../api/types'
import * as projectApi from '../api/project'
interface ProjectListState {
projects: Project[]
loading: boolean
loadProjects: () => Promise<void>
createProject: (name: string, style?: Record<string, string>) => Promise<Project>
deleteProject: (projectId: string) => Promise<void>
}
export const useProjectListStore = create<ProjectListState>((set, get) => ({
projects: [],
loading: false,
loadProjects: async () => {
set({ loading: true })
try {
const projects = await projectApi.listProjects()
set({ projects, loading: false })
} catch {
set({ loading: false })
}
},
createProject: async (name, style) => {
const project = await projectApi.createProject(name, style)
set({ projects: [project, ...get().projects] })
return project
},
deleteProject: async (projectId) => {
await projectApi.deleteProject(projectId)
set({ projects: get().projects.filter(p => p.id !== projectId) })
},
}))
+27
View File
@@ -8,6 +8,33 @@ export function mergeStyles(
return { ...projectStyle, ...taskOverrides } return { ...projectStyle, ...taskOverrides }
} }
const CUSTOM_PREFIX = 'custom:'
export function isCustomKey(key: string): boolean {
return key.startsWith(CUSTOM_PREFIX)
}
export function getCustomTags(style: Record<string, string>): string[] {
return Object.keys(style)
.filter(isCustomKey)
.map(key => key.slice(CUSTOM_PREFIX.length))
}
export function addCustomTag(
style: Record<string, string>,
tag: string
): Record<string, string> {
return { ...style, [`${CUSTOM_PREFIX}${tag}`]: 'true' }
}
export function removeCustomTag(
style: Record<string, string>,
tag: string
): Record<string, string> {
const { [`${CUSTOM_PREFIX}${tag}`]: _, ...rest } = style
return rest
}
/** /**
* 风格键分类定义 * 风格键分类定义
*/ */