mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-23 03:24:34 +08:00
remove AI3D module files
This commit is contained in:
@@ -166,15 +166,6 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
|
||||
logger.Error("load jimeng config error: ", err)
|
||||
}
|
||||
|
||||
// 加载3D生成配置
|
||||
var ai3dConfig types.AI3DConfig
|
||||
sysConfig.Id = 0
|
||||
db.Where("name", types.ConfigKeyAI3D).First(&sysConfig)
|
||||
err = utils.JsonDecode(sysConfig.Value, &ai3dConfig)
|
||||
if err != nil {
|
||||
logger.Error("load ai3d config error: ", err)
|
||||
}
|
||||
|
||||
return &types.SystemConfig{
|
||||
Base: baseConfig,
|
||||
License: license,
|
||||
@@ -186,6 +177,5 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
|
||||
WxLogin: wxLoginConfig,
|
||||
Moderation: moderationConfig,
|
||||
Jimeng: jimengConfig,
|
||||
AI3D: ai3dConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package types
|
||||
|
||||
// AI3DConfig 3D生成配置
|
||||
type AI3DConfig struct {
|
||||
Tencent Tencent3DConfig `json:"tencent,omitempty"`
|
||||
Gitee Gitee3DConfig `json:"gitee,omitempty"`
|
||||
}
|
||||
|
||||
// Tencent3DConfig 腾讯云3D配置
|
||||
type Tencent3DConfig struct {
|
||||
SecretId string `json:"secret_id,omitempty"`
|
||||
SecretKey string `json:"secret_key,omitempty"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Models []AI3DModel `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
// Gitee3DConfig Gitee 3D配置
|
||||
type Gitee3DConfig struct {
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Models []AI3DModel `json:"models,omitempty"`
|
||||
}
|
||||
|
||||
type AI3DTaskType string
|
||||
|
||||
const (
|
||||
AI3DTaskTypeTencent AI3DTaskType = "tencent"
|
||||
AI3DTaskTypeGitee AI3DTaskType = "gitee"
|
||||
)
|
||||
|
||||
// AI3DJobResult 3D任务结果
|
||||
type AI3DJobResult struct {
|
||||
TaskId string `json:"task_id"` // 任务ID
|
||||
Status string `json:"status"` // 任务状态
|
||||
FileURL string `json:"file_url"` // 3D模型文件URL
|
||||
PreviewURL string `json:"preview_url"` // 预览图片URL
|
||||
ErrorMsg string `json:"error_msg"` // 错误信息
|
||||
RawData string `json:"raw_data"` // 原始数据
|
||||
}
|
||||
|
||||
// AI3DModel 3D模型配置
|
||||
type AI3DModel struct {
|
||||
Name string `json:"name"` // 模型名称
|
||||
Desc string `json:"desc"` // 模型描述
|
||||
Power int `json:"power"` // 算力消耗
|
||||
Formats []string `json:"formats"` // 支持输出的文件格式
|
||||
}
|
||||
|
||||
// AI3DJobRequest 3D任务请求
|
||||
type AI3DJobRequest struct {
|
||||
Type string `json:"type"` // API类型 (tencent/gitee)
|
||||
Model string `json:"model"` // 3D模型类型
|
||||
Prompt string `json:"prompt"` // 文本提示词
|
||||
ImageURL string `json:"image_url"` // 输入图片URL
|
||||
Power int `json:"power"` // 消耗算力
|
||||
}
|
||||
|
||||
// AI3DJobStatus 3D任务状态
|
||||
const (
|
||||
AI3DJobStatusPending = "pending" // 等待中
|
||||
AI3DJobStatusProcessing = "processing" // 处理中
|
||||
AI3DJobStatusSuccess = "success" // 已完成
|
||||
AI3DJobStatusFailed = "failed" // 失败
|
||||
)
|
||||
@@ -107,7 +107,6 @@ type SystemConfig struct {
|
||||
Captcha CaptchaConfig
|
||||
WxLogin WxLoginConfig
|
||||
Jimeng JimengConfig
|
||||
AI3D AI3DConfig
|
||||
License License
|
||||
Moderation ModerationConfig
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ require (
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible
|
||||
github.com/shopspring/decimal v1.3.1
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d v1.1.0
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.21
|
||||
golang.org/x/image v0.15.0
|
||||
)
|
||||
|
||||
|
||||
@@ -245,11 +245,6 @@ github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gt
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d v1.1.0 h1:hOyYsl35o74hOhnnPVQIK/bdSIPNp3TKJlCEOXGO7ms=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d v1.1.0/go.mod h1:3689peGF1zp+P9c+GnUcAzkMp+kXi0Tr44zeQ57Z+7Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.0/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.21 h1:ikHhyiq1PiPytUMtEblKPkbf0zzTEi3CpE9z0MARlqY=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.21/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tklauser/go-sysconf v0.3.13 h1:GBUpcahXSpR2xN01jhkNAbTLRk2Yzgggk8IM08lq3r4=
|
||||
github.com/tklauser/go-sysconf v0.3.13/go.mod h1:zwleP4Q4OehZHGn4CYZDipCgg9usW5IJePewFCGVEa0=
|
||||
github.com/tklauser/numcpus v0.7.0 h1:yjuerZP127QG9m5Zh/mSO4wqurYil27tHrqwRoRjpr4=
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"geekai/core"
|
||||
"geekai/core/types"
|
||||
"geekai/service/ai3d"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AI3DHandler 3D管理处理器
|
||||
type AI3DHandler struct {
|
||||
app *core.AppServer
|
||||
db *gorm.DB
|
||||
service *ai3d.Service
|
||||
}
|
||||
|
||||
// NewAI3DHandler 创建3D管理处理器
|
||||
func NewAI3DHandler(app *core.AppServer, db *gorm.DB, service *ai3d.Service) *AI3DHandler {
|
||||
return &AI3DHandler{
|
||||
app: app,
|
||||
db: db,
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *AI3DHandler) RegisterRoutes() {
|
||||
admin := h.app.Engine.Group("/api/admin/ai3d")
|
||||
{
|
||||
admin.GET("/jobs", h.GetJobList)
|
||||
admin.GET("/jobs/:id", h.GetJobDetail)
|
||||
admin.DELETE("/jobs/:id", h.DeleteJob)
|
||||
admin.GET("/stats", h.GetStats)
|
||||
admin.GET("/models", h.GetModels)
|
||||
admin.POST("/config", h.SaveConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// GetJobList 获取任务列表
|
||||
func (h *AI3DHandler) GetJobList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||||
status := c.Query("status")
|
||||
jobType := c.Query("type")
|
||||
userIdStr := c.Query("user_id")
|
||||
|
||||
var userId uint
|
||||
if userIdStr != "" {
|
||||
if id, err := strconv.ParseUint(userIdStr, 10, 32); err == nil {
|
||||
userId = uint(id)
|
||||
}
|
||||
}
|
||||
|
||||
// 构建查询条件
|
||||
query := h.db.Model(&model.AI3DJob{})
|
||||
|
||||
if status != "" {
|
||||
query = query.Where("status = ?", status)
|
||||
}
|
||||
|
||||
if jobType != "" {
|
||||
query = query.Where("type = ?", jobType)
|
||||
}
|
||||
|
||||
if userId > 0 {
|
||||
query = query.Where("user_id = ?", userId)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total int64
|
||||
query.Count(&total)
|
||||
|
||||
// 获取分页数据
|
||||
var jobs []model.AI3DJob
|
||||
offset := (page - 1) * pageSize
|
||||
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&jobs).Error
|
||||
|
||||
if err != nil {
|
||||
resp.ERROR(c, "获取任务列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为VO
|
||||
var jobList []vo.AI3DJob
|
||||
for _, job := range jobs {
|
||||
var jobVo vo.AI3DJob
|
||||
err = utils.CopyObject(job, &jobVo)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
utils.JsonDecode(job.Params, &jobVo.Params)
|
||||
jobVo.CreatedAt = job.CreatedAt.Unix()
|
||||
jobVo.UpdatedAt = job.UpdatedAt.Unix()
|
||||
jobList = append(jobList, jobVo)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, page, pageSize, jobList))
|
||||
}
|
||||
|
||||
// GetJobDetail 获取任务详情
|
||||
func (h *AI3DHandler) GetJobDetail(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "无效的任务ID")
|
||||
return
|
||||
}
|
||||
|
||||
var job model.AI3DJob
|
||||
err = h.db.First(&job, uint(id)).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
} else {
|
||||
resp.ERROR(c, "获取任务详情失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var jobVo vo.AI3DJob
|
||||
err = utils.CopyObject(job, &jobVo)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "获取任务详情失败")
|
||||
return
|
||||
}
|
||||
utils.JsonDecode(job.Params, &jobVo.Params)
|
||||
jobVo.CreatedAt = job.CreatedAt.Unix()
|
||||
jobVo.UpdatedAt = job.UpdatedAt.Unix()
|
||||
resp.SUCCESS(c, jobVo)
|
||||
}
|
||||
|
||||
// DeleteJob 删除任务
|
||||
func (h *AI3DHandler) DeleteJob(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "无效的任务ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 检查任务是否存在
|
||||
var job model.AI3DJob
|
||||
err = h.db.First(&job, uint(id)).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
resp.ERROR(c, "任务不存在")
|
||||
} else {
|
||||
resp.ERROR(c, "获取任务失败")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
err = h.db.Delete(&job).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, "删除任务失败")
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, "删除成功")
|
||||
}
|
||||
|
||||
// GetStats 获取统计数据
|
||||
func (h *AI3DHandler) GetStats(c *gin.Context) {
|
||||
var stats struct {
|
||||
Pending int64 `json:"pending"`
|
||||
Processing int64 `json:"processing"`
|
||||
Success int64 `json:"success"`
|
||||
Failed int64 `json:"failed"`
|
||||
}
|
||||
|
||||
// 统计各状态的任务数量
|
||||
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 = ?", "success").Count(&stats.Success)
|
||||
h.db.Model(&model.AI3DJob{}).Where("status = ?", "failed").Count(&stats.Failed)
|
||||
|
||||
resp.SUCCESS(c, stats)
|
||||
}
|
||||
|
||||
// GetModels 获取配置
|
||||
func (h *AI3DHandler) GetModels(c *gin.Context) {
|
||||
models := h.service.GetSupportedModels()
|
||||
resp.SUCCESS(c, models)
|
||||
}
|
||||
|
||||
// SaveGlobalSettings 保存全局配置
|
||||
func (h *AI3DHandler) SaveConfig(c *gin.Context) {
|
||||
var config types.AI3DConfig
|
||||
err := c.ShouldBindJSON(&config)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "参数错误")
|
||||
return
|
||||
}
|
||||
var exist model.Config
|
||||
err = h.db.Where("name", types.ConfigKeyAI3D).First(&exist).Error
|
||||
if err != nil {
|
||||
exist.Name = types.ConfigKeyAI3D
|
||||
exist.Value = utils.JsonEncode(config)
|
||||
err = h.db.Create(&exist).Error
|
||||
} else {
|
||||
exist.Value = utils.JsonEncode(config)
|
||||
err = h.db.Updates(&exist).Error
|
||||
}
|
||||
if err != nil {
|
||||
resp.ERROR(c, "保存配置失败")
|
||||
return
|
||||
}
|
||||
|
||||
h.service.UpdateConfig(config)
|
||||
h.app.SysConfig.AI3D = config
|
||||
|
||||
resp.SUCCESS(c, "保存成功")
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
"geekai/service/ai3d"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"geekai/utils/resp"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AI3DHandler struct {
|
||||
BaseHandler
|
||||
service *ai3d.Service
|
||||
}
|
||||
|
||||
func NewAI3DHandler(app *core.AppServer, db *gorm.DB, service *ai3d.Service) *AI3DHandler {
|
||||
return &AI3DHandler{
|
||||
service: service,
|
||||
BaseHandler: BaseHandler{
|
||||
App: app,
|
||||
DB: db,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes 注册路由
|
||||
func (h *AI3DHandler) RegisterRoutes() {
|
||||
group := h.App.Engine.Group("/api/ai3d/")
|
||||
|
||||
// 公开接口,不需要授权
|
||||
group.GET("configs", h.GetConfigs)
|
||||
|
||||
// 需要用户授权的接口
|
||||
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
{
|
||||
group.POST("generate", h.Generate)
|
||||
group.GET("jobs", h.JobList)
|
||||
group.GET("jobs/mock", h.ListMock) // 演示数据接口
|
||||
group.GET("job/delete", h.DeleteJob)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate 创建3D生成任务
|
||||
func (h *AI3DHandler) Generate(c *gin.Context) {
|
||||
var request vo.AI3DJobParams
|
||||
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
resp.ERROR(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 提示词和图片不能同时为空
|
||||
if request.Prompt == "" && request.ImageURL == "" {
|
||||
resp.ERROR(c, "提示词和图片不能同时为空")
|
||||
return
|
||||
}
|
||||
|
||||
// Gitee 只支持图片
|
||||
if request.Type == types.AI3DTaskTypeGitee && request.ImageURL == "" {
|
||||
resp.ERROR(c, "Gitee 只支持图生3D")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Infof("request: %+v", request)
|
||||
|
||||
// 获取用户ID
|
||||
userId := h.GetLoginUserId(c)
|
||||
// 创建任务
|
||||
job, err := h.service.CreateJob(uint(userId), request)
|
||||
if err != nil {
|
||||
resp.ERROR(c, fmt.Sprintf("创建任务失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{
|
||||
"job_id": job.Id,
|
||||
"message": "任务创建成功",
|
||||
})
|
||||
}
|
||||
|
||||
// JobList 获取任务列表
|
||||
func (h *AI3DHandler) JobList(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.ERROR(c, "用户未登录")
|
||||
return
|
||||
}
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
|
||||
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
jobList, err := h.service.GetJobList(uint(userId), page, pageSize)
|
||||
if err != nil {
|
||||
resp.ERROR(c, fmt.Sprintf("获取任务列表失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, jobList)
|
||||
}
|
||||
|
||||
// DeleteJob 删除任务
|
||||
func (h *AI3DHandler) DeleteJob(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
id := h.GetInt(c, "id", 0)
|
||||
if id == 0 {
|
||||
resp.ERROR(c, "任务ID不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.DeleteUserJob(uint(id), uint(userId))
|
||||
if err != nil {
|
||||
resp.ERROR(c, "删除任务失败")
|
||||
return
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, gin.H{"message": "删除成功"})
|
||||
}
|
||||
|
||||
// GetConfigs 获取3D生成配置
|
||||
func (h *AI3DHandler) GetConfigs(c *gin.Context) {
|
||||
var config model.Config
|
||||
err := h.DB.Where("name", types.ConfigKeyAI3D).First(&config).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
var config3d types.AI3DConfig
|
||||
err = utils.JsonDecode(config.Value, &config3d)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
models := h.service.GetSupportedModels()
|
||||
if len(config3d.Gitee.Models) == 0 {
|
||||
config3d.Gitee.Models = models["gitee"]
|
||||
}
|
||||
if len(config3d.Tencent.Models) == 0 {
|
||||
config3d.Tencent.Models = models["tencent"]
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, config3d)
|
||||
}
|
||||
|
||||
// ListMock 返回演示数据
|
||||
func (h *AI3DHandler) ListMock(c *gin.Context) {
|
||||
// 创建各种状态的演示数据
|
||||
mockJobs := []vo.AI3DJob{
|
||||
{
|
||||
Id: 1,
|
||||
UserId: 1,
|
||||
Type: "gitee",
|
||||
Power: 10,
|
||||
TaskId: "mock_task_1",
|
||||
FileURL: "https://img.r9it.com/R03TQZ7PZ386RGL7PTMNGFOHAJW15WYF.glb",
|
||||
PreviewURL: "/static/upload/2025/9/1756873317505073.png",
|
||||
Model: "gitee-3d-v1",
|
||||
Status: types.AI3DJobStatusSuccess,
|
||||
ErrMsg: "",
|
||||
Params: vo.AI3DJobParams{Prompt: "一只可爱的小猫", ImageURL: "", Texture: true, Seed: 42},
|
||||
CreatedAt: 1704067200, // 2024-01-01 00:00:00
|
||||
UpdatedAt: 1704067800, // 2024-01-01 00:10:00
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
UserId: 1,
|
||||
Type: "tencent",
|
||||
Power: 15,
|
||||
TaskId: "mock_task_2",
|
||||
FileURL: "",
|
||||
PreviewURL: "/static/upload/2025/9/1756873317505073.png",
|
||||
Model: "tencent-3d-v2",
|
||||
Status: types.AI3DJobStatusProcessing,
|
||||
ErrMsg: "",
|
||||
Params: vo.AI3DJobParams{Prompt: "一个现代建筑模型", ImageURL: "", EnablePBR: true},
|
||||
CreatedAt: 1704070800, // 2024-01-01 01:00:00
|
||||
UpdatedAt: 1704070800, // 2024-01-01 01:00:00
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
UserId: 1,
|
||||
Type: "gitee",
|
||||
Power: 8,
|
||||
TaskId: "mock_task_3",
|
||||
FileURL: "",
|
||||
PreviewURL: "",
|
||||
Model: "gitee-3d-v1",
|
||||
Status: types.AI3DJobStatusPending,
|
||||
ErrMsg: "",
|
||||
Params: vo.AI3DJobParams{Prompt: "一辆跑车模型", ImageURL: "https://example.com/car.jpg", Texture: false},
|
||||
CreatedAt: 1704074400, // 2024-01-01 02:00:00
|
||||
UpdatedAt: 1704074400, // 2024-01-01 02:00:00
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
UserId: 1,
|
||||
Type: "tencent",
|
||||
Power: 12,
|
||||
TaskId: "mock_task_4",
|
||||
FileURL: "",
|
||||
PreviewURL: "",
|
||||
Model: "tencent-3d-v1",
|
||||
Status: types.AI3DJobStatusFailed,
|
||||
ErrMsg: "模型生成失败:输入图片质量不符合要求",
|
||||
Params: vo.AI3DJobParams{Prompt: "一个机器人模型", ImageURL: "https://example.com/robot.jpg", EnablePBR: false},
|
||||
CreatedAt: 1704078000, // 2024-01-01 03:00:00
|
||||
UpdatedAt: 1704078600, // 2024-01-01 03:10:00
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
UserId: 1,
|
||||
Type: "gitee",
|
||||
Power: 20,
|
||||
TaskId: "mock_task_5",
|
||||
FileURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
|
||||
PreviewURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
|
||||
Model: "gitee-3d-v2",
|
||||
Status: types.AI3DJobStatusSuccess,
|
||||
ErrMsg: "",
|
||||
Params: vo.AI3DJobParams{Prompt: "一个复杂的机械装置", ImageURL: "", Texture: true, OctreeResolution: 512},
|
||||
CreatedAt: 1704081600, // 2024-01-01 04:00:00
|
||||
UpdatedAt: 1704082200, // 2024-01-01 04:10:00
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
UserId: 1,
|
||||
Type: "tencent",
|
||||
Power: 18,
|
||||
TaskId: "mock_task_6",
|
||||
FileURL: "",
|
||||
PreviewURL: "",
|
||||
Model: "tencent-3d-v2",
|
||||
Status: types.AI3DJobStatusProcessing,
|
||||
ErrMsg: "",
|
||||
Params: vo.AI3DJobParams{Prompt: "一个科幻飞船", ImageURL: "", EnablePBR: true},
|
||||
CreatedAt: 1704085200, // 2024-01-01 05:00:00
|
||||
UpdatedAt: 1704085200, // 2024-01-01 05:00:00
|
||||
},
|
||||
}
|
||||
|
||||
// 创建分页响应
|
||||
mockResponse := vo.Page{
|
||||
Page: 1,
|
||||
PageSize: 10,
|
||||
Total: int64(len(mockJobs)),
|
||||
Items: mockJobs,
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, mockResponse)
|
||||
}
|
||||
18
api/main.go
18
api/main.go
@@ -16,7 +16,6 @@ import (
|
||||
"geekai/handler/admin"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service"
|
||||
"geekai/service/ai3d"
|
||||
"geekai/service/dalle"
|
||||
"geekai/service/jimeng"
|
||||
"geekai/service/mj"
|
||||
@@ -217,13 +216,6 @@ func main() {
|
||||
service.Start()
|
||||
}),
|
||||
|
||||
// 3D生成服务
|
||||
fx.Provide(ai3d.NewTencent3DClient),
|
||||
fx.Provide(ai3d.NewGitee3DClient),
|
||||
fx.Provide(ai3d.NewService),
|
||||
fx.Invoke(func(s *ai3d.Service) {
|
||||
s.Run()
|
||||
}),
|
||||
fx.Provide(service.NewSnowflake),
|
||||
|
||||
// 创建短信服务
|
||||
@@ -393,16 +385,6 @@ func main() {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
|
||||
// 3D生成处理器
|
||||
fx.Provide(handler.NewAI3DHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.AI3DHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
fx.Provide(admin.NewAI3DHandler),
|
||||
fx.Invoke(func(s *core.AppServer, h *admin.AI3DHandler) {
|
||||
h.RegisterRoutes()
|
||||
}),
|
||||
|
||||
// 即梦AI 路由
|
||||
fx.Invoke(func(s *core.AppServer, h *handler.JimengHandler) {
|
||||
h.RegisterRoutes()
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package ai3d
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"time"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
)
|
||||
|
||||
type Gitee3DClient struct {
|
||||
httpClient *req.Client
|
||||
config types.Gitee3DConfig
|
||||
apiURL string
|
||||
}
|
||||
|
||||
type Gitee3DParams struct {
|
||||
Model string `json:"model"` // 模型名称
|
||||
FileFormat string `json:"file_format,omitempty"` // 文件格式(Step1X-3D、Hi3DGen模型适用),支持 glb 和 stl
|
||||
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 {
|
||||
TaskID string `json:"task_id"`
|
||||
Output struct {
|
||||
FileURL string `json:"file_url,omitempty"`
|
||||
PreviewURL string `json:"preview_url,omitempty"`
|
||||
} `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 GiteeErrorResponse struct {
|
||||
Error int `json:"error"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func NewGitee3DClient(sysConfig *types.SystemConfig) *Gitee3DClient {
|
||||
return &Gitee3DClient{
|
||||
httpClient: req.C().SetTimeout(time.Minute * 3),
|
||||
config: sysConfig.AI3D.Gitee,
|
||||
apiURL: "https://ai.gitee.com/v1",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Gitee3DClient) UpdateConfig(config types.Gitee3DConfig) {
|
||||
c.config = config
|
||||
}
|
||||
|
||||
func (c *Gitee3DClient) GetConfig() *types.Gitee3DConfig {
|
||||
return &c.config
|
||||
}
|
||||
|
||||
// SubmitJob 提交3D生成任务
|
||||
func (c *Gitee3DClient) SubmitJob(params Gitee3DParams) (string, error) {
|
||||
|
||||
var giteeResp Gitee3DResponse
|
||||
response, err := c.httpClient.R().
|
||||
SetHeader("Authorization", "Bearer "+c.config.APIKey).
|
||||
SetHeader("Content-Type", "application/json").
|
||||
SetBody(params).
|
||||
SetSuccessResult(&giteeResp).
|
||||
Post(c.apiURL + "/async/image-to-3d")
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to submit gitee 3D job: %v", err)
|
||||
}
|
||||
|
||||
if giteeResp.TaskID == "" {
|
||||
var giteeErr GiteeErrorResponse
|
||||
_ = json.Unmarshal(response.Bytes(), &giteeErr)
|
||||
return "", fmt.Errorf("no task ID returned from gitee 3D API: %s", giteeErr.Message)
|
||||
}
|
||||
|
||||
return giteeResp.TaskID, nil
|
||||
}
|
||||
|
||||
// QueryJob 查询任务状态
|
||||
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().
|
||||
SetHeader("Authorization", "Bearer "+c.config.APIKey).
|
||||
SetSuccessResult(&giteeResp).
|
||||
Get(apiURL)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query gitee 3D job: %v", err)
|
||||
}
|
||||
|
||||
result := &types.AI3DJobResult{
|
||||
TaskId: taskId,
|
||||
Status: c.convertStatus(giteeResp.Status),
|
||||
}
|
||||
|
||||
if giteeResp.TaskID == "" {
|
||||
var giteeErr GiteeErrorResponse
|
||||
_ = json.Unmarshal(response.Bytes(), &giteeErr)
|
||||
result.ErrorMsg = giteeErr.Message
|
||||
} else if giteeResp.Status == "success" {
|
||||
result.FileURL = giteeResp.Output.FileURL
|
||||
}
|
||||
result.RawData = string(response.Bytes())
|
||||
|
||||
logger.Debugf("gitee 3D job response: %+v", result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// convertStatus 转换Gitee状态到系统状态
|
||||
func (c *Gitee3DClient) convertStatus(giteeStatus string) string {
|
||||
switch giteeStatus {
|
||||
case "waiting":
|
||||
return types.AI3DJobStatusPending
|
||||
case "in_progress":
|
||||
return types.AI3DJobStatusProcessing
|
||||
case "success":
|
||||
return types.AI3DJobStatusSuccess
|
||||
case "failure", "cancelled":
|
||||
return types.AI3DJobStatusFailed
|
||||
default:
|
||||
return types.AI3DJobStatusPending
|
||||
}
|
||||
}
|
||||
|
||||
// GetSupportedModels 获取支持的模型列表
|
||||
func (c *Gitee3DClient) GetSupportedModels() []types.AI3DModel {
|
||||
return []types.AI3DModel{
|
||||
{Name: "Hunyuan3D-2", Power: 100, Formats: []string{"GLB"}, Desc: "Hunyuan3D-2 是腾讯混元团队推出的高质量 3D 生成模型,具备高保真度、细节丰富和高效生成的特点,可快速将文本或图像转换为逼真的 3D 物体。"},
|
||||
{Name: "Step1X-3D", Power: 55, Formats: []string{"GLB", "STL"}, Desc: "Step1X-3D 是一款由阶跃星辰(StepFun)与光影焕像(LightIllusions)联合研发并开源的高保真 3D 生成模型,专为高质量、可控的 3D 内容创作而设计。"},
|
||||
{Name: "Hi3DGen", Power: 35, Formats: []string{"GLB", "STL"}, Desc: "Hi3DGen 是一个 AI 工具,它可以把你上传的普通图片,智能转换成有“立体感”的图片(法线图),常用于制作 3D 效果,比如游戏建模、虚拟现实、动画制作等。"},
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
package ai3d
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/store/vo"
|
||||
"geekai/utils"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-redis/redis/v8"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var logger = logger2.GetLogger()
|
||||
|
||||
// Service 3D生成服务
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
taskQueue *store.RedisQueue
|
||||
tencentClient *Tencent3DClient
|
||||
giteeClient *Gitee3DClient
|
||||
userService *service.UserService
|
||||
uploadManager *oss.UploaderManager
|
||||
}
|
||||
|
||||
// NewService 创建3D生成服务
|
||||
func NewService(db *gorm.DB, redisCli *redis.Client, tencentClient *Tencent3DClient, giteeClient *Gitee3DClient, userService *service.UserService, uploadManager *oss.UploaderManager) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
taskQueue: store.NewRedisQueue("3D_Task_Queue", redisCli),
|
||||
tencentClient: tencentClient,
|
||||
giteeClient: giteeClient,
|
||||
userService: userService,
|
||||
uploadManager: uploadManager,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateJob 创建3D生成任务
|
||||
func (s *Service) CreateJob(userId uint, request vo.AI3DJobParams) (*model.AI3DJob, error) {
|
||||
switch request.Type {
|
||||
case types.AI3DTaskTypeGitee:
|
||||
if s.giteeClient == nil {
|
||||
return nil, fmt.Errorf("模力方舟 3D 服务未初始化")
|
||||
}
|
||||
if !s.giteeClient.GetConfig().Enabled {
|
||||
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)
|
||||
}
|
||||
|
||||
// 创建任务记录
|
||||
job := &model.AI3DJob{
|
||||
UserId: userId,
|
||||
Type: request.Type,
|
||||
Power: request.Power,
|
||||
Model: request.Model,
|
||||
Status: types.AI3DJobStatusPending,
|
||||
PreviewURL: request.ImageURL,
|
||||
}
|
||||
|
||||
job.Params = utils.JsonEncode(request)
|
||||
|
||||
// 保存到数据库
|
||||
if err := s.db.Create(job).Error; err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
// 将任务添加到队列
|
||||
request.JobId = job.Id
|
||||
s.PushTask(request)
|
||||
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// PushTask 将任务添加到队列
|
||||
func (s *Service) PushTask(job vo.AI3DJobParams) {
|
||||
logger.Infof("add a new 3D task to the queue: %+v", job)
|
||||
if err := s.taskQueue.RPush(job); err != nil {
|
||||
logger.Errorf("push 3D task to queue failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Run 启动任务处理器
|
||||
func (s *Service) Run() {
|
||||
logger.Info("Starting 3D job consumer...")
|
||||
go func() {
|
||||
for {
|
||||
var params vo.AI3DJobParams
|
||||
err := s.taskQueue.LPop(¶ms)
|
||||
if err != nil {
|
||||
logger.Errorf("taking 3D task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
logger.Infof("handle a new 3D task: %+v", params)
|
||||
go func() {
|
||||
if err := s.processJob(¶ms); err != nil {
|
||||
logger.Errorf("error processing 3D job: %v", err)
|
||||
s.updateJobStatus(params.JobId, types.AI3DJobStatusFailed, err.Error())
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
go s.pollJobStatus()
|
||||
}
|
||||
|
||||
// processJob 处理3D任务
|
||||
func (s *Service) processJob(params *vo.AI3DJobParams) error {
|
||||
// 更新状态为处理中
|
||||
s.updateJobStatus(params.JobId, types.AI3DJobStatusProcessing, "")
|
||||
|
||||
var taskId string
|
||||
var err error
|
||||
|
||||
// 根据类型选择客户端
|
||||
switch params.Type {
|
||||
case types.AI3DTaskTypeTencent:
|
||||
if s.tencentClient == nil {
|
||||
return fmt.Errorf("tencent 3D client not initialized")
|
||||
}
|
||||
tencentParams := Tencent3DParams{
|
||||
Prompt: params.Prompt,
|
||||
ImageURL: params.ImageURL,
|
||||
ResultFormat: params.FileFormat,
|
||||
EnablePBR: params.EnablePBR,
|
||||
}
|
||||
taskId, err = s.tencentClient.SubmitJob(tencentParams)
|
||||
case types.AI3DTaskTypeGitee:
|
||||
if s.giteeClient == nil {
|
||||
return fmt.Errorf("gitee 3D client not initialized")
|
||||
}
|
||||
giteeParams := Gitee3DParams{
|
||||
Model: params.Model,
|
||||
Texture: params.Texture,
|
||||
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)
|
||||
default:
|
||||
return fmt.Errorf("unsupported 3D API type: %s", params.Type)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to submit 3D job: %v", err)
|
||||
}
|
||||
|
||||
// 更新任务ID
|
||||
s.db.Model(model.AI3DJob{}).Where("id = ?", params.JobId).Update("task_id", taskId)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollJobStatus 轮询任务状态
|
||||
func (s *Service) pollJobStatus() {
|
||||
// 10秒轮询一次
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
var jobs []model.AI3DJob
|
||||
s.db.Where("status IN (?)", []string{types.AI3DJobStatusProcessing, types.AI3DJobStatusPending}).Find(&jobs)
|
||||
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 {
|
||||
logger.Errorf("failed to query job status: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": result.Status,
|
||||
"raw_data": result.RawData,
|
||||
"err_msg": result.ErrorMsg,
|
||||
}
|
||||
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)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// queryJobStatus 查询任务状态
|
||||
func (s *Service) queryJobStatus(job *model.AI3DJob) (*types.AI3DJobResult, error) {
|
||||
switch job.Type {
|
||||
case types.AI3DTaskTypeTencent:
|
||||
if s.tencentClient == nil {
|
||||
return nil, fmt.Errorf("tencent 3D client not initialized")
|
||||
}
|
||||
return s.tencentClient.QueryJob(job.TaskId)
|
||||
case types.AI3DTaskTypeGitee:
|
||||
if s.giteeClient == nil {
|
||||
return nil, fmt.Errorf("gitee 3D client not initialized")
|
||||
}
|
||||
return s.giteeClient.QueryJob(job.TaskId)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported 3D API type: %s", job.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// updateJobStatus 更新任务状态
|
||||
func (s *Service) updateJobStatus(jobId uint, status string, errMsg string) error {
|
||||
|
||||
return s.db.Model(model.AI3DJob{}).Where("id = ?", jobId).Updates(map[string]any{
|
||||
"status": status,
|
||||
"err_msg": errMsg,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetJobList 获取任务列表
|
||||
func (s *Service) GetJobList(userId uint, page, pageSize int) (*vo.Page, error) {
|
||||
var total int64
|
||||
var jobs []model.AI3DJob
|
||||
|
||||
// 查询总数
|
||||
if err := s.db.Model(&model.AI3DJob{}).Where("user_id = ?", userId).Count(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 查询任务列表
|
||||
offset := (page - 1) * pageSize
|
||||
if err := s.db.Where("user_id = ?", userId).Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&jobs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 转换为VO
|
||||
var jobList []vo.AI3DJob
|
||||
for _, job := range jobs {
|
||||
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,
|
||||
CreatedAt: job.CreatedAt.Unix(),
|
||||
UpdatedAt: job.UpdatedAt.Unix(),
|
||||
}
|
||||
_ = utils.JsonDecode(job.Params, &jobVO.Params)
|
||||
jobList = append(jobList, jobVO)
|
||||
}
|
||||
|
||||
return &vo.Page{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Total: total,
|
||||
Items: jobList,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteJob 删除任务
|
||||
func (s *Service) DeleteUserJob(id uint, userId uint) error {
|
||||
var job model.AI3DJob
|
||||
err := s.db.Where("id = ?", id).Where("user_id = ?", userId).First(&job).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
err = tx.Delete(&job).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 失败的任务要退回算力
|
||||
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 获取支持的模型列表
|
||||
func (s *Service) GetSupportedModels() map[string][]types.AI3DModel {
|
||||
|
||||
models := make(map[string][]types.AI3DModel)
|
||||
if s.tencentClient != nil {
|
||||
models["tencent"] = s.tencentClient.GetSupportedModels()
|
||||
}
|
||||
if s.giteeClient != nil {
|
||||
models["gitee"] = s.giteeClient.GetSupportedModels()
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func (s *Service) UpdateConfig(config types.AI3DConfig) {
|
||||
if s.tencentClient != nil {
|
||||
s.tencentClient.UpdateConfig(config.Tencent)
|
||||
}
|
||||
if s.giteeClient != nil {
|
||||
s.giteeClient.UpdateConfig(config.Gitee)
|
||||
}
|
||||
}
|
||||
|
||||
// getFileExt 获取文件扩展名
|
||||
func getFileExt(fileURL string) string {
|
||||
parse, err := url.Parse(fileURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(parse.Path)
|
||||
if ext == "" {
|
||||
return ".glb"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package ai3d
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
|
||||
tencent3d "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d/v20250513"
|
||||
tencentcloud "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
|
||||
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
|
||||
)
|
||||
|
||||
type Tencent3DClient struct {
|
||||
client *tencent3d.Client
|
||||
config types.Tencent3DConfig
|
||||
}
|
||||
|
||||
type Tencent3DParams struct {
|
||||
Prompt string `json:"prompt"` // 文本提示词
|
||||
ImageURL string `json:"image_url"` // 输入图片URL
|
||||
ResultFormat string `json:"result_format"` // 输出格式
|
||||
EnablePBR bool `json:"enable_pbr"` // 是否开启PBR材质
|
||||
MultiViewImages []ViewImage `json:"multi_view_images,omitempty"` // 多视角图片
|
||||
}
|
||||
|
||||
type ViewImage struct {
|
||||
ViewType string `json:"view_type"` // 视角类型 (left/right/back)
|
||||
ViewImageURL string `json:"view_image_url"` // 图片URL
|
||||
}
|
||||
|
||||
func NewTencent3DClient(sysConfig *types.SystemConfig) (*Tencent3DClient, error) {
|
||||
config := sysConfig.AI3D.Tencent
|
||||
credential := tencentcloud.NewCredential(config.SecretId, config.SecretKey)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "ai3d.tencentcloudapi.com"
|
||||
|
||||
client, err := tencent3d.NewClient(credential, config.Region, cpf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create tencent 3D client: %v", err)
|
||||
}
|
||||
|
||||
return &Tencent3DClient{
|
||||
client: client,
|
||||
config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Tencent3DClient) UpdateConfig(config types.Tencent3DConfig) error {
|
||||
c.config = config
|
||||
credential := tencentcloud.NewCredential(config.SecretId, config.SecretKey)
|
||||
cpf := profile.NewClientProfile()
|
||||
cpf.HttpProfile.Endpoint = "ai3d.tencentcloudapi.com"
|
||||
|
||||
client, err := tencent3d.NewClient(credential, config.Region, cpf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create tencent 3D client: %v", err)
|
||||
}
|
||||
c.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Tencent3DClient) GetConfig() *types.Tencent3DConfig {
|
||||
return &c.config
|
||||
}
|
||||
|
||||
// SubmitJob 提交3D生成任务
|
||||
func (c *Tencent3DClient) SubmitJob(params Tencent3DParams) (string, error) {
|
||||
request := tencent3d.NewSubmitHunyuanTo3DJobRequest()
|
||||
|
||||
if params.Prompt != "" {
|
||||
request.Prompt = tencentcloud.StringPtr(params.Prompt)
|
||||
}
|
||||
|
||||
if params.ImageURL != "" {
|
||||
request.ImageUrl = tencentcloud.StringPtr(params.ImageURL)
|
||||
}
|
||||
|
||||
if params.ResultFormat != "" {
|
||||
request.ResultFormat = tencentcloud.StringPtr(params.ResultFormat)
|
||||
}
|
||||
|
||||
request.EnablePBR = tencentcloud.BoolPtr(params.EnablePBR)
|
||||
|
||||
if len(params.MultiViewImages) > 0 {
|
||||
var viewImages []*tencent3d.ViewImage
|
||||
for _, img := range params.MultiViewImages {
|
||||
viewImage := &tencent3d.ViewImage{
|
||||
ViewType: tencentcloud.StringPtr(img.ViewType),
|
||||
ViewImageUrl: tencentcloud.StringPtr(img.ViewImageURL),
|
||||
}
|
||||
viewImages = append(viewImages, viewImage)
|
||||
}
|
||||
request.MultiViewImages = viewImages
|
||||
}
|
||||
|
||||
response, err := c.client.SubmitHunyuanTo3DJob(request)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to submit tencent 3D job: %v", err)
|
||||
}
|
||||
|
||||
if response.Response.JobId == nil {
|
||||
return "", fmt.Errorf("no job ID returned from tencent 3D API")
|
||||
}
|
||||
|
||||
return *response.Response.JobId, nil
|
||||
}
|
||||
|
||||
// QueryJob 查询任务状态
|
||||
func (c *Tencent3DClient) QueryJob(jobId string) (*types.AI3DJobResult, error) {
|
||||
request := tencent3d.NewQueryHunyuanTo3DJobRequest()
|
||||
request.JobId = tencentcloud.StringPtr(jobId)
|
||||
|
||||
response, err := c.client.QueryHunyuanTo3DJob(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query tencent 3D job: %v", err)
|
||||
}
|
||||
|
||||
result := &types.AI3DJobResult{
|
||||
TaskId: jobId,
|
||||
}
|
||||
|
||||
// 根据状态设置进度
|
||||
switch *response.Response.Status {
|
||||
case "WAIT":
|
||||
result.Status = types.AI3DJobStatusPending
|
||||
case "RUN":
|
||||
result.Status = types.AI3DJobStatusProcessing
|
||||
case "DONE":
|
||||
result.Status = types.AI3DJobStatusSuccess
|
||||
// 处理结果文件
|
||||
if len(response.Response.ResultFile3Ds) > 0 {
|
||||
// 取第一个文件
|
||||
file := response.Response.ResultFile3Ds[0]
|
||||
if file.Url != nil {
|
||||
result.FileURL = *file.Url
|
||||
}
|
||||
if file.PreviewImageUrl != nil {
|
||||
result.PreviewURL = *file.PreviewImageUrl
|
||||
}
|
||||
}
|
||||
case "FAIL":
|
||||
result.Status = types.AI3DJobStatusFailed
|
||||
if response.Response.ErrorMessage != nil {
|
||||
result.ErrorMsg = *response.Response.ErrorMessage
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debugf("tencent 3D job result: %+v", *response.Response)
|
||||
|
||||
result.RawData = utils.JsonEncode(response.Response)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetSupportedModels 获取支持的模型列表
|
||||
func (c *Tencent3DClient) GetSupportedModels() []types.AI3DModel {
|
||||
return []types.AI3DModel{
|
||||
{Name: "Hunyuan3D-3", Power: 500, Formats: []string{"GLB", "OBJ", "STL", "USDZ", "FBX", "MP4"}, Desc: "Hunyuan3D 是腾讯混元团队推出的高质量 3D 生成模型,具备高保真度、细节丰富和高效生成的特点,可快速将文本或图像转换为逼真的 3D 物体。"},
|
||||
}
|
||||
}
|
||||
@@ -154,24 +154,6 @@ func (s *MigrationService) MigrateConfigContent() error {
|
||||
return fmt.Errorf("迁移配置内容失败: %v", err)
|
||||
}
|
||||
|
||||
// 3D生成配置
|
||||
if err := s.saveConfig(types.ConfigKeyAI3D, map[string]any{
|
||||
"tencent": map[string]any{
|
||||
"access_key": "",
|
||||
"secret_key": "",
|
||||
"region": "",
|
||||
"enabled": false,
|
||||
"models": make([]types.AI3DModel, 0),
|
||||
},
|
||||
"gitee": map[string]any{
|
||||
"api_key": "",
|
||||
"enabled": false,
|
||||
"models": make([]types.AI3DModel, 0),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("迁移配置内容失败: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,7 +161,6 @@ func (s *MigrationService) MigrateConfigContent() error {
|
||||
func (s *MigrationService) TableMigration() {
|
||||
// 新数据表
|
||||
s.db.AutoMigrate(&model.Moderation{})
|
||||
s.db.AutoMigrate(&model.AI3DJob{})
|
||||
|
||||
// 订单字段整理
|
||||
if s.db.Migrator().HasColumn(&model.Order{}, "pay_type") {
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"geekai/core/types"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AI3DJob struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
Params string `gorm:"column:params;type:text;comment:任务参数(JSON格式)" json:"params"`
|
||||
RawData string `gorm:"column:raw_data;type:text;comment:API返回的原始数据" json:"raw_data"`
|
||||
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 {
|
||||
return "geekai_3d_jobs"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package vo
|
||||
|
||||
import "geekai/core/types"
|
||||
|
||||
type AI3DJob struct {
|
||||
Id uint `json:"id"`
|
||||
UserId uint `json:"user_id"`
|
||||
Type types.AI3DTaskType `json:"type"`
|
||||
Power int `json:"power"`
|
||||
TaskId string `json:"task_id"`
|
||||
FileURL string `json:"file_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
Params AI3DJobParams `json:"params"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AI3DJobParams 创建3D任务请求
|
||||
type AI3DJobParams struct {
|
||||
// 通用参数
|
||||
JobId uint `json:"job_id,omitempty"` // 任务ID
|
||||
Type types.AI3DTaskType `json:"type,omitempty"` // API类型 (tencent/gitee)
|
||||
Model string `json:"model,omitempty"` // 3D模型类型
|
||||
Prompt string `json:"prompt,omitempty"` // 文本提示词
|
||||
ImageURL string `json:"image_url,omitempty"` // 输入图片URL
|
||||
FileFormat string `json:"file_format,omitempty"` // 输出文件格式
|
||||
Power int `json:"power,omitempty"` // 消耗算力
|
||||
// 腾讯3d专有参数
|
||||
EnablePBR bool `json:"enable_pbr,omitempty"` // 是否开启PBR材质
|
||||
// Gitee3d专有参数
|
||||
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 细节越丰富
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
.page-threed {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: var(--theme-bg-all);
|
||||
}
|
||||
|
||||
.params-panel {
|
||||
width: 400px;
|
||||
background: var(--card-bg);
|
||||
border-right: 1px solid var(--line-box);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.platform-tabs {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.platform-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.params-container {
|
||||
.param-line {
|
||||
margin-bottom: 16px;
|
||||
|
||||
&.pt {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: var(--theme-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-toggle-btn {
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-color-primary);
|
||||
border: none;
|
||||
background: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.advanced-params {
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.power-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.power-value {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
|
||||
.power-unit {
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.generate-section {
|
||||
margin-top: 30px;
|
||||
|
||||
.generate-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-panel {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.task-list {
|
||||
.list-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
background: var(--panel-bg);
|
||||
border-radius: 12px;
|
||||
color: var(--theme-text-color-primary);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--theme-text-color-primary);
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 24px;
|
||||
background: var(--theme-text-color-primary);
|
||||
margin-right: 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-button {
|
||||
// background: rgba(255, 255, 255, 0.2);
|
||||
// border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
// color: white;
|
||||
|
||||
// &:hover {
|
||||
// background: rgba(255, 255, 255, 0.3);
|
||||
// border-color: rgba(255, 255, 255, 0.5);
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-items {
|
||||
.task-card {
|
||||
background: var(--card-bg);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--line-box);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&.task-card-completed {
|
||||
border-left: 4px solid #67c23a;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.task-card-processing {
|
||||
border-left: 4px solid var(--text-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.task-card-failed {
|
||||
border-left: 4px solid #f56c6c;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.task-card-default {
|
||||
border-left: 4px solid #909399;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px dashed #eee;
|
||||
|
||||
.task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.task-id {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: var(--theme-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: 20px;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.task-platform {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-status-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
.task-status {
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.pending {
|
||||
background: var(--el-fill-color-light);
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
&.processing {
|
||||
background: var(--el-fill-color-light);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.completed {
|
||||
background: var(--el-fill-color-light);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
&.failed {
|
||||
background: var(--el-fill-color-light);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-power {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
margin-right: 4px;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-card-content {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.task-preview {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--chat-wel-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-secondary);
|
||||
min-height: 200px;
|
||||
max-height: 200px;
|
||||
min-width: 200px;
|
||||
max-width: 200px;
|
||||
border: 1px solid var(--line-box);
|
||||
|
||||
.preview-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.preview-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-primary);
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.input-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.prompt-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-secondary);
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
.task-model {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: var(--theme-text-color-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
font-size: 18px;
|
||||
margin-right: 6px;
|
||||
color: var(--text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.task-prompt {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
margin-right: 6px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.task-params {
|
||||
font-size: 14px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
margin-right: 6px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.task-time {
|
||||
font-size: 12px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.task-error {
|
||||
font-size: 12px;
|
||||
color: #f56c6c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
i {
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task-card-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed var(--line-box);
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
|
||||
&.preview-btn {
|
||||
background: var(--text-color-primary);
|
||||
// color: var(--theme-text-color-primary);
|
||||
border: 1px solid var(--text-color-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--text-color-primary);
|
||||
border-color: var(--border-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.download-btn {
|
||||
background: #67c23a;
|
||||
// color: var(--theme-text-color-primary);
|
||||
border: 1px solid #67c23a;
|
||||
|
||||
&:hover {
|
||||
background: #85ce61;
|
||||
border-color: #85ce61;
|
||||
}
|
||||
}
|
||||
|
||||
&.delete-btn {
|
||||
background: #f56c6c;
|
||||
// color: var(--theme-text-color-primary);
|
||||
border: 1px solid #f56c6c;
|
||||
|
||||
&:hover {
|
||||
background: #f78989;
|
||||
border-color: #f78989;
|
||||
}
|
||||
}
|
||||
|
||||
&.processing-btn {
|
||||
background: #909399;
|
||||
// color: var(--theme-text-color-primary);
|
||||
border: 1px solid #909399;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
background: var(--chat-wel-bg);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-secondary);
|
||||
font-size: 14px;
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
height: calc(100vh - 125px);
|
||||
background: var(--chat-wel-bg);
|
||||
border-radius: 8px;
|
||||
position: relative;
|
||||
|
||||
.three-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--chat-wel-bg);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-placeholder {
|
||||
width: 100%;
|
||||
min-height: 500px;
|
||||
background: var(--chat-wel-bg);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme-text-color-secondary);
|
||||
|
||||
i {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--theme-text-color-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-threed {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.params-panel {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--line-box);
|
||||
}
|
||||
|
||||
.task-card-content {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-preview {
|
||||
max-width: 100%;
|
||||
min-height: 150px;
|
||||
}
|
||||
|
||||
.task-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.task-status-wrapper {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.task-card {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
}
|
||||
@@ -1,562 +0,0 @@
|
||||
<template>
|
||||
<div class="three-d-preview">
|
||||
<div ref="container" class="preview-container"></div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="loading-content">
|
||||
<el-icon class="is-loading"><Loading /></el-icon>
|
||||
<p>加载3D模型中...</p>
|
||||
<div v-if="loadingProgress > 0" class="loading-progress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: loadingProgress + '%' }"></div>
|
||||
</div>
|
||||
<span class="progress-text">{{ loadingProgress.toFixed(1) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-if="error" class="error-overlay">
|
||||
<div class="error-content">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<p>{{ error }}</p>
|
||||
<el-button size="small" @click="retryLoad">重试</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Loading, Warning } from '@element-plus/icons-vue'
|
||||
import { ElButton, ElIcon } from 'element-plus'
|
||||
import * as THREE from 'three'
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
|
||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
modelUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
modelType: {
|
||||
type: String,
|
||||
default: 'glb',
|
||||
},
|
||||
})
|
||||
|
||||
// 响应式数据
|
||||
const container = ref(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const loadingProgress = ref(0)
|
||||
const modelType = computed(() => {
|
||||
if (props.modelType) {
|
||||
return props.modelType.toLowerCase()
|
||||
}
|
||||
// 从模型URL中获取类型
|
||||
if (props.modelUrl) {
|
||||
const url = new URL(props.modelUrl)
|
||||
return url.pathname.split('.').pop()
|
||||
}
|
||||
return 'glb'
|
||||
})
|
||||
|
||||
// Three.js 相关变量
|
||||
let scene, camera, renderer, controls, model, mixer, clock
|
||||
let animationId
|
||||
let baseScale = 1 // 存储基础缩放值
|
||||
|
||||
// 初始化Three.js场景
|
||||
const initThreeJS = () => {
|
||||
if (!container.value) {
|
||||
console.error('ThreeDPreview: 容器元素不存在')
|
||||
return
|
||||
}
|
||||
|
||||
// 创建场景
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0x2d2d2d) // 深灰色背景,匹配截图效果
|
||||
|
||||
// 获取容器尺寸,完全自适应父容器
|
||||
const containerRect = container.value.getBoundingClientRect()
|
||||
const width = containerRect.width || 400
|
||||
const height = containerRect.height || 300
|
||||
|
||||
// 创建相机 - 参考截图的视角(稍微俯视,从左上角观察)
|
||||
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
|
||||
camera.position.set(3, 3, 3) // 从左上角俯视角度
|
||||
|
||||
// 创建渲染器
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
antialias: true,
|
||||
alpha: true,
|
||||
preserveDrawingBuffer: true,
|
||||
})
|
||||
renderer.setSize(width, height)
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.shadowMap.enabled = false // 禁用阴影
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
// 提升曝光度让模型更加高亮
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 2.2
|
||||
|
||||
// 添加到容器
|
||||
container.value.appendChild(renderer.domElement)
|
||||
|
||||
// 创建控制器
|
||||
controls = new OrbitControls(camera, renderer.domElement)
|
||||
controls.enableDamping = true
|
||||
controls.dampingFactor = 0.05
|
||||
|
||||
// 添加光源
|
||||
addLights()
|
||||
|
||||
// 添加地面
|
||||
addGround()
|
||||
|
||||
// 添加坐标轴辅助线
|
||||
addAxesHelper()
|
||||
|
||||
// 创建时钟
|
||||
clock = new THREE.Clock()
|
||||
|
||||
// 开始渲染循环
|
||||
animate()
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// 添加光源 - 高亮显示模型,无阴影效果
|
||||
const addLights = () => {
|
||||
// 强环境光 - 提供整体高亮照明
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 1.0)
|
||||
scene.add(ambientLight)
|
||||
|
||||
// 主方向光 - 从前上方照射,高亮度无阴影
|
||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.8)
|
||||
directionalLight.position.set(5, 8, 5)
|
||||
directionalLight.castShadow = false
|
||||
scene.add(directionalLight)
|
||||
|
||||
// 补充光源 - 从左侧照射,填充光照
|
||||
const fillLight = new THREE.DirectionalLight(0xffffff, 1.2)
|
||||
fillLight.position.set(-5, 4, 3)
|
||||
fillLight.castShadow = false
|
||||
scene.add(fillLight)
|
||||
|
||||
// 背景光 - 从背后照射,增加轮廓高亮
|
||||
const rimLight = new THREE.DirectionalLight(0xffffff, 1.0)
|
||||
rimLight.position.set(0, 3, -5)
|
||||
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 gridHelper = new THREE.GridHelper(20, 20, 0x555555, 0x555555)
|
||||
gridHelper.position.y = 0
|
||||
scene.add(gridHelper)
|
||||
|
||||
// 简单透明地面平面
|
||||
const groundGeometry = new THREE.PlaneGeometry(20, 20)
|
||||
const groundMaterial = new THREE.MeshBasicMaterial({
|
||||
color: 0x404040,
|
||||
transparent: true,
|
||||
opacity: 0.1,
|
||||
})
|
||||
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
|
||||
ground.rotation.x = -Math.PI / 2
|
||||
ground.position.y = -0.01
|
||||
scene.add(ground)
|
||||
}
|
||||
|
||||
// 添加坐标轴辅助线 - 匹配截图样式
|
||||
const addAxesHelper = () => {
|
||||
const axesHelper = new THREE.AxesHelper(2)
|
||||
scene.add(axesHelper)
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
//
|
||||
|
||||
// 加载3D模型
|
||||
const loadModel = async () => {
|
||||
if (!props.modelUrl) {
|
||||
console.warn('ThreeDPreview: 没有提供模型URL')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
loadingProgress.value = 0
|
||||
error.value = ''
|
||||
|
||||
// 清除现有模型
|
||||
if (model) {
|
||||
scene.remove(model)
|
||||
model = null
|
||||
}
|
||||
|
||||
let loadedModel
|
||||
|
||||
switch (modelType.value) {
|
||||
case 'glb':
|
||||
case 'gltf':
|
||||
loadedModel = await loadGLTF(props.modelUrl)
|
||||
break
|
||||
case 'obj':
|
||||
loadedModel = await loadOBJ(props.modelUrl)
|
||||
break
|
||||
case 'stl':
|
||||
loadedModel = await loadSTL(props.modelUrl)
|
||||
break
|
||||
default:
|
||||
throw new Error(`不支持的模型格式: ${modelType.value}`)
|
||||
}
|
||||
|
||||
if (loadedModel) {
|
||||
model = loadedModel
|
||||
scene.add(model)
|
||||
|
||||
// 计算模型边界并调整相机位置
|
||||
const box = new THREE.Box3().setFromObject(model)
|
||||
const size = box.getSize(new THREE.Vector3())
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
|
||||
// 调整模型位置到原点
|
||||
model.position.sub(center)
|
||||
|
||||
// 计算并保存基础缩放值
|
||||
const maxDim = Math.max(size.x, size.y, size.z)
|
||||
baseScale = maxDim > 0 ? 2 / maxDim : 1
|
||||
|
||||
// 应用初始缩放
|
||||
model.scale.setScalar(baseScale)
|
||||
|
||||
// 根据模型大小调整相机距离 - 保持截图中的俯视角度
|
||||
const cameraDistance = maxDim > 0 ? maxDim * 2 : 5
|
||||
|
||||
// 设置相机位置 - 匹配截图中的正面稍俯视角度
|
||||
camera.position.set(cameraDistance * 0.3, cameraDistance * 0.4, cameraDistance * 1.2)
|
||||
camera.lookAt(0, 0, 0)
|
||||
|
||||
if (controls) {
|
||||
controls.target.set(0, 0, 0)
|
||||
controls.update()
|
||||
}
|
||||
|
||||
// 移除阴影设置,让模型高亮显示
|
||||
model.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.castShadow = false
|
||||
child.receiveShadow = false
|
||||
// 如果材质支持,增加发光效果
|
||||
if (child.material) {
|
||||
child.material.emissive = new THREE.Color(0x111111) // 轻微发光
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.warn('ThreeDPreview: 模型加载返回空值')
|
||||
}
|
||||
|
||||
loading.value = false
|
||||
loadingProgress.value = 100
|
||||
} catch (err) {
|
||||
console.error('ThreeDPreview: 加载3D模型失败:', err)
|
||||
error.value = `加载模型失败: ${err.message}`
|
||||
loading.value = false
|
||||
loadingProgress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 加载GLTF/GLB模型
|
||||
const loadGLTF = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new GLTFLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(gltf) => {
|
||||
const model = gltf.scene
|
||||
|
||||
// 处理动画
|
||||
if (gltf.animations && gltf.animations.length > 0) {
|
||||
mixer = new THREE.AnimationMixer(model)
|
||||
const action = mixer.clipAction(gltf.animations[0])
|
||||
action.play()
|
||||
}
|
||||
|
||||
resolve(model)
|
||||
},
|
||||
(xhr) => {
|
||||
if (xhr.total > 0) {
|
||||
const percent = (xhr.loaded / xhr.total) * 100
|
||||
loadingProgress.value = percent
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
console.error('ThreeDPreview: GLTF模型加载失败', error)
|
||||
reject(error)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载OBJ模型
|
||||
const loadOBJ = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new OBJLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(obj) => {
|
||||
// 为OBJ模型添加默认材质
|
||||
obj.traverse((child) => {
|
||||
if (child.isMesh) {
|
||||
child.material = new THREE.MeshLambertMaterial({
|
||||
color: 0x888888,
|
||||
})
|
||||
}
|
||||
})
|
||||
resolve(obj)
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 加载STL模型
|
||||
const loadSTL = (url) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const loader = new STLLoader()
|
||||
loader.load(
|
||||
url,
|
||||
(geometry) => {
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: 0x888888,
|
||||
})
|
||||
const mesh = new THREE.Mesh(geometry, material)
|
||||
resolve(mesh)
|
||||
},
|
||||
undefined,
|
||||
reject
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
// 重试加载
|
||||
const retryLoad = () => {
|
||||
loadModel()
|
||||
}
|
||||
|
||||
// 窗口大小变化处理
|
||||
const onWindowResize = () => {
|
||||
if (!container.value || !camera || !renderer) return
|
||||
|
||||
const containerRect = container.value.getBoundingClientRect()
|
||||
const width = containerRect.width || 400
|
||||
const height = containerRect.height || 300
|
||||
|
||||
camera.aspect = width / height
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(width, height)
|
||||
}
|
||||
|
||||
// 渲染循环
|
||||
const animate = () => {
|
||||
animationId = requestAnimationFrame(animate)
|
||||
|
||||
if (controls) {
|
||||
controls.update()
|
||||
}
|
||||
|
||||
if (mixer) {
|
||||
const delta = clock.getDelta()
|
||||
mixer.update(delta)
|
||||
}
|
||||
|
||||
if (renderer && scene && camera) {
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
}
|
||||
|
||||
// 清理资源
|
||||
const cleanup = () => {
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
|
||||
if (mixer) {
|
||||
mixer.stopAllAction()
|
||||
mixer.uncacheRoot(model)
|
||||
}
|
||||
|
||||
if (renderer) {
|
||||
renderer.dispose()
|
||||
}
|
||||
|
||||
if (container.value && renderer) {
|
||||
container.value.removeChild(renderer.domElement)
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
}
|
||||
|
||||
// 监听模型URL变化
|
||||
watch(
|
||||
() => props.modelUrl,
|
||||
(newUrl) => {
|
||||
if (newUrl) {
|
||||
loadModel()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听模型类型变化
|
||||
watch(
|
||||
() => props.modelType,
|
||||
() => {
|
||||
if (props.modelUrl) {
|
||||
loadModel()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 使用nextTick确保DOM完全渲染
|
||||
nextTick(() => {
|
||||
// 延迟初始化,确保容器有正确的尺寸
|
||||
setTimeout(() => {
|
||||
initThreeJS()
|
||||
if (props.modelUrl) {
|
||||
loadModel()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.three-d-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #2d2d2d;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
// 移除min-height限制,让高度完全自适应
|
||||
|
||||
// 确保在弹窗中能正确填充
|
||||
canvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-overlay,
|
||||
.error-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.loading-content,
|
||||
.error-content {
|
||||
text-align: center;
|
||||
|
||||
.el-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&.is-loading {
|
||||
animation: rotate 1s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 16px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-progress {
|
||||
width: 200px;
|
||||
margin-top: 16px;
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #409eff;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.error-content {
|
||||
.el-icon {
|
||||
color: #f56c6c;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -179,23 +179,6 @@ const items = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: 'cube',
|
||||
index: '/admin/ai3d',
|
||||
title: '3D生成',
|
||||
subs: [
|
||||
{
|
||||
icon: 'list',
|
||||
index: '/admin/ai3d/jobs',
|
||||
title: '任务管理',
|
||||
},
|
||||
{
|
||||
icon: 'config',
|
||||
index: '/admin/ai3d/config',
|
||||
title: '配置管理',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
icon: 'moderation',
|
||||
|
||||
@@ -92,18 +92,6 @@ const routes = [
|
||||
meta: { title: 'Suno音乐创作' },
|
||||
component: () => import('@/views/Suno.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ai3d',
|
||||
path: '/ai3d',
|
||||
meta: { title: 'AI3D模型生成' },
|
||||
component: () => import('@/views/AIThreeDCreate.vue'),
|
||||
},
|
||||
{
|
||||
name: 'test3d',
|
||||
path: '/test3d',
|
||||
meta: { title: '3D预览测试' },
|
||||
component: () => import('@/views/test/Test3D.vue'),
|
||||
},
|
||||
{
|
||||
name: 'ExternalLink',
|
||||
path: '/external',
|
||||
@@ -354,18 +342,6 @@ const routes = [
|
||||
meta: { title: '即梦设置' },
|
||||
component: () => import('@/views/admin/jimeng/JimengConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/ai3d/jobs',
|
||||
name: 'admin-ai3d-jobs',
|
||||
meta: { title: '3D任务管理' },
|
||||
component: () => import('@/views/admin/ai3d/AIThreeDJobs.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/ai3d/config',
|
||||
name: 'admin-ai3d-config',
|
||||
meta: { title: '3D配置管理' },
|
||||
component: () => import('@/views/admin/ai3d/AIThreeDConfig.vue'),
|
||||
},
|
||||
{
|
||||
path: '/admin/powerLog',
|
||||
name: 'admin-power-log',
|
||||
@@ -478,12 +454,6 @@ const routes = [
|
||||
name: 'mobile-jimeng',
|
||||
component: () => import('@/views/mobile/JimengCreate.vue'),
|
||||
},
|
||||
{
|
||||
path: '/mobile/3d',
|
||||
name: 'mobile-3d',
|
||||
meta: { title: '3D模型生成' },
|
||||
component: () => import('@/views/AIThreeDCreate.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { showMessageError } from '@/utils/dialog'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
export const useAI3DStore = defineStore('ai3d', () => {
|
||||
// 响应式数据
|
||||
const activePlatform = ref('gitee')
|
||||
const loading = ref(false)
|
||||
const previewVisible = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const taskList = ref([])
|
||||
const currentPreviewTask = ref({
|
||||
downloading: false,
|
||||
})
|
||||
const giteeAdvancedVisible = ref(false)
|
||||
const taskPulling = ref(false)
|
||||
|
||||
const tencentDefaultForm = {
|
||||
text3d: false,
|
||||
prompt: '',
|
||||
image_url: '',
|
||||
model: '',
|
||||
file_format: '',
|
||||
enable_pbr: false,
|
||||
model_desc: '',
|
||||
power: 0,
|
||||
}
|
||||
|
||||
const giteeDefaultForm = {
|
||||
prompt: '',
|
||||
image_url: '',
|
||||
model: '',
|
||||
file_format: '',
|
||||
texture: false,
|
||||
seed: 1234,
|
||||
num_inference_steps: 5,
|
||||
guidance_scale: 7.5,
|
||||
octree_resolution: 128,
|
||||
model_desc: '',
|
||||
power: 0,
|
||||
}
|
||||
|
||||
const tencentForm = ref({ ...tencentDefaultForm })
|
||||
const giteeForm = ref({ ...giteeDefaultForm })
|
||||
const currentPower = ref(0)
|
||||
const tencentSupportedFormats = ref([])
|
||||
const giteeSupportedFormats = ref([])
|
||||
|
||||
// 定时器引用
|
||||
let taskPullHandler = null
|
||||
|
||||
const configs = ref({
|
||||
gitee: { models: [] },
|
||||
tencent: { models: [] },
|
||||
})
|
||||
|
||||
// 计算属性
|
||||
const currentForm = computed(() =>
|
||||
activePlatform.value === 'tencent' ? tencentForm.value : giteeForm.value
|
||||
)
|
||||
const selectedModel = computed(() => currentForm.value.model)
|
||||
const currentPrompt = computed(() => currentForm.value.prompt)
|
||||
const currentImage = computed(() =>
|
||||
currentForm.value.image_url ? [{ url: currentForm.value.image_url }] : []
|
||||
)
|
||||
|
||||
// 加载配置
|
||||
const loadConfigs = async () => {
|
||||
const response = await httpGet('/api/ai3d/configs')
|
||||
configs.value = response.data
|
||||
}
|
||||
|
||||
const handleModelChange = (value) => {
|
||||
if (activePlatform.value === 'tencent') {
|
||||
const model = configs.value.tencent.models.find((m) => m.name === value)
|
||||
if (!model) return
|
||||
currentPower.value = model.power
|
||||
tencentForm.value.power = model.power
|
||||
tencentForm.value.model_desc = model.desc
|
||||
tencentForm.value.file_format = model.formats[0]
|
||||
tencentSupportedFormats.value = model.formats
|
||||
} else {
|
||||
const model = configs.value.gitee.models.find((m) => m.name === value)
|
||||
if (!model) return
|
||||
currentPower.value = model.power
|
||||
giteeForm.value.power = model.power
|
||||
giteeForm.value.model_desc = model.desc
|
||||
giteeForm.value.file_format = model.formats[0]
|
||||
giteeSupportedFormats.value = model.formats
|
||||
}
|
||||
}
|
||||
|
||||
const handlePlatformChange = (value) => {
|
||||
activePlatform.value = value
|
||||
currentPower.value = value === 'tencent' ? tencentForm.value.power : giteeForm.value.power
|
||||
}
|
||||
|
||||
const generate3D = async () => {
|
||||
const requestData = {
|
||||
...(activePlatform.value === 'tencent' ? tencentForm.value : giteeForm.value),
|
||||
}
|
||||
if (requestData.model === '') {
|
||||
ElMessage.warning('请选择模型')
|
||||
return
|
||||
}
|
||||
if (requestData.file_format === '') {
|
||||
ElMessage.warning('请选择输出格式')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
requestData.type = activePlatform.value
|
||||
const response = await httpPost('/api/ai3d/generate', requestData)
|
||||
ElMessage.success('任务创建成功')
|
||||
await loadTasks()
|
||||
} catch (error) {
|
||||
ElMessage.error('创建任务失败:' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTasks = async () => {
|
||||
try {
|
||||
const response = await httpGet('/api/ai3d/jobs', {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
})
|
||||
if (response.code === 0) {
|
||||
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
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载任务列表失败:' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshTasks = () => {
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const handleCurrentPageChange = (page) => {
|
||||
currentPage.value = page
|
||||
loadTasks()
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
const response = await httpGet(`/api/ai3d/job/delete?id=${taskId}`)
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
loadTasks()
|
||||
} else {
|
||||
ElMessage.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败:' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const preview3D = (task) => {
|
||||
currentPreviewTask.value = task
|
||||
previewVisible.value = true
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewVisible.value = false
|
||||
}
|
||||
|
||||
const downloadFile = async (item) => {
|
||||
const url = replaceImg(item.file_url)
|
||||
const downloadURL = `/api/download?url=${url}`
|
||||
const urlObj = new URL(url)
|
||||
const fileName = urlObj.pathname.split('/').pop()
|
||||
item.downloading = true
|
||||
try {
|
||||
const response = await httpDownload(downloadURL)
|
||||
const blob = new Blob([response.data])
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
item.downloading = false
|
||||
} catch (error) {
|
||||
showMessageError('下载失败')
|
||||
item.downloading = false
|
||||
}
|
||||
}
|
||||
|
||||
const downloadCurrentModel = () => {
|
||||
if (currentPreviewTask.value) {
|
||||
downloadFile(currentPreviewTask.value)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
pending: { text: '等待中', type: 'warning' },
|
||||
processing: { text: '处理中', type: 'primary' },
|
||||
success: { text: '已完成', type: 'success' },
|
||||
failed: { text: '失败', type: 'danger' },
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
const getTaskCardClass = (status) => {
|
||||
if (status === 'success') return 'task-card-completed'
|
||||
if (status === 'processing') return 'task-card-processing'
|
||||
if (status === 'failed') return 'task-card-failed'
|
||||
return 'task-card-default'
|
||||
}
|
||||
|
||||
const getPlatformIcon = (type) => {
|
||||
if (type === 'gitee') return 'iconfont icon-gitee'
|
||||
if (type === 'tencent') return 'iconfont icon-tencent'
|
||||
return 'iconfont icon-question'
|
||||
}
|
||||
|
||||
const getPlatformName = (type) => {
|
||||
if (type === 'gitee') return 'Gitee 模力方舟'
|
||||
if (type === 'tencent') return '腾讯云混元3D'
|
||||
return '未知平台'
|
||||
}
|
||||
|
||||
const getTaskPrompt = (task) => {
|
||||
return task.params.prompt ? task.params.prompt : '图生3D任务'
|
||||
}
|
||||
|
||||
const getTaskImageUrl = (task) => {
|
||||
try {
|
||||
if (task.params) {
|
||||
const parsedParams = task.params
|
||||
return parsedParams.image_url || null
|
||||
}
|
||||
return null
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const getTaskParams = (task) => {
|
||||
const parsedParams = task.params
|
||||
const params = []
|
||||
if (parsedParams.texture) params.push('纹理')
|
||||
if (parsedParams.enable_pbr) params.push('PBR材质')
|
||||
if (parsedParams.num_inference_steps)
|
||||
params.push(`迭代次数: ${parsedParams.num_inference_steps}`)
|
||||
if (parsedParams.guidance_scale) params.push(`引导系数: ${parsedParams.guidance_scale}`)
|
||||
if (parsedParams.octree_resolution) params.push(`精度: ${parsedParams.octree_resolution}`)
|
||||
if (parsedParams.seed) params.push(`Seed: ${parsedParams.seed}`)
|
||||
return params.join(',')
|
||||
}
|
||||
|
||||
const startTaskPolling = () => {
|
||||
taskPullHandler = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
loadTasks()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
const stopTaskPolling = () => {
|
||||
if (taskPullHandler) {
|
||||
clearInterval(taskPullHandler)
|
||||
taskPullHandler = null
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期:加载配置与任务
|
||||
onMounted(() => {
|
||||
loadConfigs()
|
||||
checkSession()
|
||||
.then(() => {
|
||||
loadTasks()
|
||||
startTaskPolling()
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
return {
|
||||
// 状态
|
||||
activePlatform,
|
||||
loading,
|
||||
previewVisible,
|
||||
currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
taskList,
|
||||
currentPreviewTask,
|
||||
giteeAdvancedVisible,
|
||||
taskPulling,
|
||||
tencentForm,
|
||||
giteeForm,
|
||||
currentPower,
|
||||
tencentSupportedFormats,
|
||||
giteeSupportedFormats,
|
||||
configs,
|
||||
currentForm,
|
||||
selectedModel,
|
||||
currentPrompt,
|
||||
currentImage,
|
||||
// 方法
|
||||
loadConfigs,
|
||||
handleModelChange,
|
||||
handlePlatformChange,
|
||||
generate3D,
|
||||
loadTasks,
|
||||
refreshTasks,
|
||||
handlePageSizeChange,
|
||||
handleCurrentPageChange,
|
||||
deleteTask,
|
||||
preview3D,
|
||||
closePreview,
|
||||
downloadFile,
|
||||
downloadCurrentModel,
|
||||
getStatusText,
|
||||
getTaskCardClass,
|
||||
getPlatformIcon,
|
||||
getPlatformName,
|
||||
getTaskPrompt,
|
||||
getTaskImageUrl,
|
||||
getTaskParams,
|
||||
startTaskPolling,
|
||||
stopTaskPolling,
|
||||
}
|
||||
})
|
||||
@@ -1,498 +0,0 @@
|
||||
<template>
|
||||
<div class="page-threed">
|
||||
<!-- 左侧参数设置面板 -->
|
||||
<div class="params-panel">
|
||||
<!-- 平台选择Tab -->
|
||||
<div class="platform-tabs">
|
||||
<CustomTabs v-model="activePlatform" @tab-click="handlePlatformChange">
|
||||
<CustomTabPane name="gitee" width="48%">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="iconfont icon-gitee mr-1"></i>
|
||||
<span>Gitee 模力方舟</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 参数容器 -->
|
||||
<div class="params-container">
|
||||
<!-- 图片上传区域 -->
|
||||
<div class="param-line pt">
|
||||
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片:</span>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<ImageUpload v-model="giteeForm.image_url" :max-count="1" :multiple="false" />
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<div class="param-line pt">
|
||||
<span class="label"><span class="text-red-500 mr-1">*</span>模型选择:</span>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-select
|
||||
v-model="giteeForm.model"
|
||||
placeholder="选择模型"
|
||||
@change="handleModelChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in configs.gitee.models"
|
||||
:key="model.name"
|
||||
:label="model.name"
|
||||
:value="model.name"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-alert v-if="giteeForm.model_desc" type="info" :closable="false">
|
||||
{{ giteeForm.model_desc }}
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 文件格式选择 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式:</span>
|
||||
<el-select v-model="giteeForm.file_format" style="width: 100%">
|
||||
<el-option
|
||||
v-for="format in giteeSupportedFormats"
|
||||
:key="format"
|
||||
:label="format"
|
||||
:value="format"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- 纹理开关 -->
|
||||
<div class="flex justify-between param-line">
|
||||
<span class="label">生成纹理:</span>
|
||||
<el-switch v-model="giteeForm.texture" size="large" />
|
||||
</div>
|
||||
|
||||
<!-- 高级参数 -->
|
||||
<div class="param-line pt">
|
||||
<el-button
|
||||
@click="giteeAdvancedVisible = !giteeAdvancedVisible"
|
||||
class="advanced-toggle-btn"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
giteeAdvancedVisible ? 'iconfont icon-arrow-up' : 'iconfont icon-arrow-down'
|
||||
"
|
||||
></i>
|
||||
<span>高级参数设置</span>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 高级参数内容 -->
|
||||
<div v-show="giteeAdvancedVisible" class="advanced-params">
|
||||
<!-- 随机种子 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3">随机种子:</span>
|
||||
<el-input-number
|
||||
v-model="giteeForm.seed"
|
||||
:min="0"
|
||||
:max="10000000"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 迭代次数 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3">迭代次数:</span>
|
||||
<el-input-number
|
||||
v-model="giteeForm.num_inference_steps"
|
||||
:min="1"
|
||||
:max="50"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 引导系数 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3">引导系数:</span>
|
||||
<el-input-number
|
||||
v-model="giteeForm.guidance_scale"
|
||||
:min="1"
|
||||
:max="20"
|
||||
:step="0.5"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 3D渲染精度 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3">3D渲染精度:</span>
|
||||
<el-select v-model="giteeForm.octree_resolution" style="width: 100%">
|
||||
<el-option label="64 (低精度)" :value="64" />
|
||||
<el-option label="128 (中精度)" :value="128" />
|
||||
<el-option label="256 (高精度)" :value="256" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
<CustomTabPane name="tencent" width="48%">
|
||||
<template #label>
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="iconfont icon-tencent mr-1"></i>
|
||||
<span>腾讯云混元3D</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 参数容器 -->
|
||||
<div class="params-container">
|
||||
<div class="param-line pt flex justify-between items-center">
|
||||
<span class="label">生成模式:</span>
|
||||
<custom-switch
|
||||
v-model="tencentForm.text3d"
|
||||
active-color="#9c27b0"
|
||||
inactive-color="#409eff"
|
||||
:width="120"
|
||||
size="large"
|
||||
>
|
||||
<template #active-text>
|
||||
<div class="flex items-center justify-start pl-4 text-sm">
|
||||
<i class="iconfont icon-image mr-1"></i> <span>文生3D</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #inactive-text>
|
||||
<div class="flex items-center justify-end pl-4 text-sm">
|
||||
<i class="iconfont icon-doc mr-1"></i> <span>图生3D</span>
|
||||
</div>
|
||||
</template>
|
||||
</custom-switch>
|
||||
</div>
|
||||
|
||||
<!-- 文本提示词 -->
|
||||
<div v-if="tencentForm.text3d">
|
||||
<div class="param-line pt">
|
||||
<span class="label"><span class="text-red-500 mr-1">*</span>提示词:</span>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-input
|
||||
v-model="tencentForm.prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
||||
placeholder="请输入3D模型描述,越详细越好"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- 图片上传区域 -->
|
||||
<div class="param-line pt">
|
||||
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片:</span>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<ImageUpload v-model="tencentForm.image_url" :max-count="1" :multiple="false" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模型选择 -->
|
||||
<div class="param-line pt">
|
||||
<span class="label mb-2"><span class="text-red-500 mr-1">*</span>模型选择:</span>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-select
|
||||
v-model="tencentForm.model"
|
||||
@change="handleModelChange"
|
||||
placeholder="选择模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in configs.tencent.models"
|
||||
:key="model.name"
|
||||
:label="model.name"
|
||||
:value="model.name"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="param-line">
|
||||
<el-alert v-if="tencentForm.model_desc" type="info" :closable="false">
|
||||
{{ tencentForm.model_desc }}
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 文件格式选择 -->
|
||||
<div class="param-line">
|
||||
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式:</span>
|
||||
<el-select v-model="tencentForm.file_format" style="width: 100%">
|
||||
<el-option
|
||||
v-for="format in tencentSupportedFormats"
|
||||
:key="format"
|
||||
:label="format"
|
||||
:value="format"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- PBR材质开关 -->
|
||||
<div class="flex justify-between param-line">
|
||||
<span class="label">启用PBR材质:</span>
|
||||
<el-switch v-model="tencentForm.enable_pbr" size="large" />
|
||||
</div>
|
||||
</div>
|
||||
</CustomTabPane>
|
||||
<!-- 生成按钮 -->
|
||||
<div class="generate-section">
|
||||
<button
|
||||
@click="generate3D"
|
||||
:disabled="loading"
|
||||
type="button"
|
||||
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>{{ loading ? '创作中...' : `立即生成 (${currentPower}算力)` }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</CustomTabs>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区域 -->
|
||||
<div class="content-panel">
|
||||
<!-- 任务列表 -->
|
||||
<div class="task-list">
|
||||
<div class="list-header">
|
||||
<h3>生成任务</h3>
|
||||
<el-button size="small" @click="refreshTasks">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<div class="task-items">
|
||||
<div
|
||||
v-for="task in taskList"
|
||||
:key="task.id"
|
||||
class="task-card"
|
||||
:class="getTaskCardClass(task.status)"
|
||||
>
|
||||
<!-- 任务卡片头部 -->
|
||||
<div class="task-card-header">
|
||||
<div class="task-info">
|
||||
<div class="task-id">
|
||||
<i class="iconfont icon-renwu mr-2"></i>
|
||||
#{{ task.id }}
|
||||
</div>
|
||||
<div class="task-platform">
|
||||
<i :class="getPlatformIcon(task.type)" class="mr-1"></i>
|
||||
{{ getPlatformName(task.type) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-status-wrapper">
|
||||
<div class="task-status">
|
||||
<el-button
|
||||
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 class="task-power">
|
||||
<i class="iconfont icon-power mr-1"></i>
|
||||
{{ task.power }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务卡片内容 -->
|
||||
<div class="task-card-content">
|
||||
<!-- 左侧预览图 -->
|
||||
<div class="task-preview rounded-lg">
|
||||
<div v-if="task.status === 'success' && task.preview_url" class="preview-image">
|
||||
<img :src="task.preview_url" :alt="getTaskPrompt(task)" />
|
||||
<div class="preview-overlay cursor-pointer" @click="preview3D(task)">
|
||||
<i class="iconfont icon-eye-open !text-3xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="getTaskImageUrl(task)" class="input-image">
|
||||
<img :src="getTaskImageUrl(task)" :alt="getTaskPrompt(task)" />
|
||||
<div class="input-overlay">
|
||||
<i class="iconfont icon-cube !text-3xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="prompt-placeholder">
|
||||
<i class="iconfont icon-doc"></i>
|
||||
<span>文生3D任务</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧任务详情 -->
|
||||
<div class="task-details">
|
||||
<div class="task-model">
|
||||
<i class="iconfont icon-model !text-2xl mr-1"></i>
|
||||
{{ task.model }}
|
||||
</div>
|
||||
|
||||
<div class="task-prompt" v-if="getTaskPrompt(task)">
|
||||
<i class="iconfont icon-info !text-lg mr-1"></i>
|
||||
<span>{{ getTaskPrompt(task) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="task-params" v-if="getTaskParams(task)">
|
||||
<i class="iconfont icon-tag !text-lg mr-1"></i>
|
||||
<span>{{ getTaskParams(task) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="task-time">
|
||||
<i class="iconfont icon-clock !text-xl mr-1"></i>
|
||||
{{ dateFormat(task.created_at) }}
|
||||
</div>
|
||||
|
||||
<div class="task-error" v-if="task.status === 'failed' && task.err_msg">
|
||||
<i class="iconfont icon-error !text-base mr-1"></i>
|
||||
<span>{{ task.err_msg }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务卡片底部操作 -->
|
||||
<div class="task-card-footer">
|
||||
<div class="task-actions">
|
||||
<el-button
|
||||
v-if="task.status === 'success'"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="preview3D(task)"
|
||||
class="action-btn preview-btn"
|
||||
>
|
||||
<i class="iconfont icon-eye-open mr-1"></i>
|
||||
预览
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
v-if="task.status === 'success'"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="downloadFile(task)"
|
||||
:loading="task.downloading"
|
||||
class="action-btn download-btn"
|
||||
>
|
||||
<i class="iconfont icon-download mr-1" v-if="!task.downloading"></i>
|
||||
<span v-if="task.downloading">下载中...</span>
|
||||
<span v-else>下载</span>
|
||||
</el-button>
|
||||
|
||||
<el-button
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="deleteTask(task.id)"
|
||||
class="action-btn delete-btn"
|
||||
>
|
||||
<i class="iconfont icon-remove mr-1"></i>
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="taskList.length === 0" class="empty-state">
|
||||
<i class="iconfont icon-kong"></i>
|
||||
<p>暂无任务,开始创建你的第一个3D模型吧!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination" v-if="total > 0">
|
||||
<el-pagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handlePageSizeChange"
|
||||
@current-change="handleCurrentPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3D预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="3D模型预览" fullscreen :before-close="closePreview">
|
||||
<div class="preview-container">
|
||||
<ThreeDPreview
|
||||
v-if="currentPreviewTask && currentPreviewTask.file_url"
|
||||
:model-url="currentPreviewTask.file_url"
|
||||
/>
|
||||
<div v-else class="preview-placeholder">
|
||||
<i class="iconfont icon-3d"></i>
|
||||
<p>暂无3D模型</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="closePreview">关闭</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="downloadCurrentModel"
|
||||
:loading="currentPreviewTask.downloading"
|
||||
>
|
||||
<span v-if="!currentPreviewTask.downloading">下载模型</span>
|
||||
<span v-else>下载中...</span>
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import ThreeDPreview from '@/components/ThreeDPreview.vue'
|
||||
import CustomSwitch from '@/components/ui/CustomSwitch.vue'
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { useAI3DStore } from '@/store/ai3d'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { dateFormat } from '../utils/libs'
|
||||
|
||||
const ai3d = useAI3DStore()
|
||||
const {
|
||||
activePlatform,
|
||||
loading,
|
||||
previewVisible,
|
||||
currentPage,
|
||||
pageSize,
|
||||
total,
|
||||
taskList,
|
||||
currentPreviewTask,
|
||||
giteeAdvancedVisible,
|
||||
tencentForm,
|
||||
giteeForm,
|
||||
currentPower,
|
||||
tencentSupportedFormats,
|
||||
giteeSupportedFormats,
|
||||
configs,
|
||||
} = storeToRefs(ai3d)
|
||||
|
||||
const {
|
||||
handleModelChange,
|
||||
handlePlatformChange,
|
||||
generate3D,
|
||||
refreshTasks,
|
||||
handlePageSizeChange,
|
||||
handleCurrentPageChange,
|
||||
deleteTask,
|
||||
preview3D,
|
||||
closePreview,
|
||||
downloadFile,
|
||||
downloadCurrentModel,
|
||||
getStatusText,
|
||||
getTaskCardClass,
|
||||
getPlatformIcon,
|
||||
getPlatformName,
|
||||
getTaskPrompt,
|
||||
getTaskImageUrl,
|
||||
getTaskParams,
|
||||
} = ai3d
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/ai3d.scss' as ai3d;
|
||||
</style>
|
||||
@@ -1,399 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-threed-setting">
|
||||
<!-- 配置表单 -->
|
||||
<div class="settings-container">
|
||||
<!-- 配置选项卡 -->
|
||||
<el-card class="setting-card">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- 腾讯混元3D配置 -->
|
||||
<el-tab-pane name="tencent">
|
||||
<template #label>
|
||||
<div class="tab-label">
|
||||
<i class="iconfont icon-tencent mr-2"></i>
|
||||
<span>腾讯混元3D</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="tab-content">
|
||||
<!-- 秘钥配置 -->
|
||||
<div class="config-section">
|
||||
<h4>秘钥配置</h4>
|
||||
<el-form :model="configs.tencent" label-width="140px" label-position="top">
|
||||
<el-form-item label="SecretId">
|
||||
<el-input
|
||||
v-model="configs.tencent.secret_id"
|
||||
placeholder="请输入腾讯云SecretId"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="SecretKey">
|
||||
<el-input
|
||||
v-model="configs.tencent.secret_key"
|
||||
placeholder="请输入腾讯云SecretKey"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="地域(目前仅支持广州)">
|
||||
<el-input v-model="configs.tencent.region" placeholder="请输入地域" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="configs.tencent.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 模型配置 -->
|
||||
<div class="config-section">
|
||||
<h4>模型配置</h4>
|
||||
<div class="model-config">
|
||||
<div class="model-header">
|
||||
<span>支持的3D模型格式和算力消耗</span>
|
||||
<el-button type="primary" plain @click="addTencentModel">添加模型</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="configs.tencent.models"
|
||||
border
|
||||
style="width: 100%"
|
||||
:max-height="400"
|
||||
size="small"
|
||||
>
|
||||
<el-table-column prop="name" label="模型名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="模型名称" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="desc" label="模型描述" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.desc"
|
||||
placeholder="模型描述"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="算力消耗" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.power" :min="1" :max="1000" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="formats" label="输出格式" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.formats"
|
||||
multiple
|
||||
placeholder="选择输出格式"
|
||||
style="width: 100%"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
>
|
||||
<el-option
|
||||
v-for="item in formatOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="100" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" @click="removeTencentModel($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Gitee模力方舟配置 -->
|
||||
<el-tab-pane name="gitee">
|
||||
<template #label>
|
||||
<div class="tab-label">
|
||||
<i class="iconfont icon-gitee mr-2"></i>
|
||||
<span>Gitee模力方舟</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="tab-content">
|
||||
<Alert type="info">
|
||||
如果你不知道怎么获取这些配置信息,请参考文档:
|
||||
<a href="https://ai.gitee.com/docs/organization/access-token" target="_blank"
|
||||
>模力方舟访问令牌配置</a
|
||||
>。
|
||||
</Alert>
|
||||
<!-- 秘钥配置 -->
|
||||
<div class="config-section mt-5">
|
||||
<h4>秘钥配置</h4>
|
||||
<el-form :model="configs.gitee" label-width="140px" label-position="top">
|
||||
<el-form-item label="API密钥">
|
||||
<el-input v-model="configs.gitee.api_key" placeholder="请输入Gitee API密钥" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="configs.gitee.enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 模型配置 -->
|
||||
<div class="config-section">
|
||||
<h4>模型配置</h4>
|
||||
<div class="model-config">
|
||||
<div class="model-header">
|
||||
<span>支持的3D模型格式和算力消耗</span>
|
||||
<el-button type="primary" plain @click="addGiteeModel">添加模型</el-button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="configs.gitee.models"
|
||||
border
|
||||
style="width: 100%"
|
||||
:max-height="400"
|
||||
size="small"
|
||||
>
|
||||
<el-table-column prop="name" label="模型名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="模型名称" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="desc" label="模型描述" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-input
|
||||
v-model="row.desc"
|
||||
placeholder="模型描述"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="算力消耗" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.power" :min="1" :max="1000" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="formats" label="输出格式" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.formats"
|
||||
multiple
|
||||
placeholder="选择输出格式"
|
||||
style="width: 100%"
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
>
|
||||
<el-option
|
||||
v-for="item in formatOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="100" fixed="right">
|
||||
<template #default="{ $index }">
|
||||
<el-button size="small" type="danger" @click="removeGiteeModel($index)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<div class="flex justify-center mb-5">
|
||||
<el-button type="primary" @click="saveConfig" :loading="loading">保存配置</el-button>
|
||||
</div>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Alert from '@/components/ui/Alert.vue'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('tencent')
|
||||
const loading = ref(false)
|
||||
const configs = ref({
|
||||
tencent: { region: 'ap-guangzhou', enabled: true, models: [] },
|
||||
gitee: { models: [] },
|
||||
})
|
||||
|
||||
const formatOptions = ref([
|
||||
{ label: 'OBJ', value: 'OBJ' },
|
||||
{ label: 'GLB', value: 'GLB' },
|
||||
{ label: 'STL', value: 'STL' },
|
||||
{ label: 'USDZ', value: 'USDZ' },
|
||||
{ label: 'FBX', value: 'FBX' },
|
||||
{ label: 'MP4', value: 'MP4' },
|
||||
])
|
||||
|
||||
// 方法
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/admin/config/get?key=ai3d')
|
||||
configs.value = res.data
|
||||
const models = await httpGet('/api/admin/ai3d/models')
|
||||
if (!configs.value.tencent.models || configs.value.tencent.models.length === 0) {
|
||||
configs.value.tencent.models = models.data.tencent
|
||||
}
|
||||
if (!configs.value.gitee.models || configs.value.gitee.models.length === 0) {
|
||||
configs.value.gitee.models = models.data.gitee
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载配置失败:' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await httpPost('/api/admin/ai3d/config', configs.value)
|
||||
ElMessage.success('所有配置保存成功')
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('保存失败:' + error.message)
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 模型操作 - 腾讯
|
||||
const addTencentModel = () => {
|
||||
configs.value.tencent.models.push({
|
||||
name: '',
|
||||
desc: '',
|
||||
power: 1,
|
||||
formats: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeTencentModel = async (index) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模型吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
configs.value.tencent.models.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
} catch (e) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 模型操作 - Gitee
|
||||
const addGiteeModel = () => {
|
||||
configs.value.gitee.models.push({
|
||||
name: '',
|
||||
desc: '',
|
||||
power: 1,
|
||||
formats: [],
|
||||
})
|
||||
}
|
||||
|
||||
const removeGiteeModel = async (index) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除该模型吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
configs.value.gitee.models.splice(index, 1)
|
||||
ElMessage.success('删除成功')
|
||||
} catch (e) {
|
||||
// 用户取消
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.admin-threed-setting {
|
||||
padding: 20px;
|
||||
|
||||
a {
|
||||
color: #409eff;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
.el-form-item__label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.setting-card {
|
||||
.el-card__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 30px;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #409eff;
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
|
||||
.model-config {
|
||||
.model-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.el-button {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-form-item {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,469 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-threed-jobs">
|
||||
<!-- 搜索和筛选 -->
|
||||
<div class="search-section">
|
||||
<el-form :model="searchForm" inline>
|
||||
<el-form-item label="任务状态">
|
||||
<el-select
|
||||
v-model="searchForm.status"
|
||||
placeholder="选择状态"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="等待中" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已完成" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="平台类型">
|
||||
<el-select
|
||||
v-model="searchForm.type"
|
||||
placeholder="选择平台"
|
||||
style="width: 120px"
|
||||
clearable
|
||||
>
|
||||
<el-option label="全部" value="" />
|
||||
<el-option label="魔力方舟" value="gitee" />
|
||||
<el-option label="腾讯混元" value="tencent" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户ID">
|
||||
<el-input v-model="searchForm.userId" placeholder="输入用户ID" clearable />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 数据统计 -->
|
||||
<div class="stats-section">
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon pending">
|
||||
<i class="iconfont icon-clock"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.pending }}</div>
|
||||
<div class="stat-label">等待中</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon processing">
|
||||
<i class="iconfont icon-loading"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.processing }}</div>
|
||||
<div class="stat-label">处理中</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon completed">
|
||||
<i class="iconfont icon-check"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.success }}</div>
|
||||
<div class="stat-label">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon failed">
|
||||
<i class="iconfont icon-error"></i>
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<div class="stat-number">{{ stats.failed }}</div>
|
||||
<div class="stat-label">失败</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<div class="table-section w-full">
|
||||
<el-table :data="taskList" v-loading="loading" border style="width: 100%">
|
||||
<el-table-column prop="user_id" label="用户ID" width="80" />
|
||||
<el-table-column prop="type" label="平台">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.type === 'gitee' ? 'success' : 'primary'">
|
||||
{{ row.type === 'gitee' ? '魔力方舟' : '腾讯混元' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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="status" label="状态">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)">
|
||||
{{ getStatusText(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间">
|
||||
<template #default="{ row }">
|
||||
{{ dateFormat(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间">
|
||||
<template #default="{ row }">
|
||||
{{ dateFormat(row.updated_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-section">
|
||||
<el-pagination
|
||||
:current-page="currentPage"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="taskDetailVisible"
|
||||
title="任务详情"
|
||||
width="60%"
|
||||
:before-close="closeTaskDetail"
|
||||
>
|
||||
<div v-if="currentTask" class="task-detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="任务ID">{{ currentTask.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户ID">{{ currentTask.user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="平台类型">
|
||||
<el-tag :type="currentTask.type === 'gitee' ? 'success' : 'primary'">
|
||||
{{ currentTask.type === 'gitee' ? '魔力方舟' : '腾讯混元' }}
|
||||
</el-tag>
|
||||
</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="任务状态">
|
||||
<el-tag :type="getStatusType(currentTask.status)">
|
||||
{{ getStatusText(currentTask.status) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{
|
||||
dateFormat(currentTask.created_at)
|
||||
}}</el-descriptions-item>
|
||||
<el-descriptions-item label="更新时间">{{
|
||||
dateFormat(currentTask.updated_at)
|
||||
}}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="task-params">
|
||||
<h4>任务参数</h4>
|
||||
<div class="params-content">
|
||||
<pre>{{ JSON.stringify(currentTask.params, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTask.img_url || currentTask.file_url" class="task-result">
|
||||
<h4>生成结果</h4>
|
||||
<div class="result-links">
|
||||
<el-button type="primary" @click="downloadModel(currentTask)"> 下载3D模型 </el-button>
|
||||
<el-button
|
||||
v-if="currentTask.file_url"
|
||||
type="success"
|
||||
plain
|
||||
@click="openModelPreview(currentTask)"
|
||||
>
|
||||
预览模型
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentTask.err_msg" class="task-error">
|
||||
<h4>错误信息</h4>
|
||||
<el-alert :title="currentTask.err_msg" type="error" :closable="false" show-icon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="closeTaskDetail">关闭</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 3D 模型预览弹窗 -->
|
||||
<el-dialog
|
||||
v-model="modelPreviewVisible"
|
||||
:class="['model-preview-dialog', { dark: isDarkTheme }]"
|
||||
title="模型预览"
|
||||
fullscreen
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="model-preview-wrapper">
|
||||
<ThreeDPreview :model-url="modelPreviewUrl" />
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 { onMounted, reactive, ref } from 'vue'
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const taskList = ref([])
|
||||
const taskDetailVisible = ref(false)
|
||||
const currentTask = ref({
|
||||
downloading: false,
|
||||
})
|
||||
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({
|
||||
status: '',
|
||||
type: '',
|
||||
userId: '',
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = reactive({
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
})
|
||||
|
||||
// 方法
|
||||
const loadData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const params = {
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
...searchForm,
|
||||
}
|
||||
|
||||
// 移除空值
|
||||
Object.keys(params).forEach((key) => {
|
||||
if (params[key] === '') {
|
||||
delete params[key]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await httpGet('/api/admin/ai3d/jobs', params)
|
||||
|
||||
if (response.code === 0) {
|
||||
taskList.value = response.data.items
|
||||
total.value = response.data.total
|
||||
} else {
|
||||
ElMessage.error(response.message || '加载数据失败')
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error('加载数据失败:' + error.message)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await httpGet('/api/admin/ai3d/stats')
|
||||
if (response.code === 0) {
|
||||
Object.assign(stats, response.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
Object.assign(searchForm, {
|
||||
status: '',
|
||||
type: '',
|
||||
userId: '',
|
||||
})
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size) => {
|
||||
pageSize.value = size
|
||||
currentPage.value = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleCurrentChange = (page) => {
|
||||
currentPage.value = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
const refreshData = () => {
|
||||
loadData()
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const viewTask = (task) => {
|
||||
currentTask.value = task
|
||||
taskDetailVisible.value = true
|
||||
}
|
||||
|
||||
const closeTaskDetail = () => {
|
||||
taskDetailVisible.value = false
|
||||
currentTask.value = null
|
||||
}
|
||||
|
||||
const deleteTask = async (taskId) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
|
||||
const response = await httpGet(`/api/admin/ai3d/jobs/${taskId}/delete`)
|
||||
|
||||
if (response.code === 0) {
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
loadStats()
|
||||
} else {
|
||||
ElMessage.error(response.message || '删除失败')
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败:' + error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadModel = async (task) => {
|
||||
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')
|
||||
link.href = URL.createObjectURL(blob)
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(link.href)
|
||||
task.downloading = false
|
||||
} catch (error) {
|
||||
showMessageError('下载失败:' + error.message)
|
||||
task.downloading = false
|
||||
}
|
||||
}
|
||||
|
||||
const openModelPreview = (task) => {
|
||||
// 优先使用文件直链,后端下载代理也可拼接
|
||||
const url = task.file_url
|
||||
if (!url) {
|
||||
ElMessage.warning('暂无可预览的模型文件')
|
||||
return
|
||||
}
|
||||
currentTask.value = task
|
||||
modelPreviewUrl.value = url
|
||||
modelPreviewVisible.value = true
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const typeMap = {
|
||||
pending: 'warning',
|
||||
processing: 'primary',
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const textMap = {
|
||||
pending: '等待中',
|
||||
processing: '处理中',
|
||||
success: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
return textMap[status] || status
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/admin/ai3d.scss' as *;
|
||||
</style>
|
||||
Reference in New Issue
Block a user