AI3D 功能完成

This commit is contained in:
GeekMaster
2025-09-04 18:36:49 +08:00
parent 53866d1461
commit 52d297624d
30 changed files with 829 additions and 969 deletions

View File

@@ -31,12 +31,12 @@ const (
// AI3DJobResult 3D任务结果 // AI3DJobResult 3D任务结果
type AI3DJobResult struct { type AI3DJobResult struct {
JobId string `json:"job_id"` // 任务ID TaskId string `json:"task_id"` // 任务ID
Status string `json:"status"` // 任务状态 Status string `json:"status"` // 任务状态
Progress int `json:"progress"` // 任务进度 (0-100)
FileURL string `json:"file_url"` // 3D模型文件URL FileURL string `json:"file_url"` // 3D模型文件URL
PreviewURL string `json:"preview_url"` // 预览图片URL PreviewURL string `json:"preview_url"` // 预览图片URL
ErrorMsg string `json:"error_msg"` // 错误信息 ErrorMsg string `json:"error_msg"` // 错误信息
RawData string `json:"raw_data"` // 原始数据
} }
// AI3DModel 3D模型配置 // AI3DModel 3D模型配置
@@ -60,6 +60,6 @@ type AI3DJobRequest struct {
const ( const (
AI3DJobStatusPending = "pending" // 等待中 AI3DJobStatusPending = "pending" // 等待中
AI3DJobStatusProcessing = "processing" // 处理中 AI3DJobStatusProcessing = "processing" // 处理中
AI3DJobStatusCompleted = "completed" // 已完成 AI3DJobStatusSuccess = "success" // 已完成
AI3DJobStatusFailed = "failed" // 失败 AI3DJobStatusFailed = "failed" // 失败
) )

View File

@@ -96,6 +96,9 @@ func (h *AI3DHandler) GetJobList(c *gin.Context) {
if err != nil { if err != nil {
continue continue
} }
utils.JsonDecode(job.Params, &jobVo.Params)
jobVo.CreatedAt = job.CreatedAt.Unix()
jobVo.UpdatedAt = job.UpdatedAt.Unix()
jobList = append(jobList, jobVo) jobList = append(jobList, jobVo)
} }
@@ -128,6 +131,9 @@ func (h *AI3DHandler) GetJobDetail(c *gin.Context) {
resp.ERROR(c, "获取任务详情失败") resp.ERROR(c, "获取任务详情失败")
return return
} }
utils.JsonDecode(job.Params, &jobVo.Params)
jobVo.CreatedAt = job.CreatedAt.Unix()
jobVo.UpdatedAt = job.UpdatedAt.Unix()
resp.SUCCESS(c, jobVo) resp.SUCCESS(c, jobVo)
} }
@@ -167,14 +173,14 @@ func (h *AI3DHandler) GetStats(c *gin.Context) {
var stats struct { var stats struct {
Pending int64 `json:"pending"` Pending int64 `json:"pending"`
Processing int64 `json:"processing"` Processing int64 `json:"processing"`
Completed int64 `json:"completed"` Success int64 `json:"success"`
Failed int64 `json:"failed"` Failed int64 `json:"failed"`
} }
// 统计各状态的任务数量 // 统计各状态的任务数量
h.db.Model(&model.AI3DJob{}).Where("status = ?", "pending").Count(&stats.Pending) h.db.Model(&model.AI3DJob{}).Where("status = ?", "pending").Count(&stats.Pending)
h.db.Model(&model.AI3DJob{}).Where("status = ?", "processing").Count(&stats.Processing) h.db.Model(&model.AI3DJob{}).Where("status = ?", "processing").Count(&stats.Processing)
h.db.Model(&model.AI3DJob{}).Where("status = ?", "completed").Count(&stats.Completed) h.db.Model(&model.AI3DJob{}).Where("status = ?", "success").Count(&stats.Success)
h.db.Model(&model.AI3DJob{}).Where("status = ?", "failed").Count(&stats.Failed) h.db.Model(&model.AI3DJob{}).Where("status = ?", "failed").Count(&stats.Failed)
resp.SUCCESS(c, stats) resp.SUCCESS(c, stats)

View File

@@ -5,7 +5,6 @@ import (
"geekai/core" "geekai/core"
"geekai/core/middleware" "geekai/core/middleware"
"geekai/core/types" "geekai/core/types"
"geekai/service"
"geekai/service/ai3d" "geekai/service/ai3d"
"geekai/store/model" "geekai/store/model"
"geekai/store/vo" "geekai/store/vo"
@@ -19,14 +18,12 @@ import (
type AI3DHandler struct { type AI3DHandler struct {
BaseHandler BaseHandler
service *ai3d.Service service *ai3d.Service
userService *service.UserService
} }
func NewAI3DHandler(app *core.AppServer, db *gorm.DB, service *ai3d.Service, userService *service.UserService) *AI3DHandler { func NewAI3DHandler(app *core.AppServer, db *gorm.DB, service *ai3d.Service) *AI3DHandler {
return &AI3DHandler{ return &AI3DHandler{
service: service, service: service,
userService: userService,
BaseHandler: BaseHandler{ BaseHandler: BaseHandler{
App: app, App: app,
DB: db, DB: db,
@@ -47,30 +44,14 @@ func (h *AI3DHandler) RegisterRoutes() {
group.POST("generate", h.Generate) group.POST("generate", h.Generate)
group.GET("jobs", h.JobList) group.GET("jobs", h.JobList)
group.GET("jobs/mock", h.ListMock) // 演示数据接口 group.GET("jobs/mock", h.ListMock) // 演示数据接口
group.GET("job/:id", h.JobDetail)
group.GET("job/delete", h.DeleteJob) group.GET("job/delete", h.DeleteJob)
group.GET("download/:id", h.Download)
} }
} }
// Generate 创建3D生成任务 // Generate 创建3D生成任务
func (h *AI3DHandler) Generate(c *gin.Context) { func (h *AI3DHandler) Generate(c *gin.Context) {
var request struct { var request vo.AI3DJobParams
// 通用参数
Type types.AI3DTaskType `json:"type" binding:"required"` // API类型 (tencent/gitee)
Model string `json:"model" binding:"required"` // 3D模型类型
Prompt string `json:"prompt"` // 文本提示词
ImageURL string `json:"image_url"` // 输入图片URL
FileFormat string `json:"file_format"` // 输出文件格式
// 腾讯3d专有参数
EnablePBR bool `json:"enable_pbr"` // 是否开启PBR材质
// Gitee3d专有参数
Texture bool `json:"texture"` // 是否开启纹理
Seed int `json:"seed"` // 随机种子
NumInferenceSteps int `json:"num_inference_steps"` //迭代次数
GuidanceScale float64 `json:"guidance_scale"` //引导系数
OctreeResolution int `json:"octree_resolution"` // 3D 渲染精度越高3D 细节越丰富
}
if err := c.ShouldBindJSON(&request); err != nil { if err := c.ShouldBindJSON(&request); err != nil {
resp.ERROR(c, "参数错误") resp.ERROR(c, "参数错误")
return return
@@ -90,17 +71,17 @@ func (h *AI3DHandler) Generate(c *gin.Context) {
logger.Infof("request: %+v", request) logger.Infof("request: %+v", request)
// // 获取用户ID // 获取用户ID
// userId := h.GetLoginUserId(c) userId := h.GetLoginUserId(c)
// // 创建任务 // 创建任务
// job, err := h.service.CreateJob(uint(userId), request) job, err := h.service.CreateJob(uint(userId), request)
// if err != nil { if err != nil {
// resp.ERROR(c, fmt.Sprintf("创建任务失败: %v", err)) resp.ERROR(c, fmt.Sprintf("创建任务失败: %v", err))
// return return
// } }
resp.SUCCESS(c, gin.H{ resp.SUCCESS(c, gin.H{
"job_id": 0, "job_id": job.Id,
"message": "任务创建成功", "message": "任务创建成功",
}) })
} }
@@ -132,133 +113,24 @@ func (h *AI3DHandler) JobList(c *gin.Context) {
resp.SUCCESS(c, jobList) resp.SUCCESS(c, jobList)
} }
// JobDetail 获取任务详情
func (h *AI3DHandler) JobDetail(c *gin.Context) {
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "任务ID格式错误")
return
}
job, err := h.service.GetJobById(uint(id))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
// 检查权限
if job.UserId != uint(userId) {
resp.ERROR(c, "无权限访问此任务")
return
}
// 转换为VO
jobVO := vo.AI3DJob{
Id: job.Id,
UserId: job.UserId,
Type: job.Type,
Power: job.Power,
TaskId: job.TaskId,
FileURL: job.FileURL,
PreviewURL: job.PreviewURL,
Model: job.Model,
Status: job.Status,
ErrMsg: job.ErrMsg,
Params: job.Params,
CreatedAt: job.CreatedAt.Unix(),
UpdatedAt: job.UpdatedAt.Unix(),
}
resp.SUCCESS(c, jobVO)
}
// DeleteJob 删除任务 // DeleteJob 删除任务
func (h *AI3DHandler) DeleteJob(c *gin.Context) { func (h *AI3DHandler) DeleteJob(c *gin.Context) {
userId := h.GetLoginUserId(c) userId := h.GetLoginUserId(c)
id := c.Query("id") id := h.GetInt(c, "id", 0)
if id == "" { if id == 0 {
resp.ERROR(c, "任务ID不能为空") resp.ERROR(c, "任务ID不能为空")
return return
} }
var job model.AI3DJob err := h.service.DeleteUserJob(uint(id), uint(userId))
err := h.DB.Where("id = ?", id).Where("user_id = ?", userId).First(&job).Error
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, "删除任务失败")
return return
} }
err = h.DB.Delete(&job).Error
if err != nil {
resp.ERROR(c, err.Error())
return
}
// 失败的任务要退回算力
if job.Status == types.AI3DJobStatusFailed {
err = h.userService.IncreasePower(userId, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: job.Model,
Remark: fmt.Sprintf("删除任务,退回%d算力", job.Power),
})
if err != nil {
resp.ERROR(c, err.Error())
return
}
}
resp.SUCCESS(c, gin.H{"message": "删除成功"}) resp.SUCCESS(c, gin.H{"message": "删除成功"})
} }
// Download 下载3D模型
func (h *AI3DHandler) Download(c *gin.Context) {
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "任务ID格式错误")
return
}
job, err := h.service.GetJobById(uint(id))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
// 检查权限
if job.UserId != uint(userId) {
resp.ERROR(c, "无权限访问此任务")
return
}
// 检查任务状态
if job.Status != types.AI3DJobStatusCompleted {
resp.ERROR(c, "任务尚未完成")
return
}
if job.FileURL == "" {
resp.ERROR(c, "模型文件不存在")
return
}
// 重定向到下载链接
c.Redirect(302, job.FileURL)
}
// GetConfigs 获取3D生成配置 // GetConfigs 获取3D生成配置
func (h *AI3DHandler) GetConfigs(c *gin.Context) { func (h *AI3DHandler) GetConfigs(c *gin.Context) {
var config model.Config var config model.Config
@@ -281,8 +153,6 @@ func (h *AI3DHandler) GetConfigs(c *gin.Context) {
config3d.Tencent.Models = models["tencent"] config3d.Tencent.Models = models["tencent"]
} }
logger.Info("config3d: ", config3d)
resp.SUCCESS(c, config3d) resp.SUCCESS(c, config3d)
} }
@@ -299,9 +169,9 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
FileURL: "https://img.r9it.com/R03TQZ7PZ386RGL7PTMNGFOHAJW15WYF.glb", FileURL: "https://img.r9it.com/R03TQZ7PZ386RGL7PTMNGFOHAJW15WYF.glb",
PreviewURL: "/static/upload/2025/9/1756873317505073.png", PreviewURL: "/static/upload/2025/9/1756873317505073.png",
Model: "gitee-3d-v1", Model: "gitee-3d-v1",
Status: types.AI3DJobStatusCompleted, Status: types.AI3DJobStatusSuccess,
ErrMsg: "", ErrMsg: "",
Params: `{"prompt":"一只可爱的小猫","image_url":"","texture":true,"seed":42}`, Params: vo.AI3DJobParams{Prompt: "一只可爱的小猫", ImageURL: "", Texture: true, Seed: 42},
CreatedAt: 1704067200, // 2024-01-01 00:00:00 CreatedAt: 1704067200, // 2024-01-01 00:00:00
UpdatedAt: 1704067800, // 2024-01-01 00:10:00 UpdatedAt: 1704067800, // 2024-01-01 00:10:00
}, },
@@ -316,7 +186,7 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
Model: "tencent-3d-v2", Model: "tencent-3d-v2",
Status: types.AI3DJobStatusProcessing, Status: types.AI3DJobStatusProcessing,
ErrMsg: "", ErrMsg: "",
Params: `{"prompt":"一个现代建筑模型","image_url":"","enable_pbr":true}`, Params: vo.AI3DJobParams{Prompt: "一个现代建筑模型", ImageURL: "", EnablePBR: true},
CreatedAt: 1704070800, // 2024-01-01 01:00:00 CreatedAt: 1704070800, // 2024-01-01 01:00:00
UpdatedAt: 1704070800, // 2024-01-01 01:00:00 UpdatedAt: 1704070800, // 2024-01-01 01:00:00
}, },
@@ -331,7 +201,7 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
Model: "gitee-3d-v1", Model: "gitee-3d-v1",
Status: types.AI3DJobStatusPending, Status: types.AI3DJobStatusPending,
ErrMsg: "", ErrMsg: "",
Params: `{"prompt":"一辆跑车模型","image_url":"https://example.com/car.jpg","texture":false}`, Params: vo.AI3DJobParams{Prompt: "一辆跑车模型", ImageURL: "https://example.com/car.jpg", Texture: false},
CreatedAt: 1704074400, // 2024-01-01 02:00:00 CreatedAt: 1704074400, // 2024-01-01 02:00:00
UpdatedAt: 1704074400, // 2024-01-01 02:00:00 UpdatedAt: 1704074400, // 2024-01-01 02:00:00
}, },
@@ -346,7 +216,7 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
Model: "tencent-3d-v1", Model: "tencent-3d-v1",
Status: types.AI3DJobStatusFailed, Status: types.AI3DJobStatusFailed,
ErrMsg: "模型生成失败:输入图片质量不符合要求", ErrMsg: "模型生成失败:输入图片质量不符合要求",
Params: `{"prompt":"一个机器人模型","image_url":"https://example.com/robot.jpg","enable_pbr":false}`, Params: vo.AI3DJobParams{Prompt: "一个机器人模型", ImageURL: "https://example.com/robot.jpg", EnablePBR: false},
CreatedAt: 1704078000, // 2024-01-01 03:00:00 CreatedAt: 1704078000, // 2024-01-01 03:00:00
UpdatedAt: 1704078600, // 2024-01-01 03:10:00 UpdatedAt: 1704078600, // 2024-01-01 03:10:00
}, },
@@ -359,9 +229,9 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
FileURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac", FileURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
PreviewURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac", PreviewURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
Model: "gitee-3d-v2", Model: "gitee-3d-v2",
Status: types.AI3DJobStatusCompleted, Status: types.AI3DJobStatusSuccess,
ErrMsg: "", ErrMsg: "",
Params: `{"prompt":"一个复杂的机械装置","image_url":"","texture":true,"octree_resolution":512}`, Params: vo.AI3DJobParams{Prompt: "一个复杂的机械装置", ImageURL: "", Texture: true, OctreeResolution: 512},
CreatedAt: 1704081600, // 2024-01-01 04:00:00 CreatedAt: 1704081600, // 2024-01-01 04:00:00
UpdatedAt: 1704082200, // 2024-01-01 04:10:00 UpdatedAt: 1704082200, // 2024-01-01 04:10:00
}, },
@@ -376,17 +246,17 @@ func (h *AI3DHandler) ListMock(c *gin.Context) {
Model: "tencent-3d-v2", Model: "tencent-3d-v2",
Status: types.AI3DJobStatusProcessing, Status: types.AI3DJobStatusProcessing,
ErrMsg: "", ErrMsg: "",
Params: `{"prompt":"一个科幻飞船","image_url":"","enable_pbr":true}`, Params: vo.AI3DJobParams{Prompt: "一个科幻飞船", ImageURL: "", EnablePBR: true},
CreatedAt: 1704085200, // 2024-01-01 05:00:00 CreatedAt: 1704085200, // 2024-01-01 05:00:00
UpdatedAt: 1704085200, // 2024-01-01 05:00:00 UpdatedAt: 1704085200, // 2024-01-01 05:00:00
}, },
} }
// 创建分页响应 // 创建分页响应
mockResponse := vo.ThreeDJobList{ mockResponse := vo.Page{
Page: 1, Page: 1,
PageSize: 10, PageSize: 10,
Total: len(mockJobs), Total: int64(len(mockJobs)),
Items: mockJobs, Items: mockJobs,
} }

View File

@@ -16,36 +16,43 @@ type Gitee3DClient struct {
} }
type Gitee3DParams struct { type Gitee3DParams struct {
Prompt string `json:"prompt"` // 文本提示词 Model string `json:"model"` // 模型名称
ImageURL string `json:"image_url"` // 输入图片URL FileFormat string `json:"file_format,omitempty"` // 文件格式(Step1X-3D、Hi3DGen模型适用),支持 glb 和 stl
ResultFormat string `json:"result_format"` // 输出格式 Type string `json:"type,omitempty"` // 输出格式(Hunyuan3D-2模型适用)
ImageURL string `json:"image_url"` // 输入图片URL
Texture bool `json:"texture,omitempty"` // 是否开启纹理
Seed int `json:"seed,omitempty"` // 随机种子
NumInferenceSteps int `json:"num_inference_steps,omitempty"` //迭代次数
GuidanceScale float64 `json:"guidance_scale,omitempty"` //引导系数
OctreeResolution int `json:"octree_resolution,omitempty"` // 3D 渲染精度越高3D 细节越丰富
} }
type Gitee3DResponse struct { type Gitee3DResponse struct {
Code int `json:"code"` TaskID string `json:"task_id"`
Message string `json:"message"` Output struct {
Data struct { FileURL string `json:"file_url,omitempty"`
TaskID string `json:"task_id"` PreviewURL string `json:"preview_url,omitempty"`
} `json:"data"` } `json:"output"`
Status string `json:"status"`
CreatedAt any `json:"created_at"`
StartedAt any `json:"started_at"`
CompletedAt any `json:"completed_at"`
Urls struct {
Get string `json:"get"`
Cancel string `json:"cancel"`
} `json:"urls"`
} }
type Gitee3DQueryResponse struct { type GiteeErrorResponse struct {
Code int `json:"code"` Error int `json:"error"`
Message string `json:"message"` Message string `json:"message"`
Data struct {
Status string `json:"status"`
Progress int `json:"progress"`
ResultURL string `json:"result_url"`
PreviewURL string `json:"preview_url"`
ErrorMsg string `json:"error_msg"`
} `json:"data"`
} }
func NewGitee3DClient(sysConfig *types.SystemConfig) *Gitee3DClient { func NewGitee3DClient(sysConfig *types.SystemConfig) *Gitee3DClient {
return &Gitee3DClient{ return &Gitee3DClient{
httpClient: req.C().SetTimeout(time.Minute * 3), httpClient: req.C().SetTimeout(time.Minute * 3),
config: sysConfig.AI3D.Gitee, config: sysConfig.AI3D.Gitee,
apiURL: "https://ai.gitee.com/v1/async/image-to-3d", apiURL: "https://ai.gitee.com/v1",
} }
} }
@@ -53,73 +60,62 @@ func (c *Gitee3DClient) UpdateConfig(config types.Gitee3DConfig) {
c.config = config c.config = config
} }
func (c *Gitee3DClient) GetConfig() *types.Gitee3DConfig {
return &c.config
}
// SubmitJob 提交3D生成任务 // SubmitJob 提交3D生成任务
func (c *Gitee3DClient) SubmitJob(params Gitee3DParams) (string, error) { func (c *Gitee3DClient) SubmitJob(params Gitee3DParams) (string, error) {
requestBody := map[string]any{
"prompt": params.Prompt,
"image_url": params.ImageURL,
"result_format": params.ResultFormat,
}
var giteeResp Gitee3DResponse
response, err := c.httpClient.R(). response, err := c.httpClient.R().
SetHeader("Authorization", "Bearer "+c.config.APIKey). SetHeader("Authorization", "Bearer "+c.config.APIKey).
SetHeader("Content-Type", "application/json"). SetHeader("Content-Type", "application/json").
SetBody(requestBody). SetBody(params).
SetSuccessResult(&giteeResp).
Post(c.apiURL + "/async/image-to-3d") Post(c.apiURL + "/async/image-to-3d")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to submit gitee 3D job: %v", err) return "", fmt.Errorf("failed to submit gitee 3D job: %v", err)
} }
var giteeResp Gitee3DResponse if giteeResp.TaskID == "" {
if err := json.Unmarshal(response.Bytes(), &giteeResp); err != nil { var giteeErr GiteeErrorResponse
return "", fmt.Errorf("failed to parse gitee response: %v", err) _ = json.Unmarshal(response.Bytes(), &giteeErr)
return "", fmt.Errorf("no task ID returned from gitee 3D API: %s", giteeErr.Message)
} }
if giteeResp.Code != 0 { return giteeResp.TaskID, nil
return "", fmt.Errorf("gitee API error: %s", giteeResp.Message)
}
if giteeResp.Data.TaskID == "" {
return "", fmt.Errorf("no task ID returned from gitee 3D API")
}
return giteeResp.Data.TaskID, nil
} }
// QueryJob 查询任务状态 // QueryJob 查询任务状态
func (c *Gitee3DClient) QueryJob(taskId string) (*types.AI3DJobResult, error) { func (c *Gitee3DClient) QueryJob(taskId string) (*types.AI3DJobResult, error) {
var giteeResp Gitee3DResponse
apiURL := fmt.Sprintf("%s/task/%s", c.apiURL, taskId)
response, err := c.httpClient.R(). response, err := c.httpClient.R().
SetHeader("Authorization", "Bearer "+c.config.APIKey). SetHeader("Authorization", "Bearer "+c.config.APIKey).
Get(fmt.Sprintf("%s/task/%s/get", c.apiURL, taskId)) SetSuccessResult(&giteeResp).
Get(apiURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to query gitee 3D job: %v", err) return nil, fmt.Errorf("failed to query gitee 3D job: %v", err)
} }
var giteeResp Gitee3DQueryResponse
if err := json.Unmarshal(response.Bytes(), &giteeResp); err != nil {
return nil, fmt.Errorf("failed to parse gitee query response: %v", err)
}
if giteeResp.Code != 0 {
return nil, fmt.Errorf("gitee API error: %s", giteeResp.Message)
}
result := &types.AI3DJobResult{ result := &types.AI3DJobResult{
JobId: taskId, TaskId: taskId,
Status: c.convertStatus(giteeResp.Data.Status), Status: c.convertStatus(giteeResp.Status),
Progress: giteeResp.Data.Progress,
} }
// 根据状态设置结果 if giteeResp.TaskID == "" {
switch giteeResp.Data.Status { var giteeErr GiteeErrorResponse
case "completed": _ = json.Unmarshal(response.Bytes(), &giteeErr)
result.FileURL = giteeResp.Data.ResultURL result.ErrorMsg = giteeErr.Message
result.PreviewURL = giteeResp.Data.PreviewURL } else if giteeResp.Status == "success" {
case "failed": result.FileURL = giteeResp.Output.FileURL
result.ErrorMsg = giteeResp.Data.ErrorMsg
} }
result.RawData = string(response.Bytes())
logger.Debugf("gitee 3D job response: %+v", result)
return result, nil return result, nil
} }
@@ -127,13 +123,13 @@ func (c *Gitee3DClient) QueryJob(taskId string) (*types.AI3DJobResult, error) {
// convertStatus 转换Gitee状态到系统状态 // convertStatus 转换Gitee状态到系统状态
func (c *Gitee3DClient) convertStatus(giteeStatus string) string { func (c *Gitee3DClient) convertStatus(giteeStatus string) string {
switch giteeStatus { switch giteeStatus {
case "pending": case "waiting":
return types.AI3DJobStatusPending return types.AI3DJobStatusPending
case "processing": case "in_progress":
return types.AI3DJobStatusProcessing return types.AI3DJobStatusProcessing
case "completed": case "success":
return types.AI3DJobStatusCompleted return types.AI3DJobStatusSuccess
case "failed": case "failure", "cancelled":
return types.AI3DJobStatusFailed return types.AI3DJobStatusFailed
default: default:
return types.AI3DJobStatusPending return types.AI3DJobStatusPending

View File

@@ -1,13 +1,18 @@
package ai3d package ai3d
import ( import (
"encoding/json"
"fmt" "fmt"
"geekai/core/types" "geekai/core/types"
logger2 "geekai/logger" logger2 "geekai/logger"
"geekai/service"
"geekai/service/oss"
"geekai/store" "geekai/store"
"geekai/store/model" "geekai/store/model"
"geekai/store/vo" "geekai/store/vo"
"geekai/utils"
"net/url"
"path/filepath"
"strings"
"time" "time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
@@ -22,52 +27,81 @@ type Service struct {
taskQueue *store.RedisQueue taskQueue *store.RedisQueue
tencentClient *Tencent3DClient tencentClient *Tencent3DClient
giteeClient *Gitee3DClient giteeClient *Gitee3DClient
userService *service.UserService
uploadManager *oss.UploaderManager
} }
// NewService 创建3D生成服务 // NewService 创建3D生成服务
func NewService(db *gorm.DB, redisCli *redis.Client, tencentClient *Tencent3DClient, giteeClient *Gitee3DClient) *Service { func NewService(db *gorm.DB, redisCli *redis.Client, tencentClient *Tencent3DClient, giteeClient *Gitee3DClient, userService *service.UserService, uploadManager *oss.UploaderManager) *Service {
return &Service{ return &Service{
db: db, db: db,
taskQueue: store.NewRedisQueue("3D_Task_Queue", redisCli), taskQueue: store.NewRedisQueue("3D_Task_Queue", redisCli),
tencentClient: tencentClient, tencentClient: tencentClient,
giteeClient: giteeClient, giteeClient: giteeClient,
userService: userService,
uploadManager: uploadManager,
} }
} }
// CreateJob 创建3D生成任务 // CreateJob 创建3D生成任务
func (s *Service) CreateJob(userId uint, request vo.AI3DJobCreate) (*model.AI3DJob, error) { func (s *Service) CreateJob(userId uint, request vo.AI3DJobParams) (*model.AI3DJob, error) {
// 创建任务记录 switch request.Type {
job := &model.AI3DJob{ case types.AI3DTaskTypeGitee:
UserId: userId, if s.giteeClient == nil {
Type: request.Type, return nil, fmt.Errorf("模力方舟 3D 服务未初始化")
Power: request.Power, }
Model: request.Model, if !s.giteeClient.GetConfig().Enabled {
Status: types.AI3DJobStatusPending, return nil, fmt.Errorf("模力方舟 3D 服务未启用")
}
case types.AI3DTaskTypeTencent:
if s.tencentClient == nil {
return nil, fmt.Errorf("腾讯云 3D 服务未初始化")
}
if !s.tencentClient.GetConfig().Enabled {
return nil, fmt.Errorf("腾讯云 3D 服务未启用")
}
default:
return nil, fmt.Errorf("不支持的 3D 服务类型: %s", request.Type)
} }
// 序列化参数 // 创建任务记录
params := map[string]any{ job := &model.AI3DJob{
"prompt": request.Prompt, UserId: userId,
"image_url": request.ImageURL, Type: request.Type,
"model": request.Model, Power: request.Power,
"power": request.Power, Model: request.Model,
Status: types.AI3DJobStatusPending,
PreviewURL: request.ImageURL,
} }
paramsJSON, _ := json.Marshal(params)
job.Params = string(paramsJSON) job.Params = utils.JsonEncode(request)
// 保存到数据库 // 保存到数据库
if err := s.db.Create(job).Error; err != nil { if err := s.db.Create(job).Error; err != nil {
return nil, fmt.Errorf("failed to create 3D job: %v", err) return nil, fmt.Errorf("failed to create 3D job: %v", err)
} }
// 更新用户算力
err := s.userService.DecreasePower(userId, job.Power, model.PowerLog{
Type: types.PowerConsume,
Model: job.Model,
Remark: fmt.Sprintf("创建3D任务消耗%d算力", job.Power),
})
if err != nil {
return nil, fmt.Errorf("failed to update user power: %v", err)
}
// 将任务添加到队列 // 将任务添加到队列
s.PushTask(job) request.JobId = job.Id
s.PushTask(request)
return job, nil return job, nil
} }
// PushTask 将任务添加到队列 // PushTask 将任务添加到队列
func (s *Service) PushTask(job *model.AI3DJob) { func (s *Service) PushTask(job vo.AI3DJobParams) {
logger.Infof("add a new 3D task to the queue: %+v", job) logger.Infof("add a new 3D task to the queue: %+v", job)
if err := s.taskQueue.RPush(job); err != nil { if err := s.taskQueue.RPush(job); err != nil {
logger.Errorf("push 3D task to queue failed: %v", err) logger.Errorf("push 3D task to queue failed: %v", err)
@@ -76,72 +110,70 @@ func (s *Service) PushTask(job *model.AI3DJob) {
// Run 启动任务处理器 // Run 启动任务处理器
func (s *Service) Run() { func (s *Service) Run() {
// 将数据库中未完成的任务加载到队列
var jobs []model.AI3DJob
s.db.Where("status IN ?", []string{types.AI3DJobStatusPending, types.AI3DJobStatusProcessing}).Find(&jobs)
for _, job := range jobs {
s.PushTask(&job)
}
logger.Info("Starting 3D job consumer...") logger.Info("Starting 3D job consumer...")
go func() { go func() {
for { for {
var job model.AI3DJob var params vo.AI3DJobParams
err := s.taskQueue.LPop(&job) err := s.taskQueue.LPop(&params)
if err != nil { if err != nil {
logger.Errorf("taking 3D task with error: %v", err) logger.Errorf("taking 3D task with error: %v", err)
continue continue
} }
logger.Infof("handle a new 3D task: %+v", job) logger.Infof("handle a new 3D task: %+v", params)
go func() { go func() {
if err := s.processJob(&job); err != nil { if err := s.processJob(&params); err != nil {
logger.Errorf("error processing 3D job: %v", err) logger.Errorf("error processing 3D job: %v", err)
s.updateJobStatus(&job, types.AI3DJobStatusFailed, 0, err.Error()) s.updateJobStatus(params.JobId, types.AI3DJobStatusFailed, err.Error())
} }
}() }()
} }
}() }()
go s.pollJobStatus()
} }
// processJob 处理3D任务 // processJob 处理3D任务
func (s *Service) processJob(job *model.AI3DJob) error { func (s *Service) processJob(params *vo.AI3DJobParams) error {
// 更新状态为处理中 // 更新状态为处理中
s.updateJobStatus(job, types.AI3DJobStatusProcessing, 10, "") s.updateJobStatus(params.JobId, types.AI3DJobStatusProcessing, "")
// 解析参数
var params map[string]any
if err := json.Unmarshal([]byte(job.Params), &params); err != nil {
return fmt.Errorf("failed to parse job params: %v", err)
}
var taskId string var taskId string
var err error var err error
// 根据类型选择客户端 // 根据类型选择客户端
switch job.Type { switch params.Type {
case "tencent": case types.AI3DTaskTypeTencent:
if s.tencentClient == nil { if s.tencentClient == nil {
return fmt.Errorf("tencent 3D client not initialized") return fmt.Errorf("tencent 3D client not initialized")
} }
tencentParams := Tencent3DParams{ tencentParams := Tencent3DParams{
Prompt: s.getString(params, "prompt"), Prompt: params.Prompt,
ImageURL: s.getString(params, "image_url"), ImageURL: params.ImageURL,
ResultFormat: job.Model, ResultFormat: params.FileFormat,
EnablePBR: false, EnablePBR: params.EnablePBR,
} }
taskId, err = s.tencentClient.SubmitJob(tencentParams) taskId, err = s.tencentClient.SubmitJob(tencentParams)
case "gitee": case types.AI3DTaskTypeGitee:
if s.giteeClient == nil { if s.giteeClient == nil {
return fmt.Errorf("gitee 3D client not initialized") return fmt.Errorf("gitee 3D client not initialized")
} }
giteeParams := Gitee3DParams{ giteeParams := Gitee3DParams{
Prompt: s.getString(params, "prompt"), Model: params.Model,
ImageURL: s.getString(params, "image_url"), Texture: params.Texture,
ResultFormat: job.Model, Seed: params.Seed,
NumInferenceSteps: params.NumInferenceSteps,
GuidanceScale: params.GuidanceScale,
OctreeResolution: params.OctreeResolution,
ImageURL: params.ImageURL,
}
if params.Model == "Hunyuan3D-2" {
giteeParams.Type = strings.ToLower(params.FileFormat)
} else {
giteeParams.FileFormat = strings.ToLower(params.FileFormat)
} }
taskId, err = s.giteeClient.SubmitJob(giteeParams) taskId, err = s.giteeClient.SubmitJob(giteeParams)
default: default:
return fmt.Errorf("unsupported 3D API type: %s", job.Type) return fmt.Errorf("unsupported 3D API type: %s", params.Type)
} }
if err != nil { if err != nil {
@@ -149,43 +181,65 @@ func (s *Service) processJob(job *model.AI3DJob) error {
} }
// 更新任务ID // 更新任务ID
job.TaskId = taskId s.db.Model(model.AI3DJob{}).Where("id = ?", params.JobId).Update("task_id", taskId)
s.db.Model(job).Update("task_id", taskId)
// 开始轮询任务状态
go s.pollJobStatus(job)
return nil return nil
} }
// pollJobStatus 轮询任务状态 // pollJobStatus 轮询任务状态
func (s *Service) pollJobStatus(job *model.AI3DJob) { func (s *Service) pollJobStatus() {
// 10秒轮询一次
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop() defer ticker.Stop()
for { for range ticker.C {
select { var jobs []model.AI3DJob
case <-ticker.C: s.db.Where("status IN (?)", []string{types.AI3DJobStatusProcessing, types.AI3DJobStatusPending}).Find(&jobs)
result, err := s.queryJobStatus(job) if len(jobs) == 0 {
logger.Debug("no 3D jobs to poll, sleep 10s")
continue
}
for _, job := range jobs {
// 15 分钟超时
if job.CreatedAt.Before(time.Now().Add(-20 * time.Minute)) {
s.updateJobStatus(job.Id, types.AI3DJobStatusFailed, "task timeout")
continue
}
result, err := s.queryJobStatus(&job)
if err != nil { if err != nil {
logger.Errorf("failed to query job status: %v", err) logger.Errorf("failed to query job status: %v", err)
continue continue
} }
// 更新进度 updates := map[string]any{
s.updateJobStatus(job, result.Status, result.Progress, result.ErrorMsg) "status": result.Status,
"raw_data": result.RawData,
// 如果任务完成或失败,停止轮询 "err_msg": result.ErrorMsg,
if result.Status == types.AI3DJobStatusCompleted || result.Status == types.AI3DJobStatusFailed {
if result.Status == types.AI3DJobStatusCompleted {
// 更新结果文件URL
s.db.Model(job).Updates(map[string]interface{}{
"img_url": result.FileURL,
"preview_url": result.PreviewURL,
})
}
return
} }
if result.FileURL != "" {
// 下载文件到本地
url, err := s.uploadManager.GetUploadHandler().PutUrlFile(result.FileURL, getFileExt(result.FileURL), false)
if err != nil {
logger.Errorf("failed to download file: %v", err)
continue
}
updates["file_url"] = url
logger.Infof("download file: %s", url)
}
if result.PreviewURL != "" {
url, err := s.uploadManager.GetUploadHandler().PutUrlFile(result.PreviewURL, getFileExt(result.PreviewURL), false)
if err != nil {
logger.Errorf("failed to download preview image: %v", err)
continue
}
updates["preview_url"] = url
logger.Infof("download preview image: %s", url)
}
s.db.Model(&model.AI3DJob{}).Where("id = ?", job.Id).Updates(updates)
} }
} }
} }
@@ -193,12 +247,12 @@ func (s *Service) pollJobStatus(job *model.AI3DJob) {
// queryJobStatus 查询任务状态 // queryJobStatus 查询任务状态
func (s *Service) queryJobStatus(job *model.AI3DJob) (*types.AI3DJobResult, error) { func (s *Service) queryJobStatus(job *model.AI3DJob) (*types.AI3DJobResult, error) {
switch job.Type { switch job.Type {
case "tencent": case types.AI3DTaskTypeTencent:
if s.tencentClient == nil { if s.tencentClient == nil {
return nil, fmt.Errorf("tencent 3D client not initialized") return nil, fmt.Errorf("tencent 3D client not initialized")
} }
return s.tencentClient.QueryJob(job.TaskId) return s.tencentClient.QueryJob(job.TaskId)
case "gitee": case types.AI3DTaskTypeGitee:
if s.giteeClient == nil { if s.giteeClient == nil {
return nil, fmt.Errorf("gitee 3D client not initialized") return nil, fmt.Errorf("gitee 3D client not initialized")
} }
@@ -209,19 +263,12 @@ func (s *Service) queryJobStatus(job *model.AI3DJob) (*types.AI3DJobResult, erro
} }
// updateJobStatus 更新任务状态 // updateJobStatus 更新任务状态
func (s *Service) updateJobStatus(job *model.AI3DJob, status string, progress int, errMsg string) { func (s *Service) updateJobStatus(jobId uint, status string, errMsg string) error {
updates := map[string]interface{}{
"status": status,
"progress": progress,
"updated_at": time.Now(),
}
if errMsg != "" {
updates["err_msg"] = errMsg
}
if err := s.db.Model(job).Updates(updates).Error; err != nil { return s.db.Model(model.AI3DJob{}).Where("id = ?", jobId).Updates(map[string]any{
logger.Errorf("failed to update job status: %v", err) "status": status,
} "err_msg": errMsg,
}).Error
} }
// GetJobList 获取任务列表 // GetJobList 获取任务列表
@@ -254,10 +301,10 @@ func (s *Service) GetJobList(userId uint, page, pageSize int) (*vo.Page, error)
Model: job.Model, Model: job.Model,
Status: job.Status, Status: job.Status,
ErrMsg: job.ErrMsg, ErrMsg: job.ErrMsg,
Params: job.Params,
CreatedAt: job.CreatedAt.Unix(), CreatedAt: job.CreatedAt.Unix(),
UpdatedAt: job.UpdatedAt.Unix(), UpdatedAt: job.UpdatedAt.Unix(),
} }
_ = utils.JsonDecode(job.Params, &jobVO.Params)
jobList = append(jobList, jobVO) jobList = append(jobList, jobVO)
} }
@@ -269,29 +316,34 @@ func (s *Service) GetJobList(userId uint, page, pageSize int) (*vo.Page, error)
}, nil }, nil
} }
// GetJobById 根据ID获取任务
func (s *Service) GetJobById(id uint) (*model.AI3DJob, error) {
var job model.AI3DJob
if err := s.db.Where("id = ?", id).First(&job).Error; err != nil {
return nil, err
}
return &job, nil
}
// DeleteJob 删除任务 // DeleteJob 删除任务
func (s *Service) DeleteJob(id uint, userId uint) error { func (s *Service) DeleteUserJob(id uint, userId uint) error {
var job model.AI3DJob var job model.AI3DJob
if err := s.db.Where("id = ? AND user_id = ?", id, userId).First(&job).Error; err != nil { err := s.db.Where("id = ?", id).Where("user_id = ?", userId).First(&job).Error
if err != nil {
return err return err
} }
// 如果任务已完成,退还算力 tx := s.db.Begin()
if job.Status == types.AI3DJobStatusCompleted { err = tx.Delete(&job).Error
// TODO: 实现算力退还逻辑 if err != nil {
logger2.GetLogger().Infof("should refund power %d for user %d", job.Power, userId) return err
} }
return s.db.Delete(&job).Error // 失败的任务要退回算力
if job.Status == types.AI3DJobStatusFailed {
err = s.userService.IncreasePower(userId, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: job.Model,
Remark: fmt.Sprintf("删除任务,退回%d算力", job.Power),
})
if err != nil {
tx.Rollback()
return err
}
}
tx.Commit()
return nil
} }
// GetSupportedModels 获取支持的模型列表 // GetSupportedModels 获取支持的模型列表
@@ -316,12 +368,15 @@ func (s *Service) UpdateConfig(config types.AI3DConfig) {
} }
} }
// getString 从map中获取字符串值 // getFileExt 获取文件扩展名
func (s *Service) getString(params map[string]interface{}, key string) string { func getFileExt(fileURL string) string {
if val, ok := params[key]; ok { parse, err := url.Parse(fileURL)
if str, ok := val.(string); ok { if err != nil {
return str return ""
}
} }
return "" ext := filepath.Ext(parse.Path)
if ext == "" {
return ".glb"
}
return ext
} }

View File

@@ -3,6 +3,7 @@ package ai3d
import ( import (
"fmt" "fmt"
"geekai/core/types" "geekai/core/types"
"geekai/utils"
tencent3d "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d/v20250513" tencent3d "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d/v20250513"
tencentcloud "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common" tencentcloud "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
@@ -58,6 +59,10 @@ func (c *Tencent3DClient) UpdateConfig(config types.Tencent3DConfig) error {
return nil return nil
} }
func (c *Tencent3DClient) GetConfig() *types.Tencent3DConfig {
return &c.config
}
// SubmitJob 提交3D生成任务 // SubmitJob 提交3D生成任务
func (c *Tencent3DClient) SubmitJob(params Tencent3DParams) (string, error) { func (c *Tencent3DClient) SubmitJob(params Tencent3DParams) (string, error) {
request := tencent3d.NewSubmitHunyuanTo3DJobRequest() request := tencent3d.NewSubmitHunyuanTo3DJobRequest()
@@ -111,42 +116,39 @@ func (c *Tencent3DClient) QueryJob(jobId string) (*types.AI3DJobResult, error) {
} }
result := &types.AI3DJobResult{ result := &types.AI3DJobResult{
JobId: jobId, TaskId: jobId,
Status: *response.Response.Status,
Progress: 0,
} }
// 根据状态设置进度 // 根据状态设置进度
switch *response.Response.Status { switch *response.Response.Status {
case "WAIT": case "WAIT":
result.Status = "pending" result.Status = types.AI3DJobStatusPending
result.Progress = 10
case "RUN": case "RUN":
result.Status = "processing" result.Status = types.AI3DJobStatusProcessing
result.Progress = 50
case "DONE": case "DONE":
result.Status = "completed" result.Status = types.AI3DJobStatusSuccess
result.Progress = 100
// 处理结果文件 // 处理结果文件
if len(response.Response.ResultFile3Ds) > 0 { if len(response.Response.ResultFile3Ds) > 0 {
for _, file := range response.Response.ResultFile3Ds { // 取第一个文件
if file.Url != nil { file := response.Response.ResultFile3Ds[0]
result.FileURL = *file.Url if file.Url != nil {
} result.FileURL = *file.Url
if file.PreviewImageUrl != nil { }
result.PreviewURL = *file.PreviewImageUrl if file.PreviewImageUrl != nil {
} result.PreviewURL = *file.PreviewImageUrl
// TODO 取第一个文件
} }
} }
case "FAIL": case "FAIL":
result.Status = "failed" result.Status = types.AI3DJobStatusFailed
result.Progress = 0
if response.Response.ErrorMessage != nil { if response.Response.ErrorMessage != nil {
result.ErrorMsg = *response.Response.ErrorMessage result.ErrorMsg = *response.Response.ErrorMessage
} }
} }
logger.Debugf("tencent 3D job result: %+v", *response.Response)
result.RawData = utils.JsonEncode(response.Response)
return result, nil return result, nil
} }

View File

@@ -32,11 +32,9 @@ func NewAliYunOss(sysConfig *types.SystemConfig, appConfig *types.AppConfig) (*A
s := &AliYunOss{ s := &AliYunOss{
proxyURL: appConfig.ProxyURL, proxyURL: appConfig.ProxyURL,
} }
if sysConfig.OSS.Active == AliYun { err := s.UpdateConfig(sysConfig.OSS.AliYun)
err := s.UpdateConfig(sysConfig.OSS.AliYun) if err != nil {
if err != nil { logger.Warnf("阿里云OSS初始化失败: %v", err)
logger.Errorf("阿里云OSS初始化失败: %v", err)
}
} }
return s, nil return s, nil

View File

@@ -32,11 +32,9 @@ type MiniOss struct {
func NewMiniOss(sysConfig *types.SystemConfig, appConfig *types.AppConfig) (*MiniOss, error) { func NewMiniOss(sysConfig *types.SystemConfig, appConfig *types.AppConfig) (*MiniOss, error) {
s := &MiniOss{proxyURL: appConfig.ProxyURL} s := &MiniOss{proxyURL: appConfig.ProxyURL}
if sysConfig.OSS.Active == Minio { err := s.UpdateConfig(sysConfig.OSS.Minio)
err := s.UpdateConfig(sysConfig.OSS.Minio) if err != nil {
if err != nil { logger.Warnf("MinioOSS初始化失败: %v", err)
logger.Errorf("MinioOSS初始化失败: %v", err)
}
} }
return s, nil return s, nil
} }

View File

@@ -37,9 +37,7 @@ func NewQiNiuOss(sysConfig *types.SystemConfig, appConfig *types.AppConfig) *QiN
s := &QiNiuOss{ s := &QiNiuOss{
proxyURL: appConfig.ProxyURL, proxyURL: appConfig.ProxyURL,
} }
if sysConfig.OSS.Active == QiNiu { s.UpdateConfig(sysConfig.OSS.QiNiu)
s.UpdateConfig(sysConfig.OSS.QiNiu)
}
return s return s
} }

View File

@@ -9,10 +9,10 @@ package oss
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
const Local = "LOCAL" const Local = "local"
const Minio = "MINIO" const Minio = "minio"
const QiNiu = "QINIU" const QiNiu = "qiniu"
const AliYun = "ALIYUN" const AliYun = "aliyun"
type File struct { type File struct {
Name string `json:"name"` Name string `json:"name"`

View File

@@ -9,7 +9,6 @@ package oss
import ( import (
"geekai/core/types" "geekai/core/types"
"strings"
logger2 "geekai/logger" logger2 "geekai/logger"
) )
@@ -28,7 +27,6 @@ func NewUploaderManager(sysConfig *types.SystemConfig, local *LocalStorage, aliy
if sysConfig.OSS.Active == "" { if sysConfig.OSS.Active == "" {
sysConfig.OSS.Active = Local sysConfig.OSS.Active = Local
} }
sysConfig.OSS.Active = strings.ToLower(sysConfig.OSS.Active)
return &UploaderManager{ return &UploaderManager{
active: sysConfig.OSS.Active, active: sysConfig.OSS.Active,

View File

@@ -1,21 +1,25 @@
package model package model
import "time" import (
"geekai/core/types"
"time"
)
type AI3DJob struct { type AI3DJob struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"` UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"`
Type string `gorm:"column:type;type:varchar(20);not null;comment:API类型 (tencent/gitee)" json:"type"` Type types.AI3DTaskType `gorm:"column:type;type:varchar(20);not null;comment:API类型 (tencent/gitee)" json:"type"`
Power int `gorm:"column:power;type:int(11);not null;comment:消耗算力" json:"power"` Power int `gorm:"column:power;type:int(11);not null;comment:消耗算力" json:"power"`
TaskId string `gorm:"column:task_id;type:varchar(100);comment:第三方任务ID" json:"task_id"` TaskId string `gorm:"column:task_id;type:varchar(100);comment:第三方任务ID" json:"task_id"`
FileURL string `gorm:"column:file_url;type:varchar(1024);comment:生成的3D模型文件地址" json:"file_url"` FileURL string `gorm:"column:file_url;type:varchar(1024);comment:生成的3D模型文件地址" json:"file_url"`
PreviewURL string `gorm:"column:preview_url;type:varchar(1024);comment:预览图片地址" json:"preview_url"` PreviewURL string `gorm:"column:preview_url;type:varchar(1024);comment:预览图片地址" json:"preview_url"`
Model string `gorm:"column:model;type:varchar(50);comment:使用的3D模型类型" json:"model"` Model string `gorm:"column:model;type:varchar(50);comment:使用的3D模型类型" json:"model"`
Status string `gorm:"column:status;type:varchar(20);not null;default:pending;comment:任务状态" json:"status"` Status string `gorm:"column:status;type:varchar(20);not null;default:pending;comment:任务状态" json:"status"`
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"` ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
Params string `gorm:"column:params;type:text;comment:任务参数(JSON格式)" json:"params"` Params string `gorm:"column:params;type:text;comment:任务参数(JSON格式)" json:"params"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"` RawData string `gorm:"column:raw_data;type:text;comment:API返回的原始数据" json:"raw_data"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"` CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
} }
func (m *AI3DJob) TableName() string { func (m *AI3DJob) TableName() string {

View File

@@ -1,33 +1,39 @@
package vo package vo
import "geekai/core/types"
type AI3DJob struct { type AI3DJob struct {
Id uint `json:"id"` Id uint `json:"id"`
UserId uint `json:"user_id"` UserId uint `json:"user_id"`
Type string `json:"type"` Type types.AI3DTaskType `json:"type"`
Power int `json:"power"` Power int `json:"power"`
TaskId string `json:"task_id"` TaskId string `json:"task_id"`
FileURL string `json:"file_url"` FileURL string `json:"file_url"`
PreviewURL string `json:"preview_url"` PreviewURL string `json:"preview_url"`
Model string `json:"model"` Model string `json:"model"`
Status string `json:"status"` Status string `json:"status"`
ErrMsg string `json:"err_msg"` ErrMsg string `json:"err_msg"`
Params string `json:"params"` Params AI3DJobParams `json:"params"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
} }
type AI3DJobCreate struct { // AI3DJobParams 创建3D任务请求
Type string `json:"type" binding:"required"` // API类型 (tencent/gitee) type AI3DJobParams struct {
Model string `json:"model" binding:"required"` // 3D模型类型 // 通用参数
Prompt string `json:"prompt"` // 文本提示词 JobId uint `json:"job_id,omitempty"` // 任务ID
ImageURL string `json:"image_url"` // 输入图片URL Type types.AI3DTaskType `json:"type,omitempty"` // API类型 (tencent/gitee)
Power int `json:"power" binding:"required"` // 消耗算力 Model string `json:"model,omitempty"` // 3D模型类型
} Prompt string `json:"prompt,omitempty"` // 文本提示词
ImageURL string `json:"image_url,omitempty"` // 输入图片URL
type ThreeDJobList struct { FileFormat string `json:"file_format,omitempty"` // 输出文件格式
Page int `json:"page"` Power int `json:"power,omitempty"` // 消耗算力
PageSize int `json:"page_size"` // 腾讯3d专有参数
Total int `json:"total"` EnablePBR bool `json:"enable_pbr,omitempty"` // 是否开启PBR材质
List []AI3DJob `json:"list"` // Gitee3d专有参数
Items []AI3DJob `json:"items"` Texture bool `json:"texture,omitempty"` // 是否开启纹理
Seed int `json:"seed,omitempty"` // 随机种子
NumInferenceSteps int `json:"num_inference_steps,omitempty"` //迭代次数
GuidanceScale float64 `json:"guidance_scale,omitempty"` //引导系数
OctreeResolution int `json:"octree_resolution"` // 3D 渲染精度越高3D 细节越丰富
} }

View File

@@ -0,0 +1,145 @@
.admin-threed-jobs {
padding: 20px;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
color: var(--theme-text-color-primary);
}
}
.search-section {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
.el-form-item {
margin-bottom: 0;
.el-select__wrapper {
height: 36px;
line-height: 36px;
}
}
}
.stats-section {
margin-bottom: 20px;
.stat-card {
background: var(--card-bg);
padding: 20px;
border-radius: 8px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: white;
}
&.pending {
background: #e6a23c;
}
&.processing {
background: #409eff;
}
&.completed {
background: #67c23a;
}
&.failed {
background: #f56c6c;
}
}
.stat-content {
.stat-number {
font-size: 24px;
font-weight: bold;
color: var(--theme-text-color-primary);
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: var(--theme-text-color-secondary);
}
}
}
}
.table-section {
background: var(--card-bg);
border-radius: 8px;
box-shadow: var(--el-box-shadow, 0 2px 4px rgba(0, 0, 0, 0.1));
overflow: hidden;
}
.pagination-section {
padding: 20px;
text-align: center;
}
.task-detail {
.task-params,
.task-result,
.task-error {
margin-top: 20px;
h4 {
margin: 0 0 12px 0;
color: var(--theme-text-color-primary);
font-size: 16px;
}
.params-content {
background: var(--card-bg);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--line-box);
}
}
.result-links {
display: flex;
gap: 12px;
}
}
.preview-container {
text-align: center;
}
// 3D 模型预览弹窗
.model-preview-dialog {
.el-dialog__body {
padding: 0 0 16px 0;
background: var(--el-bg-color-overlay);
}
.model-preview-wrapper {
height: calc(100vh - 125px);
padding: 12px;
background: var(--card-bg);
}
}
}

View File

@@ -288,8 +288,11 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--theme-text-color-secondary); color: var(--theme-text-color-secondary);
min-height: 120px; min-height: 200px;
max-height: 200px;
min-width: 200px;
max-width: 200px; max-width: 200px;
border: 1px solid var(--line-box);
.preview-image { .preview-image {
width: 100%; width: 100%;
@@ -542,15 +545,14 @@
.preview-container { .preview-container {
width: 100%; width: 100%;
height: 500px; height: calc(100vh - 125px);
min-height: 500px;
background: var(--chat-wel-bg); background: var(--chat-wel-bg);
border-radius: 8px; border-radius: 8px;
position: relative; position: relative;
.three-container { .three-container {
width: 100%; width: 100%;
height: 500px; height: 100%;
background: var(--chat-wel-bg); background: var(--chat-wel-bg);
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;
@@ -561,7 +563,7 @@
.preview-placeholder { .preview-placeholder {
width: 100%; width: 100%;
height: 500px; min-height: 500px;
background: var(--chat-wel-bg); background: var(--chat-wel-bg);
border-radius: 8px; border-radius: 8px;
display: flex; display: flex;

View File

@@ -1,8 +1,8 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 4125778 */ font-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1756786244728') format('woff2'), src: url('iconfont.woff2?t=1756954977612') format('woff2'),
url('iconfont.woff?t=1756786244728') format('woff'), url('iconfont.woff?t=1756954977612') format('woff'),
url('iconfont.ttf?t=1756786244728') format('truetype'); url('iconfont.ttf?t=1756954977612') format('truetype');
} }
.iconfont { .iconfont {
@@ -14,7 +14,11 @@
} }
.icon-cube:before { .icon-cube:before {
content: "\e876"; content: "\e72c";
}
.icon-tag:before {
content: "\e657";
} }
.icon-tencent:before { .icon-tencent:before {
@@ -45,7 +49,7 @@
content: "\e652"; content: "\e652";
} }
.icon-suanli:before { .icon-power:before {
content: "\e651"; content: "\e651";
} }

File diff suppressed because one or more lines are too long

View File

@@ -6,11 +6,18 @@
"description": "", "description": "",
"glyphs": [ "glyphs": [
{ {
"icon_id": "34453337", "icon_id": "544492",
"name": "3D会场", "name": "cube",
"font_class": "cube", "font_class": "cube",
"unicode": "e876", "unicode": "e72c",
"unicode_decimal": 59510 "unicode_decimal": 59180
},
{
"icon_id": "5072110",
"name": "tag",
"font_class": "tag",
"unicode": "e657",
"unicode_decimal": 58967
}, },
{ {
"icon_id": "3547761", "icon_id": "3547761",
@@ -64,7 +71,7 @@
{ {
"icon_id": "25677845", "icon_id": "25677845",
"name": "算力", "name": "算力",
"font_class": "suanli", "font_class": "power",
"unicode": "e651", "unicode": "e651",
"unicode_decimal": 58961 "unicode_decimal": 58961
}, },

Binary file not shown.

View File

@@ -2,34 +2,6 @@
<div class="three-d-preview"> <div class="three-d-preview">
<div ref="container" class="preview-container"></div> <div ref="container" class="preview-container"></div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-group">
<label>缩放</label>
<div class="scale-controls">
<el-button size="small" @click="zoomOut" :disabled="scale <= 0.1">
<el-icon><Minus /></el-icon>
</el-button>
<span class="scale-value">{{ scale.toFixed(1) }}x</span>
<el-button size="small" @click="zoomIn" :disabled="scale >= 3">
<el-icon><Plus /></el-icon>
</el-button>
</div>
</div>
<div class="control-group">
<label>模型颜色</label>
<div class="color-picker">
<el-color-picker
v-model="modelColor"
@change="updateModelColor"
:predefine="predefineColors"
size="small"
/>
</div>
</div>
</div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loading" class="loading-overlay"> <div v-if="loading" class="loading-overlay">
<div class="loading-content"> <div class="loading-content">
@@ -56,7 +28,7 @@
</template> </template>
<script setup> <script setup>
import { Loading, Minus, Plus, Warning } from '@element-plus/icons-vue' import { Loading, Warning } from '@element-plus/icons-vue'
import { ElButton, ElIcon } from 'element-plus' import { ElButton, ElIcon } from 'element-plus'
import * as THREE from 'three' import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
@@ -82,20 +54,17 @@ const container = ref(null)
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
const loadingProgress = ref(0) const loadingProgress = ref(0)
const scale = ref(1) const modelType = computed(() => {
const modelColor = ref('#00ff88') if (props.modelType) {
const predefineColors = ref([ return props.modelType.toLowerCase()
'#00ff88', // 亮绿色 }
'#ff6b6b', // 亮红色 // 从模型URL中获取类型
'#4ecdc4', // 亮青色 if (props.modelUrl) {
'#45b7d1', // 亮蓝色 const url = new URL(props.modelUrl)
'#f9ca24', // 亮黄色 return url.pathname.split('.').pop()
'#f0932b', // 亮橙色 }
'#eb4d4b', // 亮粉红 return 'glb'
'#6c5ce7', // 亮紫色 })
'#a29bfe', // 亮靛蓝
'#fd79a8', // 亮玫瑰
])
// Three.js 相关变量 // Three.js 相关变量
let scene, camera, renderer, controls, model, mixer, clock let scene, camera, renderer, controls, model, mixer, clock
@@ -111,12 +80,12 @@ const initThreeJS = () => {
// 创建场景 // 创建场景
scene = new THREE.Scene() scene = new THREE.Scene()
scene.background = new THREE.Color(0x2a2a2a) // 深灰色背景,类似截图 scene.background = new THREE.Color(0x2d2d2d) // 深灰色背景,匹配截图效果
// 获取容器尺寸,确保有最小尺寸 // 获取容器尺寸,完全自适应父容器
const containerRect = container.value.getBoundingClientRect() const containerRect = container.value.getBoundingClientRect()
const width = Math.max(containerRect.width || 400, 400) const width = containerRect.width || 400
const height = Math.max(containerRect.height || 300, 300) const height = containerRect.height || 300
// 创建相机 - 参考截图的视角(稍微俯视,从左上角观察) // 创建相机 - 参考截图的视角(稍微俯视,从左上角观察)
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000) camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
@@ -130,9 +99,11 @@ const initThreeJS = () => {
}) })
renderer.setSize(width, height) renderer.setSize(width, height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true renderer.shadowMap.enabled = false // 禁用阴影
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.outputColorSpace = THREE.SRGBColorSpace renderer.outputColorSpace = THREE.SRGBColorSpace
// 提升曝光度让模型更加高亮
renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = 2.2
// 添加到容器 // 添加到容器
container.value.appendChild(renderer.domElement) container.value.appendChild(renderer.domElement)
@@ -163,60 +134,60 @@ const initThreeJS = () => {
// //
// 添加光源 - 参考截图的柔和光照效果 // 添加光源 - 高亮显示模型,无阴影效果
const addLights = () => { const addLights = () => {
// 环境光 - 提供基础照明,参考截图的柔和效果 // 环境光 - 提供整体高亮照明
const ambientLight = new THREE.AmbientLight(0x404040, 0.6) const ambientLight = new THREE.AmbientLight(0xffffff, 1.0)
scene.add(ambientLight) scene.add(ambientLight)
// 主方向光 - 从左上角照射,模拟截图中的光照方向 // 主方向光 - 从前上方照射,高亮度无阴影
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.8)
directionalLight.position.set(5, 5, 3) directionalLight.position.set(5, 8, 5)
directionalLight.castShadow = true directionalLight.castShadow = false
directionalLight.shadow.mapSize.width = 2048
directionalLight.shadow.mapSize.height = 2048
directionalLight.shadow.camera.near = 0.5
directionalLight.shadow.camera.far = 50
directionalLight.shadow.camera.left = -10
directionalLight.shadow.camera.right = 10
directionalLight.shadow.camera.top = 10
directionalLight.shadow.camera.bottom = -10
scene.add(directionalLight) scene.add(directionalLight)
// 补充光源 - 从侧照射,提供更均匀的光照 // 补充光源 - 从侧照射,填充光照
const fillLight = new THREE.DirectionalLight(0xffffff, 0.4) const fillLight = new THREE.DirectionalLight(0xffffff, 1.2)
fillLight.position.set(-3, 3, 3) fillLight.position.set(-5, 4, 3)
fillLight.castShadow = false
scene.add(fillLight) scene.add(fillLight)
// 背光 - 增加轮廓,但强度较低 // 背光 - 从背后照射,增加轮廓高亮
const backLight = new THREE.DirectionalLight(0xffffff, 0.15) const rimLight = new THREE.DirectionalLight(0xffffff, 1.0)
backLight.position.set(0, 2, -5) rimLight.position.set(0, 3, -5)
scene.add(backLight) rimLight.castShadow = false
scene.add(rimLight)
// 顶部光源 - 增加顶部高亮
const topLight = new THREE.DirectionalLight(0xffffff, 0.8)
topLight.position.set(0, 10, 0)
topLight.castShadow = false
scene.add(topLight)
} }
// 添加地面网格 - 参考截图的深色背景和浅色网格线 // 添加地面网格 - 简洁网格,无阴影
const addGround = () => { const addGround = () => {
// 创建网格辅助线 - 使用深色背景配浅色网格线,增加网格密度 // 创建网格辅助线 - 使用深色线条
const gridHelper = new THREE.GridHelper(20, 40, 0x666666, 0x666666) const gridHelper = new THREE.GridHelper(20, 20, 0x555555, 0x555555)
gridHelper.position.y = -0.01 // 稍微向下一点避免z-fighting gridHelper.position.y = 0
scene.add(gridHelper) scene.add(gridHelper)
// 添加半透明地面 - 使用更深的颜色 // 简单透明地面平面
const groundGeometry = new THREE.PlaneGeometry(20, 20) const groundGeometry = new THREE.PlaneGeometry(20, 20)
const groundMaterial = new THREE.MeshLambertMaterial({ const groundMaterial = new THREE.MeshBasicMaterial({
color: 0x1a1a1a, // 更深的背景色 color: 0x404040,
transparent: true, transparent: true,
opacity: 0.3, opacity: 0.1,
}) })
const ground = new THREE.Mesh(groundGeometry, groundMaterial) const ground = new THREE.Mesh(groundGeometry, groundMaterial)
ground.rotation.x = -Math.PI / 2 ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true ground.position.y = -0.01
scene.add(ground) scene.add(ground)
} }
// 添加坐标轴辅助线 - 参考截图样式 // 添加坐标轴辅助线 - 匹配截图样式
const addAxesHelper = () => { const addAxesHelper = () => {
const axesHelper = new THREE.AxesHelper(3) // 稍微小一点的坐标轴 const axesHelper = new THREE.AxesHelper(2)
scene.add(axesHelper) scene.add(axesHelper)
} }
@@ -244,7 +215,7 @@ const loadModel = async () => {
let loadedModel let loadedModel
switch (props.modelType.toLowerCase()) { switch (modelType.value) {
case 'glb': case 'glb':
case 'gltf': case 'gltf':
loadedModel = await loadGLTF(props.modelUrl) loadedModel = await loadGLTF(props.modelUrl)
@@ -256,7 +227,7 @@ const loadModel = async () => {
loadedModel = await loadSTL(props.modelUrl) loadedModel = await loadSTL(props.modelUrl)
break break
default: default:
throw new Error(`不支持的模型格式: ${props.modelType}`) throw new Error(`不支持的模型格式: ${modelType.value}`)
} }
if (loadedModel) { if (loadedModel) {
@@ -276,13 +247,13 @@ const loadModel = async () => {
baseScale = maxDim > 0 ? 2 / maxDim : 1 baseScale = maxDim > 0 ? 2 / maxDim : 1
// 应用初始缩放 // 应用初始缩放
model.scale.setScalar(baseScale * scale.value) model.scale.setScalar(baseScale)
// 根据模型大小调整相机距离 - 保持截图中的俯视角度 // 根据模型大小调整相机距离 - 保持截图中的俯视角度
const cameraDistance = maxDim > 0 ? maxDim * 2 : 5 const cameraDistance = maxDim > 0 ? maxDim * 2 : 5
// 设置相机位置为左上角俯视角度 // 设置相机位置 - 匹配截图中的正面稍俯视角度
camera.position.set(cameraDistance * 0.6, cameraDistance * 0.6, cameraDistance * 0.6) camera.position.set(cameraDistance * 0.3, cameraDistance * 0.4, cameraDistance * 1.2)
camera.lookAt(0, 0, 0) camera.lookAt(0, 0, 0)
if (controls) { if (controls) {
@@ -290,28 +261,14 @@ const loadModel = async () => {
controls.update() controls.update()
} }
// 设置阴影和材质 // 移除阴影设置,让模型高亮显示
model.traverse((child) => { model.traverse((child) => {
if (child.isMesh) { if (child.isMesh) {
child.castShadow = true child.castShadow = false
child.receiveShadow = true child.receiveShadow = false
// 如果材质支持,增加发光效果
// 将模型材质改为亮色
if (child.material) { if (child.material) {
const colorHex = modelColor.value.replace('#', '0x') child.material.emissive = new THREE.Color(0x111111) // 轻微发光
// 如果是数组材质
if (Array.isArray(child.material)) {
child.material.forEach((mat) => {
if (mat.color) {
mat.color.setHex(colorHex)
}
})
} else {
// 单个材质
if (child.material.color) {
child.material.color.setHex(colorHex)
}
}
} }
} }
}) })
@@ -403,51 +360,6 @@ const loadSTL = (url) => {
}) })
} }
// 放大
const zoomIn = () => {
if (scale.value < 3) {
scale.value = Math.min(scale.value + 0.1, 3)
updateScale(scale.value)
}
}
// 缩小
const zoomOut = () => {
if (scale.value > 0.1) {
scale.value = Math.max(scale.value - 0.1, 0.1)
updateScale(scale.value)
}
}
// 更新缩放
const updateScale = (value) => {
if (model) {
model.scale.setScalar(baseScale * value)
console.log('ThreeDPreview: 更新缩放', { value, baseScale, finalScale: baseScale * value })
}
}
// 更新模型颜色
const updateModelColor = (color) => {
if (model && color) {
model.traverse((child) => {
if (child.isMesh && child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((mat) => {
if (mat.color) {
mat.color.setHex(color.replace('#', '0x'))
}
})
} else {
if (child.material.color) {
child.material.color.setHex(color.replace('#', '0x'))
}
}
}
})
}
}
// //
// 重试加载 // 重试加载
@@ -460,8 +372,8 @@ const onWindowResize = () => {
if (!container.value || !camera || !renderer) return if (!container.value || !camera || !renderer) return
const containerRect = container.value.getBoundingClientRect() const containerRect = container.value.getBoundingClientRect()
const width = Math.max(containerRect.width || 400, 400) const width = containerRect.width || 400
const height = Math.max(containerRect.height || 300, 300) const height = containerRect.height || 300
camera.aspect = width / height camera.aspect = width / height
camera.updateProjectionMatrix() camera.updateProjectionMatrix()
@@ -552,62 +464,24 @@ onUnmounted(() => {
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
flex-direction: column;
} }
.preview-container { .preview-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
min-height: 400px;
position: relative; position: relative;
background: #f0f0f0; background: #2d2d2d;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
} // 移除min-height限制让高度完全自适应
.control-panel { // 确保在弹窗中能正确填充
position: absolute; canvas {
top: 20px; width: 100% !important;
right: 20px; height: 100% !important;
background: rgba(255, 255, 255, 0.9); display: block;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
.control-group {
margin-bottom: 16px;
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
}
.color-picker {
display: flex;
justify-content: center;
.el-color-picker--small {
width: 100% !important;
}
}
.scale-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
.scale-value {
min-width: 40px;
text-align: center;
font-size: 14px;
color: #333;
font-weight: 500;
}
} }
} }
@@ -685,15 +559,4 @@ onUnmounted(() => {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
// 响应式设计
@media (max-width: 768px) {
.control-panel {
position: relative;
top: auto;
right: auto;
margin: 16px;
border-radius: 8px;
}
}
</style> </style>

View File

@@ -15,8 +15,11 @@ export const useAI3DStore = defineStore('ai3d', () => {
const pageSize = ref(10) const pageSize = ref(10)
const total = ref(0) const total = ref(0)
const taskList = ref([]) const taskList = ref([])
const currentPreviewTask = ref(null) const currentPreviewTask = ref({
downloading: false,
})
const giteeAdvancedVisible = ref(false) const giteeAdvancedVisible = ref(false)
const taskPulling = ref(false)
const tencentDefaultForm = { const tencentDefaultForm = {
text3d: false, text3d: false,
@@ -49,6 +52,9 @@ export const useAI3DStore = defineStore('ai3d', () => {
const tencentSupportedFormats = ref([]) const tencentSupportedFormats = ref([])
const giteeSupportedFormats = ref([]) const giteeSupportedFormats = ref([])
// 定时器引用
let taskPullHandler = null
const configs = ref({ const configs = ref({
gitee: { models: [] }, gitee: { models: [] },
tencent: { models: [] }, tencent: { models: [] },
@@ -111,19 +117,9 @@ export const useAI3DStore = defineStore('ai3d', () => {
try { try {
loading.value = true loading.value = true
requestData.type = activePlatform.value requestData.type = activePlatform.value
if (requestData.image_url !== '') {
requestData.image_url = replaceImg(requestData.image_url[0].url)
}
const response = await httpPost('/api/ai3d/generate', requestData) const response = await httpPost('/api/ai3d/generate', requestData)
if (response.code === 0) { ElMessage.success('任务创建成功')
ElMessage.success('任务创建成功') await loadTasks()
tencentForm.value = { ...tencentDefaultForm }
giteeForm.value = { ...giteeDefaultForm }
currentPower.value = 0
await loadTasks()
} else {
ElMessage.error(response.message || '创建任务失败')
}
} catch (error) { } catch (error) {
ElMessage.error('创建任务失败:' + error.message) ElMessage.error('创建任务失败:' + error.message)
} finally { } finally {
@@ -133,12 +129,24 @@ export const useAI3DStore = defineStore('ai3d', () => {
const loadTasks = async () => { const loadTasks = async () => {
try { try {
const response = await httpGet('/api/ai3d/jobs/mock', { const response = await httpGet('/api/ai3d/jobs', {
page: currentPage.value, page: currentPage.value,
page_size: pageSize.value, page_size: pageSize.value,
}) })
if (response.code === 0) { if (response.code === 0) {
taskList.value = response.data.items let needPull = false
const items = response.data.items
// 检查是否有进行中的任务
for (let item of items) {
if (item.status === 'pending' || item.status === 'processing') {
needPull = true
break
}
}
taskPulling.value = needPull
taskList.value = items
total.value = response.data.total total.value = response.data.total
} }
} catch (error) { } catch (error) {
@@ -222,16 +230,16 @@ export const useAI3DStore = defineStore('ai3d', () => {
const getStatusText = (status) => { const getStatusText = (status) => {
const statusMap = { const statusMap = {
pending: '等待中', pending: { text: '等待中', type: 'warning' },
processing: '处理中', processing: { text: '处理中', type: 'primary' },
completed: '已完成', success: { text: '已完成', type: 'success' },
failed: '失败', failed: { text: '失败', type: 'danger' },
} }
return statusMap[status] || status return statusMap[status] || status
} }
const getTaskCardClass = (status) => { const getTaskCardClass = (status) => {
if (status === 'completed') return 'task-card-completed' if (status === 'success') return 'task-card-completed'
if (status === 'processing') return 'task-card-processing' if (status === 'processing') return 'task-card-processing'
if (status === 'failed') return 'task-card-failed' if (status === 'failed') return 'task-card-failed'
return 'task-card-default' return 'task-card-default'
@@ -249,30 +257,14 @@ export const useAI3DStore = defineStore('ai3d', () => {
return '未知平台' return '未知平台'
} }
const getStatusIcon = (status) => {
if (status === 'pending') return 'iconfont icon-pending'
if (status === 'processing') return 'iconfont icon-processing'
if (status === 'completed') return 'iconfont icon-completed'
if (status === 'failed') return 'iconfont icon-failed'
return 'iconfont icon-question'
}
const getTaskPrompt = (task) => { const getTaskPrompt = (task) => {
try { return task.params.prompt ? task.params.prompt : '图生3D任务'
if (task.params) {
const parsedParams = JSON.parse(task.params)
return parsedParams.prompt || '文生3D任务'
}
return '文生3D任务'
} catch (e) {
return '文生3D任务'
}
} }
const getTaskImageUrl = (task) => { const getTaskImageUrl = (task) => {
try { try {
if (task.params) { if (task.params) {
const parsedParams = JSON.parse(task.params) const parsedParams = task.params
return parsedParams.image_url || null return parsedParams.image_url || null
} }
return null return null
@@ -282,25 +274,30 @@ export const useAI3DStore = defineStore('ai3d', () => {
} }
const getTaskParams = (task) => { const getTaskParams = (task) => {
try { const parsedParams = task.params
if (task.params) { const params = []
const parsedParams = JSON.parse(task.params) if (parsedParams.texture) params.push('纹理')
const params = [] if (parsedParams.enable_pbr) params.push('PBR材质')
if (parsedParams.texture) params.push('纹理') if (parsedParams.num_inference_steps)
if (parsedParams.enable_pbr) params.push('PBR材质') params.push(`迭代次数: ${parsedParams.num_inference_steps}`)
if (parsedParams.num_inference_steps && parsedParams.num_inference_steps !== 5) if (parsedParams.guidance_scale) params.push(`引导系数: ${parsedParams.guidance_scale}`)
params.push(`迭代次数: ${parsedParams.num_inference_steps}`) if (parsedParams.octree_resolution) params.push(`精度: ${parsedParams.octree_resolution}`)
if (parsedParams.guidance_scale && parsedParams.guidance_scale !== 7.5) if (parsedParams.seed) params.push(`Seed: ${parsedParams.seed}`)
params.push(`引导系数: ${parsedParams.guidance_scale}`) return params.join('')
if (parsedParams.octree_resolution && parsedParams.octree_resolution !== 128) }
params.push(`精度: ${parsedParams.octree_resolution}`)
if (parsedParams.seed && parsedParams.seed !== 1234) const startTaskPolling = () => {
params.push(`种子: ${parsedParams.seed}`) taskPullHandler = setInterval(() => {
return params.join('') if (taskPulling.value) {
loadTasks()
} }
return '' }, 5000)
} catch (e) { }
return ''
const stopTaskPolling = () => {
if (taskPullHandler) {
clearInterval(taskPullHandler)
taskPullHandler = null
} }
} }
@@ -310,6 +307,7 @@ export const useAI3DStore = defineStore('ai3d', () => {
checkSession() checkSession()
.then(() => { .then(() => {
loadTasks() loadTasks()
startTaskPolling()
}) })
.catch(() => {}) .catch(() => {})
}) })
@@ -325,6 +323,7 @@ export const useAI3DStore = defineStore('ai3d', () => {
taskList, taskList,
currentPreviewTask, currentPreviewTask,
giteeAdvancedVisible, giteeAdvancedVisible,
taskPulling,
tencentForm, tencentForm,
giteeForm, giteeForm,
currentPower, currentPower,
@@ -353,9 +352,10 @@ export const useAI3DStore = defineStore('ai3d', () => {
getTaskCardClass, getTaskCardClass,
getPlatformIcon, getPlatformIcon,
getPlatformName, getPlatformName,
getStatusIcon,
getTaskPrompt, getTaskPrompt,
getTaskImageUrl, getTaskImageUrl,
getTaskParams, getTaskParams,
startTaskPolling,
stopTaskPolling,
} }
}) })

View File

@@ -87,8 +87,8 @@
<span class="label mb-3">随机种子</span> <span class="label mb-3">随机种子</span>
<el-input-number <el-input-number
v-model="giteeForm.seed" v-model="giteeForm.seed"
:min="1" :min="0"
:max="999999" :max="10000000"
controls-position="right" controls-position="right"
style="width: 100%" style="width: 100%"
/> />
@@ -278,12 +278,23 @@
</div> </div>
</div> </div>
<div class="task-status-wrapper"> <div class="task-status-wrapper">
<div class="task-status" :class="task.status"> <div class="task-status">
<i :class="getStatusIcon(task.status)" class="mr-1"></i> <el-button
{{ getStatusText(task.status) }} size="small"
:type="getStatusText(task.status).type"
class="action-btn processing-btn"
disabled
round
>
<i
class="iconfont icon-loading animate-spin mr-1"
v-if="task.status === 'processing'"
></i>
{{ getStatusText(task.status).text }}
</el-button>
</div> </div>
<div class="task-power"> <div class="task-power">
<i class="iconfont icon-suanli mr-1"></i> <i class="iconfont icon-power mr-1"></i>
{{ task.power }} {{ task.power }}
</div> </div>
</div> </div>
@@ -292,49 +303,49 @@
<!-- 任务卡片内容 --> <!-- 任务卡片内容 -->
<div class="task-card-content"> <div class="task-card-content">
<!-- 左侧预览图 --> <!-- 左侧预览图 -->
<div class="task-preview"> <div class="task-preview rounded-lg">
<div v-if="task.status === 'completed' && task.preview_url" class="preview-image"> <div v-if="task.status === 'success' && task.preview_url" class="preview-image">
<img :src="task.preview_url" :alt="getTaskPrompt(task)" /> <img :src="task.preview_url" :alt="getTaskPrompt(task)" />
<div class="preview-overlay"> <div class="preview-overlay cursor-pointer" @click="preview3D(task)">
<i class="iconfont icon-yulan"></i> <i class="iconfont icon-eye-open !text-3xl"></i>
</div> </div>
</div> </div>
<div v-else-if="getTaskImageUrl(task)" class="input-image"> <div v-else-if="getTaskImageUrl(task)" class="input-image">
<img :src="getTaskImageUrl(task)" :alt="getTaskPrompt(task)" /> <img :src="getTaskImageUrl(task)" :alt="getTaskPrompt(task)" />
<div class="input-overlay"> <div class="input-overlay">
<i class="iconfont icon-tupian"></i> <i class="iconfont icon-cube !text-3xl"></i>
</div> </div>
</div> </div>
<div v-else class="prompt-placeholder"> <div v-else class="prompt-placeholder">
<i class="iconfont icon-wenzi"></i> <i class="iconfont icon-doc"></i>
<span>{{ getTaskPrompt(task) }}</span> <span>文生3D任务</span>
</div> </div>
</div> </div>
<!-- 右侧任务详情 --> <!-- 右侧任务详情 -->
<div class="task-details"> <div class="task-details">
<div class="task-model"> <div class="task-model">
<i class="iconfont icon-moxing mr-1"></i> <i class="iconfont icon-model !text-2xl mr-1"></i>
{{ task.model }} {{ task.model }}
</div> </div>
<div class="task-prompt" v-if="getTaskPrompt(task)"> <div class="task-prompt" v-if="getTaskPrompt(task)">
<i class="iconfont icon-tishi mr-1"></i> <i class="iconfont icon-info !text-lg mr-1"></i>
<span>{{ getTaskPrompt(task) }}</span> <span>{{ getTaskPrompt(task) }}</span>
</div> </div>
<div class="task-params" v-if="getTaskParams(task)"> <div class="task-params" v-if="getTaskParams(task)">
<i class="iconfont icon-shezhi mr-1"></i> <i class="iconfont icon-tag !text-lg mr-1"></i>
<span>{{ getTaskParams(task) }}</span> <span>{{ getTaskParams(task) }}</span>
</div> </div>
<div class="task-time"> <div class="task-time">
<i class="iconfont icon-shijian mr-1"></i> <i class="iconfont icon-clock !text-xl mr-1"></i>
{{ dateFormat(task.created_at) }} {{ dateFormat(task.created_at) }}
</div> </div>
<div class="task-error" v-if="task.status === 'failed' && task.err_msg"> <div class="task-error" v-if="task.status === 'failed' && task.err_msg">
<i class="iconfont icon-cuowu mr-1"></i> <i class="iconfont icon-error !text-base mr-1"></i>
<span>{{ task.err_msg }}</span> <span>{{ task.err_msg }}</span>
</div> </div>
</div> </div>
@@ -344,7 +355,7 @@
<div class="task-card-footer"> <div class="task-card-footer">
<div class="task-actions"> <div class="task-actions">
<el-button <el-button
v-if="task.status === 'completed'" v-if="task.status === 'success'"
size="small" size="small"
type="primary" type="primary"
@click="preview3D(task)" @click="preview3D(task)"
@@ -355,7 +366,7 @@
</el-button> </el-button>
<el-button <el-button
v-if="task.status === 'completed'" v-if="task.status === 'success'"
size="small" size="small"
type="success" type="success"
@click="downloadFile(task)" @click="downloadFile(task)"
@@ -376,17 +387,6 @@
<i class="iconfont icon-remove mr-1"></i> <i class="iconfont icon-remove mr-1"></i>
删除 删除
</el-button> </el-button>
<el-button
v-if="task.status === 'processing'"
size="small"
type="info"
disabled
class="action-btn processing-btn"
>
<i class="iconfont icon-loading animate-spin mr-1"></i>
处理中...
</el-button>
</div> </div>
</div> </div>
</div> </div>
@@ -414,7 +414,7 @@
</div> </div>
<!-- 3D预览弹窗 --> <!-- 3D预览弹窗 -->
<el-dialog v-model="previewVisible" title="3D模型预览" width="80%" :before-close="closePreview"> <el-dialog v-model="previewVisible" title="3D模型预览" fullscreen :before-close="closePreview">
<div class="preview-container"> <div class="preview-container">
<ThreeDPreview <ThreeDPreview
v-if="currentPreviewTask && currentPreviewTask.file_url" v-if="currentPreviewTask && currentPreviewTask.file_url"
@@ -487,7 +487,6 @@ const {
getTaskCardClass, getTaskCardClass,
getPlatformIcon, getPlatformIcon,
getPlatformName, getPlatformName,
getStatusIcon,
getTaskPrompt, getTaskPrompt,
getTaskImageUrl, getTaskImageUrl,
getTaskParams, getTaskParams,

View File

@@ -22,7 +22,6 @@
<el-input <el-input
v-model="configs.tencent.secret_id" v-model="configs.tencent.secret_id"
placeholder="请输入腾讯云SecretId" placeholder="请输入腾讯云SecretId"
show-password
/> />
</el-form-item> </el-form-item>
@@ -30,7 +29,6 @@
<el-input <el-input
v-model="configs.tencent.secret_key" v-model="configs.tencent.secret_key"
placeholder="请输入腾讯云SecretKey" placeholder="请输入腾讯云SecretKey"
show-password
/> />
</el-form-item> </el-form-item>
@@ -132,11 +130,7 @@
<h4>秘钥配置</h4> <h4>秘钥配置</h4>
<el-form :model="configs.gitee" label-width="140px" label-position="top"> <el-form :model="configs.gitee" label-width="140px" label-position="top">
<el-form-item label="API密钥"> <el-form-item label="API密钥">
<el-input <el-input v-model="configs.gitee.api_key" placeholder="请输入Gitee API密钥" />
v-model="configs.gitee.api_key"
placeholder="请输入Gitee API密钥"
show-password
/>
</el-form-item> </el-form-item>
<el-form-item label="启用状态"> <el-form-item label="启用状态">

View File

@@ -13,7 +13,7 @@
<el-option label="全部" value="" /> <el-option label="全部" value="" />
<el-option label="等待中" value="pending" /> <el-option label="等待中" value="pending" />
<el-option label="处理中" value="processing" /> <el-option label="处理中" value="processing" />
<el-option label="已完成" value="completed" /> <el-option label="已完成" value="success" />
<el-option label="失败" value="failed" /> <el-option label="失败" value="failed" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -73,7 +73,7 @@
<i class="iconfont icon-check"></i> <i class="iconfont icon-check"></i>
</div> </div>
<div class="stat-content"> <div class="stat-content">
<div class="stat-number">{{ stats.completed }}</div> <div class="stat-number">{{ stats.success }}</div>
<div class="stat-label">已完成</div> <div class="stat-label">已完成</div>
</div> </div>
</div> </div>
@@ -94,8 +94,8 @@
<!-- 任务列表 --> <!-- 任务列表 -->
<div class="table-section w-full"> <div class="table-section w-full">
<el-table :data="taskList" v-loading="loading" stripe border style="width: 100%"> <el-table :data="taskList" v-loading="loading" border style="width: 100%">
<el-table-column prop="user_id" label="用户ID" /> <el-table-column prop="user_id" label="用户ID" width="80" />
<el-table-column prop="type" label="平台"> <el-table-column prop="type" label="平台">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.type === 'gitee' ? 'success' : 'primary'"> <el-tag :type="row.type === 'gitee' ? 'success' : 'primary'">
@@ -103,7 +103,12 @@
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="model" label="模型格式" /> <el-table-column prop="model" label="模型名称" />
<el-table-column label="模型格式">
<template #default="{ row }">
{{ row.params.file_format }}
</template>
</el-table-column>
<el-table-column prop="power" label="算力消耗" /> <el-table-column prop="power" label="算力消耗" />
<el-table-column prop="status" label="状态"> <el-table-column prop="status" label="状态">
<template #default="{ row }"> <template #default="{ row }">
@@ -114,17 +119,26 @@
</el-table-column> </el-table-column>
<el-table-column prop="created_at" label="创建时间"> <el-table-column prop="created_at" label="创建时间">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.created_at) }} {{ dateFormat(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="updated_at" label="更新时间"> <el-table-column prop="updated_at" label="更新时间">
<template #default="{ row }"> <template #default="{ row }">
{{ formatTime(row.updated_at) }} {{ dateFormat(row.updated_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="200" fixed="right"> <el-table-column label="操作" width="300" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button size="small" @click="viewTask(row)">查看</el-button> <el-button size="small" @click="viewTask(row)">查看</el-button>
<el-button
size="small"
type="primary"
plain
v-if="row.status === 'success'"
@click="openModelPreview(row)"
>
预览模型
</el-button>
<el-button size="small" type="danger" @click="deleteTask(row.id)"> 删除 </el-button> <el-button size="small" type="danger" @click="deleteTask(row.id)"> 删除 </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -160,7 +174,7 @@
{{ currentTask.type === 'gitee' ? '魔力方舟' : '腾讯混元' }} {{ currentTask.type === 'gitee' ? '魔力方舟' : '腾讯混元' }}
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="模型格式">{{ currentTask.model }}</el-descriptions-item> <el-descriptions-item label="模型名称">{{ currentTask.model }}</el-descriptions-item>
<el-descriptions-item label="算力消耗">{{ currentTask.power }}</el-descriptions-item> <el-descriptions-item label="算力消耗">{{ currentTask.power }}</el-descriptions-item>
<el-descriptions-item label="任务状态"> <el-descriptions-item label="任务状态">
<el-tag :type="getStatusType(currentTask.status)"> <el-tag :type="getStatusType(currentTask.status)">
@@ -168,24 +182,31 @@
</el-tag> </el-tag>
</el-descriptions-item> </el-descriptions-item>
<el-descriptions-item label="创建时间">{{ <el-descriptions-item label="创建时间">{{
formatTime(currentTask.created_at) dateFormat(currentTask.created_at)
}}</el-descriptions-item> }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ <el-descriptions-item label="更新时间">{{
formatTime(currentTask.updated_at) dateFormat(currentTask.updated_at)
}}</el-descriptions-item> }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div class="task-params"> <div class="task-params">
<h4>任务参数</h4> <h4>任务参数</h4>
<el-input v-model="taskParamsDisplay" type="textarea" :rows="6" readonly /> <div class="params-content">
<pre>{{ JSON.stringify(currentTask.params, null, 2) }}</pre>
</div>
</div> </div>
<div v-if="currentTask.img_url" class="task-result"> <div v-if="currentTask.img_url || currentTask.file_url" class="task-result">
<h4>生成结果</h4> <h4>生成结果</h4>
<div class="result-links"> <div class="result-links">
<el-button type="primary" @click="downloadModel(currentTask)"> 下载3D模型 </el-button> <el-button type="primary" @click="downloadModel(currentTask)"> 下载3D模型 </el-button>
<el-button v-if="currentTask.preview_url" @click="viewPreview(currentTask.preview_url)"> <el-button
查看预览 v-if="currentTask.file_url"
type="success"
plain
@click="openModelPreview(currentTask)"
>
预览模型
</el-button> </el-button>
</div> </div>
</div> </div>
@@ -203,19 +224,40 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- 预览图片弹窗 --> <!-- 3D 模型预览弹窗 -->
<el-dialog v-model="previewVisible" title="预览图片" width="50%"> <el-dialog
<div class="preview-container"> v-model="modelPreviewVisible"
<el-image :src="previewUrl" fit="contain" style="width: 100%; height: 400px" /> :class="['model-preview-dialog', { dark: isDarkTheme }]"
title="模型预览"
fullscreen
destroy-on-close
>
<div class="model-preview-wrapper">
<ThreeDPreview :model-url="modelPreviewUrl" />
</div> </div>
<template #footer>
<span class="dialog-footer">
<el-button
type="primary"
@click="downloadModel(currentTask)"
:loading="currentTask.downloading"
>
下载3D模型
</el-button>
<el-button @click="modelPreviewVisible = false">关闭</el-button>
</span>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { httpGet } from '@/utils/http' import ThreeDPreview from '@/components/ThreeDPreview.vue'
import { showMessageError } from '@/utils/dialog'
import { httpDownload, httpGet } from '@/utils/http'
import { dateFormat, replaceImg } from '@/utils/libs'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
// 响应式数据 // 响应式数据
const loading = ref(false) const loading = ref(false)
@@ -224,9 +266,17 @@ const pageSize = ref(20)
const total = ref(0) const total = ref(0)
const taskList = ref([]) const taskList = ref([])
const taskDetailVisible = ref(false) const taskDetailVisible = ref(false)
const previewVisible = ref(false) const currentTask = ref({
const currentTask = ref(null) downloading: false,
})
const previewUrl = ref('') const previewUrl = ref('')
// 3D 预览
const modelPreviewVisible = ref(false)
const modelPreviewUrl = ref('')
// 简单检测暗色主题(若全局有主题管理可替换)
const isDarkTheme = ref(
document.documentElement.classList.contains('dark') || document.body.classList.contains('dark')
)
// 搜索表单 // 搜索表单
const searchForm = reactive({ const searchForm = reactive({
@@ -243,18 +293,6 @@ const stats = reactive({
failed: 0, failed: 0,
}) })
// 计算属性
const taskParamsDisplay = computed(() => {
if (!currentTask.value?.params) return '无参数'
try {
const params = JSON.parse(currentTask.value.params)
return JSON.stringify(params, null, 2)
} catch {
return currentTask.value.params
}
})
// 方法 // 方法
const loadData = async () => { const loadData = async () => {
try { try {
@@ -276,7 +314,7 @@ const loadData = async () => {
const response = await httpGet('/api/admin/ai3d/jobs', params) const response = await httpGet('/api/admin/ai3d/jobs', params)
if (response.code === 0) { if (response.code === 0) {
taskList.value = response.data.list taskList.value = response.data.items
total.value = response.data.total total.value = response.data.total
} else { } else {
ElMessage.error(response.message || '加载数据失败') ElMessage.error(response.message || '加载数据失败')
@@ -364,31 +402,46 @@ const deleteTask = async (taskId) => {
} }
} }
const downloadModel = (task) => { const downloadModel = async (task) => {
if (task.img_url) { const url = replaceImg(task.file_url)
const downloadURL = `/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
task.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a') const link = document.createElement('a')
link.href = task.img_url link.href = URL.createObjectURL(blob)
link.download = `3d_model_${task.id}.${task.model}` link.download = fileName
link.style.display = 'none'
document.body.appendChild(link) document.body.appendChild(link)
link.click() link.click()
document.body.removeChild(link) document.body.removeChild(link)
ElMessage.success('开始下载3D模型') URL.revokeObjectURL(link.href)
} else { task.downloading = false
ElMessage.warning('模型文件不存在') } catch (error) {
showMessageError('下载失败:' + error.message)
task.downloading = false
} }
} }
const viewPreview = (url) => { const openModelPreview = (task) => {
previewUrl.value = url // 优先使用文件直链,后端下载代理也可拼接
previewVisible.value = true const url = task.file_url
if (!url) {
ElMessage.warning('暂无可预览的模型文件')
return
}
currentTask.value = task
modelPreviewUrl.value = url
modelPreviewVisible.value = true
} }
const getStatusType = (status) => { const getStatusType = (status) => {
const typeMap = { const typeMap = {
pending: 'warning', pending: 'warning',
processing: 'primary', processing: 'primary',
completed: 'success', success: 'success',
failed: 'danger', failed: 'danger',
} }
return typeMap[status] || 'info' return typeMap[status] || 'info'
@@ -398,24 +451,12 @@ const getStatusText = (status) => {
const textMap = { const textMap = {
pending: '等待中', pending: '等待中',
processing: '处理中', processing: '处理中',
completed: '已完成', success: '已完成',
failed: '失败', failed: '失败',
} }
return textMap[status] || status return textMap[status] || status
} }
const getProgressStatus = (status) => {
if (status === 'failed') return 'exception'
if (status === 'completed') return 'success'
return ''
}
const formatTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString()
}
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
loadData() loadData()
@@ -424,128 +465,5 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.admin-threed-jobs { @use '@/assets/css/admin/ai3d.scss' as *;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
color: #333;
}
}
.search-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.el-form-item {
margin-bottom: 0;
.el-select__wrapper {
height: 36px;
line-height: 36px;
}
}
}
.stats-section {
margin-bottom: 20px;
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: white;
}
&.pending {
background: #e6a23c;
}
&.processing {
background: #409eff;
}
&.completed {
background: #67c23a;
}
&.failed {
background: #f56c6c;
}
}
.stat-content {
.stat-number {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
}
}
}
.table-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.pagination-section {
padding: 20px;
text-align: center;
}
.task-detail {
.task-params,
.task-result,
.task-error {
margin-top: 20px;
h4 {
margin: 0 0 12px 0;
color: #333;
font-size: 16px;
}
}
.result-links {
display: flex;
gap: 12px;
}
}
.preview-container {
text-align: center;
}
</style> </style>

View File

@@ -4,62 +4,56 @@
<el-form <el-form
:model="jimengConfig" :model="jimengConfig"
label-width="150px" label-width="150px"
label-position="right" label-position="top"
ref="configFormRef" ref="configFormRef"
:rules="rules" :rules="rules"
class="py-3 px-5" class="py-3 px-5"
> >
<!-- 秘钥配置分组 --> <!-- 秘钥配置分组 -->
<div class="mb-3"> <div class="mb-3">
<h3 class="mb-2">秘钥配置</h3> <h3 class="heading-3 mb-2">秘钥配置</h3>
<el-alert type="info" :closable="false" show-icon> <div class="py-3">
<p class="mb-1"> <Alert type="info">
1. 要使用即梦 AI 功能需要先在火山引擎控制台开通 <p class="mb-1">
<a 1. 要使用即梦 AI 功能需要先在火山引擎控制台开通
href="https://console.volcengine.com/ai/ability/detail/10" <a
target="_blank" href="https://console.volcengine.com/ai/ability/detail/10"
class="text-blue-500" target="_blank"
>即梦 AI</a class="text-blue-500"
> >即梦 AI</a
>
<a
href="https://console.volcengine.com/ai/ability/detail/9" <a
target="_blank" href="https://console.volcengine.com/ai/ability/detail/9"
class="text-blue-500" target="_blank"
>智能绘图</a class="text-blue-500"
> >智能绘图</a
服务 >
</p> 服务
<p> </p>
2. AccessKey和SecretKey 请在火山引擎控制台 -> <p>
<a 2. AccessKey和SecretKey 请在火山引擎控制台 ->
href="https://console.volcengine.com/iam/keymanage/" <a
target="_blank" href="https://console.volcengine.com/iam/keymanage/"
class="text-blue-500" target="_blank"
>秘钥管理</a class="text-blue-500"
> >秘钥管理</a
获取 >
</p> 获取
</el-alert> </p>
</Alert>
</div>
<el-form-item label="AccessKey" prop="access_key"> <el-form-item label="AccessKey" prop="access_key">
<el-input <el-input v-model="jimengConfig.access_key" placeholder="请输入即梦AI的AccessKey" />
v-model="jimengConfig.access_key"
placeholder="请输入即梦AI的AccessKey"
show-password
/>
</el-form-item> </el-form-item>
<el-form-item label="SecretKey" prop="secret_key"> <el-form-item label="SecretKey" prop="secret_key">
<el-input <el-input v-model="jimengConfig.secret_key" placeholder="请输入即梦AI的SecretKey" />
v-model="jimengConfig.secret_key"
placeholder="请输入即梦AI的SecretKey"
show-password
/>
</el-form-item> </el-form-item>
</div> </div>
<el-divider /> <el-divider />
<!-- 算力配置分组 --> <!-- 算力配置分组 -->
<div class="mb-3"> <div class="mb-3">
<h3 class="mb-3">算力配置</h3> <h3 class="heading-3 mb-3">算力配置</h3>
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
@@ -205,6 +199,7 @@
</template> </template>
<script setup> <script setup>
import Alert from '@/components/ui/Alert.vue'
import { httpGet, httpPost } from '@/utils/http' import { httpGet, httpPost } from '@/utils/http'
import { InfoFilled } from '@element-plus/icons-vue' import { InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -297,6 +292,10 @@ const resetConfig = () => {
max-width: 800px; max-width: 800px;
} }
.heading-3 {
color: var(--theme-text-color-primary);
}
.label-title { .label-title {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -104,7 +104,6 @@
:data="taskList" :data="taskList"
v-loading="loading" v-loading="loading"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
stripe
border border
> >
<el-table-column type="selection" width="55" /> <el-table-column type="selection" width="55" />

View File

@@ -168,7 +168,7 @@
<p class="text-sm text-gray-500 mt-2">检测结果仅供参考</p> <p class="text-sm text-gray-500 mt-2">检测结果仅供参考</p>
</div> </div>
<el-table :data="testResult.details" border stripe class="result-table"> <el-table :data="testResult.details" border class="result-table">
<el-table-column prop="category" label="类别" width="120"> <el-table-column prop="category" label="类别" width="120">
<template #default="{ row }"> <template #default="{ row }">
<span class="font-medium">{{ row.category }}</span> <span class="font-medium">{{ row.category }}</span>

View File

@@ -83,7 +83,6 @@
:data="tableData" :data="tableData"
v-loading="loading" v-loading="loading"
@selection-change="handleSelectionChange" @selection-change="handleSelectionChange"
stripe
border border
style="width: 100%" style="width: 100%"
> >