3D生成服务已经完成

This commit is contained in:
GeekMaster
2025-09-02 18:55:45 +08:00
parent 85b4cc0a3c
commit f8e4d2880f
40 changed files with 4920 additions and 395 deletions

View File

@@ -157,6 +157,24 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
logger.Error("load moderation config error: ", err)
}
// 加载即梦AI配置
var jimengConfig types.JimengConfig
sysConfig.Id = 0
db.Where("name", types.ConfigKeyJimeng).First(&sysConfig)
err = utils.JsonDecode(sysConfig.Value, &jimengConfig)
if err != nil {
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,
@@ -167,5 +185,7 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
Captcha: captchaConfig,
WxLogin: wxLoginConfig,
Moderation: moderationConfig,
Jimeng: jimengConfig,
AI3D: ai3dConfig,
}
}

58
api/core/types/ai3d.go Normal file
View File

@@ -0,0 +1,58 @@
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"`
}
// AI3DJobResult 3D任务结果
type AI3DJobResult struct {
JobId string `json:"job_id"` // 任务ID
Status string `json:"status"` // 任务状态
Progress int `json:"progress"` // 任务进度 (0-100)
FileURL string `json:"file_url"` // 3D模型文件URL
PreviewURL string `json:"preview_url"` // 预览图片URL
ErrorMsg string `json:"error_msg"` // 错误信息
}
// 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" // 处理中
AI3DJobStatusCompleted = "completed" // 已完成
AI3DJobStatusFailed = "failed" // 失败
)

View File

@@ -108,6 +108,7 @@ type SystemConfig struct {
Captcha CaptchaConfig
WxLogin WxLoginConfig
Jimeng JimengConfig
AI3D AI3DConfig
License License
Moderation ModerationConfig
}
@@ -127,4 +128,6 @@ const (
ConfigKeyOss = "oss"
ConfigKeyPayment = "payment"
ConfigKeyModeration = "moderation"
ConfigKeyAI3D = "ai3d"
ConfigKeyJimeng = "jimeng"
)

View File

@@ -33,6 +33,8 @@ 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
)
@@ -46,8 +48,6 @@ require (
github.com/go-pay/xtime v0.0.2 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ai3d v1.1.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.20 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect

View File

@@ -248,8 +248,8 @@ github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpP
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.20 h1:8B80/p+WvzBVz+jM6dosTcfhRe7Jotpyqj0NoGW1wfE=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.20/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=

View File

@@ -0,0 +1,216 @@
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
}
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
}
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"`
Completed int64 `json:"completed"`
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 = ?", "completed").Count(&stats.Completed)
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, "保存成功")
}

View File

@@ -8,13 +8,11 @@ package admin
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"fmt"
"geekai/core"
"geekai/core/middleware"
"geekai/core/types"
"geekai/handler"
"geekai/service"
"geekai/service/moderation"
"geekai/service/oss"
"geekai/service/payment"
"geekai/service/sms"
@@ -28,17 +26,16 @@ import (
type ConfigHandler struct {
handler.BaseHandler
licenseService *service.LicenseService
sysConfig *types.SystemConfig
alipayService *payment.AlipayService
wxpayService *payment.WxPayService
epayService *payment.EPayService
smsManager *sms.SmsManager
uploaderManager *oss.UploaderManager
smtpService *service.SmtpService
captchaService *service.CaptchaService
wxLoginService *service.WxLoginService
moderationManager *moderation.ServiceManager
licenseService *service.LicenseService
sysConfig *types.SystemConfig
alipayService *payment.AlipayService
wxpayService *payment.WxPayService
epayService *payment.EPayService
smsManager *sms.SmsManager
uploaderManager *oss.UploaderManager
smtpService *service.SmtpService
captchaService *service.CaptchaService
wxLoginService *service.WxLoginService
}
func NewConfigHandler(
@@ -54,21 +51,19 @@ func NewConfigHandler(
smtpService *service.SmtpService,
captchaService *service.CaptchaService,
wxLoginService *service.WxLoginService,
moderationManager *moderation.ServiceManager,
) *ConfigHandler {
return &ConfigHandler{
BaseHandler: handler.BaseHandler{App: app, DB: db},
licenseService: licenseService,
sysConfig: sysConfig,
alipayService: alipayService,
wxpayService: wxpayService,
epayService: epayService,
smsManager: smsManager,
uploaderManager: uploaderManager,
moderationManager: moderationManager,
smtpService: smtpService,
captchaService: captchaService,
wxLoginService: wxLoginService,
BaseHandler: handler.BaseHandler{App: app, DB: db},
licenseService: licenseService,
sysConfig: sysConfig,
alipayService: alipayService,
wxpayService: wxpayService,
epayService: epayService,
smsManager: smsManager,
uploaderManager: uploaderManager,
smtpService: smtpService,
captchaService: captchaService,
wxLoginService: wxLoginService,
}
}
@@ -91,8 +86,6 @@ func (h *ConfigHandler) RegisterRoutes() {
rg.POST("update/sms", h.UpdateSms)
rg.POST("update/oss", h.UpdateOss)
rg.POST("update/smtp", h.UpdateStmp)
rg.POST("update/moderation", h.UpdateModeration)
rg.POST("moderation/test", h.TestModeration)
rg.GET("get", h.Get)
rg.POST("license/active", h.Active)
rg.GET("license/get", h.GetLicense)
@@ -450,90 +443,3 @@ func (h *ConfigHandler) GetLicense(c *gin.Context) {
license := h.licenseService.GetLicense()
resp.SUCCESS(c, license)
}
// UpdateModeration 更新文本审查配置
func (h *ConfigHandler) UpdateModeration(c *gin.Context) {
var data types.ModerationConfig
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
err := h.Update(types.ConfigKeyModeration, data)
if err != nil {
resp.ERROR(c, err.Error())
return
}
h.moderationManager.UpdateConfig(data)
h.sysConfig.Moderation = data
resp.SUCCESS(c, data)
}
// 测试结果类型,用于前端显示
type ModerationTestResult struct {
IsAbnormal bool `json:"isAbnormal"`
Details []ModerationTestDetail `json:"details"`
}
type ModerationTestDetail struct {
Category string `json:"category"`
Description string `json:"description"`
Confidence string `json:"confidence"`
IsCategory bool `json:"isCategory"`
}
// TestModeration 测试文本审查服务
func (h *ConfigHandler) TestModeration(c *gin.Context) {
var data struct {
Text string `json:"text"`
Service string `json:"service"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
if data.Text == "" {
resp.ERROR(c, "测试文本不能为空")
return
}
// 检查是否启用了文本审查
if !h.sysConfig.Moderation.Enable {
resp.ERROR(c, "文本审查服务未启用")
return
}
// 获取当前激活的审核服务
service := h.moderationManager.GetService()
// 执行文本审核
result, err := service.Moderate(data.Text)
if err != nil {
resp.ERROR(c, "审核服务调用失败: "+err.Error())
return
}
// 转换为前端需要的格式
testResult := ModerationTestResult{
IsAbnormal: result.Flagged,
Details: make([]ModerationTestDetail, 0),
}
// 构建详细信息
for category, description := range types.ModerationCategories {
score := result.CategoryScores[category]
isCategory := result.Categories[category]
testResult.Details = append(testResult.Details, ModerationTestDetail{
Category: category,
Description: description,
Confidence: fmt.Sprintf("%.2f", score),
IsCategory: isCategory,
})
}
resp.SUCCESS(c, testResult)
}

View File

@@ -21,18 +21,18 @@ import (
// AdminJimengHandler 管理后台即梦AI处理器
type AdminJimengHandler struct {
handler.BaseHandler
jimengService *jimeng.Service
userService *service.UserService
uploader *oss.UploaderManager
jimengClient *jimeng.Client
userService *service.UserService
uploader *oss.UploaderManager
}
// NewAdminJimengHandler 创建管理后台即梦AI处理器
func NewAdminJimengHandler(app *core.AppServer, db *gorm.DB, jimengService *jimeng.Service, userService *service.UserService, uploader *oss.UploaderManager) *AdminJimengHandler {
func NewAdminJimengHandler(app *core.AppServer, db *gorm.DB, jimengClient *jimeng.Client, userService *service.UserService, uploader *oss.UploaderManager) *AdminJimengHandler {
return &AdminJimengHandler{
BaseHandler: handler.BaseHandler{App: app, DB: db},
jimengService: jimengService,
userService: userService,
uploader: uploader,
BaseHandler: handler.BaseHandler{App: app, DB: db},
jimengClient: jimengClient,
userService: userService,
uploader: uploader,
}
}
@@ -43,7 +43,6 @@ func (h *AdminJimengHandler) RegisterRoutes() {
rg.GET("/jobs/:id", h.JobDetail)
rg.POST("/jobs/remove", h.BatchRemove)
rg.GET("/stats", h.Stats)
rg.GET("/config", h.GetConfig)
rg.POST("/config/update", h.UpdateConfig)
}
@@ -213,12 +212,6 @@ func (h *AdminJimengHandler) Stats(c *gin.Context) {
resp.SUCCESS(c, result)
}
// GetConfig 获取即梦AI配置
func (h *AdminJimengHandler) GetConfig(c *gin.Context) {
jimengConfig := h.jimengService.GetConfig()
resp.SUCCESS(c, jimengConfig)
}
// UpdateConfig 更新即梦AI配置
func (h *AdminJimengHandler) UpdateConfig(c *gin.Context) {
var req types.JimengConfig
@@ -266,9 +259,9 @@ func (h *AdminJimengHandler) UpdateConfig(c *gin.Context) {
// 保存配置
tx := h.DB.Begin()
value := utils.JsonEncode(&req)
config := model.Config{Name: "jimeng", Value: value}
config := model.Config{Name: types.ConfigKeyJimeng, Value: value}
err := tx.FirstOrCreate(&config, model.Config{Name: "jimeng"}).Error
err := tx.FirstOrCreate(&config).Error
if err != nil {
resp.ERROR(c, "保存配置失败: "+err.Error())
return
@@ -284,13 +277,14 @@ func (h *AdminJimengHandler) UpdateConfig(c *gin.Context) {
}
// 更新服务中的客户端配置
updateErr := h.jimengService.UpdateClientConfig(req.AccessKey, req.SecretKey)
if updateErr != nil {
resp.ERROR(c, updateErr.Error())
err = h.jimengClient.UpdateConfig(req)
if err != nil {
resp.ERROR(c, err.Error())
tx.Rollback()
return
}
tx.Commit()
h.App.SysConfig.Jimeng = req
resp.SUCCESS(c, gin.H{"message": "配置更新成功"})
}

View File

@@ -8,10 +8,12 @@ package admin
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"fmt"
"geekai/core"
"geekai/core/middleware"
"geekai/core/types"
"geekai/handler"
"geekai/service/moderation"
"geekai/store/model"
"geekai/utils"
"geekai/utils/resp"
@@ -22,10 +24,12 @@ import (
type ModerationHandler struct {
handler.BaseHandler
sysConfig *types.SystemConfig
moderationManager *moderation.ServiceManager
}
func NewModerationHandler(app *core.AppServer, db *gorm.DB) *ModerationHandler {
return &ModerationHandler{BaseHandler: handler.BaseHandler{DB: db, App: app}}
func NewModerationHandler(app *core.AppServer, db *gorm.DB, sysConfig *types.SystemConfig, moderationManager *moderation.ServiceManager) *ModerationHandler {
return &ModerationHandler{BaseHandler: handler.BaseHandler{DB: db, App: app}, sysConfig: sysConfig, moderationManager: moderationManager}
}
// RegisterRoutes 注册路由
@@ -39,6 +43,8 @@ func (h *ModerationHandler) RegisterRoutes() {
group.GET("remove", h.Remove)
group.POST("batch-remove", h.BatchRemove)
group.GET("source-list", h.GetSourceList)
group.POST("config", h.UpdateModeration)
group.POST("test", h.TestModeration)
}
}
@@ -229,3 +235,90 @@ func (h *ModerationHandler) GetSourceList(c *gin.Context) {
resp.SUCCESS(c, sources)
}
// UpdateModeration 更新文本审查配置
func (h *ModerationHandler) UpdateModeration(c *gin.Context) {
var data types.ModerationConfig
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
err := h.DB.Where("name", types.ConfigKeyModeration).FirstOrCreate(&model.Config{Name: types.ConfigKeyModeration, Value: utils.JsonEncode(data)}).Error
if err != nil {
resp.ERROR(c, err.Error())
return
}
h.moderationManager.UpdateConfig(data)
h.sysConfig.Moderation = data
resp.SUCCESS(c, data)
}
// 测试结果类型,用于前端显示
type ModerationTestResult struct {
IsAbnormal bool `json:"isAbnormal"`
Details []ModerationTestDetail `json:"details"`
}
type ModerationTestDetail struct {
Category string `json:"category"`
Description string `json:"description"`
Confidence string `json:"confidence"`
IsCategory bool `json:"isCategory"`
}
// TestModeration 测试文本审查服务
func (h *ModerationHandler) TestModeration(c *gin.Context) {
var data struct {
Text string `json:"text"`
Service string `json:"service"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
if data.Text == "" {
resp.ERROR(c, "测试文本不能为空")
return
}
// 检查是否启用了文本审查
if !h.sysConfig.Moderation.Enable {
resp.ERROR(c, "文本审查服务未启用")
return
}
// 获取当前激活的审核服务
service := h.moderationManager.GetService()
// 执行文本审核
result, err := service.Moderate(data.Text)
if err != nil {
resp.ERROR(c, "审核服务调用失败: "+err.Error())
return
}
// 转换为前端需要的格式
testResult := ModerationTestResult{
IsAbnormal: result.Flagged,
Details: make([]ModerationTestDetail, 0),
}
// 构建详细信息
for category, description := range types.ModerationCategories {
score := result.CategoryScores[category]
isCategory := result.Categories[category]
testResult.Details = append(testResult.Details, ModerationTestDetail{
Category: category,
Description: description,
Confidence: fmt.Sprintf("%.2f", score),
IsCategory: isCategory,
})
}
resp.SUCCESS(c, testResult)
}

236
api/handler/ai3d_handler.go Normal file
View File

@@ -0,0 +1,236 @@
package handler
import (
"fmt"
"geekai/core"
"geekai/core/middleware"
"geekai/core/types"
"geekai/service"
"geekai/service/ai3d"
"geekai/store/vo"
"geekai/utils/resp"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AI3DHandler struct {
BaseHandler
service *ai3d.Service
userService *service.UserService
}
func NewAI3DHandler(app *core.AppServer, db *gorm.DB, service *ai3d.Service, userService *service.UserService) *AI3DHandler {
return &AI3DHandler{
service: service,
userService: userService,
BaseHandler: BaseHandler{
App: app,
DB: db,
},
}
}
// RegisterRoutes 注册路由
func (h *AI3DHandler) RegisterRoutes() {
group := h.App.Engine.Group("/api/3d/")
// 公开接口,不需要授权
group.GET("models/:type", h.GetModels)
// 需要用户授权的接口
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
{
group.POST("generate", h.Generate)
group.GET("jobs", h.JobList)
group.GET("job/:id", h.JobDetail)
group.DELETE("job/:id", h.DeleteJob)
group.GET("download/:id", h.Download)
}
}
// Generate 创建3D生成任务
func (h *AI3DHandler) Generate(c *gin.Context) {
var request vo.AI3DJobCreate
if err := c.ShouldBindJSON(&request); err != nil {
resp.ERROR(c, "参数错误")
return
}
// 验证必填参数
if request.Type == "" || request.Model == "" || request.Power <= 0 {
resp.ERROR(c, "缺少必要参数")
return
}
// 获取用户ID
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
// 创建任务
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)
}
// JobDetail 获取任务详情
func (h *AI3DHandler) JobDetail(c *gin.Context) {
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "任务ID格式错误")
return
}
job, err := h.service.GetJobById(uint(id))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
// 检查权限
if job.UserId != uint(userId) {
resp.ERROR(c, "无权限访问此任务")
return
}
// 转换为VO
jobVO := vo.AI3DJob{
Id: job.Id,
UserId: job.UserId,
Type: job.Type,
Power: job.Power,
TaskId: job.TaskId,
ImgURL: job.FileURL,
PreviewURL: job.PreviewURL,
Model: job.Model,
Status: job.Status,
ErrMsg: job.ErrMsg,
Params: job.Params,
CreatedAt: job.CreatedAt.Unix(),
UpdatedAt: job.UpdatedAt.Unix(),
}
resp.SUCCESS(c, jobVO)
}
// DeleteJob 删除任务
func (h *AI3DHandler) DeleteJob(c *gin.Context) {
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "任务ID格式错误")
return
}
err = h.service.DeleteJob(uint(id), uint(userId))
if err != nil {
resp.ERROR(c, fmt.Sprintf("删除任务失败: %v", err))
return
}
resp.SUCCESS(c, gin.H{"message": "删除成功"})
}
// Download 下载3D模型
func (h *AI3DHandler) Download(c *gin.Context) {
userId := h.GetLoginUserId(c)
if userId == 0 {
resp.ERROR(c, "用户未登录")
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "任务ID格式错误")
return
}
job, err := h.service.GetJobById(uint(id))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
// 检查权限
if job.UserId != uint(userId) {
resp.ERROR(c, "无权限访问此任务")
return
}
// 检查任务状态
if job.Status != types.AI3DJobStatusCompleted {
resp.ERROR(c, "任务尚未完成")
return
}
if job.FileURL == "" {
resp.ERROR(c, "模型文件不存在")
return
}
// 重定向到下载链接
c.Redirect(302, job.FileURL)
}
// GetModels 获取支持的模型列表
func (h *AI3DHandler) GetModels(c *gin.Context) {
models := h.service.GetSupportedModels()
if len(models) == 0 {
resp.ERROR(c, "无可用3D模型")
return
}
resp.SUCCESS(c, models)
}

View File

@@ -435,7 +435,7 @@ func (h *JimengHandler) Retry(c *gin.Context) {
// getPowerFromConfig 从配置中获取指定类型的算力消耗
func (h *JimengHandler) getPowerFromConfig(taskType model.JMTaskType) int {
config := h.jimengService.GetConfig()
config := h.App.SysConfig.Jimeng
switch taskType {
case model.JMTaskTypeTextToImage:
@@ -457,7 +457,7 @@ func (h *JimengHandler) getPowerFromConfig(taskType model.JMTaskType) int {
// GetPowerConfig 获取即梦各任务类型算力消耗配置
func (h *JimengHandler) GetPowerConfig(c *gin.Context) {
config := h.jimengService.GetConfig()
config := h.App.SysConfig.Jimeng
resp.SUCCESS(c, gin.H{
"text_to_image": config.Power.TextToImage,
"image_to_image": config.Power.ImageToImage,

View File

@@ -16,6 +16,7 @@ import (
"geekai/handler/admin"
logger2 "geekai/logger"
"geekai/service"
"geekai/service/ai3d"
"geekai/service/dalle"
"geekai/service/jimeng"
"geekai/service/mj"
@@ -210,10 +211,19 @@ func main() {
}),
// 即梦AI 服务
fx.Provide(jimeng.NewClient),
fx.Provide(jimeng.NewService),
fx.Invoke(func(service *jimeng.Service) {
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),
// 创建短信服务
@@ -383,6 +393,16 @@ 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()

View File

@@ -0,0 +1,150 @@
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 {
Prompt string `json:"prompt"` // 文本提示词
ImageURL string `json:"image_url"` // 输入图片URL
ResultFormat string `json:"result_format"` // 输出格式
}
type Gitee3DResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
TaskID string `json:"task_id"`
} `json:"data"`
}
type Gitee3DQueryResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
Status string `json:"status"`
Progress int `json:"progress"`
ResultURL string `json:"result_url"`
PreviewURL string `json:"preview_url"`
ErrorMsg string `json:"error_msg"`
} `json:"data"`
}
func NewGitee3DClient(sysConfig *types.SystemConfig) *Gitee3DClient {
return &Gitee3DClient{
httpClient: req.C().SetTimeout(time.Minute * 3),
config: sysConfig.AI3D.Gitee,
apiURL: "https://ai.gitee.com/v1/async/image-to-3d",
}
}
func (c *Gitee3DClient) UpdateConfig(config types.Gitee3DConfig) {
c.config = config
}
// SubmitJob 提交3D生成任务
func (c *Gitee3DClient) SubmitJob(params Gitee3DParams) (string, error) {
requestBody := map[string]any{
"prompt": params.Prompt,
"image_url": params.ImageURL,
"result_format": params.ResultFormat,
}
response, err := c.httpClient.R().
SetHeader("Authorization", "Bearer "+c.config.APIKey).
SetHeader("Content-Type", "application/json").
SetBody(requestBody).
Post(c.apiURL + "/async/image-to-3d")
if err != nil {
return "", fmt.Errorf("failed to submit gitee 3D job: %v", err)
}
var giteeResp Gitee3DResponse
if err := json.Unmarshal(response.Bytes(), &giteeResp); err != nil {
return "", fmt.Errorf("failed to parse gitee response: %v", err)
}
if giteeResp.Code != 0 {
return "", fmt.Errorf("gitee API error: %s", giteeResp.Message)
}
if giteeResp.Data.TaskID == "" {
return "", fmt.Errorf("no task ID returned from gitee 3D API")
}
return giteeResp.Data.TaskID, nil
}
// QueryJob 查询任务状态
func (c *Gitee3DClient) QueryJob(taskId string) (*types.AI3DJobResult, error) {
response, err := c.httpClient.R().
SetHeader("Authorization", "Bearer "+c.config.APIKey).
Get(fmt.Sprintf("%s/task/%s/get", c.apiURL, taskId))
if err != nil {
return nil, fmt.Errorf("failed to query gitee 3D job: %v", err)
}
var giteeResp Gitee3DQueryResponse
if err := json.Unmarshal(response.Bytes(), &giteeResp); err != nil {
return nil, fmt.Errorf("failed to parse gitee query response: %v", err)
}
if giteeResp.Code != 0 {
return nil, fmt.Errorf("gitee API error: %s", giteeResp.Message)
}
result := &types.AI3DJobResult{
JobId: taskId,
Status: c.convertStatus(giteeResp.Data.Status),
Progress: giteeResp.Data.Progress,
}
// 根据状态设置结果
switch giteeResp.Data.Status {
case "completed":
result.FileURL = giteeResp.Data.ResultURL
result.PreviewURL = giteeResp.Data.PreviewURL
case "failed":
result.ErrorMsg = giteeResp.Data.ErrorMsg
}
return result, nil
}
// convertStatus 转换Gitee状态到系统状态
func (c *Gitee3DClient) convertStatus(giteeStatus string) string {
switch giteeStatus {
case "pending":
return types.AI3DJobStatusPending
case "processing":
return types.AI3DJobStatusProcessing
case "completed":
return types.AI3DJobStatusCompleted
case "failed":
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 效果,比如游戏建模、虚拟现实、动画制作等。"},
}
}

327
api/service/ai3d/service.go Normal file
View File

@@ -0,0 +1,327 @@
package ai3d
import (
"encoding/json"
"fmt"
"geekai/core/types"
logger2 "geekai/logger"
"geekai/store"
"geekai/store/model"
"geekai/store/vo"
"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
}
// NewService 创建3D生成服务
func NewService(db *gorm.DB, redisCli *redis.Client, tencentClient *Tencent3DClient, giteeClient *Gitee3DClient) *Service {
return &Service{
db: db,
taskQueue: store.NewRedisQueue("3D_Task_Queue", redisCli),
tencentClient: tencentClient,
giteeClient: giteeClient,
}
}
// CreateJob 创建3D生成任务
func (s *Service) CreateJob(userId uint, request vo.AI3DJobCreate) (*model.AI3DJob, error) {
// 创建任务记录
job := &model.AI3DJob{
UserId: userId,
Type: request.Type,
Power: request.Power,
Model: request.Model,
Status: types.AI3DJobStatusPending,
}
// 序列化参数
params := map[string]any{
"prompt": request.Prompt,
"image_url": request.ImageURL,
"model": request.Model,
"power": request.Power,
}
paramsJSON, _ := json.Marshal(params)
job.Params = string(paramsJSON)
// 保存到数据库
if err := s.db.Create(job).Error; err != nil {
return nil, fmt.Errorf("failed to create 3D job: %v", err)
}
// 将任务添加到队列
s.PushTask(job)
return job, nil
}
// PushTask 将任务添加到队列
func (s *Service) PushTask(job *model.AI3DJob) {
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() {
// 将数据库中未完成的任务加载到队列
var jobs []model.AI3DJob
s.db.Where("status IN ?", []string{types.AI3DJobStatusPending, types.AI3DJobStatusProcessing}).Find(&jobs)
for _, job := range jobs {
s.PushTask(&job)
}
logger.Info("Starting 3D job consumer...")
go func() {
for {
var job model.AI3DJob
err := s.taskQueue.LPop(&job)
if err != nil {
logger.Errorf("taking 3D task with error: %v", err)
continue
}
logger.Infof("handle a new 3D task: %+v", job)
go func() {
if err := s.processJob(&job); err != nil {
logger.Errorf("error processing 3D job: %v", err)
s.updateJobStatus(&job, types.AI3DJobStatusFailed, 0, err.Error())
}
}()
}
}()
}
// processJob 处理3D任务
func (s *Service) processJob(job *model.AI3DJob) error {
// 更新状态为处理中
s.updateJobStatus(job, types.AI3DJobStatusProcessing, 10, "")
// 解析参数
var params map[string]any
if err := json.Unmarshal([]byte(job.Params), &params); err != nil {
return fmt.Errorf("failed to parse job params: %v", err)
}
var taskId string
var err error
// 根据类型选择客户端
switch job.Type {
case "tencent":
if s.tencentClient == nil {
return fmt.Errorf("tencent 3D client not initialized")
}
tencentParams := Tencent3DParams{
Prompt: s.getString(params, "prompt"),
ImageURL: s.getString(params, "image_url"),
ResultFormat: job.Model,
EnablePBR: false,
}
taskId, err = s.tencentClient.SubmitJob(tencentParams)
case "gitee":
if s.giteeClient == nil {
return fmt.Errorf("gitee 3D client not initialized")
}
giteeParams := Gitee3DParams{
Prompt: s.getString(params, "prompt"),
ImageURL: s.getString(params, "image_url"),
ResultFormat: job.Model,
}
taskId, err = s.giteeClient.SubmitJob(giteeParams)
default:
return fmt.Errorf("unsupported 3D API type: %s", job.Type)
}
if err != nil {
return fmt.Errorf("failed to submit 3D job: %v", err)
}
// 更新任务ID
job.TaskId = taskId
s.db.Model(job).Update("task_id", taskId)
// 开始轮询任务状态
go s.pollJobStatus(job)
return nil
}
// pollJobStatus 轮询任务状态
func (s *Service) pollJobStatus(job *model.AI3DJob) {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
result, err := s.queryJobStatus(job)
if err != nil {
logger.Errorf("failed to query job status: %v", err)
continue
}
// 更新进度
s.updateJobStatus(job, result.Status, result.Progress, result.ErrorMsg)
// 如果任务完成或失败,停止轮询
if result.Status == types.AI3DJobStatusCompleted || result.Status == types.AI3DJobStatusFailed {
if result.Status == types.AI3DJobStatusCompleted {
// 更新结果文件URL
s.db.Model(job).Updates(map[string]interface{}{
"img_url": result.FileURL,
"preview_url": result.PreviewURL,
})
}
return
}
}
}
}
// queryJobStatus 查询任务状态
func (s *Service) queryJobStatus(job *model.AI3DJob) (*types.AI3DJobResult, error) {
switch job.Type {
case "tencent":
if s.tencentClient == nil {
return nil, fmt.Errorf("tencent 3D client not initialized")
}
return s.tencentClient.QueryJob(job.TaskId)
case "gitee":
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(job *model.AI3DJob, status string, progress int, errMsg string) {
updates := map[string]interface{}{
"status": status,
"progress": progress,
"updated_at": time.Now(),
}
if errMsg != "" {
updates["err_msg"] = errMsg
}
if err := s.db.Model(job).Updates(updates).Error; err != nil {
logger.Errorf("failed to update job status: %v", err)
}
}
// 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,
ImgURL: job.FileURL,
PreviewURL: job.PreviewURL,
Model: job.Model,
Status: job.Status,
ErrMsg: job.ErrMsg,
Params: job.Params,
CreatedAt: job.CreatedAt.Unix(),
UpdatedAt: job.UpdatedAt.Unix(),
}
jobList = append(jobList, jobVO)
}
return &vo.Page{
Page: page,
PageSize: pageSize,
Total: total,
Items: jobList,
}, nil
}
// GetJobById 根据ID获取任务
func (s *Service) GetJobById(id uint) (*model.AI3DJob, error) {
var job model.AI3DJob
if err := s.db.Where("id = ?", id).First(&job).Error; err != nil {
return nil, err
}
return &job, nil
}
// DeleteJob 删除任务
func (s *Service) DeleteJob(id uint, userId uint) error {
var job model.AI3DJob
if err := s.db.Where("id = ? AND user_id = ?", id, userId).First(&job).Error; err != nil {
return err
}
// 如果任务已完成,退还算力
if job.Status == types.AI3DJobStatusCompleted {
// TODO: 实现算力退还逻辑
logger2.GetLogger().Infof("should refund power %d for user %d", job.Power, userId)
}
return s.db.Delete(&job).Error
}
// 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)
}
}
// getString 从map中获取字符串值
func (s *Service) getString(params map[string]interface{}, key string) string {
if val, ok := params[key]; ok {
if str, ok := val.(string); ok {
return str
}
}
return ""
}

View File

@@ -0,0 +1,158 @@
package ai3d
import (
"fmt"
"geekai/core/types"
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
}
// 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{
JobId: jobId,
Status: *response.Response.Status,
Progress: 0,
}
// 根据状态设置进度
switch *response.Response.Status {
case "WAIT":
result.Status = "pending"
result.Progress = 10
case "RUN":
result.Status = "processing"
result.Progress = 50
case "DONE":
result.Status = "completed"
result.Progress = 100
// 处理结果文件
if len(response.Response.ResultFile3Ds) > 0 {
for _, file := range response.Response.ResultFile3Ds {
if file.Url != nil {
result.FileURL = *file.Url
}
if file.PreviewImageUrl != nil {
result.PreviewURL = *file.PreviewImageUrl
}
break // 取第一个文件
}
}
case "FAIL":
result.Status = "failed"
result.Progress = 0
if response.Response.ErrorMessage != nil {
result.ErrorMsg = *response.Response.ErrorMessage
}
}
return result, nil
}
// GetSupportedModels 获取支持的模型列表
func (c *Tencent3DClient) GetSupportedModels() []types.AI3DModel {
return []types.AI3DModel{
{Name: "Hunyuan3D-3", Power: 500, Formats: []string{"OBJ", "GLB", "STL", "USDZ", "FBX", "MP4"}, Desc: "Hunyuan3D 是腾讯混元团队推出的高质量 3D 生成模型,具备高保真度、细节丰富和高效生成的特点,可快速将文本或图像转换为逼真的 3D 物体。"},
}
}

View File

@@ -3,8 +3,10 @@ package jimeng
import (
"encoding/json"
"fmt"
"geekai/core/types"
"net/http"
"net/url"
"strings"
"github.com/volcengine/volc-sdk-golang/base"
"github.com/volcengine/volc-sdk-golang/service/visual"
@@ -13,14 +15,22 @@ import (
// Client 即梦API客户端
type Client struct {
visual *visual.Visual
config types.JimengConfig
}
// NewClient 创建即梦API客户端
func NewClient(accessKey, secretKey string) *Client {
func NewClient(sysConfig *types.SystemConfig) *Client {
client := &Client{}
client.UpdateConfig(sysConfig.Jimeng)
return client
}
func (c *Client) UpdateConfig(config types.JimengConfig) error {
// 使用官方SDK的visual实例
visualInstance := visual.NewInstance()
visualInstance.Client.SetAccessKey(accessKey)
visualInstance.Client.SetSecretKey(secretKey)
visualInstance.Client.SetAccessKey(config.AccessKey)
visualInstance.Client.SetSecretKey(config.SecretKey)
// 添加即梦AI专有的API配置
jimengApis := map[string]*base.ApiInfo{
@@ -55,9 +65,32 @@ func NewClient(accessKey, secretKey string) *Client {
visualInstance.Client.ApiInfoList[name] = info
}
return &Client{
visual: visualInstance,
c.config = config
c.visual = visualInstance
return c.testConnection()
}
// testConnection 测试即梦AI连接
func (c *Client) testConnection() error {
// 使用一个简单的查询任务来测试连接
testReq := &QueryTaskRequest{
ReqKey: "test_connection",
TaskId: "test_task_id_12345",
}
_, err := c.QueryTask(testReq)
// 即使任务不存在,只要不是认证错误就说明连接正常
if err != nil {
// 检查是否是认证错误
if strings.Contains(err.Error(), "InvalidAccessKey") {
return fmt.Errorf("认证失败请检查AccessKey和SecretKey是否正确")
}
// 其他错误(如任务不存在)说明连接正常
return nil
}
return nil
}
// SubmitTask 提交异步任务

View File

@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -16,8 +15,6 @@ import (
"geekai/store/model"
"geekai/utils"
"geekai/core/types"
"github.com/go-redis/redis/v8"
)
@@ -36,17 +33,8 @@ type Service struct {
}
// NewService 创建即梦服务
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager) *Service {
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager, client *Client) *Service {
taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli)
// 从数据库加载配置
var config model.Config
db.Where("name = ?", "Jimeng").First(&config)
var jimengConfig types.JimengConfig
if config.Id > 0 {
_ = utils.JsonDecode(config.Value, &jimengConfig)
}
client := NewClient(jimengConfig.AccessKey, jimengConfig.SecretKey)
ctx, cancel := context.WithCancel(context.Background())
return &Service{
db: db,
@@ -522,77 +510,3 @@ func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
}
return &job, nil
}
// testConnection 测试即梦AI连接
func (s *Service) testConnection(accessKey, secretKey string) error {
testClient := NewClient(accessKey, secretKey)
// 使用一个简单的查询任务来测试连接
testReq := &QueryTaskRequest{
ReqKey: "test_connection",
TaskId: "test_task_id_12345",
}
_, err := testClient.QueryTask(testReq)
// 即使任务不存在,只要不是认证错误就说明连接正常
if err != nil {
// 检查是否是认证错误
if strings.Contains(err.Error(), "InvalidAccessKey") {
return fmt.Errorf("认证失败请检查AccessKey和SecretKey是否正确")
}
// 其他错误(如任务不存在)说明连接正常
return nil
}
return nil
}
// UpdateClientConfig 更新客户端配置
func (s *Service) UpdateClientConfig(accessKey, secretKey string) error {
// 创建新的客户端
newClient := NewClient(accessKey, secretKey)
// 测试新客户端是否可用
err := s.testConnection(accessKey, secretKey)
if err != nil {
return err
}
// 更新客户端
s.client = newClient
return nil
}
var defaultPower = types.JimengPower{
TextToImage: 20,
ImageToImage: 20,
ImageEdit: 20,
ImageEffects: 20,
TextToVideo: 300,
ImageToVideo: 300,
}
// GetConfig 获取即梦AI配置
func (s *Service) GetConfig() *types.JimengConfig {
var config model.Config
err := s.db.Where("name", "jimeng").First(&config).Error
if err != nil {
// 如果配置不存在,返回默认配置
return &types.JimengConfig{
AccessKey: "",
SecretKey: "",
Power: defaultPower,
}
}
var jimengConfig types.JimengConfig
err = utils.JsonDecode(config.Value, &jimengConfig)
if err != nil {
return &types.JimengConfig{
AccessKey: "",
SecretKey: "",
Power: defaultPower,
}
}
return &jimengConfig
}

View File

@@ -154,6 +154,24 @@ 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
}
@@ -161,6 +179,8 @@ 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") {
s.db.Migrator().RenameColumn(&model.Order{}, "pay_type", "channel")

View File

@@ -0,0 +1,23 @@
package model
import "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 string `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"`
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"
}

32
api/store/vo/ai3d_job.go Normal file
View File

@@ -0,0 +1,32 @@
package vo
type AI3DJob struct {
Id uint `json:"id"`
UserId uint `json:"user_id"`
Type string `json:"type"`
Power int `json:"power"`
TaskId string `json:"task_id"`
ImgURL string `json:"img_url"`
PreviewURL string `json:"preview_url"`
Model string `json:"model"`
Status string `json:"status"`
ErrMsg string `json:"err_msg"`
Params string `json:"params"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type AI3DJobCreate struct {
Type string `json:"type" binding:"required"` // API类型 (tencent/gitee)
Model string `json:"model" binding:"required"` // 3D模型类型
Prompt string `json:"prompt"` // 文本提示词
ImageURL string `json:"image_url"` // 输入图片URL
Power int `json:"power" binding:"required"` // 消耗算力
}
type ThreeDJobList struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int `json:"total"`
List []AI3DJob `json:"list"`
}

122
docs.md
View File

@@ -1,122 +0,0 @@
## 开发 3D 图片生成功能
对接 3D 图片生成接口,为当前系统添加 3D 模型生成功能,默认支持腾讯云和 Gitee 的图生 3D API 接口。
## 要求
1. 完成数据库设计,后端 API 设计,前端页面设计。
2. 完成前端功能页面以及后台管理页面,具体设计结构可以参考即梦 AI在对应的模块建立独立的模块 。
3. 页面设计要精美,但是整体风格要跟整站风格一致。
4. 支持前端 3D 模型预览,支持 3D 模型下载。
## 腾讯云图生 3D API 接口文档
1. 提交任务: https://cloud.tencent.com/document/product/1804/120826
2. 查询任务: https://cloud.tencent.com/document/product/1804/120827
3. Golang SDK https://gitee.com/TencentCloud/tencentcloud-sdk-go/blob/master/tencentcloud/ai3d/v20250513/client.go 依赖已经安装到本地了
## Gitee 图生 3D API 接口文档
1. 提交任务: https://ai.gitee.com/docs/openapi/v1#tag/3d-%E7%94%9F%E6%88%90/post/async/image-to-3d
2. 查询任务https://ai.gitee.com/docs/openapi/v1#tag/%E5%BC%82%E6%AD%A5%E4%BB%BB%E5%8A%A1/get/task/{task_id}/get
首先,你需要认真阅读上述接口文档,然后按照接口文档的示例代码实现腾讯云和 Gitee 的图生 3D API 接口,并且将接口集成到现有的系统中。
📋 功能概述
为现有的GeekAI-Plus系统添加3D图片生成功能集成腾讯云和Gitee的图生3D API接口包含完整的前后端功能和管理界面。
🗄️ 数据库设计
新增数据表geekai_3d_jobs
- id (uint): 主键
- type (string): API类型 (tencent/gitee)
- user_id (uint): 用户ID
- power (int): 消耗算力
- task_id (string): 第三方任务ID
- img_url (string): 生成的3D模型文件地址
- model (string): 使用的3D模型类型
- status (string): 任务状态
- err_msg (string): 错误信息
- params (JSON): 任务参数(包含输入图片、提示词等所有参数)
- created_at (int64): 创建时间
🔧 后端API实现
路由结构:/api/3d/*
- POST /api/3d/generate - 创建3D生成任务
- GET /api/3d/jobs - 获取任务列表
- GET /api/3d/job/{id} - 获取任务详情
- GET /api/3d/download/{id} - 下载3D模型
- DELETE /api/3d/job/{id} - 删除任务
核心服务:
- service/3d/tencent_client.go - 腾讯云3D API客户端
- service/3d/gitee_client.go - Gitee 3D API客户端
- service/3d/service.go - 3D生成服务统一接口
- handler/3d_handler.go - HTTP处理器
- store/vo/3d_job.go - 数据模型
🎨 前端界面设计
用户端页面:/3d - 3D生成主页面
- 参考JiMeng.vue的设计风格和布局
- 使用CustomTab组件分离平台参数
- Tab 1: "魔力方舟" (Gitee平台参数)
- Tab 2: "腾讯混元" (腾讯云平台参数)
- 每个Tab内包含
- 图片上传区域
- 模型选择下拉框
- 算力消耗实时显示
- 平台特定的参数配置
- 生成按钮
- 任务列表和状态显示
- 集成3D模型预览器 (three.js)
- 模型下载功能
移动端适配:
- mobile/3dCreate.vue - 移动端3D生成页面
- 保持Tab切换功能
- 响应式设计,触控优化
🛠️ 管理后台
管理功能:
- admin/3d/3dJobs.vue - 任务管理列表
- admin/3d/3dSetting.vue - API配置页面
- 模型配置管理:
- 分平台配置模型列表
- 设置每个模型的算力消耗值
- API密钥和端点配置
🔌 API集成方案
腾讯云集成:
- 使用官方Golang SDK
- 支持异步任务提交和状态查询
Gitee集成
- HTTP客户端实现
- 标准化响应处理
🎯 核心功能特性
- 平台切换通过CustomTab在魔力方舟和腾讯混元间切换
- 模型选择每个平台支持不同的3D模型
- 动态算力:切换模型时实时更新算力消耗显示
- 参数隔离:不同平台的参数配置完全分离
- 3D预览集成Three.js实现模型预览
- 统一体验保持与JiMeng.vue相似的交互风格
📱 用户体验
- JiMeng.vue风格的简洁界面
- Tab切换流畅的平台选择
- 模型选择时算力消耗实时更新
- 支持拖拽上传图片
- 实时任务状态显示
- 3D模型交互式预览
这个设计将创建一个与现有JiMeng功能风格一致的3D生成模块通过Tab分离实现平台参数的清晰管理。
整个实现严格按照现有系统的代码规范和架构模式,与 JiMeng 等模块保持一致的用户体验!

510
docs/ai3d.md Normal file
View File

@@ -0,0 +1,510 @@
## 开发 3D 图片生成功能
对接 3D 图片生成接口,为当前系统添加 3D 模型生成功能,默认支持腾讯云和 Gitee 的图生 3D API 接口。
## 要求
1. 完成数据库设计,后端 API 设计,前端页面设计。
2. 完成前端功能页面以及后台管理页面,具体设计结构可以参考即梦 AI在对应的模块建立独立的模块 。
3. 页面设计要精美,但是整体风格要跟整站风格一致。
4. 支持前端 3D 模型预览,支持 3D 模型下载。
## 腾讯云图生 3D API 接口文档
1. 提交任务: https://cloud.tencent.com/document/product/1804/120826
2. 查询任务: https://cloud.tencent.com/document/product/1804/120827
3. Golang SDK https://gitee.com/TencentCloud/tencentcloud-sdk-go/blob/master/tencentcloud/ai3d/v20250513/client.go 依赖已经安装到本地了,如果你无法读取远程文件,下面是文件主要内容:
client.go
```go
// Copyright (c) 2017-2025 Tencent. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v20250513
import (
"context"
"errors"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
tchttp "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/http"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
)
const APIVersion = "2025-05-13"
type Client struct {
common.Client
}
// Deprecated
func NewClientWithSecretId(secretId, secretKey, region string) (client *Client, err error) {
cpf := profile.NewClientProfile()
client = &Client{}
client.Init(region).WithSecretId(secretId, secretKey).WithProfile(cpf)
return
}
func NewClient(credential common.CredentialIface, region string, clientProfile *profile.ClientProfile) (client *Client, err error) {
client = &Client{}
client.Init(region).
WithCredential(credential).
WithProfile(clientProfile)
return
}
func NewQueryHunyuanTo3DJobRequest() (request *QueryHunyuanTo3DJobRequest) {
request = &QueryHunyuanTo3DJobRequest{
BaseRequest: &tchttp.BaseRequest{},
}
request.Init().WithApiInfo("ai3d", APIVersion, "QueryHunyuanTo3DJob")
return
}
func NewQueryHunyuanTo3DJobResponse() (response *QueryHunyuanTo3DJobResponse) {
response = &QueryHunyuanTo3DJobResponse{
BaseResponse: &tchttp.BaseResponse{},
}
return
}
// QueryHunyuanTo3DJob
// 混元生3D接口基于混元大模型根据输入的文本描述/图片智能生成3D。
//
// 默认提供1个并发代表最多能同时处理1个已提交的任务上一个任务处理完毕后才能开始处理下一个任务。
func (c *Client) QueryHunyuanTo3DJob(request *QueryHunyuanTo3DJobRequest) (response *QueryHunyuanTo3DJobResponse, err error) {
return c.QueryHunyuanTo3DJobWithContext(context.Background(), request)
}
// QueryHunyuanTo3DJob
// 混元生3D接口基于混元大模型根据输入的文本描述/图片智能生成3D。
//
// 默认提供1个并发代表最多能同时处理1个已提交的任务上一个任务处理完毕后才能开始处理下一个任务。
func (c *Client) QueryHunyuanTo3DJobWithContext(ctx context.Context, request *QueryHunyuanTo3DJobRequest) (response *QueryHunyuanTo3DJobResponse, err error) {
if request == nil {
request = NewQueryHunyuanTo3DJobRequest()
}
c.InitBaseRequest(&request.BaseRequest, "ai3d", APIVersion, "QueryHunyuanTo3DJob")
if c.GetCredential() == nil {
return nil, errors.New("QueryHunyuanTo3DJob require credential")
}
request.SetContext(ctx)
response = NewQueryHunyuanTo3DJobResponse()
err = c.Send(request, response)
return
}
func NewSubmitHunyuanTo3DJobRequest() (request *SubmitHunyuanTo3DJobRequest) {
request = &SubmitHunyuanTo3DJobRequest{
BaseRequest: &tchttp.BaseRequest{},
}
request.Init().WithApiInfo("ai3d", APIVersion, "SubmitHunyuanTo3DJob")
return
}
func NewSubmitHunyuanTo3DJobResponse() (response *SubmitHunyuanTo3DJobResponse) {
response = &SubmitHunyuanTo3DJobResponse{
BaseResponse: &tchttp.BaseResponse{},
}
return
}
// SubmitHunyuanTo3DJob
// 混元生3D接口基于混元大模型根据输入的文本描述/图片智能生成3D。
//
// 默认提供1个并发代表最多能同时处理1个已提交的任务上一个任务处理完毕后才能开始处理下一个任务。
func (c *Client) SubmitHunyuanTo3DJob(request *SubmitHunyuanTo3DJobRequest) (response *SubmitHunyuanTo3DJobResponse, err error) {
return c.SubmitHunyuanTo3DJobWithContext(context.Background(), request)
}
// SubmitHunyuanTo3DJob
// 混元生3D接口基于混元大模型根据输入的文本描述/图片智能生成3D。
//
// 默认提供1个并发代表最多能同时处理1个已提交的任务上一个任务处理完毕后才能开始处理下一个任务。
func (c *Client) SubmitHunyuanTo3DJobWithContext(ctx context.Context, request *SubmitHunyuanTo3DJobRequest) (response *SubmitHunyuanTo3DJobResponse, err error) {
if request == nil {
request = NewSubmitHunyuanTo3DJobRequest()
}
c.InitBaseRequest(&request.BaseRequest, "ai3d", APIVersion, "SubmitHunyuanTo3DJob")
if c.GetCredential() == nil {
return nil, errors.New("SubmitHunyuanTo3DJob require credential")
}
request.SetContext(ctx)
response = NewSubmitHunyuanTo3DJobResponse()
err = c.Send(request, response)
return
}
```
model.go
```go
// Copyright (c) 2017-2025 Tencent. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package v20250513
import (
tcerr "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/errors"
tchttp "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/http"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/json"
)
type File3D struct {
// 文件格式
Type *string `json:"Type,omitnil,omitempty" name:"Type"`
// 文件的Url有效期24小时
Url *string `json:"Url,omitnil,omitempty" name:"Url"`
// 预览图片Url
PreviewImageUrl *string `json:"PreviewImageUrl,omitnil,omitempty" name:"PreviewImageUrl"`
}
// Predefined struct for user
type QueryHunyuanTo3DJobRequestParams struct {
// 任务ID。
JobId *string `json:"JobId,omitnil,omitempty" name:"JobId"`
}
type QueryHunyuanTo3DJobRequest struct {
*tchttp.BaseRequest
// 任务ID。
JobId *string `json:"JobId,omitnil,omitempty" name:"JobId"`
}
func (r *QueryHunyuanTo3DJobRequest) ToJsonString() string {
b, _ := json.Marshal(r)
return string(b)
}
// FromJsonString It is highly **NOT** recommended to use this function
// because it has no param check, nor strict type check
func (r *QueryHunyuanTo3DJobRequest) FromJsonString(s string) error {
f := make(map[string]interface{})
if err := json.Unmarshal([]byte(s), &f); err != nil {
return err
}
delete(f, "JobId")
if len(f) > 0 {
return tcerr.NewTencentCloudSDKError("ClientError.BuildRequestError", "QueryHunyuanTo3DJobRequest has unknown keys!", "")
}
return json.Unmarshal([]byte(s), &r)
}
// Predefined struct for user
type QueryHunyuanTo3DJobResponseParams struct {
// 任务状态。WAIT等待中RUN执行中FAIL任务失败DONE任务成功
Status *string `json:"Status,omitnil,omitempty" name:"Status"`
// 错误码
ErrorCode *string `json:"ErrorCode,omitnil,omitempty" name:"ErrorCode"`
// 错误信息
ErrorMessage *string `json:"ErrorMessage,omitnil,omitempty" name:"ErrorMessage"`
// 生成的3D文件数组。
ResultFile3Ds []*File3D `json:"ResultFile3Ds,omitnil,omitempty" name:"ResultFile3Ds"`
// 唯一请求 ID由服务端生成每次请求都会返回若请求因其他原因未能抵达服务端则该次请求不会获得 RequestId。定位问题时需要提供该次请求的 RequestId。
RequestId *string `json:"RequestId,omitnil,omitempty" name:"RequestId"`
}
type QueryHunyuanTo3DJobResponse struct {
*tchttp.BaseResponse
Response *QueryHunyuanTo3DJobResponseParams `json:"Response"`
}
func (r *QueryHunyuanTo3DJobResponse) ToJsonString() string {
b, _ := json.Marshal(r)
return string(b)
}
// FromJsonString It is highly **NOT** recommended to use this function
// because it has no param check, nor strict type check
func (r *QueryHunyuanTo3DJobResponse) FromJsonString(s string) error {
return json.Unmarshal([]byte(s), &r)
}
// Predefined struct for user
type SubmitHunyuanTo3DJobRequestParams struct {
// 文生3D3D内容的描述中文正向提示词。
// 最多支持1024个 utf-8 字符。
// 文生3D, image、image_url和 prompt必填其一且prompt和image/image_url不能同时存在。
Prompt *string `json:"Prompt,omitnil,omitempty" name:"Prompt"`
// 输入图 Base64 数据。
// 大小单边分辨率要求不小于128不大于5000。大小不超过8mbase64编码后会大30%左右建议实际输入图片不超过6m
// 格式jpgpngjpegwebp。
// ImageBase64、ImageUrl和 Prompt必填其一且Prompt和ImageBase64/ImageUrl不能同时存在。
ImageBase64 *string `json:"ImageBase64,omitnil,omitempty" name:"ImageBase64"`
// 输入图Url。
// 大小单边分辨率要求不小于128不大于5000。大小不超过8mbase64编码后会大30%左右建议实际输入图片不超过6m
// 格式jpgpngjpegwebp。
// ImageBase64/ImageUrl和 Prompt必填其一且Prompt和ImageBase64/ImageUrl不能同时存在。
ImageUrl *string `json:"ImageUrl,omitnil,omitempty" name:"ImageUrl"`
// 多视角的模型图片,视角参考值:
// left左视图
// right右视图
// back后视图
//
// 每个视角仅限制一张图片。
// ●图片大小限制编码后大小不可超过8M。
// ●图片分辨率限制单边分辨率小于5000且大于128。
// ●支持图片格式支持jpg或png
MultiViewImages []*ViewImage `json:"MultiViewImages,omitnil,omitempty" name:"MultiViewImages"`
// 生成模型的格式,仅限制生成一种格式。
// 生成模型文件组默认返回obj格式。
// 可选值OBJGLBSTLUSDZFBXMP4。
ResultFormat *string `json:"ResultFormat,omitnil,omitempty" name:"ResultFormat"`
// 是否开启 PBR材质生成默认 false。
EnablePBR *bool `json:"EnablePBR,omitnil,omitempty" name:"EnablePBR"`
}
type SubmitHunyuanTo3DJobRequest struct {
*tchttp.BaseRequest
// 文生3D3D内容的描述中文正向提示词。
// 最多支持1024个 utf-8 字符。
// 文生3D, image、image_url和 prompt必填其一且prompt和image/image_url不能同时存在。
Prompt *string `json:"Prompt,omitnil,omitempty" name:"Prompt"`
// 输入图 Base64 数据。
// 大小单边分辨率要求不小于128不大于5000。大小不超过8mbase64编码后会大30%左右建议实际输入图片不超过6m
// 格式jpgpngjpegwebp。
// ImageBase64、ImageUrl和 Prompt必填其一且Prompt和ImageBase64/ImageUrl不能同时存在。
ImageBase64 *string `json:"ImageBase64,omitnil,omitempty" name:"ImageBase64"`
// 输入图Url。
// 大小单边分辨率要求不小于128不大于5000。大小不超过8mbase64编码后会大30%左右建议实际输入图片不超过6m
// 格式jpgpngjpegwebp。
// ImageBase64/ImageUrl和 Prompt必填其一且Prompt和ImageBase64/ImageUrl不能同时存在。
ImageUrl *string `json:"ImageUrl,omitnil,omitempty" name:"ImageUrl"`
// 多视角的模型图片,视角参考值:
// left左视图
// right右视图
// back后视图
//
// 每个视角仅限制一张图片。
// ●图片大小限制编码后大小不可超过8M。
// ●图片分辨率限制单边分辨率小于5000且大于128。
// ●支持图片格式支持jpg或png
MultiViewImages []*ViewImage `json:"MultiViewImages,omitnil,omitempty" name:"MultiViewImages"`
// 生成模型的格式,仅限制生成一种格式。
// 生成模型文件组默认返回obj格式。
// 可选值OBJGLBSTLUSDZFBXMP4。
ResultFormat *string `json:"ResultFormat,omitnil,omitempty" name:"ResultFormat"`
// 是否开启 PBR材质生成默认 false。
EnablePBR *bool `json:"EnablePBR,omitnil,omitempty" name:"EnablePBR"`
}
func (r *SubmitHunyuanTo3DJobRequest) ToJsonString() string {
b, _ := json.Marshal(r)
return string(b)
}
// FromJsonString It is highly **NOT** recommended to use this function
// because it has no param check, nor strict type check
func (r *SubmitHunyuanTo3DJobRequest) FromJsonString(s string) error {
f := make(map[string]interface{})
if err := json.Unmarshal([]byte(s), &f); err != nil {
return err
}
delete(f, "Prompt")
delete(f, "ImageBase64")
delete(f, "ImageUrl")
delete(f, "MultiViewImages")
delete(f, "ResultFormat")
delete(f, "EnablePBR")
if len(f) > 0 {
return tcerr.NewTencentCloudSDKError("ClientError.BuildRequestError", "SubmitHunyuanTo3DJobRequest has unknown keys!", "")
}
return json.Unmarshal([]byte(s), &r)
}
// Predefined struct for user
type SubmitHunyuanTo3DJobResponseParams struct {
// 任务ID有效期24小时
JobId *string `json:"JobId,omitnil,omitempty" name:"JobId"`
// 唯一请求 ID由服务端生成每次请求都会返回若请求因其他原因未能抵达服务端则该次请求不会获得 RequestId。定位问题时需要提供该次请求的 RequestId。
RequestId *string `json:"RequestId,omitnil,omitempty" name:"RequestId"`
}
type SubmitHunyuanTo3DJobResponse struct {
*tchttp.BaseResponse
Response *SubmitHunyuanTo3DJobResponseParams `json:"Response"`
}
func (r *SubmitHunyuanTo3DJobResponse) ToJsonString() string {
b, _ := json.Marshal(r)
return string(b)
}
// FromJsonString It is highly **NOT** recommended to use this function
// because it has no param check, nor strict type check
func (r *SubmitHunyuanTo3DJobResponse) FromJsonString(s string) error {
return json.Unmarshal([]byte(s), &r)
}
type ViewImage struct {
// 视角类型。
// 取值back、left、right
ViewType *string `json:"ViewType,omitnil,omitempty" name:"ViewType"`
// 图片Url地址
ViewImageUrl *string `json:"ViewImageUrl,omitnil,omitempty" name:"ViewImageUrl"`
}
```
## Gitee 图生 3D API 接口文档
1. 提交任务: https://ai.gitee.com/docs/openapi/v1#tag/3d-%E7%94%9F%E6%88%90/post/async/image-to-3d
2. 查询任务https://ai.gitee.com/docs/openapi/v1#tag/%E5%BC%82%E6%AD%A5%E4%BB%BB%E5%8A%A1/get/task/{task_id}/get
首先,你需要认真阅读上述接口文档,然后按照接口文档的示例代码实现腾讯云和 Gitee 的图生 3D API 接口,并且将接口集成到现有的系统中。
📋 功能概述
为现有的GeekAI-Plus系统添加3D图片生成功能集成腾讯云和Gitee的图生3D API接口包含完整的前后端功能和管理界面。
🗄️ 数据库设计
新增数据表geekai_3d_jobs
- id (uint): 主键
- type (string): API类型 (tencent/gitee)
- user_id (uint): 用户ID
- power (int): 消耗算力
- task_id (string): 第三方任务ID
- img_url (string): 生成的3D模型文件地址
- model (string): 使用的3D模型类型
- status (string): 任务状态
- err_msg (string): 错误信息
- params (JSON): 任务参数(包含输入图片、提示词等所有参数)
- created_at (int64): 创建时间
🔧 后端API实现
路由结构:/api/3d/*
- POST /api/3d/generate - 创建3D生成任务
- GET /api/3d/jobs - 获取任务列表
- GET /api/3d/job/{id} - 获取任务详情
- GET /api/3d/download/{id} - 下载3D模型
- DELETE /api/3d/job/{id} - 删除任务
核心服务:
- service/3d/tencent_client.go - 腾讯云3D API客户端
- service/3d/gitee_client.go - Gitee 3D API客户端
- service/3d/service.go - 3D生成服务统一接口
- handler/3d_handler.go - HTTP处理器
- store/vo/3d_job.go - 数据模型
🎨 前端界面设计
用户端页面:/3d - 3D生成主页面
- 参考JiMeng.vue的设计风格和布局
- 使用CustomTab组件分离平台参数
- Tab 1: "魔力方舟" (Gitee平台参数)
- Tab 2: "腾讯混元" (腾讯云平台参数)
- 每个Tab内包含
- 图片上传区域
- 模型选择下拉框
- 算力消耗实时显示
- 平台特定的参数配置
- 生成按钮
- 任务列表和状态显示
- 集成3D模型预览器 (three.js)
- 模型下载功能
移动端适配:
- mobile/3dCreate.vue - 移动端3D生成页面
- 保持Tab切换功能
- 响应式设计,触控优化
🛠️ 管理后台
管理功能:
- admin/3d/3dJobs.vue - 任务管理列表
- admin/3d/3dSetting.vue - API配置页面
- 模型配置管理:
- 分平台配置API秘钥和模型列表
- 设置每个模型的算力消耗值
- API密钥和端点配置
🔌 API集成方案
腾讯云集成:
- 使用官方Golang SDK
- 支持异步任务提交和状态查询
Gitee集成
- HTTP客户端实现
- 标准化响应处理
🎯 核心功能特性
- 平台切换通过CustomTab在魔力方舟和腾讯混元间切换
- 模型选择每个平台支持不同的3D模型
- 动态算力:切换模型时实时更新算力消耗显示
- 参数隔离:不同平台的参数配置完全分离
- 3D预览集成Three.js实现模型预览
- 统一体验保持与JiMeng.vue相似的交互风格
📱 用户体验
- JiMeng.vue风格的简洁界面
- Tab切换流畅的平台选择
- 模型选择时算力消耗实时更新
- 支持拖拽上传图片
- 实时任务状态显示
- 3D模型交互式预览
这个设计将创建一个与现有JiMeng功能风格一致的3D生成模块通过Tab分离实现平台参数的清晰管理。
整个实现严格按照现有系统的代码规范和架构模式,与 JiMeng 等模块保持一致的用户体验!

11
web/pnpm-lock.yaml generated
View File

@@ -458,56 +458,67 @@ packages:
resolution: {integrity: sha512-K4ncpWl7sQuyp6rWiGUvb6Q18ba8mzM0rjWJ5JgYKlIXAau1db7hZnR0ldJvqKWWJDxqzSLwGUhA4jp+KqgDtQ==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.46.1':
resolution: {integrity: sha512-YykPnXsjUjmXE6j6k2QBBGAn1YsJUix7pYaPLK3RVE0bQL2jfdbfykPxfF8AgBlqtYbfEnYHmLXNa6QETjdOjQ==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.46.1':
resolution: {integrity: sha512-kKvqBGbZ8i9pCGW3a1FH3HNIVg49dXXTsChGFsHGXQaVJPLA4f/O+XmTxfklhccxdF5FefUn2hvkoGJH0ScWOA==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.46.1':
resolution: {integrity: sha512-zzX5nTw1N1plmqC9RGC9vZHFuiM7ZP7oSWQGqpbmfjK7p947D518cVK1/MQudsBdcD84t6k70WNczJOct6+hdg==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.46.1':
resolution: {integrity: sha512-O8CwgSBo6ewPpktFfSDgB6SJN9XDcPSvuwxfejiddbIC/hn9Tg6Ai0f0eYDf3XvB/+PIWzOQL+7+TZoB8p9Yuw==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-gnu@4.46.1':
resolution: {integrity: sha512-JnCfFVEKeq6G3h3z8e60kAp8Rd7QVnWCtPm7cxx+5OtP80g/3nmPtfdCXbVl063e3KsRnGSKDHUQMydmzc/wBA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.46.1':
resolution: {integrity: sha512-dVxuDqS237eQXkbYzQQfdf/njgeNw6LZuVyEdUaWwRpKHhsLI+y4H/NJV8xJGU19vnOJCVwaBFgr936FHOnJsQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.46.1':
resolution: {integrity: sha512-CvvgNl2hrZrTR9jXK1ye0Go0HQRT6ohQdDfWR47/KFKiLd5oN5T14jRdUVGF4tnsN8y9oSfMOqH6RuHh+ck8+w==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.46.1':
resolution: {integrity: sha512-x7ANt2VOg2565oGHJ6rIuuAon+A8sfe1IeUx25IKqi49OjSr/K3awoNqr9gCwGEJo9OuXlOn+H2p1VJKx1psxA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.46.1':
resolution: {integrity: sha512-9OADZYryz/7E8/qt0vnaHQgmia2Y0wrjSSn1V/uL+zw/i7NUhxbX4cHXdEQ7dnJgzYDS81d8+tf6nbIdRFZQoQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.46.1':
resolution: {integrity: sha512-NuvSCbXEKY+NGWHyivzbjSVJi68Xfq1VnIvGmsuXs6TCtveeoDRKutI5vf2ntmNnVq64Q4zInet0UDQ+yMB6tA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.46.1':
resolution: {integrity: sha512-mWz+6FSRb82xuUMMV1X3NGiaPFqbLN9aIueHleTZCc46cJvwTlvIh7reQLk4p97dv0nddyewBhwzryBHH7wtPw==}

View File

@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1756631578371') format('woff2'),
url('iconfont.woff?t=1756631578371') format('woff'),
url('iconfont.ttf?t=1756631578371') format('truetype');
src: url('iconfont.woff2?t=1756786244728') format('woff2'),
url('iconfont.woff?t=1756786244728') format('woff'),
url('iconfont.ttf?t=1756786244728') format('truetype');
}
.iconfont {
@@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
.icon-cube:before {
content: "\e876";
}
.icon-tencent:before {
content: "\e655";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "34453337",
"name": "3D会场",
"font_class": "cube",
"unicode": "e876",
"unicode_decimal": 59510
},
{
"icon_id": "3547761",
"name": "tencent",

Binary file not shown.

View File

@@ -0,0 +1,547 @@
<template>
<div class="three-d-preview">
<div ref="container" class="preview-container"></div>
<!-- 控制面板 -->
<div class="control-panel">
<div class="control-group">
<label>旋转速度</label>
<el-slider
v-model="rotationSpeed"
:min="0"
:max="0.1"
:step="0.01"
@change="updateRotationSpeed"
/>
</div>
<div class="control-group">
<label>缩放</label>
<el-slider v-model="scale" :min="0.1" :max="3" :step="0.1" @change="updateScale" />
</div>
<div class="control-buttons">
<el-button size="small" @click="resetCamera">重置视角</el-button>
<el-button size="small" @click="toggleAutoRotate">
{{ autoRotate ? '停止旋转' : '自动旋转' }}
</el-button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-overlay">
<div class="loading-content">
<el-icon class="is-loading"><Loading /></el-icon>
<p>加载3D模型中...</p>
</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, ElSlider } from 'element-plus'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
import { onMounted, onUnmounted, ref, watch } from 'vue'
// Props
const props = defineProps({
modelUrl: {
type: String,
required: true,
},
modelType: {
type: String,
default: 'glb',
},
})
// 响应式数据
const container = ref(null)
const loading = ref(true)
const error = ref('')
const rotationSpeed = ref(0.02)
const scale = ref(1)
const autoRotate = ref(true)
// Three.js 相关变量
let scene, camera, renderer, controls, model, mixer, clock
let animationId
// 初始化Three.js场景
const initThreeJS = () => {
if (!container.value) return
// 创建场景
scene = new THREE.Scene()
scene.background = new THREE.Color(0xf0f0f0)
// 创建相机
const containerRect = container.value.getBoundingClientRect()
camera = new THREE.PerspectiveCamera(75, containerRect.width / containerRect.height, 0.1, 1000)
camera.position.set(0, 0, 5)
// 创建渲染器
renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(containerRect.width, containerRect.height)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
// 添加到容器
container.value.appendChild(renderer.domElement)
// 创建控制器
controls = new OrbitControls(camera, renderer.domElement)
controls.enableDamping = true
controls.dampingFactor = 0.05
controls.autoRotate = autoRotate.value
controls.autoRotateSpeed = rotationSpeed.value
// 添加光源
addLights()
// 添加地面
addGround()
// 创建时钟
clock = new THREE.Clock()
// 开始渲染循环
animate()
// 监听窗口大小变化
window.addEventListener('resize', onWindowResize)
}
// 添加光源
const addLights = () => {
// 环境光
const ambientLight = new THREE.AmbientLight(0x404040, 0.6)
scene.add(ambientLight)
// 方向光
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
directionalLight.position.set(10, 10, 5)
directionalLight.castShadow = true
directionalLight.shadow.mapSize.width = 2048
directionalLight.shadow.mapSize.height = 2048
scene.add(directionalLight)
// 点光源
const pointLight = new THREE.PointLight(0xffffff, 0.5)
pointLight.position.set(-10, 10, -5)
scene.add(pointLight)
}
// 添加地面
const addGround = () => {
const groundGeometry = new THREE.PlaneGeometry(20, 20)
const groundMaterial = new THREE.MeshLambertMaterial({
color: 0xcccccc,
transparent: true,
opacity: 0.3,
})
const ground = new THREE.Mesh(groundGeometry, groundMaterial)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
}
// 加载3D模型
const loadModel = async () => {
if (!props.modelUrl) return
try {
loading.value = true
error.value = ''
// 清除现有模型
if (model) {
scene.remove(model)
model = null
}
let loadedModel
switch (props.modelType.toLowerCase()) {
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(`不支持的模型格式: ${props.modelType}`)
}
if (loadedModel) {
model = loadedModel
scene.add(model)
// 调整模型位置和大小
centerModel()
fitCameraToModel()
// 设置阴影
model.traverse((child) => {
if (child.isMesh) {
child.castShadow = true
child.receiveShadow = true
}
})
}
loading.value = false
} catch (err) {
console.error('加载3D模型失败:', err)
error.value = `加载模型失败: ${err.message}`
loading.value = false
}
}
// 加载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)
},
undefined,
reject
)
})
}
// 加载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 centerModel = () => {
if (!model) return
const box = new THREE.Box3().setFromObject(model)
const center = box.getCenter(new THREE.Vector3())
const size = box.getSize(new THREE.Vector3())
// 居中
model.position.sub(center)
// 调整缩放
const maxDim = Math.max(size.x, size.y, size.z)
const scale = 2 / maxDim
model.scale.setScalar(scale * props.scale)
}
// 调整相机以适应模型
const fitCameraToModel = () => {
if (!model) return
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const center = box.getCenter(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const fov = camera.fov * (Math.PI / 180)
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2))
camera.position.set(center.x, center.y, center.z + cameraZ)
camera.lookAt(center)
controls.target.copy(center)
controls.update()
}
// 更新旋转速度
const updateRotationSpeed = (value) => {
if (controls) {
controls.autoRotateSpeed = value
}
}
// 更新缩放
const updateScale = (value) => {
if (model) {
const box = new THREE.Box3().setFromObject(model)
const size = box.getSize(new THREE.Vector3())
const maxDim = Math.max(size.x, size.y, size.z)
const baseScale = 2 / maxDim
model.scale.setScalar(baseScale * value)
}
}
// 重置相机
const resetCamera = () => {
if (camera && model) {
fitCameraToModel()
}
}
// 切换自动旋转
const toggleAutoRotate = () => {
autoRotate.value = !autoRotate.value
if (controls) {
controls.autoRotate = autoRotate.value
}
}
// 重试加载
const retryLoad = () => {
loadModel()
}
// 窗口大小变化处理
const onWindowResize = () => {
if (!container.value || !camera || !renderer) return
const containerRect = container.value.getBoundingClientRect()
camera.aspect = containerRect.width / containerRect.height
camera.updateProjectionMatrix()
renderer.setSize(containerRect.width, containerRect.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(() => {
initThreeJS()
if (props.modelUrl) {
loadModel()
}
})
onUnmounted(() => {
cleanup()
})
</script>
<style lang="scss" scoped>
.three-d-preview {
position: relative;
width: 100%;
height: 100%;
}
.preview-container {
width: 100%;
height: 100%;
position: relative;
}
.control-panel {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.9);
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
.control-group {
margin-bottom: 16px;
label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
font-weight: 500;
}
}
.control-buttons {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
.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;
}
}
.error-content {
.el-icon {
color: #f56c6c;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// 响应式设计
@media (max-width: 768px) {
.control-panel {
position: relative;
top: auto;
right: auto;
margin: 16px;
border-radius: 8px;
}
}
</style>

View File

@@ -179,22 +179,39 @@ 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',
index: '/admin/config/moderation',
title: '文本审查',
subs: [
{
icon: 'config',
index: '/admin/config/moderation',
title: '审查配置',
},
{
icon: 'list',
index: '/admin/moderation/list',
title: '审核记录',
},
{
icon: 'config',
index: '/admin/moderation/config',
title: '审查配置',
},
],
},
{

View File

@@ -92,6 +92,12 @@ const routes = [
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: 'ai3d',
path: '/ai3d',
meta: { title: 'AI3D模型生成' },
component: () => import('@/views/AIThreeDCreate.vue'),
},
{
name: 'ExternalLink',
path: '/external',
@@ -148,12 +154,7 @@ const routes = [
meta: { title: '控制台登录' },
component: () => import('@/views/admin/Login.vue'),
},
{
path: '/payReturn',
name: 'pay-return',
meta: { title: '支付回调' },
component: () => import('@/views/PayReturn.vue'),
},
{
name: 'admin',
path: '/admin',
@@ -210,7 +211,7 @@ const routes = [
component: () => import('@/views/admin/settings/PluginConfig.vue'),
},
{
path: '/admin/config/moderation',
path: '/admin/moderation/config',
name: 'admin-config-moderation',
meta: { title: '文本审查配置' },
component: () => import('@/views/admin/moderation/ModerationConfig.vue'),
@@ -345,7 +346,19 @@ const routes = [
path: '/admin/jimeng/config',
name: 'admin-jimeng-config',
meta: { title: '即梦设置' },
component: () => import('@/views/admin/jimeng/JimengSetting.vue'),
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',
@@ -459,6 +472,12 @@ const routes = [
name: 'mobile-jimeng',
component: () => import('@/views/mobile/JimengCreate.vue'),
},
{
path: '/mobile/3d',
name: 'mobile-3d',
meta: { title: '3D模型生成' },
component: () => import('@/views/mobile/ThreeDCreate.vue'),
},
],
},

View File

@@ -0,0 +1,619 @@
<template>
<div class="page-threed">
<!-- 左侧参数设置面板 -->
<div class="params-panel">
<!-- 平台选择Tab -->
<div class="platform-tabs">
<CustomTabs v-model="activePlatform" @change="handlePlatformChange">
<CustomTabPane label="魔力方舟" name="gitee">
<div class="platform-info">
<i class="iconfont icon-gitee"></i>
<span>Gitee AI 3D生成</span>
</div>
</CustomTabPane>
<CustomTabPane label="腾讯混元" name="tencent">
<div class="platform-info">
<i class="iconfont icon-tencent"></i>
<span>腾讯云混元3D生成</span>
</div>
</CustomTabPane>
</CustomTabs>
</div>
<!-- 参数容器 -->
<div class="params-container">
<!-- 图片上传区域 -->
<div class="param-line pt">
<span class="label">上传图片</span>
</div>
<div class="param-line">
<ImageUpload
v-model="currentImage"
:max-count="1"
:multiple="false"
@change="handleImageChange"
/>
</div>
<!-- 文本提示词 -->
<div class="param-line pt">
<span class="label">提示词</span>
</div>
<div class="param-line">
<el-input
v-model="currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入3D模型描述越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
<!-- 模型选择 -->
<div class="param-line pt">
<span class="label">输出格式</span>
</div>
<div class="param-line">
<el-select v-model="selectedModel" placeholder="选择输出格式" @change="handleModelChange">
<el-option
v-for="(model, key) in availableModels"
:key="key"
:label="model.name"
:value="key"
/>
</el-select>
</div>
<!-- 算力消耗显示 -->
<div class="param-line pt">
<span class="label">算力消耗</span>
</div>
<div class="power-display">
<span class="power-value">{{ currentPower }}</span>
<span class="power-unit"></span>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<el-button
type="primary"
size="large"
:loading="generating"
:disabled="!canGenerate"
@click="generate3D"
class="generate-btn"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
</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-item"
:class="{ completed: task.status === 'completed' }"
>
<div class="task-header">
<span class="task-id">#{{ task.id }}</span>
<span class="task-status" :class="task.status">
{{ getStatusText(task.status) }}
</span>
</div>
<div class="task-content">
<div class="task-prompt">
{{ task.params ? getPromptFromParams(task.params) : '' }}
</div>
<div class="task-progress" v-if="task.status === 'processing'">
<el-progress :percentage="task.progress" :stroke-width="4" />
</div>
</div>
<div class="task-actions" v-if="task.status === 'completed'">
<el-button size="small" @click="preview3D(task)">预览</el-button>
<el-button size="small" type="primary" @click="download3D(task)">下载</el-button>
</div>
<div class="task-actions" v-else>
<el-button size="small" @click="deleteTask(task.id)">删除</el-button>
</div>
</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="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
<!-- 3D预览弹窗 -->
<el-dialog v-model="previewVisible" title="3D模型预览" width="80%" :before-close="closePreview">
<div class="preview-container">
<ThreeDPreview
v-if="currentPreviewTask && currentPreviewTask.img_url"
:model-url="currentPreviewTask.img_url"
:model-type="currentPreviewTask.model"
/>
<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">下载模型</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ImageUpload from '@/components/ImageUpload.vue'
import ThreeDPreview from '@/components/ThreeDPreview.vue'
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
import CustomTabs from '@/components/ui/CustomTabs.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
// 响应式数据
const activePlatform = ref('gitee')
const currentImage = ref([])
const currentPrompt = ref('')
const selectedModel = ref('obj')
const generating = ref(false)
const previewVisible = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskList = ref([])
const currentPreviewTask = ref(null)
// 平台配置
const platformConfig = {
gitee: {
name: '魔力方舟',
models: {
obj: { name: 'OBJ格式', power: 45 },
glb: { name: 'GLB格式', power: 55 },
stl: { name: 'STL格式', power: 35 },
usdz: { name: 'USDZ格式', power: 65 },
fbx: { name: 'FBX格式', power: 75 },
mp4: { name: 'MP4格式', power: 85 },
},
},
tencent: {
name: '腾讯混元',
models: {
obj: { name: 'OBJ格式', power: 50 },
glb: { name: 'GLB格式', power: 60 },
stl: { name: 'STL格式', power: 40 },
usdz: { name: 'USDZ格式', power: 70 },
fbx: { name: 'FBX格式', power: 80 },
mp4: { name: 'MP4格式', power: 90 },
},
},
}
// 计算属性
const availableModels = computed(() => {
return platformConfig[activePlatform.value]?.models || {}
})
const currentPower = computed(() => {
return availableModels.value[selectedModel.value]?.power || 0
})
const canGenerate = computed(() => {
return currentPrompt.value.trim() && currentImage.value.length > 0 && selectedModel.value
})
// 方法
const handlePlatformChange = (platform) => {
// 切换平台时重置模型选择
if (!availableModels.value[selectedModel.value]) {
selectedModel.value = Object.keys(availableModels.value)[0]
}
}
const handleImageChange = (files) => {
currentImage.value = files
}
const handleModelChange = () => {
// 模型改变时的处理逻辑
}
const generate3D = async () => {
if (!canGenerate.value) {
ElMessage.warning('请完善生成参数')
return
}
try {
generating.value = true
const requestData = {
type: activePlatform.value,
model: selectedModel.value,
prompt: currentPrompt.value,
image_url: currentImage.value[0]?.url || '',
power: currentPower.value,
}
const response = await httpPost('/api/3d/generate', requestData)
if (response.code === 0) {
ElMessage.success('任务创建成功')
// 清空表单
currentImage.value = []
currentPrompt.value = ''
// 刷新任务列表
loadTasks()
} else {
ElMessage.error(response.message || '创建任务失败')
}
} catch (error) {
ElMessage.error('创建任务失败:' + error.message)
} finally {
generating.value = false
}
}
const loadTasks = async () => {
try {
const response = await httpGet('/api/3d/jobs', {
page: currentPage.value,
page_size: pageSize.value,
})
if (response.code === 0) {
taskList.value = response.data.list
total.value = response.data.total
}
} catch (error) {
ElMessage.error('加载任务列表失败:' + error.message)
}
}
const refreshTasks = () => {
loadTasks()
}
const handleSizeChange = (size) => {
pageSize.value = size
currentPage.value = 1
loadTasks()
}
const handleCurrentChange = (page) => {
currentPage.value = page
loadTasks()
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/3d/job/${taskId}/delete`)
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
currentPreviewTask.value = null
}
const download3D = async (task) => {
if (!task.img_url) {
ElMessage.warning('模型文件不存在')
return
}
try {
// 创建一个隐藏的a标签来下载文件
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败,请重试')
}
}
const downloadCurrentModel = () => {
if (currentPreviewTask.value) {
download3D(currentPreviewTask.value)
}
}
const getStatusText = (status) => {
const statusMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
const getPromptFromParams = (paramsStr) => {
try {
const params = JSON.parse(paramsStr)
return params.prompt || ''
} catch {
return ''
}
}
// 生命周期
onMounted(() => {
loadTasks()
})
</script>
<style lang="scss" scoped>
.page-threed {
display: flex;
height: 100vh;
background: #f5f5f5;
}
.params-panel {
width: 400px;
background: white;
border-right: 1px solid #e4e7ed;
padding: 20px;
overflow-y: auto;
}
.platform-tabs {
margin-bottom: 20px;
}
.platform-info {
display: flex;
align-items: center;
gap: 8px;
i {
font-size: 18px;
color: #409eff;
}
}
.params-container {
.param-line {
margin-bottom: 16px;
&.pt {
margin-top: 20px;
}
.label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #333;
}
}
}
.power-display {
display: flex;
align-items: center;
gap: 8px;
.power-value {
font-size: 24px;
font-weight: bold;
color: #409eff;
}
.power-unit {
color: #666;
}
}
.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: 20px;
h3 {
margin: 0;
color: #333;
}
}
}
.task-items {
.task-item {
background: white;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
border: 1px solid #e4e7ed;
&.completed {
border-color: #67c23a;
background: #f0f9ff;
}
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.task-id {
font-weight: 500;
color: #666;
}
.task-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.pending {
background: #fdf6ec;
color: #e6a23c;
}
&.processing {
background: #ecf5ff;
color: #409eff;
}
&.completed {
background: #f0f9ff;
color: #67c23a;
}
&.failed {
background: #fef0f0;
color: #f56c6c;
}
}
}
.task-content {
margin-bottom: 12px;
.task-prompt {
color: #666;
margin-bottom: 8px;
line-height: 1.4;
}
}
.task-actions {
display: flex;
gap: 8px;
}
}
}
.pagination {
display: flex;
justify-content: center;
margin-top: 20px;
}
.preview-container {
.three-container {
width: 100%;
height: 500px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #666;
}
.preview-placeholder {
width: 100%;
height: 500px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #666;
i {
font-size: 48px;
margin-bottom: 12px;
color: #999;
}
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 #e4e7ed;
}
}
</style>

View File

@@ -1,18 +0,0 @@
<template>
<div>
支付回调
</div>
</template>
<script setup>
import {useRouter} from "vue-router";
import {isMobile} from "@/utils/libs";
const router = useRouter()
console.log(router.currentRoute.value.query)
if (isMobile()) {
router.push('/mobile/profile')
} else {
window.close()
}
</script>

View File

@@ -0,0 +1,405 @@
<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"
show-password
/>
</el-form-item>
<el-form-item label="SecretKey">
<el-input
v-model="configs.tencent.secret_key"
placeholder="请输入腾讯云SecretKey"
show-password
/>
</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密钥"
show-password
/>
</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>

View File

@@ -0,0 +1,551 @@
<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="completed" />
<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.completed }}</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" stripe border style="width: 100%">
<el-table-column prop="user_id" label="用户ID" />
<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 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 }">
{{ formatTime(row.created_at) }}
</template>
</el-table-column>
<el-table-column prop="updated_at" label="更新时间">
<template #default="{ row }">
{{ formatTime(row.updated_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="viewTask(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="创建时间">{{
formatTime(currentTask.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
formatTime(currentTask.updated_at)
}}</el-descriptions-item>
</el-descriptions>
<div class="task-params">
<h4>任务参数</h4>
<el-input v-model="taskParamsDisplay" type="textarea" :rows="6" readonly />
</div>
<div v-if="currentTask.img_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.preview_url" @click="viewPreview(currentTask.preview_url)">
查看预览
</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>
<!-- 预览图片弹窗 -->
<el-dialog v-model="previewVisible" title="预览图片" width="50%">
<div class="preview-container">
<el-image :src="previewUrl" fit="contain" style="width: 100%; height: 400px" />
</div>
</el-dialog>
</div>
</template>
<script setup>
import { httpGet } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, 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 previewVisible = ref(false)
const currentTask = ref(null)
const previewUrl = ref('')
// 搜索表单
const searchForm = reactive({
status: '',
type: '',
userId: '',
})
// 统计数据
const stats = reactive({
pending: 0,
processing: 0,
completed: 0,
failed: 0,
})
// 计算属性
const taskParamsDisplay = computed(() => {
if (!currentTask.value?.params) return '无参数'
try {
const params = JSON.parse(currentTask.value.params)
return JSON.stringify(params, null, 2)
} catch {
return currentTask.value.params
}
})
// 方法
const loadData = async () => {
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.list
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 = (task) => {
if (task.img_url) {
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} else {
ElMessage.warning('模型文件不存在')
}
}
const viewPreview = (url) => {
previewUrl.value = url
previewVisible.value = true
}
const getStatusType = (status) => {
const typeMap = {
pending: 'warning',
processing: 'primary',
completed: 'success',
failed: 'danger',
}
return typeMap[status] || 'info'
}
const getStatusText = (status) => {
const textMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return textMap[status] || status
}
const getProgressStatus = (status) => {
if (status === 'failed') return 'exception'
if (status === 'completed') return 'success'
return ''
}
const formatTime = (timestamp) => {
if (!timestamp) return '-'
const date = new Date(timestamp * 1000)
return date.toLocaleString()
}
// 生命周期
onMounted(() => {
loadData()
loadStats()
})
</script>
<style lang="scss" scoped>
.admin-threed-jobs {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
h2 {
margin: 0;
color: #333;
}
}
.search-section {
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
.el-form-item {
margin-bottom: 0;
.el-select__wrapper {
height: 36px;
line-height: 36px;
}
}
}
.stats-section {
margin-bottom: 20px;
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 16px;
.stat-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: white;
}
&.pending {
background: #e6a23c;
}
&.processing {
background: #409eff;
}
&.completed {
background: #67c23a;
}
&.failed {
background: #f56c6c;
}
}
.stat-content {
.stat-number {
font-size: 24px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #666;
}
}
}
}
.table-section {
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.pagination-section {
padding: 20px;
text-align: center;
}
.task-detail {
.task-params,
.task-result,
.task-error {
margin-top: 20px;
h4 {
margin: 0 0 12px 0;
color: #333;
font-size: 16px;
}
}
.result-links {
display: flex;
gap: 12px;
}
}
.preview-container {
text-align: center;
}
</style>

View File

@@ -241,7 +241,7 @@ onMounted(() => {
//
const loadConfig = async () => {
try {
const res = await httpGet('/api/admin/jimeng/config')
const res = await httpGet('/api/admin/config/get?key=jimeng')
jimengConfig.value = res.data
} catch (e) {
ElMessage.error('加载配置失败: ' + e.message)

View File

@@ -265,7 +265,7 @@ watch(activeTab, (newTab) => {
const saveModerationConfig = async () => {
loading.value = true
try {
await httpPost('/api/admin/config/update/moderation', configs.value)
await httpPost('/api/admin/moderation/config', configs.value)
ElMessage.success('保存成功')
} catch (e) {
ElMessage.error('保存失败:' + (e.message || '未知错误'))
@@ -289,7 +289,7 @@ const testModeration = async () => {
testLoading.value = true
try {
const res = await httpPost('/api/admin/config/moderation/test', {
const res = await httpPost('/api/admin/moderation/test', {
text: testForm.value.text.trim(),
service: configs.value.active,
})

View File

@@ -167,6 +167,13 @@ const features = ref([
color: '#F97316',
url: '/mobile/jimeng',
},
{
key: '3d',
name: '3D生成',
icon: 'icon-3d',
color: '#8B5CF6',
url: '/mobile/3d',
},
{ key: 'agent', name: '智能体', icon: 'icon-app', color: '#3B82F6', url: '/mobile/apps' },
{
key: 'imgWall',

View File

@@ -0,0 +1,765 @@
<template>
<div class="mobile-threed-create">
<!-- 顶部导航 -->
<div class="top-nav">
<div class="nav-left" @click="$router.go(-1)">
<i class="iconfont icon-arrow-left"></i>
</div>
<div class="nav-title">3D模型生成</div>
<div class="nav-right"></div>
</div>
<!-- 平台选择 -->
<div class="platform-selector">
<div class="selector-tabs">
<div
v-for="platform in platforms"
:key="platform.key"
:class="['selector-tab', { active: activePlatform === platform.key }]"
@click="activePlatform = platform.key"
>
<div class="tab-icon">
<i :class="platform.icon"></i>
</div>
<div class="tab-name">{{ platform.name }}</div>
</div>
</div>
</div>
<!-- 参数设置 -->
<div class="params-section">
<!-- 图片上传 -->
<div class="param-group">
<div class="param-label">上传图片</div>
<div class="image-upload-area">
<ImageUpload
v-model="currentImage"
:max-count="1"
:multiple="false"
@change="handleImageChange"
/>
</div>
</div>
<!-- 提示词输入 -->
<div class="param-group">
<div class="param-label">提示词描述</div>
<div class="prompt-input">
<el-input
v-model="currentPrompt"
type="textarea"
:rows="4"
placeholder="请输入3D模型描述越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
</div>
<!-- 模型选择 -->
<div class="param-group">
<div class="param-label">输出格式</div>
<div class="model-selector">
<div
v-for="(model, key) in availableModels"
:key="key"
:class="['model-option', { active: selectedModel === key }]"
@click="selectedModel = key"
>
<div class="model-name">{{ model.name }}</div>
<div class="model-power">{{ model.power }}</div>
</div>
</div>
</div>
<!-- 算力消耗 -->
<div class="power-info">
<div class="power-label">算力消耗</div>
<div class="power-value">{{ currentPower }} </div>
</div>
</div>
<!-- 生成按钮 -->
<div class="generate-section">
<el-button
type="primary"
size="large"
:loading="generating"
:disabled="!canGenerate"
@click="generate3D"
class="generate-btn"
>
{{ generating ? '生成中...' : '开始生成' }}
</el-button>
</div>
<!-- 任务列表 -->
<div class="task-section">
<div class="section-header">
<h3>生成任务</h3>
<el-button size="small" @click="refreshTasks">刷新</el-button>
</div>
<div class="task-list">
<div
v-for="task in taskList"
:key="task.id"
class="task-item"
:class="{ completed: task.status === 'completed' }"
>
<div class="task-main">
<div class="task-info">
<div class="task-id">#{{ task.id }}</div>
<div class="task-status" :class="task.status">
{{ getStatusText(task.status) }}
</div>
</div>
<div class="task-prompt">
{{ task.params ? getPromptFromParams(task.params) : '' }}
</div>
<div class="task-progress" v-if="task.status === 'processing'">
<el-progress :percentage="task.progress" :stroke-width="6" />
</div>
</div>
<div class="task-actions">
<template v-if="task.status === 'completed'">
<el-button size="small" @click="preview3D(task)">预览</el-button>
<el-button size="small" type="primary" @click="download3D(task)">下载</el-button>
</template>
<template v-else>
<el-button size="small" @click="deleteTask(task.id)">删除</el-button>
</template>
</div>
</div>
</div>
<!-- 加载更多 -->
<div class="load-more" v-if="hasMore">
<el-button size="small" @click="loadMoreTasks">加载更多</el-button>
</div>
</div>
<!-- 3D预览弹窗 -->
<el-dialog
v-model="previewVisible"
title="3D模型预览"
width="90%"
:before-close="closePreview"
class="mobile-dialog"
>
<div class="preview-container">
<div id="three-container" class="three-container">
<div class="preview-placeholder">
<i class="iconfont icon-3d"></i>
<p>3D模型预览</p>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="closePreview">关闭</el-button>
<el-button type="primary" @click="downloadCurrentModel">下载模型</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import ImageUpload from '@/components/ImageUpload.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, nextTick, onMounted, ref } from 'vue'
// 响应式数据
const activePlatform = ref('gitee')
const currentImage = ref([])
const currentPrompt = ref('')
const selectedModel = ref('obj')
const generating = ref(false)
const previewVisible = ref(false)
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskList = ref([])
const currentPreviewTask = ref(null)
const hasMore = ref(true)
// 平台配置
const platforms = [
{
key: 'gitee',
name: '魔力方舟',
icon: 'icon-gitee',
},
{
key: 'tencent',
name: '腾讯混元',
icon: 'icon-tencent',
},
]
const platformConfig = {
gitee: {
name: '魔力方舟',
models: {
obj: { name: 'OBJ格式', power: 45 },
glb: { name: 'GLB格式', power: 55 },
stl: { name: 'STL格式', power: 35 },
usdz: { name: 'USDZ格式', power: 65 },
fbx: { name: 'FBX格式', power: 75 },
mp4: { name: 'MP4格式', power: 85 },
},
},
tencent: {
name: '腾讯混元',
models: {
obj: { name: 'OBJ格式', power: 50 },
glb: { name: 'GLB格式', power: 60 },
stl: { name: 'STL格式', power: 40 },
usdz: { name: 'USDZ格式', power: 70 },
fbx: { name: 'FBX格式', power: 80 },
mp4: { name: 'MP4格式', power: 90 },
},
},
}
// 计算属性
const availableModels = computed(() => {
return platformConfig[activePlatform.value]?.models || {}
})
const currentPower = computed(() => {
return availableModels.value[selectedModel.value]?.power || 0
})
const canGenerate = computed(() => {
return currentPrompt.value.trim() && currentImage.value.length > 0 && selectedModel.value
})
// 方法
const handleImageChange = (files) => {
currentImage.value = files
}
const generate3D = async () => {
if (!canGenerate.value) {
ElMessage.warning('请完善生成参数')
return
}
try {
generating.value = true
const requestData = {
type: activePlatform.value,
model: selectedModel.value,
prompt: currentPrompt.value,
image_url: currentImage.value[0]?.url || '',
power: currentPower.value,
}
const response = await httpPost('/api/3d/generate', requestData)
if (response.code === 0) {
ElMessage.success('任务创建成功')
// 清空表单
currentImage.value = []
currentPrompt.value = ''
// 刷新任务列表
loadTasks(true)
} else {
ElMessage.error(response.message || '创建任务失败')
}
} catch (error) {
ElMessage.error('创建任务失败:' + error.message)
} finally {
generating.value = false
}
}
const loadTasks = async (reset = false) => {
try {
if (reset) {
currentPage.value = 1
taskList.value = []
}
const response = await httpGet('/api/3d/jobs', {
page: currentPage.value,
page_size: pageSize.value,
})
if (response.code === 0) {
if (reset) {
taskList.value = response.data.list
} else {
taskList.value.push(...response.data.list)
}
total.value = response.data.total
hasMore.value = taskList.value.length < total.value
}
} catch (error) {
ElMessage.error('加载任务列表失败:' + error.message)
}
}
const refreshTasks = () => {
loadTasks(true)
}
const loadMoreTasks = () => {
if (hasMore.value) {
currentPage.value++
loadTasks()
}
}
const deleteTask = async (taskId) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet(`/api/3d/job/${taskId}/delete`)
if (response.code === 0) {
ElMessage.success('删除成功')
loadTasks(true)
} else {
ElMessage.error(response.message || '删除失败')
}
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败:' + error.message)
}
}
}
const preview3D = (task) => {
currentPreviewTask.value = task
previewVisible.value = true
nextTick(() => {
initThreeJS(task)
})
}
const closePreview = () => {
previewVisible.value = false
currentPreviewTask.value = null
}
const download3D = async (task) => {
if (!task.img_url) {
ElMessage.warning('模型文件不存在')
return
}
try {
// 创建一个隐藏的a标签来下载文件
const link = document.createElement('a')
link.href = task.img_url
link.download = `3d_model_${task.id}.${task.model}`
link.style.display = 'none'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
ElMessage.success('开始下载3D模型')
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败,请重试')
}
}
const downloadCurrentModel = () => {
if (currentPreviewTask.value) {
download3D(currentPreviewTask.value)
}
}
const getStatusText = (status) => {
const statusMap = {
pending: '等待中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
const getPromptFromParams = (paramsStr) => {
try {
const params = JSON.parse(paramsStr)
return params.prompt || ''
} catch {
return ''
}
}
// Three.js 初始化
const initThreeJS = (task) => {
// TODO: 实现Three.js 3D模型预览
console.log('初始化Three.js预览:', task)
}
// 生命周期
onMounted(() => {
loadTasks(true)
})
</script>
<style lang="scss" scoped>
.mobile-threed-create {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 20px;
}
.top-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: white;
border-bottom: 1px solid #e4e7ed;
position: sticky;
top: 0;
z-index: 100;
.nav-left {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
i {
font-size: 20px;
color: #333;
}
}
.nav-title {
font-size: 18px;
font-weight: 500;
color: #333;
}
.nav-right {
width: 24px;
}
}
.platform-selector {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.selector-tabs {
display: flex;
gap: 12px;
.selector-tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
border-radius: 8px;
border: 2px solid #e4e7ed;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: #409eff;
background: #ecf5ff;
}
.tab-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
i {
font-size: 24px;
color: #409eff;
}
}
.tab-name {
font-size: 14px;
color: #333;
text-align: center;
}
}
}
}
.params-section {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.param-group {
margin-bottom: 24px;
.param-label {
font-size: 16px;
font-weight: 500;
color: #333;
margin-bottom: 12px;
}
.image-upload-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 20px;
text-align: center;
transition: border-color 0.3s;
&:hover {
border-color: #409eff;
}
}
.prompt-input {
.el-textarea {
.el-textarea__inner {
border-radius: 8px;
border: 1px solid #d9d9d9;
}
}
}
.model-selector {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.model-option {
padding: 16px 12px;
border: 2px solid #e4e7ed;
border-radius: 8px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
&.active {
border-color: #409eff;
background: #ecf5ff;
}
.model-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.model-power {
font-size: 12px;
color: #409eff;
font-weight: 500;
}
}
}
}
}
.power-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f0f9ff;
border-radius: 8px;
border: 1px solid #b3d8ff;
.power-label {
font-size: 16px;
color: #333;
}
.power-value {
font-size: 20px;
font-weight: bold;
color: #409eff;
}
}
.generate-section {
margin: 16px 20px;
.generate-btn {
width: 100%;
height: 48px;
font-size: 16px;
border-radius: 12px;
}
}
.task-section {
background: white;
margin: 16px 20px;
border-radius: 12px;
padding: 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h3 {
margin: 0;
font-size: 18px;
color: #333;
}
}
}
.task-list {
.task-item {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
&.completed {
border-color: #67c23a;
background: #f0f9ff;
}
.task-main {
margin-bottom: 16px;
.task-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.task-id {
font-weight: 500;
color: #666;
}
.task-status {
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
&.pending {
background: #fdf6ec;
color: #e6a23c;
}
&.processing {
background: #ecf5ff;
color: #409eff;
}
&.completed {
background: #f0f9ff;
color: #67c23a;
}
&.failed {
background: #fef0f0;
color: #f56c6c;
}
}
}
.task-prompt {
color: #666;
margin-bottom: 12px;
line-height: 1.4;
font-size: 14px;
}
}
.task-actions {
display: flex;
gap: 8px;
.el-button {
flex: 1;
}
}
}
}
.load-more {
text-align: center;
margin-top: 20px;
}
.preview-container {
.three-container {
width: 100%;
height: 300px;
background: #f0f0f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
.preview-placeholder {
text-align: center;
color: #666;
i {
font-size: 48px;
margin-bottom: 12px;
display: block;
}
p {
margin: 0;
font-size: 14px;
}
}
}
}
// 移动端弹窗样式
.mobile-dialog {
:deep(.el-dialog) {
margin: 20px;
border-radius: 12px;
}
:deep(.el-dialog__header) {
padding: 20px 20px 0;
}
:deep(.el-dialog__body) {
padding: 20px;
}
:deep(.el-dialog__footer) {
padding: 0 20px 20px;
.dialog-footer {
display: flex;
gap: 12px;
.el-button {
flex: 1;
}
}
}
}
</style>