From 74c17254739c504aad49e4f0d1a995efd0426bc3 Mon Sep 17 00:00:00 2001 From: Gmaker689 <1711322114@qq.com> Date: Mon, 25 May 2026 12:27:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(generate):=20=E5=9B=BE=E7=89=87=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=E5=88=B0=E6=9C=AC=E5=9C=B0=20generation/=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=EF=BC=8C=E5=89=8D=E7=AB=AF=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - POST /api/v1/generate 接收 projectId,生成 taskId,将图片保存至 generation/{projectId}/{taskId}/ - 新增 GET /api/v1/tasks/:taskId 和 GET /api/v1/tasks/:taskId/assets 查询端点 - 添加 /generation 静态文件服务,前端可通过 URL 直接访问生成的图片 - PipelineInput 增加 ProjectID/TaskID 字段 前端: - generate.ts 对接真实后端 API,移除 mock 模式 - 401 时不再强制登出跳转,改为抛错由调用方处理 - GeneratePage 加载工程风格,传递 projectId 和完整参数 - generation store 简化为同步 API 模式,移除 WebSocket mock - ResultPage 使用 getTask/getAssets 按 taskId 查询结果 --- .gitignore | 3 + backend/.gitignore | 1 + backend/cmd/main.go | 13 ++- backend/internal/handler/edit.go | 12 ++- backend/internal/handler/generate.go | 118 ++++++++++++++++++++++++--- backend/internal/service/types.go | 2 + frontend/src/api/client.ts | 4 - frontend/src/api/generate.ts | 47 +++++++---- frontend/src/api/types.ts | 43 ++++++++-- frontend/src/hooks/useGenerate.ts | 3 - frontend/src/pages/GeneratePage.tsx | 83 +++++++++++++------ frontend/src/pages/ResultPage.tsx | 0 frontend/src/stores/generation.ts | 82 +++++++++---------- 13 files changed, 297 insertions(+), 114 deletions(-) mode change 100644 => 100755 .gitignore mode change 100644 => 100755 backend/.gitignore mode change 100644 => 100755 backend/cmd/main.go mode change 100644 => 100755 backend/internal/handler/edit.go mode change 100644 => 100755 backend/internal/handler/generate.go mode change 100644 => 100755 backend/internal/service/types.go mode change 100644 => 100755 frontend/src/api/client.ts mode change 100644 => 100755 frontend/src/api/generate.ts mode change 100644 => 100755 frontend/src/api/types.ts mode change 100644 => 100755 frontend/src/hooks/useGenerate.ts mode change 100644 => 100755 frontend/src/pages/GeneratePage.tsx mode change 100644 => 100755 frontend/src/pages/ResultPage.tsx mode change 100644 => 100755 frontend/src/stores/generation.ts diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 1fa64c0..bc4fa7d --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ backend/bin/ backend/data/ backend/.env backend/main + +# Generated output +generation/ diff --git a/backend/.gitignore b/backend/.gitignore old mode 100644 new mode 100755 index 46c8ed0..71eeeb9 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -1 +1,2 @@ test_output/ +generation/ diff --git a/backend/cmd/main.go b/backend/cmd/main.go old mode 100644 new mode 100755 index 2f2e859..2a447dd --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -4,6 +4,7 @@ package main import ( "fmt" "log" + "os" "gen2d/internal/config" "gen2d/internal/db" @@ -20,6 +21,9 @@ func main() { gin.SetMode(cfg.Server.Mode) + // 确保 generation 输出目录存在(项目根级别) + _ = os.MkdirAll("../generation", 0755) + // 初始化 SQLite 数据库 if err := db.Init(cfg.Database.DSN, &model.User{}); err != nil { log.Fatalf("db init failed: %v", err) @@ -35,6 +39,9 @@ func main() { r := gin.New() r.Use(gin.Recovery()) // panic 恢复中间件,防止服务因未捕获异常宕机 + // 静态文件服务 — 生成的图片 + r.Static("/generation", "../generation") + // API v1 路由组 — 公开接口 v1 := r.Group("/api/v1") { @@ -46,8 +53,10 @@ func main() { v1Auth := r.Group("/api/v1") v1Auth.Use(mildware.AuthMiddleware(cfg.JWT.Secret)) { - v1Auth.POST("/generate", handler.Generate) // 素材生成管线 - v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑 + v1Auth.POST("/generate", handler.Generate) // 素材生成管线 + v1Auth.GET("/tasks/:taskId", handler.GetTask) // 查询任务 + v1Auth.GET("/tasks/:taskId/assets", handler.GetAssets) // 查询任务素材 + v1Auth.POST("/images/edit", handler.EditImage) // 图片编辑 } // Auth 路由组 diff --git a/backend/internal/handler/edit.go b/backend/internal/handler/edit.go old mode 100644 new mode 100755 index b17d052..f77b05a --- a/backend/internal/handler/edit.go +++ b/backend/internal/handler/edit.go @@ -17,9 +17,15 @@ type EditImageRequest struct { Count int `json:"count"` // 生成数量,默认 1 } +// editAssetResponse 编辑结果素材(返回 base64)。 +type editAssetResponse struct { + Data string `json:"data"` + Format string `json:"format"` +} + // EditImageResponse 图片编辑响应体。 type EditImageResponse struct { - Assets []AssetResponse `json:"assets"` + Assets []editAssetResponse `json:"assets"` } // EditImage 图片编辑接口,基于已有图片和文本指令生成修改后的图片。 @@ -47,9 +53,9 @@ func EditImage(c *gin.Context) { return } - assets := make([]AssetResponse, len(images)) + assets := make([]editAssetResponse, len(images)) for i, img := range images { - assets[i] = AssetResponse{ + assets[i] = editAssetResponse{ Data: base64.StdEncoding.EncodeToString(img.Data), Format: img.Format, } diff --git a/backend/internal/handler/generate.go b/backend/internal/handler/generate.go old mode 100644 new mode 100755 index 52b38af..ebf5ca7 --- a/backend/internal/handler/generate.go +++ b/backend/internal/handler/generate.go @@ -1,8 +1,12 @@ package handler import ( - "encoding/base64" + "fmt" "net/http" + "os" + "path/filepath" + "sync" + "time" "gen2d/internal/model" "gen2d/internal/service" @@ -12,6 +16,7 @@ import ( // GenerateRequest 素材生成请求。 type GenerateRequest struct { + ProjectID string `json:"projectId"` Prompt string `json:"prompt"` AssetType string `json:"assetType" binding:"required"` Tags []string `json:"tags"` @@ -26,18 +31,48 @@ type GenerateRequest struct { // GenerateResponse 素材生成响应体。 type GenerateResponse struct { - Assets []AssetResponse `json:"assets"` - Metadata service.AssetMetadata `json:"metadata"` + TaskID string `json:"taskId"` + Assets []AssetResponse `json:"assets"` + Metadata service.AssetMetadata `json:"metadata"` } -// AssetResponse 单个素材响应(二进制 Data 转 base64)。 +// AssetResponse 单个素材响应。 type AssetResponse struct { - Data string `json:"data"` - Format string `json:"format"` URL string `json:"url"` + Format string `json:"format"` } -// Generate 素材生成接口,调用完整生成管线(PromptOptimizer → AssetGenerator → QualitySupervisor → FormatAdapter)。 +// TaskResponse 任务查询响应。 +type TaskResponse struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + Prompt string `json:"prompt"` + AssetType string `json:"assetType"` + Status string `json:"status"` + Progress int `json:"progress"` + RetryCount int `json:"retryCount"` + Error string `json:"error,omitempty"` + CreatedAt string `json:"createdAt"` +} + +// AssetsResponse 素材列表响应。 +type AssetsResponse struct { + Assets []AssetResponse `json:"assets"` + Metadata service.AssetMetadata `json:"metadata"` +} + +// taskRecord 内存中的任务记录。 +type taskRecord struct { + task TaskResponse + assets []AssetResponse + metadata service.AssetMetadata +} + +var ( + taskStore = sync.Map{} // taskID → *taskRecord +) + +// Generate 素材生成接口。 func Generate(c *gin.Context) { var req GenerateRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -45,7 +80,15 @@ func Generate(c *gin.Context) { return } + projectID := req.ProjectID + if projectID == "" { + projectID = "default" + } + taskID := fmt.Sprintf("task-%d", time.Now().UnixMilli()) + in := service.PipelineInput{ + ProjectID: projectID, + TaskID: taskID, Prompt: req.Prompt, AssetType: req.AssetType, Tags: req.Tags, @@ -55,7 +98,7 @@ func Generate(c *gin.Context) { Params: service.AssetParams{ Resolution: req.Resolution, Frames: service.FrameParams{ - Directions: req.Directions, + Directions: req.Directions, FramesPerDirection: req.FramesPerDir, }, Format: req.Format, @@ -68,17 +111,72 @@ func Generate(c *gin.Context) { return } + // 保存图片到 ../generation/{projectId}/{taskId}/ + genDir := filepath.Join("..", "generation", projectID, taskID) + if err := os.MkdirAll(genDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "创建输出目录失败: "+err.Error())) + return + } + assets := make([]AssetResponse, len(output.Assets)) for i, a := range output.Assets { + filename := fmt.Sprintf("%d.%s", i, a.Format) + filePath := filepath.Join(genDir, filename) + if err := os.WriteFile(filePath, a.Data, 0644); err != nil { + c.JSON(http.StatusInternalServerError, model.Fail(http.StatusInternalServerError, "保存图片失败: "+err.Error())) + return + } assets[i] = AssetResponse{ - Data: base64.StdEncoding.EncodeToString(a.Data), + URL: fmt.Sprintf("/generation/%s/%s/%s", projectID, taskID, filename), Format: a.Format, - URL: a.URL, } } + // 存储任务记录到内存 + taskStore.Store(taskID, &taskRecord{ + task: TaskResponse{ + ID: taskID, + ProjectID: projectID, + Prompt: req.Prompt, + AssetType: req.AssetType, + Status: "completed", + Progress: 100, + CreatedAt: time.Now().Format(time.RFC3339), + }, + assets: assets, + metadata: output.Metadata, + }) + c.JSON(http.StatusOK, model.OK(GenerateResponse{ + TaskID: taskID, Assets: assets, Metadata: output.Metadata, })) } + +// GetTask 查询任务信息。 +func GetTask(c *gin.Context) { + taskID := c.Param("taskId") + rec, ok := taskStore.Load(taskID) + if !ok { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "任务不存在")) + return + } + r := rec.(*taskRecord) + c.JSON(http.StatusOK, model.OK(r.task)) +} + +// GetAssets 查询任务素材列表。 +func GetAssets(c *gin.Context) { + taskID := c.Param("taskId") + rec, ok := taskStore.Load(taskID) + if !ok { + c.JSON(http.StatusNotFound, model.Fail(http.StatusNotFound, "任务不存在")) + return + } + r := rec.(*taskRecord) + c.JSON(http.StatusOK, model.OK(AssetsResponse{ + Assets: r.assets, + Metadata: r.metadata, + })) +} diff --git a/backend/internal/service/types.go b/backend/internal/service/types.go old mode 100644 new mode 100755 index ed684b0..9db9f27 --- a/backend/internal/service/types.go +++ b/backend/internal/service/types.go @@ -2,6 +2,8 @@ package service // PipelineInput 管线入口输入 type PipelineInput struct { + ProjectID string // 工程 ID,用于输出目录 + TaskID string // 任务 ID,用于输出目录 Prompt string // 用户原始文本 AssetType string // 素材类型:sprite / background / ui / animation ProjectStyle map[string]string // 工程风格键值对 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts old mode 100644 new mode 100755 index 9ff77c8..0ec0a26 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -41,10 +41,6 @@ async function request(url: string, options: RequestInit = {}): Promise { const json: ApiResponse = await res.json() if (json.code !== 0) { - if (json.code === 401) { - clearToken() - window.location.href = '/login' - } throw new ApiError(json.code, json.message) } diff --git a/frontend/src/api/generate.ts b/frontend/src/api/generate.ts old mode 100644 new mode 100755 index 40670a8..971d9b2 --- a/frontend/src/api/generate.ts +++ b/frontend/src/api/generate.ts @@ -1,23 +1,42 @@ -import type { Asset, Task } from './types' -import { mockGetAssets, mockGetTask, mockSubmitGenerate } from './mock' - -const USE_MOCK = true +import { post, get } from './client' +import type { + Asset, + AssetsResponse, + GenerateRequest, + GenerateResponse, + Task, +} from './types' export async function submitGenerate( - projectId: string, - prompt: string, - assetType: string -): Promise { - if (USE_MOCK) return mockSubmitGenerate(projectId, prompt, assetType) - throw new Error('Not implemented') + req: GenerateRequest, +): Promise { + return post('/api/v1/generate', req) } export async function getTask(taskId: string): Promise { - if (USE_MOCK) return mockGetTask(taskId) - throw new Error('Not implemented') + return get(`/api/v1/tasks/${taskId}`) } export async function getAssets(taskId: string): Promise { - if (USE_MOCK) return mockGetAssets(taskId) - throw new Error('Not implemented') + const resp = await get(`/api/v1/tasks/${taskId}/assets`) + return toAssetList(resp) +} + +/** 将响应转为 Asset[] 供前端组件使用 */ +function toAssetList( + resp: AssetsResponse | GenerateResponse, +): Asset[] { + return resp.assets.map((a, i) => ({ + id: `asset-${i}`, + url: a.url, + format: a.format, + width: resp.metadata.frameWidth, + height: resp.metadata.frameHeight, + metadata: { + frameWidth: resp.metadata.frameWidth, + frameHeight: resp.metadata.frameHeight, + frameCount: resp.metadata.frameCount, + directions: resp.metadata.directions, + }, + })) } diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts old mode 100644 new mode 100755 index b9eaa96..5c5f8a9 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -91,16 +91,47 @@ export interface Asset { } } -// 生成请求 +// 生成请求 — 对应 POST /api/v1/generate export interface GenerateRequest { projectId: string - prompt: string + prompt?: string assetType: AssetType + tags?: string[] + userNote?: string + projectStyle?: Record taskStyle?: Record - params?: { - resolution?: number - frames?: { directions?: number; framesPerDirection?: number } - format?: 'spritesheet' | 'individual' + resolution?: number + directions?: number + framesPerDir?: number + format?: 'spritesheet' | 'individual' +} + +// 生成响应 — 对应 POST /api/v1/generate 返回 +export interface GenerateResponse { + taskId: string + assets: { + url: string + format: string + }[] + metadata: { + frameWidth: number + frameHeight: number + frameCount: number + directions: number + } +} + +// 素材列表响应 — 对应 GET /api/v1/tasks/:taskId/assets +export interface AssetsResponse { + assets: { + url: string + format: string + }[] + metadata: { + frameWidth: number + frameHeight: number + frameCount: number + directions: number } } diff --git a/frontend/src/hooks/useGenerate.ts b/frontend/src/hooks/useGenerate.ts old mode 100644 new mode 100755 index 49220c7..6bf510e --- a/frontend/src/hooks/useGenerate.ts +++ b/frontend/src/hooks/useGenerate.ts @@ -5,11 +5,8 @@ export function useGenerate() { return { submit: store.submit, taskId: store.taskId, - stage: store.stage, progress: store.progress, status: store.status, - retryCount: store.retryCount, - rejectReason: store.rejectReason, assets: store.assets, error: store.error, reset: store.reset, diff --git a/frontend/src/pages/GeneratePage.tsx b/frontend/src/pages/GeneratePage.tsx old mode 100644 new mode 100755 index bb17ec7..d21356c --- a/frontend/src/pages/GeneratePage.tsx +++ b/frontend/src/pages/GeneratePage.tsx @@ -1,27 +1,35 @@ 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 { assetType, reset: resetTask } = useTaskStore() const addToast = useToastStore(s => s.addToast) + + const taskStore = useTaskStore() + const { style: projectStyle, loadProject } = useProjectStore() const { status, - stage, progress, - retryCount, - rejectReason, taskId, submit, reset: resetGeneration, } = useGenerationStore() + // 加载工程风格 + useEffect(() => { + loadProject(projectId) + }, [projectId, loadProject]) + // 组件卸载时重置生成状态 useEffect(() => { return () => resetGeneration() @@ -30,48 +38,57 @@ export default function GeneratePage() { // 失败时显示 toast useEffect(() => { if (status === 'failed') { - addToast({ type: 'error', message: '素材生成失败,请重试' }) + const errText = useGenerationStore.getState().error || '未知错误' + addToast({ type: 'error', message: `素材生成失败:${errText}` }) } }, [status, addToast]) - // 完成后自动跳转 - useEffect(() => { - if (status === 'completed' && taskId) { - const timer = setTimeout(() => { - navigate(`/projects/${projectId}/tasks/${taskId}`) - }, 1500) - return () => clearTimeout(timer) - } - }, [status, taskId, projectId, navigate]) - const handleSubmit = async (finalPrompt: string) => { - await submit(projectId, finalPrompt, assetType) + 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() - resetTask() + taskStore.reset() } return (

新建生成

- {/* 生成表单 */} {status === 'idle' || status === 'submitting' ? ( ) : (
- {/* 进度条 */} - {/* 状态提示 */} {status === 'running' && (

管线执行中,请稍候... @@ -79,14 +96,26 @@ export default function GeneratePage() { )} {status === 'completed' && ( -

- ✓ 生成完成,正在跳转到结果页... -

+
+

+ 生成完成 +

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

生成失败

+

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

diff --git a/frontend/src/pages/ResultPage.tsx b/frontend/src/pages/ResultPage.tsx old mode 100644 new mode 100755 diff --git a/frontend/src/stores/generation.ts b/frontend/src/stores/generation.ts old mode 100644 new mode 100755 index b904755..bb3ec9c --- a/frontend/src/stores/generation.ts +++ b/frontend/src/stores/generation.ts @@ -1,82 +1,74 @@ import { create } from 'zustand' -import type { Asset, PipelineProgress, PipelineStage } from '../api/types' +import type { Asset, GenerateRequest, GenerateResponse } from '../api/types' import { submitGenerate } from '../api/generate' -import { createMockWebSocket } from '../api/mock' type Status = 'idle' | 'submitting' | 'running' | 'completed' | 'failed' interface GenerationState { taskId: string | null - stage: PipelineStage | null + projectId: string | null progress: number status: Status - retryCount: number - rejectReason: string | null assets: Asset[] error: string | null - submit: (projectId: string, prompt: string, assetType: string) => Promise - handleProgress: (msg: PipelineProgress) => void + submit: (req: GenerateRequest) => Promise reset: () => void } -let cleanupWs: (() => void) | null = null - -export const useGenerationStore = create((set, get) => ({ +export const useGenerationStore = create((set) => ({ taskId: null, - stage: null, + projectId: null, progress: 0, status: 'idle', - retryCount: 0, - rejectReason: null, assets: [], error: null, - submit: async (projectId, prompt, assetType) => { + submit: async (req) => { set({ status: 'submitting', error: null }) try { - const taskId = await submitGenerate(projectId, prompt, assetType) - set({ taskId, status: 'running', progress: 0 }) + set({ status: 'running', progress: 30 }) + const result = await submitGenerate(req) - // 启动 mock WebSocket - cleanupWs = createMockWebSocket( - taskId, - (msg) => get().handleProgress(msg), - (assets) => { - set({ status: 'completed', assets, progress: 100 }) - }, - (error) => { - set({ status: 'failed', error }) - } - ) + set({ progress: 80 }) + const assets = mapAssets(result) + + set({ + taskId: result.taskId, + projectId: req.projectId, + status: 'completed', + progress: 100, + assets, + }) } catch (err) { - set({ status: 'failed', error: (err as Error).message }) - } - }, - - handleProgress: (msg) => { - set({ - stage: msg.stage, - progress: msg.progress, - retryCount: msg.retryCount ?? get().retryCount, - rejectReason: msg.rejectReason ?? null, - }) - if (msg.result?.assets) { - set({ assets: msg.result.assets }) + const errMsg = (err as Error).message + set({ status: 'failed', error: errMsg }) } }, reset: () => { - cleanupWs?.() - cleanupWs = null set({ taskId: null, - stage: null, + projectId: null, progress: 0, status: 'idle', - retryCount: 0, - rejectReason: null, assets: [], error: null, }) }, })) + +function mapAssets(resp: GenerateResponse): Asset[] { + return resp.assets.map((a, i) => ({ + id: `asset-${i}`, + url: a.url, + format: a.format, + width: resp.metadata.frameWidth, + height: resp.metadata.frameHeight, + metadata: { + frameWidth: resp.metadata.frameWidth, + frameHeight: resp.metadata.frameHeight, + frameCount: resp.metadata.frameCount, + directions: resp.metadata.directions, + }, + })) +}