From a603f5548ceb43ce9e841ad9132943b50f7af008 Mon Sep 17 00:00:00 2001 From: wonder Date: Mon, 25 May 2026 16:15:31 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=89=8D=E7=AB=AF=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E4=B8=BA=E9=A1=B9=E7=9B=AE+=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E5=8F=8C=E6=A0=B8=E5=BF=83=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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) --- docs/_index.md | 2 +- docs/api.md | 21 +- docs/frontend.md | 371 +++++++++--------- docs/style-keys.md | 15 + frontend/src/api/client.ts | 4 + frontend/src/api/mock.ts | 230 ++++++++--- frontend/src/api/project.ts | 33 +- frontend/src/api/prompt.ts | 8 +- frontend/src/api/types.ts | 3 + .../src/components/CreateProjectModal.tsx | 115 ++++++ frontend/src/components/CustomTagsEditor.tsx | 87 ++++ frontend/src/components/ProjectCard.tsx | 92 +++++ .../src/components/ProjectConfigPanel.tsx | 109 +++++ frontend/src/components/StatusBadge.tsx | 31 ++ frontend/src/components/TaskListPanel.tsx | 115 ++++++ frontend/src/hooks/useGenerate.ts | 14 - frontend/src/hooks/useProjectStyle.ts | 14 - frontend/src/pages/GeneratePage.tsx | 132 ------- frontend/src/pages/HomePage.tsx | 80 ++++ frontend/src/pages/ProjectDetailPage.tsx | 173 ++++++++ frontend/src/pages/ProjectPage.tsx | 165 -------- frontend/src/pages/ResultPage.tsx | 81 ++-- frontend/src/router/index.tsx | 11 +- frontend/src/stores/project.ts | 71 +++- frontend/src/stores/projectList.ts | 37 ++ frontend/src/utils/style.ts | 27 ++ 26 files changed, 1397 insertions(+), 644 deletions(-) create mode 100644 frontend/src/components/CreateProjectModal.tsx create mode 100644 frontend/src/components/CustomTagsEditor.tsx create mode 100644 frontend/src/components/ProjectCard.tsx create mode 100644 frontend/src/components/ProjectConfigPanel.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/TaskListPanel.tsx delete mode 100755 frontend/src/hooks/useGenerate.ts delete mode 100755 frontend/src/hooks/useProjectStyle.ts delete mode 100755 frontend/src/pages/GeneratePage.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/ProjectDetailPage.tsx delete mode 100755 frontend/src/pages/ProjectPage.tsx create mode 100644 frontend/src/stores/projectList.ts diff --git a/docs/_index.md b/docs/_index.md index b1a5453..5fc8745 100755 --- a/docs/_index.md +++ b/docs/_index.md @@ -9,7 +9,7 @@ Go + Gin + Eino / Vite + React + TypeScript + zustand / SQLite / 七牛云对象 ## 文档索引 - [后端工程](backend.md) — 分层结构、管线设计(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter)、风格模型 -- [前端工程](frontend.md) — 组件树、状态管理、路由、WebSocket 通信 +- [前端工程](frontend.md) — 组件树、状态管理、路由、三栏工作台、自定义标签 - [异步任务](async-tasks.md) — 任务队列、状态机、并发控制、失败重试 - [数据存储](database.md) — SQLite 选型、表结构、七牛云对象存储 - [API 设计](api.md) — 接口列表、请求/响应示例、实现状态 diff --git a/docs/api.md b/docs/api.md index 36bf89d..06948af 100755 --- a/docs/api.md +++ b/docs/api.md @@ -217,9 +217,10 @@ POST /api/v1/prompt/optimize | 状态 | 方法 | 路径 | 说明 | |------|------|------|------| -| [ ] | GET | `/api/v1/projects` | 获取当前用户的工程列表 | -| [ ] | POST | `/api/v1/projects` | 创建工程 | +| [ ] | GET | `/api/v1/projects` | 获取当前用户的工程列表(前端已对接 mock) | +| [ ] | POST | `/api/v1/projects` | 创建工程(前端已对接 mock) | | [ ] | GET | `/api/v1/projects/:projectId` | 获取工程信息 | +| [ ] | PUT | `/api/v1/projects/:projectId` | 更新工程信息(如名称) | | [ ] | DELETE | `/api/v1/projects/:projectId` | 删除工程(级联删除任务和素材) | | [ ] | GET | `/api/v1/projects/:projectId/tasks` | 获取工程下的任务列表 | @@ -244,7 +245,9 @@ POST /api/v1/prompt/optimize { "id": "proj_abc123", "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 { - "name": "我的像素游戏" + "name": "我的像素游戏", + "style": { + "artStyle": "pixel", + "palette": "warm" + } } ``` @@ -268,8 +275,10 @@ POST /api/v1/prompt/optimize "data": { "id": "proj_abc123", "name": "我的像素游戏", - "style": { "kvPairs": {} }, - "createdAt": "2026-05-24T10:00:00Z" + "style": { "kvPairs": { "artStyle": "pixel", "palette": "warm" } }, + "createdAt": "2026-05-24T10:00:00Z", + "taskCount": 0, + "lastActivityAt": "2026-05-24T10:00:00Z" } } ``` diff --git a/docs/frontend.md b/docs/frontend.md index 12a2cc8..91353d2 100755 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -10,7 +10,7 @@ Vite 6 + React 18 + TypeScript + zustand + react-router-dom | UI 框架 | React 18 | 函数组件 + Hooks | | 状态管理 | zustand | 轻量,支持 devtools 中间件 | | 路由 | react-router-dom v6 | SPA 模式 | -| 实时通信 | 原生 WebSocket | 连接后端 `/api/v1/tasks/:taskId/ws`(Cookie 认证) | +| 实时通信 | HTTP 轮询 | 每 1.5s 轮询任务状态(WebSocket 待后端实现) | | HTTP 请求 | fetch + 封装层 | 统一错误处理、响应解包 | ## 目录结构 @@ -18,40 +18,51 @@ Vite 6 + React 18 + TypeScript + zustand + react-router-dom ``` frontend/src/ ├── main.tsx # 入口 -├── App.tsx # 根组件 + 路由配置 +├── App.tsx # 根组件 ├── api/ # API 封装层 -│ ├── client.ts # fetch 封装:baseURL、统一错误处理、响应解包 +│ ├── client.ts # fetch 封装:统一错误处理、响应解包(get/post/put/del) │ ├── auth.ts # 注册/登录(真实后端调用) -│ ├── project.ts # 工程 CRUD + 风格 GET/PUT(mock 切换) -│ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets(mock 切换) -│ ├── mock.ts # Mock 数据 + 模拟 WebSocket 管线进度 -│ └── types.ts # API 请求/响应类型定义(Task、Asset、Style、PipelineProgress 等) +│ ├── project.ts # 工程 CRUD + 风格 + 任务列表(mock 切换) +│ ├── generate.ts # POST /generate、GET /tasks/:taskId、GET /tasks/:taskId/assets +│ ├── prompt.ts # 提示词优化 + extractTags +│ ├── mock.ts # 多工程 Mock 数据 + 模拟 WebSocket 管线进度 +│ └── types.ts # API 类型定义 ├── stores/ # zustand stores -│ ├── auth.ts # 用户认证状态(login/register/logout/loadFromStorage) -│ ├── project.ts # 当前工程(id、风格、任务列表) -│ ├── task.ts # 当前任务草稿(用户文本、素材类型、任务风格覆盖、技术参数) -│ └── generation.ts # 生成状态(taskId、进度、阶段、结果、错误) +│ ├── auth.ts # 用户认证状态 +│ ├── project.ts # 当前工程详情(含 draft/commit 风格编辑) +│ ├── projectList.ts # 工程列表(画廊页) +│ ├── task.ts # 任务草稿(生成表单状态) +│ ├── generation.ts # 生成状态(提交、轮询、结果) +│ ├── theme.ts # 主题切换(light/dark) +│ └── toast.ts # Toast 通知 ├── pages/ # 页面级组件 │ ├── LoginPage.tsx # 登录页 │ ├── RegisterPage.tsx # 注册页 -│ ├── ProjectPage.tsx # 工程首页:工程风格配置 + 任务列表 -│ ├── GeneratePage.tsx # 生成页:提示词构建 + 提交 -│ └── ResultPage.tsx # 结果页:素材预览 + 下载 + 元数据 +│ ├── HomePage.tsx # 项目画廊(工程列表 + 新建弹窗) +│ ├── ProjectDetailPage.tsx # 三栏工作台(配置 + 生成 + 任务列表) +│ └── ResultPage.tsx # 结果页(素材预览 + 下载) ├── components/ # 可复用组件 -│ ├── StyleSelector.tsx # 风格选择器(CSS Modules) +│ ├── Layout.tsx # 导航栏 + Outlet + ToastContainer +│ ├── StyleSelector.tsx # 预设风格选择器(pill 按钮) +│ ├── CustomTagsEditor.tsx # 自定义标签编辑器(可添加/删除) │ ├── PromptEditor.tsx # 三段式提示词编辑器 -│ ├── GenerateForm.tsx # 生成表单 -│ ├── ProgressBar.tsx # 管线进度条(显示当前阶段 + 重试状态) -│ └── AssetPreview.tsx # 素材预览(spritesheet 预览、单帧预览) -├── hooks/ # 自定义 Hooks -│ ├── useGenerate.ts # 提交生成任务 + WebSocket 订阅进度 -│ └── useProjectStyle.ts # 工程风格加载/保存 +│ ├── GenerateForm.tsx # 生成表单(素材类型 + 风格覆盖 + 提示词 + 参数) +│ ├── ProgressBar.tsx # 管线进度条 +│ ├── AssetPreview.tsx # 素材预览 +│ ├── ProjectCard.tsx # 画廊卡片 +│ ├── CreateProjectModal.tsx # 新建工程弹窗 +│ ├── ProjectConfigPanel.tsx # 左栏:工程配置编辑面板 +│ ├── TaskListPanel.tsx # 右栏:任务列表面板 +│ ├── StatusBadge.tsx # 状态徽标组件 +│ ├── EmptyState.tsx # 空状态占位 +│ ├── Skeleton.tsx # 加载骨架屏 +│ └── Toast.tsx # Toast 通知容器 ├── router/ # 路由定义 │ └── index.tsx ├── styles/ # 全局样式 -│ └── global.css # 暗色主题 CSS 变量 + reset +│ └── global.css # 暗色/亮色主题 CSS 变量 + reset └── utils/ # 工具函数 - └── style.ts # 风格合并逻辑 + 风格键分类定义 + └── style.ts # 风格合并、预设分类、自定义标签工具 ``` ## 实现状态 @@ -59,167 +70,219 @@ frontend/src/ | 模块 | 状态 | 说明 | |------|------|------| | API 封装层 | [x] | client.ts 统一 fetch 封装,auth.ts 真实调用,project/generate 使用 mock | -| Mock 层 | [x] | mock.ts 提供 mock 数据 + 模拟 WebSocket 管线进度 | +| Mock 层 | [x] | mock.ts 提供多工程 mock 数据 + 模拟 WebSocket 管线进度 | | 认证流程 | [x] | 注册/登录/退出/token 持久化,真实后端对接 | -| zustand stores | [x] | auth/project/task/generation 四个 store | -| 页面 | [x] | 5 个页面全部实现(Login/Register/Project/Generate/Result) | -| 组件 | [x] | StyleSelector/PromptEditor/GenerateForm/ProgressBar/AssetPreview | +| zustand stores | [x] | auth/project/projectList/task/generation/theme/toast 七个 store | +| 页面 | [x] | 5 个页面(Login/Register/Home/ProjectDetail/Result) | +| 组件 | [x] | StyleSelector/CustomTagsEditor/PromptEditor/GenerateForm/ProgressBar/AssetPreview/ProjectCard/CreateProjectModal/ProjectConfigPanel/TaskListPanel/StatusBadge 等 | | 路由 | [x] | 含 ProtectedRoute 守卫 | -| WebSocket | [ ] | 当前使用 mock 模拟,待后端实现后切换为真实 WS | +| 自定义标签 | [x] | `custom:` 前缀存入 kvPairs,零后端改动 | ## 路由规划 | 路径 | 页面 | 说明 | |------|------|------| -| `/` | — | 重定向到默认工程 | -| `/projects/:projectId` | ProjectPage | 工程首页,配置工程风格,查看历史任务 | -| `/projects/:projectId/generate` | GeneratePage | 提示词构建 + 提交生成 | -| `/projects/:projectId/tasks/:taskId` | ResultPage | 任务结果页,预览素材、下载 | +| `/` | HomePage | 项目画廊,展示所有工程 | +| `/projects/:projectId` | ProjectDetailPage | 三栏工作台(配置 + 生成 + 任务列表) | +| `/projects/:projectId/tasks/:taskId` | ResultPage | 素材预览 + 下载 | ## 组件树 ```mermaid graph TD App --> Router - Router --> ProjectPage - Router --> GeneratePage + Router --> HomePage + Router --> ProjectDetailPage Router --> ResultPage - ProjectPage --> StyleSelector["StyleSelector(工程风格编辑)"] - ProjectPage --> TaskList["TaskList(历史任务列表)"] + HomePage --> ProjectCard["ProjectCard(工程卡片)"] + HomePage --> CreateProjectModal["CreateProjectModal(新建弹窗)"] + CreateProjectModal --> StyleSelector + CreateProjectModal --> CustomTagsEditor - GeneratePage --> GenerateForm - GenerateForm --> AssetTypePicker["AssetTypePicker"] - GenerateForm --> StyleSelector2["StyleSelector(任务风格覆盖)"] - GenerateForm --> PromptEditor["PromptEditor(三段式预览)"] - GenerateForm --> ParamsForm["ParamsForm(分辨率、帧数)"] - GeneratePage --> ProgressBar["ProgressBar(管线进度)"] + ProjectDetailPage --> ProjectConfigPanel["ProjectConfigPanel(左栏配置)"] + ProjectDetailPage --> GenerateForm["GenerateForm(中间上半)"] + ProjectDetailPage --> ProgressBar["ProgressBar(中间下半)"] + ProjectDetailPage --> TaskListPanel["TaskListPanel(右栏任务)"] - ResultPage --> AssetPreview["AssetPreview(素材预览)"] - ResultPage --> MetadataPanel["MetadataPanel(元数据)"] - ResultPage --> DownloadButton["DownloadButton"] + ProjectConfigPanel --> StyleSelector2["StyleSelector"] + ProjectConfigPanel --> CustomTagsEditor2["CustomTagsEditor"] + 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 sequenceDiagram actor User - participant GP as GeneratePage + participant PDP as ProjectDetailPage participant API as 后端 API - participant WS as WebSocket - User->>GP: 填写 prompt、选择素材类型 - User->>GP: StyleSelector 点选任务风格覆盖 - GP->>GP: PromptEditor 实时预览三段式提示词 - User->>GP: 点击提交 - GP->>API: POST /api/v1/generate - API-->>GP: 返回 taskId - GP->>WS: ws://host/api/v1/tasks/:taskId/ws(Cookie 自动携带) - loop 管线执行中 - WS-->>GP: PipelineProgress(stage + progress) - GP->>GP: ProgressBar 更新阶段和进度 + User->>PDP: 填写 prompt、选择素材类型 + User->>PDP: 点击提交 + PDP->>API: POST /api/v1/generate + API-->>PDP: 返回 taskId + PDP->>PDP: ProgressBar 显示进度 + + loop 轮询任务状态(1.5s) + PDP->>API: GET /api/v1/tasks/:taskId + API-->>PDP: 任务状态 + 进度 + PDP->>PDP: 更新 ProgressBar 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 sequenceDiagram actor User - participant SP as StyleSelector + participant PCP as ProjectConfigPanel + participant Store as project store participant API as 后端 API - User->>SP: 进入 ProjectPage - SP->>API: GET /api/v1/projects/:id/style - API-->>SP: 返回 kvPairs - SP->>SP: 按分类展示风格键值对 - User->>SP: 点选/修改风格 - SP->>SP: 实时展示当前配置 - User->>SP: 点击保存 - SP->>API: PUT /api/v1/projects/:id/style - API-->>SP: 保存成功 + User->>PCP: 进入工程页 + PCP->>Store: startEditing() — 复制 style 到 draftStyle + User->>PCP: 修改预设风格 / 添加自定义标签 + PCP->>Store: updateDraft(key, value) — 更新 draftStyle + Store->>Store: hasUnsavedChanges = true + + alt 点击保存 + User->>PCP: 点击「保存」 + 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` 的响应,抛出业务异常 -- 401:跳转到登录页(Token 已过期或无效,Cookie 会被服务端清除) -- 403:提示无权访问,不自动跳转 -- 429:提示请求过于频繁,稍后重试 -- 500:展示通用错误提示 +```json +{ + "artStyle": "pixel", + "palette": "warm", + "custom:cel-shading": "true", + "custom:glow-effects": "true" +} +``` -### WebSocket 断连 +工具函数(`utils/style.ts`): +- `isCustomKey(key)` — 判断是否为自定义标签 +- `getCustomTags(style)` — 提取自定义标签列表 +- `addCustomTag(style, tag)` — 添加标签 +- `removeCustomTag(style, tag)` — 删除标签 -- 连接断开后自动重连(指数退避,最大间隔 10 秒) -- 重连失败超过 3 次后,降级为轮询模式(`GET /api/v1/tasks/:taskId`,间隔 2-3 秒) -- 连接恢复后自动切回 WebSocket - -### 加载状态 - -- 页面级加载(如 ProjectPage 进入时):展示骨架屏或 Loading 指示器 -- 操作级提交(如保存风格、提交生成):按钮置为 loading 态,防止重复提交 +`extractTags()` 函数在提取标签时自动包含自定义标签值。 ## 状态管理 ### zustand stores -**project store** — 当前工程 +**projectList store** — 工程列表(HomePage) ```typescript -interface ProjectStore { - projectId: string; - name: string; - style: Record; // kvPairs +interface ProjectListState { + projects: Project[]; loading: boolean; - loadProject: (projectId: string) => Promise; - loadStyle: (projectId: string) => Promise; - updateStyle: (kvPairs: Record) => void; - saveStyle: () => Promise; + loadProjects: () => Promise; + createProject: (name: string, style?: Record) => Promise; + deleteProject: (projectId: string) => Promise; } ``` -**task store** — 任务草稿(对应后端 `PipelineInput`) +**project store** — 当前工程详情(ProjectDetailPage) ```typescript -interface TaskStore { +interface ProjectState { + projectId: string; + name: string; + style: Record; + draftStyle: Record; + draftName: string; + hasUnsavedChanges: boolean; + loading: boolean; + loadProject: (projectId: string) => Promise; + loadStyle: (projectId: string) => Promise; + startEditing: () => void; + updateDraft: (key: string, value: string) => void; + setDraftStyle: (style: Record) => void; + setDraftName: (name: string) => void; + discardDraft: () => void; + commitDraft: () => Promise; +} +``` + +**task store** — 任务草稿 + +```typescript +interface TaskState { prompt: string; assetType: 'sprite' | 'background' | 'ui' | 'animation'; - taskStyle: Record; // 仅覆盖的键 - params: { - resolution: number; - frames?: { directions: number; framesPerDirection: number }; - format: 'spritesheet' | 'individual'; - }; - setPrompt: (text: string) => void; - setAssetType: (type: TaskStore['assetType']) => void; - toggleTaskStyle: (key: string, value: string) => void; - setParams: (params: Partial) => void; - reset: () => void; + taskStyle: Record; + params: TaskParams; + enableAI: boolean; + optimizedPrompt: string | null; + // ... setters, runOptimize, reset } ``` **generation store** — 生成状态 ```typescript -interface GenerationStore { +interface GenerationState { taskId: string | null; stage: PipelineStage | null; progress: number; @@ -228,57 +291,26 @@ interface GenerationStore { rejectReason: string | null; assets: Asset[]; error: string | null; - submit: (projectId: string, task: TaskStore) => Promise; + submit: (req: GenerateRequest) => Promise; reset: () => void; } - -type PipelineStage = - | 'prompt_optimizer' - | 'asset_generator' - | 'quality_supervisor' - | 'format_adapter'; ``` -## API 封装 - -`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) +## API 类型定义(api/types.ts) ```typescript -// 请求 -interface CreateProjectRequest { - name: string; -} - -interface GenerateRequest { - projectId: string; - prompt: string; - assetType: 'sprite' | 'background' | 'ui' | 'animation'; - taskStyle?: Record; - params?: { - resolution?: number; - frames?: { directions?: number; framesPerDirection?: number }; - format?: 'spritesheet' | 'individual'; - }; -} - -interface UpdateStyleRequest { - kvPairs: Record; -} - -// 响应 interface Project { id: string; name: string; style: { kvPairs: Record }; createdAt: string; + taskCount?: number; + lastActivityAt?: string; +} + +interface CreateProjectRequest { + name: string; + style?: Record; } interface Task { @@ -286,7 +318,7 @@ interface Task { projectId: string; prompt: string; assetType: string; - status: 'pending' | 'running' | 'completed' | 'failed'; + status: 'pending' | 'submitted' | 'running' | 'completed' | 'failed'; stage?: PipelineStage; progress?: number; retryCount?: number; @@ -297,6 +329,7 @@ interface Task { interface Asset { id: string; + key: string; url: string; format: string; width: number; @@ -308,18 +341,6 @@ interface Asset { 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 两种使用场景: -1. **工程风格**(ProjectPage):全量编辑,保存到后端 -2. **任务覆盖**(GeneratePage):基于工程风格展示,高亮已覆盖的键,仅记录差异 +1. **工程风格**(ProjectConfigPanel / CreateProjectModal):全量编辑,保存到后端 +2. **任务覆盖**(GenerateForm):基于工程风格展示,仅记录差异 diff --git a/docs/style-keys.md b/docs/style-keys.md index cc20738..075bea7 100755 --- a/docs/style-keys.md +++ b/docs/style-keys.md @@ -12,3 +12,18 @@ | 情绪 | `mood` | cheerful, dark, mysterious, epic, calm | (具体键值后续可扩展,这里是初始集合) + +## 自定义标签 + +除预设分类外,用户可添加自定义标签。标签存储在 `kvPairs` 中,使用 `custom:` 前缀区分: + +```json +{ + "artStyle": "pixel", + "palette": "warm", + "custom:cel-shading": "true", + "custom:glow-effects": "true" +} +``` + +前端 `extractTags()` 函数在提取标签时自动包含自定义标签值(去掉前缀),送入提示词优化管线。 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 0ec0a26..598ebf3 100755 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -64,3 +64,7 @@ export function put(url: string, body?: unknown): Promise { body: body ? JSON.stringify(body) : undefined, }) } + +export function del(url: string): Promise { + return request(url, { method: 'DELETE' }) +} diff --git a/frontend/src/api/mock.ts b/frontend/src/api/mock.ts index e4d001e..5ad7ea3 100755 --- a/frontend/src/api/mock.ts +++ b/frontend/src/api/mock.ts @@ -3,60 +3,114 @@ import type { Asset, PipelineProgress, PipelineStage, Project, Task } from './ty const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) const randomDelay = () => delay(300 + Math.random() * 500) -// Mock 数据 -const MOCK_PROJECT: Project = { - id: 'proj-default', - name: '我的工程', - style: { - kvPairs: { - artStyle: 'pixel', - palette: 'warm', - lineWeight: 'thin', - scene: 'forest', - lighting: 'bright', - mood: 'cheerful', +// Mock 工程数据 +const MOCK_PROJECTS: Project[] = [ + { + id: 'proj-pixel-forest', + name: '像素森林', + style: { + kvPairs: { + artStyle: 'pixel', + palette: 'warm', + lineWeight: 'thin', + scene: 'forest', + lighting: 'bright', + mood: 'cheerful', + }, }, - }, - createdAt: '2026-05-20T10:00:00Z', -} - -const MOCK_TASKS: Task[] = [ - { - id: 'task-001', - projectId: 'proj-default', - prompt: '一个拿剑的小人', - assetType: 'sprite', - status: 'completed', - progress: 100, - createdAt: '2026-05-24T09:00:00Z', - updatedAt: '2026-05-24T09:02:00Z', + createdAt: '2026-05-20T10:00:00Z', + taskCount: 3, + lastActivityAt: '2026-05-24T09:02:00Z', }, { - id: 'task-002', - projectId: 'proj-default', - prompt: '森林背景', - assetType: 'background', - status: 'failed', - error: '生成超时', - createdAt: '2026-05-24T08:00:00Z', - updatedAt: '2026-05-24T08:10:00Z', + id: 'proj-dark-dungeon', + name: '暗黑地牢', + style: { + kvPairs: { + artStyle: 'hand-drawn', + palette: 'muted', + lineWeight: 'thick', + scene: 'dungeon', + lighting: 'dim', + mood: 'dark', + }, + }, + createdAt: '2026-05-18T14:00:00Z', + taskCount: 1, + lastActivityAt: '2026-05-22T16:30:00Z', }, { - id: 'task-003', - projectId: 'proj-default', - prompt: '魔法药水瓶', - assetType: 'ui', - status: 'completed', - progress: 100, - createdAt: '2026-05-23T15:00:00Z', - updatedAt: '2026-05-23T15:03:00Z', + id: 'proj-neon-city', + name: '霓虹都市', + style: { + kvPairs: { + artStyle: 'vector', + palette: 'vibrant', + lineWeight: 'medium', + 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 = { + '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[] = [ { 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', format: 'png', width: 256, @@ -70,36 +124,89 @@ const MOCK_ASSETS: Asset[] = [ }, ] -// Mock API 函数 -export async function mockGetProject(_id: string): Promise { +// Mock API 函数 — 工程 +export async function mockListProjects(): Promise { await randomDelay() - return { ...MOCK_PROJECT } + return MOCK_PROJECTS.map(p => ({ ...p })) } +export async function mockGetProject(projectId: string): Promise { + 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 +): Promise { + 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 { + 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 { + 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( - _projectId: string + projectId: string ): Promise> { 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( - _projectId: string, - _kvPairs: Record + projectId: string, + kvPairs: Record ): Promise { await randomDelay() + const project = MOCK_PROJECTS.find(p => p.id === projectId) + if (project) project.style.kvPairs = { ...kvPairs } } -export async function mockGetTasks(_projectId: string): Promise { +// Mock API 函数 — 任务 +export async function mockGetTasks(projectId: string): Promise { await randomDelay() - return MOCK_TASKS.map(t => ({ ...t })) + return (MOCK_TASKS[projectId] || []).map(t => ({ ...t })) } export async function mockGetTask(taskId: string): Promise { await randomDelay() - const task = MOCK_TASKS.find(t => t.id === taskId) - if (!task) throw new Error('任务不存在') - return { ...task } + for (const tasks of Object.values(MOCK_TASKS)) { + const task = tasks.find(t => t.id === taskId) + if (task) return { ...task } + } + throw new Error('任务不存在') } export async function mockGetAssets(_taskId: string): Promise { @@ -108,21 +215,28 @@ export async function mockGetAssets(_taskId: string): Promise { } export async function mockSubmitGenerate( - _projectId: string, + projectId: string, prompt: string, assetType: string ): Promise { await delay(500) const taskId = `task-${Date.now()}` - MOCK_TASKS.unshift({ + if (!MOCK_TASKS[projectId]) MOCK_TASKS[projectId] = [] + MOCK_TASKS[projectId].unshift({ id: taskId, - projectId: _projectId, + projectId, prompt, assetType, status: 'pending', createdAt: 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 } diff --git a/frontend/src/api/project.ts b/frontend/src/api/project.ts index 3aa3447..a87d19c 100755 --- a/frontend/src/api/project.ts +++ b/frontend/src/api/project.ts @@ -1,16 +1,45 @@ import type { Project, Task } from './types' import { + mockListProjects, mockGetProject, + mockCreateProject, + mockDeleteProject, + mockUpdateProject, mockGetStyle, - mockGetTasks, mockSaveStyle, + mockGetTasks, } from './mock' const USE_MOCK = true +export async function listProjects(): Promise { + if (USE_MOCK) return mockListProjects() + throw new Error('Not implemented') +} + export async function getProject(projectId: string): Promise { if (USE_MOCK) return mockGetProject(projectId) - // TODO: 真实 API 调用 + throw new Error('Not implemented') +} + +export async function createProject( + name: string, + style?: Record +): Promise { + if (USE_MOCK) return mockCreateProject(name, style) + throw new Error('Not implemented') +} + +export async function deleteProject(projectId: string): Promise { + if (USE_MOCK) return mockDeleteProject(projectId) + throw new Error('Not implemented') +} + +export async function updateProject( + projectId: string, + data: { name?: string } +): Promise { + if (USE_MOCK) return mockUpdateProject(projectId, data) throw new Error('Not implemented') } diff --git a/frontend/src/api/prompt.ts b/frontend/src/api/prompt.ts index 0d41baf..89464fe 100755 --- a/frontend/src/api/prompt.ts +++ b/frontend/src/api/prompt.ts @@ -1,5 +1,5 @@ import { post } from './client' -import { STYLE_CATEGORIES } from '../utils/style' +import { STYLE_CATEGORIES, getCustomTags } from '../utils/style' interface OptimizePromptParams { tags: string[] @@ -14,15 +14,17 @@ interface OptimizePromptResponse { } /** - * 从风格键值对中提取标签的中文名称作为 tags + * 从风格键值对中提取标签的中文名称作为 tags,包含自定义标签 */ export function extractTags(style: Record): string[] { - return STYLE_CATEGORIES.flatMap(cat => { + const presetTags = STYLE_CATEGORIES.flatMap(cat => { const value = style[cat.key] if (!value) return [] const option = cat.options.find(o => o.value === value) return option ? [option.label] : [] }) + const customTags = getCustomTags(style) + return [...presetTags, ...customTags] } /** diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 4bd6529..37cf694 100755 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -50,10 +50,13 @@ export interface Project { name: string style: { kvPairs: Record } createdAt: string + taskCount?: number + lastActivityAt?: string } export interface CreateProjectRequest { name: string + style?: Record } // 风格 diff --git a/frontend/src/components/CreateProjectModal.tsx b/frontend/src/components/CreateProjectModal.tsx new file mode 100644 index 0000000..919860e --- /dev/null +++ b/frontend/src/components/CreateProjectModal.tsx @@ -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) => Promise +} + +export default function CreateProjectModal({ + open, + onClose, + onSubmit, +}: CreateProjectModalProps) { + const [name, setName] = useState('') + const [style, setStyle] = useState>({}) + 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 ( +
+
+
e.stopPropagation()} + > +

新建工程

+ + + +
+ + 风格配置 + + + +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/CustomTagsEditor.tsx b/frontend/src/components/CustomTagsEditor.tsx new file mode 100644 index 0000000..6407e0b --- /dev/null +++ b/frontend/src/components/CustomTagsEditor.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { getCustomTags, addCustomTag, removeCustomTag } from '../utils/style' + +interface CustomTagsEditorProps { + style: Record + onChange: (style: Record) => 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 ( +
+
+ 自定义标签 +
+
+ {tags.map(tag => ( + + {tag} + + + ))} +
+
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="输入标签后按 Enter 添加" + style={{ flex: 1, padding: '6px 10px', fontSize: 13 }} + /> + +
+
+ ) +} diff --git a/frontend/src/components/ProjectCard.tsx b/frontend/src/components/ProjectCard.tsx new file mode 100644 index 0000000..127dc5d --- /dev/null +++ b/frontend/src/components/ProjectCard.tsx @@ -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 ( +
navigate(`/projects/${project.id}`)} + > + {onDelete && ( + + )} +

{project.name}

+
+ {presetTags.map(tag => ( + + {tag} + + ))} +
+
+ {project.taskCount || 0} 个任务 + {project.lastActivityAt && ( + {formatRelativeTime(project.lastActivityAt)} + )} +
+
+ ) +} diff --git a/frontend/src/components/ProjectConfigPanel.tsx b/frontend/src/components/ProjectConfigPanel.tsx new file mode 100644 index 0000000..a312d29 --- /dev/null +++ b/frontend/src/components/ProjectConfigPanel.tsx @@ -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 ( +
+ +
+ ) + } + + return ( +
+
+ 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')} + /> +
+ +
+
+ 工程风格 +
+ + +
+ +
+ + +
+
+ ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..34822fc --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,31 @@ +interface StatusBadgeProps { + status: string +} + +const STATUS_CONFIG: Record = { + 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 ( + + {config.label} + + ) +} diff --git a/frontend/src/components/TaskListPanel.tsx b/frontend/src/components/TaskListPanel.tsx new file mode 100644 index 0000000..32347fe --- /dev/null +++ b/frontend/src/components/TaskListPanel.tsx @@ -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 = { + 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([]) + 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 ( +
+ +
+ ) + } + + return ( + + ) +} diff --git a/frontend/src/hooks/useGenerate.ts b/frontend/src/hooks/useGenerate.ts deleted file mode 100755 index 6bf510e..0000000 --- a/frontend/src/hooks/useGenerate.ts +++ /dev/null @@ -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, - } -} diff --git a/frontend/src/hooks/useProjectStyle.ts b/frontend/src/hooks/useProjectStyle.ts deleted file mode 100755 index 74287d1..0000000 --- a/frontend/src/hooks/useProjectStyle.ts +++ /dev/null @@ -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 } -} diff --git a/frontend/src/pages/GeneratePage.tsx b/frontend/src/pages/GeneratePage.tsx deleted file mode 100755 index a5581e6..0000000 --- a/frontend/src/pages/GeneratePage.tsx +++ /dev/null @@ -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 ( -
-

新建生成

- - {status === 'idle' || status === 'submitting' ? ( - - ) : ( -
- - - {status === 'running' && ( -

- {statusText || '管线执行中,请稍候...'} -

- )} - - {status === 'completed' && ( -
-

- 生成完成 -

-
- - -
-
- )} - - {status === 'failed' && ( -
-

- {useGenerationStore.getState().error || '生成失败'} -

- -
- )} -
- )} -
- ) -} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..7db0daf --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -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) => { + 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 ( +
+ +
+ +
+ ) + } + + return ( +
+
+

我的工程

+ +
+ + {projects.length === 0 ? ( + setModalOpen(true) }} + /> + ) : ( +
+ {projects.map(project => ( + + ))} +
+ )} + + setModalOpen(false)} + onSubmit={handleCreate} + /> +
+ ) +} diff --git a/frontend/src/pages/ProjectDetailPage.tsx b/frontend/src/pages/ProjectDetailPage.tsx new file mode 100644 index 0000000..210a50b --- /dev/null +++ b/frontend/src/pages/ProjectDetailPage.tsx @@ -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 ( +
+ {/* 左栏:工程配置 */} + + + {/* 中间:生成表单 + 进度 */} +
+
+ {/* 上半:生成表单 */} +
+

新建生成

+ +
+ + {/* 下半:当前任务进度 */} + {status !== 'idle' && ( +
+

生成进度

+ + + {status === 'running' && ( +

+ {statusText || '管线执行中,请稍候...'} +

+ )} + + {status === 'completed' && ( +
+

+ 生成完成 +

+
+ + +
+
+ )} + + {status === 'failed' && ( +
+

+ {useGenerationStore.getState().error || '生成失败'} +

+ +
+ )} +
+ )} +
+
+ + {/* 右栏:任务列表 */} + +
+ ) +} diff --git a/frontend/src/pages/ProjectPage.tsx b/frontend/src/pages/ProjectPage.tsx deleted file mode 100755 index 971c503..0000000 --- a/frontend/src/pages/ProjectPage.tsx +++ /dev/null @@ -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 = { - 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([]) - 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 ( -
-

{loading ? : name}

- - {/* 工程风格 */} -
-
-

工程风格

- -
- -
- - {/* 任务列表 */} -
-
-

历史任务

- - 新建生成 - -
- - {tasks.length === 0 ? ( - navigate(`/projects/${projectId}/generate`) }} - /> - ) : ( - - - - - - - - - - - - {tasks.map(task => { - const statusInfo = STATUS_LABELS[task.status] ?? STATUS_LABELS.pending - return ( - - - - - - - - ) - })} - -
提示词类型状态创建时间操作
{task.prompt}{task.assetType}{statusInfo.label} - {new Date(task.createdAt).toLocaleString('zh-CN')} - - {task.status === 'completed' && ( - - 查看结果 - - )} -
- )} -
-
- ) -} - -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, -} diff --git a/frontend/src/pages/ResultPage.tsx b/frontend/src/pages/ResultPage.tsx index 85da8b3..a2dd75d 100755 --- a/frontend/src/pages/ResultPage.tsx +++ b/frontend/src/pages/ResultPage.tsx @@ -6,7 +6,7 @@ import AssetPreview from '../components/AssetPreview' import Skeleton from '../components/Skeleton' export default function ResultPage() { - const { projectId = 'proj-default', taskId } = useParams() + const { projectId = '', taskId } = useParams() const [task, setTask] = useState(null) const [assets, setAssets] = useState([]) const [loading, setLoading] = useState(true) @@ -33,12 +33,10 @@ export default function ResultPage() { setPolling(false) setLoading(false) } else if (!pollRef.current) { - // 开始轮询 setPolling(true) pollRef.current = setInterval(fetchTask, 2000) } } catch { - // 出错也停止加载态 setLoading(false) } } @@ -53,10 +51,10 @@ export default function ResultPage() { if (loading || polling) { return (
-
+
- +
{task && (

@@ -66,9 +64,6 @@ export default function ResultPage() {

)}
-
- -
) } @@ -86,58 +81,28 @@ export default function ResultPage() { return (
-

生成结果

- -
-
-

任务信息

-
- {assets.length > 0 && ( - - )} - - 继续生成 - -
+
+
+ + ← 返回工程 + +

生成结果

-
- 提示词 - {task.prompt} - 素材类型 - {task.assetType} - 状态 - - {task.status === 'completed' ? '已完成' : '失败'} - - 创建时间 - {new Date(task.createdAt).toLocaleString('zh-CN')} - {task.retryCount != null && task.retryCount > 0 && ( - <> - 重试次数 - {task.retryCount} - - )} - {task.error && ( - <> - 错误 - {task.error} - - )} -
-
+ {assets.length > 0 && ( + + )} +
-
-

素材预览

+
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx index 8c6f1c7..4ecec09 100755 --- a/frontend/src/router/index.tsx +++ b/frontend/src/router/index.tsx @@ -3,8 +3,7 @@ import { useAuthStore } from '../stores/auth' import Layout from '../components/Layout' import LoginPage from '../pages/LoginPage' import RegisterPage from '../pages/RegisterPage' -import ProjectPage from '../pages/ProjectPage' -import GeneratePage from '../pages/GeneratePage' +import HomePage from '../pages/HomePage' import ResultPage from '../pages/ResultPage' function ProtectedRoute() { @@ -30,15 +29,11 @@ export const router = createBrowserRouter([ children: [ { path: '/', - element: , + element: , }, { path: '/projects/:projectId', - element: , - }, - { - path: '/projects/:projectId/generate', - element: , + lazy: () => import('../pages/ProjectDetailPage').then(m => ({ Component: m.default })), }, { path: '/projects/:projectId/tasks/:taskId', diff --git a/frontend/src/stores/project.ts b/frontend/src/stores/project.ts index f3097e0..0ca6b3b 100755 --- a/frontend/src/stores/project.ts +++ b/frontend/src/stores/project.ts @@ -5,17 +5,28 @@ interface ProjectState { projectId: string name: string style: Record + draftStyle: Record + draftName: string + hasUnsavedChanges: boolean loading: boolean loadProject: (projectId: string) => Promise loadStyle: (projectId: string) => Promise - updateStyle: (key: string, value: string) => void - saveStyle: () => Promise + startEditing: () => void + updateDraft: (key: string, value: string) => void + setDraftStyle: (style: Record) => void + setDraftName: (name: string) => void + discardDraft: () => void + commitDraft: () => Promise + updateName: (name: string) => Promise } export const useProjectStore = create((set, get) => ({ projectId: '', name: '', style: {}, + draftStyle: {}, + draftName: '', + hasUnsavedChanges: false, loading: false, loadProject: async (projectId) => { @@ -25,7 +36,10 @@ export const useProjectStore = create((set, get) => ({ set({ projectId: project.id, name: project.name, + draftName: project.name, style: project.style.kvPairs, + draftStyle: { ...project.style.kvPairs }, + hasUnsavedChanges: false, loading: false, }) } catch { @@ -35,15 +49,56 @@ export const useProjectStore = create((set, get) => ({ loadStyle: async (projectId) => { const kvPairs = await projectApi.getStyle(projectId) - set({ style: kvPairs }) + set({ style: kvPairs, draftStyle: { ...kvPairs }, hasUnsavedChanges: false }) }, - updateStyle: (key, value) => { - set(state => ({ style: { ...state.style, [key]: value } })) + startEditing: () => { + const { style, name } = get() + set({ draftStyle: { ...style }, draftName: name, hasUnsavedChanges: false }) }, - saveStyle: async () => { - const { projectId, style } = get() - await projectApi.saveStyle(projectId, style) + updateDraft: (key, value) => { + set(state => { + 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 }) }, })) diff --git a/frontend/src/stores/projectList.ts b/frontend/src/stores/projectList.ts new file mode 100644 index 0000000..a4183e0 --- /dev/null +++ b/frontend/src/stores/projectList.ts @@ -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 + createProject: (name: string, style?: Record) => Promise + deleteProject: (projectId: string) => Promise +} + +export const useProjectListStore = create((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) }) + }, +})) diff --git a/frontend/src/utils/style.ts b/frontend/src/utils/style.ts index 07520aa..d7cf065 100755 --- a/frontend/src/utils/style.ts +++ b/frontend/src/utils/style.ts @@ -8,6 +8,33 @@ export function mergeStyles( return { ...projectStyle, ...taskOverrides } } +const CUSTOM_PREFIX = 'custom:' + +export function isCustomKey(key: string): boolean { + return key.startsWith(CUSTOM_PREFIX) +} + +export function getCustomTags(style: Record): string[] { + return Object.keys(style) + .filter(isCustomKey) + .map(key => key.slice(CUSTOM_PREFIX.length)) +} + +export function addCustomTag( + style: Record, + tag: string +): Record { + return { ...style, [`${CUSTOM_PREFIX}${tag}`]: 'true' } +} + +export function removeCustomTag( + style: Record, + tag: string +): Record { + const { [`${CUSTOM_PREFIX}${tag}`]: _, ...rest } = style + return rest +} + /** * 风格键分类定义 */