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:
+1
-1
@@ -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
@@ -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
@@ -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):基于工程风格展示,仅记录差异
|
||||||
|
|||||||
@@ -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()` 函数在提取标签时自动包含自定义标签值(去掉前缀),送入提示词优化管线。
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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 && (
|
← 返回工程
|
||||||
<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 +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',
|
||||||
|
|||||||
@@ -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 })
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -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) })
|
||||||
|
},
|
||||||
|
}))
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 风格键分类定义
|
* 风格键分类定义
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user