mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-05-09 11:14:57 +08:00
AI3D 页面功能完成
This commit is contained in:
@@ -22,6 +22,13 @@ type Gitee3DConfig struct {
|
|||||||
Models []AI3DModel `json:"models,omitempty"`
|
Models []AI3DModel `json:"models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AI3DTaskType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AI3DTaskTypeTencent AI3DTaskType = "tencent"
|
||||||
|
AI3DTaskTypeGitee AI3DTaskType = "gitee"
|
||||||
|
)
|
||||||
|
|
||||||
// AI3DJobResult 3D任务结果
|
// AI3DJobResult 3D任务结果
|
||||||
type AI3DJobResult struct {
|
type AI3DJobResult struct {
|
||||||
JobId string `json:"job_id"` // 任务ID
|
JobId string `json:"job_id"` // 任务ID
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ func NewUploadHandler(app *core.AppServer, db *gorm.DB, manager *oss.UploaderMan
|
|||||||
|
|
||||||
// RegisterRoutes 注册路由
|
// RegisterRoutes 注册路由
|
||||||
func (h *UploadHandler) RegisterRoutes() {
|
func (h *UploadHandler) RegisterRoutes() {
|
||||||
group := h.App.Engine.Group("/api/admin/upload/")
|
group := h.App.Engine.Group("/api/admin/upload")
|
||||||
|
|
||||||
// 需要管理员授权的接口
|
// 需要管理员授权的接口
|
||||||
group.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
group.Use(middleware.AdminAuthMiddleware(h.App.Config.AdminSession.SecretKey, h.App.Redis))
|
||||||
{
|
{
|
||||||
group.POST("upload", h.Upload)
|
group.POST("", h.Upload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,42 +46,61 @@ func (h *AI3DHandler) RegisterRoutes() {
|
|||||||
{
|
{
|
||||||
group.POST("generate", h.Generate)
|
group.POST("generate", h.Generate)
|
||||||
group.GET("jobs", h.JobList)
|
group.GET("jobs", h.JobList)
|
||||||
|
group.GET("jobs/mock", h.ListMock) // 演示数据接口
|
||||||
group.GET("job/:id", h.JobDetail)
|
group.GET("job/:id", h.JobDetail)
|
||||||
group.DELETE("job/:id", h.DeleteJob)
|
group.GET("job/delete", h.DeleteJob)
|
||||||
group.GET("download/:id", h.Download)
|
group.GET("download/:id", h.Download)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate 创建3D生成任务
|
// Generate 创建3D生成任务
|
||||||
func (h *AI3DHandler) Generate(c *gin.Context) {
|
func (h *AI3DHandler) Generate(c *gin.Context) {
|
||||||
var request vo.AI3DJobCreate
|
var request struct {
|
||||||
|
// 通用参数
|
||||||
|
Type types.AI3DTaskType `json:"type" binding:"required"` // API类型 (tencent/gitee)
|
||||||
|
Model string `json:"model" binding:"required"` // 3D模型类型
|
||||||
|
Prompt string `json:"prompt"` // 文本提示词
|
||||||
|
ImageURL string `json:"image_url"` // 输入图片URL
|
||||||
|
FileFormat string `json:"file_format"` // 输出文件格式
|
||||||
|
// 腾讯3d专有参数
|
||||||
|
EnablePBR bool `json:"enable_pbr"` // 是否开启PBR材质
|
||||||
|
// Gitee3d专有参数
|
||||||
|
Texture bool `json:"texture"` // 是否开启纹理
|
||||||
|
Seed int `json:"seed"` // 随机种子
|
||||||
|
NumInferenceSteps int `json:"num_inference_steps"` //迭代次数
|
||||||
|
GuidanceScale float64 `json:"guidance_scale"` //引导系数
|
||||||
|
OctreeResolution int `json:"octree_resolution"` // 3D 渲染精度,越高3D 细节越丰富
|
||||||
|
}
|
||||||
if err := c.ShouldBindJSON(&request); err != nil {
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
resp.ERROR(c, "参数错误")
|
resp.ERROR(c, "参数错误")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证必填参数
|
// 提示词和图片不能同时为空
|
||||||
if request.Type == "" || request.Model == "" || request.Power <= 0 {
|
if request.Prompt == "" && request.ImageURL == "" {
|
||||||
resp.ERROR(c, "缺少必要参数")
|
resp.ERROR(c, "提示词和图片不能同时为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取用户ID
|
// Gitee 只支持图片
|
||||||
userId := h.GetLoginUserId(c)
|
if request.Type == types.AI3DTaskTypeGitee && request.ImageURL == "" {
|
||||||
if userId == 0 {
|
resp.ERROR(c, "Gitee 只支持图生3D")
|
||||||
resp.ERROR(c, "用户未登录")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建任务
|
logger.Infof("request: %+v", request)
|
||||||
job, err := h.service.CreateJob(uint(userId), request)
|
|
||||||
if err != nil {
|
// // 获取用户ID
|
||||||
resp.ERROR(c, fmt.Sprintf("创建任务失败: %v", err))
|
// userId := h.GetLoginUserId(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{
|
resp.SUCCESS(c, gin.H{
|
||||||
"job_id": job.Id,
|
"job_id": 0,
|
||||||
"message": "任务创建成功",
|
"message": "任务创建成功",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -147,7 +166,7 @@ func (h *AI3DHandler) JobDetail(c *gin.Context) {
|
|||||||
Type: job.Type,
|
Type: job.Type,
|
||||||
Power: job.Power,
|
Power: job.Power,
|
||||||
TaskId: job.TaskId,
|
TaskId: job.TaskId,
|
||||||
ImgURL: job.FileURL,
|
FileURL: job.FileURL,
|
||||||
PreviewURL: job.PreviewURL,
|
PreviewURL: job.PreviewURL,
|
||||||
Model: job.Model,
|
Model: job.Model,
|
||||||
Status: job.Status,
|
Status: job.Status,
|
||||||
@@ -163,24 +182,38 @@ func (h *AI3DHandler) JobDetail(c *gin.Context) {
|
|||||||
// DeleteJob 删除任务
|
// DeleteJob 删除任务
|
||||||
func (h *AI3DHandler) DeleteJob(c *gin.Context) {
|
func (h *AI3DHandler) DeleteJob(c *gin.Context) {
|
||||||
userId := h.GetLoginUserId(c)
|
userId := h.GetLoginUserId(c)
|
||||||
if userId == 0 {
|
id := c.Query("id")
|
||||||
resp.ERROR(c, "用户未登录")
|
if id == "" {
|
||||||
|
resp.ERROR(c, "任务ID不能为空")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
idStr := c.Param("id")
|
var job model.AI3DJob
|
||||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
err := h.DB.Where("id = ?", id).Where("user_id = ?", userId).First(&job).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.ERROR(c, "任务ID格式错误")
|
resp.ERROR(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = h.service.DeleteJob(uint(id), uint(userId))
|
err = h.DB.Delete(&job).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
resp.ERROR(c, fmt.Sprintf("删除任务失败: %v", err))
|
resp.ERROR(c, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 失败的任务要退回算力
|
||||||
|
if job.Status == types.AI3DJobStatusFailed {
|
||||||
|
err = h.userService.IncreasePower(userId, job.Power, model.PowerLog{
|
||||||
|
Type: types.PowerRefund,
|
||||||
|
Model: job.Model,
|
||||||
|
Remark: fmt.Sprintf("删除任务,退回%d算力", job.Power),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
resp.ERROR(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resp.SUCCESS(c, gin.H{"message": "删除成功"})
|
resp.SUCCESS(c, gin.H{"message": "删除成功"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,3 +285,110 @@ func (h *AI3DHandler) GetConfigs(c *gin.Context) {
|
|||||||
|
|
||||||
resp.SUCCESS(c, config3d)
|
resp.SUCCESS(c, config3d)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListMock 返回演示数据
|
||||||
|
func (h *AI3DHandler) ListMock(c *gin.Context) {
|
||||||
|
// 创建各种状态的演示数据
|
||||||
|
mockJobs := []vo.AI3DJob{
|
||||||
|
{
|
||||||
|
Id: 1,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "gitee",
|
||||||
|
Power: 10,
|
||||||
|
TaskId: "mock_task_1",
|
||||||
|
FileURL: "https://img.r9it.com/R03TQZ7PZ386RGL7PTMNGFOHAJW15WYF.glb",
|
||||||
|
PreviewURL: "/static/upload/2025/9/1756873317505073.png",
|
||||||
|
Model: "gitee-3d-v1",
|
||||||
|
Status: types.AI3DJobStatusCompleted,
|
||||||
|
ErrMsg: "",
|
||||||
|
Params: `{"prompt":"一只可爱的小猫","image_url":"","texture":true,"seed":42}`,
|
||||||
|
CreatedAt: 1704067200, // 2024-01-01 00:00:00
|
||||||
|
UpdatedAt: 1704067800, // 2024-01-01 00:10:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 2,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "tencent",
|
||||||
|
Power: 15,
|
||||||
|
TaskId: "mock_task_2",
|
||||||
|
FileURL: "",
|
||||||
|
PreviewURL: "/static/upload/2025/9/1756873317505073.png",
|
||||||
|
Model: "tencent-3d-v2",
|
||||||
|
Status: types.AI3DJobStatusProcessing,
|
||||||
|
ErrMsg: "",
|
||||||
|
Params: `{"prompt":"一个现代建筑模型","image_url":"","enable_pbr":true}`,
|
||||||
|
CreatedAt: 1704070800, // 2024-01-01 01:00:00
|
||||||
|
UpdatedAt: 1704070800, // 2024-01-01 01:00:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 3,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "gitee",
|
||||||
|
Power: 8,
|
||||||
|
TaskId: "mock_task_3",
|
||||||
|
FileURL: "",
|
||||||
|
PreviewURL: "",
|
||||||
|
Model: "gitee-3d-v1",
|
||||||
|
Status: types.AI3DJobStatusPending,
|
||||||
|
ErrMsg: "",
|
||||||
|
Params: `{"prompt":"一辆跑车模型","image_url":"https://example.com/car.jpg","texture":false}`,
|
||||||
|
CreatedAt: 1704074400, // 2024-01-01 02:00:00
|
||||||
|
UpdatedAt: 1704074400, // 2024-01-01 02:00:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 4,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "tencent",
|
||||||
|
Power: 12,
|
||||||
|
TaskId: "mock_task_4",
|
||||||
|
FileURL: "",
|
||||||
|
PreviewURL: "",
|
||||||
|
Model: "tencent-3d-v1",
|
||||||
|
Status: types.AI3DJobStatusFailed,
|
||||||
|
ErrMsg: "模型生成失败:输入图片质量不符合要求",
|
||||||
|
Params: `{"prompt":"一个机器人模型","image_url":"https://example.com/robot.jpg","enable_pbr":false}`,
|
||||||
|
CreatedAt: 1704078000, // 2024-01-01 03:00:00
|
||||||
|
UpdatedAt: 1704078600, // 2024-01-01 03:10:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 5,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "gitee",
|
||||||
|
Power: 20,
|
||||||
|
TaskId: "mock_task_5",
|
||||||
|
FileURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
|
||||||
|
PreviewURL: "https://ai.gitee.com/a8c1af8e-26e9-4ca6-aa5c-6d4ba86bfdac",
|
||||||
|
Model: "gitee-3d-v2",
|
||||||
|
Status: types.AI3DJobStatusCompleted,
|
||||||
|
ErrMsg: "",
|
||||||
|
Params: `{"prompt":"一个复杂的机械装置","image_url":"","texture":true,"octree_resolution":512}`,
|
||||||
|
CreatedAt: 1704081600, // 2024-01-01 04:00:00
|
||||||
|
UpdatedAt: 1704082200, // 2024-01-01 04:10:00
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: 6,
|
||||||
|
UserId: 1,
|
||||||
|
Type: "tencent",
|
||||||
|
Power: 18,
|
||||||
|
TaskId: "mock_task_6",
|
||||||
|
FileURL: "",
|
||||||
|
PreviewURL: "",
|
||||||
|
Model: "tencent-3d-v2",
|
||||||
|
Status: types.AI3DJobStatusProcessing,
|
||||||
|
ErrMsg: "",
|
||||||
|
Params: `{"prompt":"一个科幻飞船","image_url":"","enable_pbr":true}`,
|
||||||
|
CreatedAt: 1704085200, // 2024-01-01 05:00:00
|
||||||
|
UpdatedAt: 1704085200, // 2024-01-01 05:00:00
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建分页响应
|
||||||
|
mockResponse := vo.ThreeDJobList{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 10,
|
||||||
|
Total: len(mockJobs),
|
||||||
|
Items: mockJobs,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.SUCCESS(c, mockResponse)
|
||||||
|
}
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func (s *Service) GetJobList(userId uint, page, pageSize int) (*vo.Page, error)
|
|||||||
Type: job.Type,
|
Type: job.Type,
|
||||||
Power: job.Power,
|
Power: job.Power,
|
||||||
TaskId: job.TaskId,
|
TaskId: job.TaskId,
|
||||||
ImgURL: job.FileURL,
|
FileURL: job.FileURL,
|
||||||
PreviewURL: job.PreviewURL,
|
PreviewURL: job.PreviewURL,
|
||||||
Model: job.Model,
|
Model: job.Model,
|
||||||
Status: job.Status,
|
Status: job.Status,
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ func (c *Tencent3DClient) QueryJob(jobId string) (*types.AI3DJobResult, error) {
|
|||||||
if file.PreviewImageUrl != nil {
|
if file.PreviewImageUrl != nil {
|
||||||
result.PreviewURL = *file.PreviewImageUrl
|
result.PreviewURL = *file.PreviewImageUrl
|
||||||
}
|
}
|
||||||
break // 取第一个文件
|
// TODO 取第一个文件
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "FAIL":
|
case "FAIL":
|
||||||
@@ -153,6 +153,6 @@ func (c *Tencent3DClient) QueryJob(jobId string) (*types.AI3DJobResult, error) {
|
|||||||
// GetSupportedModels 获取支持的模型列表
|
// GetSupportedModels 获取支持的模型列表
|
||||||
func (c *Tencent3DClient) GetSupportedModels() []types.AI3DModel {
|
func (c *Tencent3DClient) GetSupportedModels() []types.AI3DModel {
|
||||||
return []types.AI3DModel{
|
return []types.AI3DModel{
|
||||||
{Name: "Hunyuan3D-3", Power: 500, Formats: []string{"OBJ", "GLB", "STL", "USDZ", "FBX", "MP4"}, Desc: "Hunyuan3D 是腾讯混元团队推出的高质量 3D 生成模型,具备高保真度、细节丰富和高效生成的特点,可快速将文本或图像转换为逼真的 3D 物体。"},
|
{Name: "Hunyuan3D-3", Power: 500, Formats: []string{"GLB", "OBJ", "STL", "USDZ", "FBX", "MP4"}, Desc: "Hunyuan3D 是腾讯混元团队推出的高质量 3D 生成模型,具备高保真度、细节丰富和高效生成的特点,可快速将文本或图像转换为逼真的 3D 物体。"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ type AI3DJob struct {
|
|||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Power int `json:"power"`
|
Power int `json:"power"`
|
||||||
TaskId string `json:"task_id"`
|
TaskId string `json:"task_id"`
|
||||||
ImgURL string `json:"img_url"`
|
FileURL string `json:"file_url"`
|
||||||
PreviewURL string `json:"preview_url"`
|
PreviewURL string `json:"preview_url"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
@@ -29,4 +29,5 @@ type ThreeDJobList struct {
|
|||||||
PageSize int `json:"page_size"`
|
PageSize int `json:"page_size"`
|
||||||
Total int `json:"total"`
|
Total int `json:"total"`
|
||||||
List []AI3DJob `json:"list"`
|
List []AI3DJob `json:"list"`
|
||||||
|
Items []AI3DJob `json:"items"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"qrcode": "^1.5.3",
|
"qrcode": "^1.5.3",
|
||||||
"qs": "^6.11.1",
|
"qs": "^6.11.1",
|
||||||
"sortablejs": "^1.15.0",
|
"sortablejs": "^1.15.0",
|
||||||
"three": "^0.128.0",
|
"three": "^0.160.0",
|
||||||
"unplugin-auto-import": "^0.18.5",
|
"unplugin-auto-import": "^0.18.5",
|
||||||
"vant": "^4.5.0",
|
"vant": "^4.5.0",
|
||||||
"vue": "^3.2.13",
|
"vue": "^3.2.13",
|
||||||
|
|||||||
10
web/pnpm-lock.yaml
generated
10
web/pnpm-lock.yaml
generated
@@ -105,8 +105,8 @@ importers:
|
|||||||
specifier: ^1.15.0
|
specifier: ^1.15.0
|
||||||
version: 1.15.6
|
version: 1.15.6
|
||||||
three:
|
three:
|
||||||
specifier: ^0.128.0
|
specifier: ^0.160.0
|
||||||
version: 0.128.0
|
version: 0.160.1
|
||||||
unplugin-auto-import:
|
unplugin-auto-import:
|
||||||
specifier: ^0.18.5
|
specifier: ^0.18.5
|
||||||
version: 0.18.6(@vueuse/core@9.13.0(vue@3.5.18))(rollup@4.46.1)
|
version: 0.18.6(@vueuse/core@9.13.0(vue@3.5.18))(rollup@4.46.1)
|
||||||
@@ -2240,8 +2240,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
tslib: ^2
|
tslib: ^2
|
||||||
|
|
||||||
three@0.128.0:
|
three@0.160.1:
|
||||||
resolution: {integrity: sha512-i0ap/E+OaSfzw7bD1TtYnPo3VEplkl70WX5fZqZnfZsE3k3aSFudqrrC9ldFZfYFkn1zwDmBcdGfiIm/hnbyZA==}
|
resolution: {integrity: sha512-Bgl2wPJypDOZ1stAxwfWAcJ0WQf7QzlptsxkjYiURPz+n5k4RBDLsq+6f9Y75TYxn6aHLcWz+JNmwTOXWrQTBQ==}
|
||||||
|
|
||||||
tiny-emitter@2.1.0:
|
tiny-emitter@2.1.0:
|
||||||
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
resolution: {integrity: sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==}
|
||||||
@@ -4549,7 +4549,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
|
|
||||||
three@0.128.0: {}
|
three@0.160.1: {}
|
||||||
|
|
||||||
tiny-emitter@2.1.0: {}
|
tiny-emitter@2.1.0: {}
|
||||||
|
|
||||||
|
|||||||
632
web/src/assets/css/ai3d.scss
Normal file
632
web/src/assets/css/ai3d.scss
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
.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;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #414141;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-toggle-btn {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #409eff;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #66b1ff;
|
||||||
|
background: #f0f9ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.advanced-params {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #2196f3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 24px;
|
||||||
|
background: white;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-items {
|
||||||
|
.task-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border: 1px solid #e4e7ed;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.task-card-completed {
|
||||||
|
border-left: 4px solid #67c23a;
|
||||||
|
background: #f0f9eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.task-card-processing {
|
||||||
|
border-left: 4px solid #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.task-card-failed {
|
||||||
|
border-left: 4px solid #f56c6c;
|
||||||
|
background: #fef0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.task-card-default {
|
||||||
|
border-left: 4px solid #909399;
|
||||||
|
background: #f4f4f4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px dashed #eee;
|
||||||
|
|
||||||
|
.task-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.task-id {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-platform {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&.pending {
|
||||||
|
background: #fffbe6;
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.processing {
|
||||||
|
background: #e1f3d8;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.completed {
|
||||||
|
background: #e1f3d8;
|
||||||
|
color: #67c23a;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.failed {
|
||||||
|
background: #fef0f0;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-power {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.task-preview {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #666;
|
||||||
|
min-height: 120px;
|
||||||
|
max-width: 200px;
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.task-model {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-prompt {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-params {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-error {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #f56c6c;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed #eee;
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
|
||||||
|
&.preview-btn {
|
||||||
|
background: #409eff;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #409eff;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #66b1ff;
|
||||||
|
border-color: #66b1ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.download-btn {
|
||||||
|
background: #67c23a;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #67c23a;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #85ce61;
|
||||||
|
border-color: #85ce61;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.delete-btn {
|
||||||
|
background: #f56c6c;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #f56c6c;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f78989;
|
||||||
|
border-color: #f78989;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.processing-btn {
|
||||||
|
background: #909399;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #909399;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
width: 100%;
|
||||||
|
height: 200px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
min-height: 500px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
min-height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-wrapper {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.task-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,26 +5,28 @@
|
|||||||
<!-- 控制面板 -->
|
<!-- 控制面板 -->
|
||||||
<div class="control-panel">
|
<div class="control-panel">
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label>旋转速度</label>
|
<label>缩放</label>
|
||||||
<el-slider
|
<div class="scale-controls">
|
||||||
v-model="rotationSpeed"
|
<el-button size="small" @click="zoomOut" :disabled="scale <= 0.1">
|
||||||
:min="0"
|
<el-icon><Minus /></el-icon>
|
||||||
:max="0.1"
|
</el-button>
|
||||||
:step="0.01"
|
<span class="scale-value">{{ scale.toFixed(1) }}x</span>
|
||||||
@change="updateRotationSpeed"
|
<el-button size="small" @click="zoomIn" :disabled="scale >= 3">
|
||||||
/>
|
<el-icon><Plus /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<label>缩放</label>
|
<label>模型颜色</label>
|
||||||
<el-slider v-model="scale" :min="0.1" :max="3" :step="0.1" @change="updateScale" />
|
<div class="color-picker">
|
||||||
|
<el-color-picker
|
||||||
|
v-model="modelColor"
|
||||||
|
@change="updateModelColor"
|
||||||
|
:predefine="predefineColors"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -33,6 +35,12 @@
|
|||||||
<div class="loading-content">
|
<div class="loading-content">
|
||||||
<el-icon class="is-loading"><Loading /></el-icon>
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
<p>加载3D模型中...</p>
|
<p>加载3D模型中...</p>
|
||||||
|
<div v-if="loadingProgress > 0" class="loading-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" :style="{ width: loadingProgress + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{{ loadingProgress.toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,20 +56,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Loading, Warning } from '@element-plus/icons-vue'
|
import { Loading, Minus, Plus, Warning } from '@element-plus/icons-vue'
|
||||||
import { ElButton, ElIcon, ElSlider } from 'element-plus'
|
import { ElButton, ElIcon } from 'element-plus'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||||
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader.js'
|
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'
|
||||||
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
|
import { STLLoader } from 'three/addons/loaders/STLLoader.js'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelUrl: {
|
modelUrl: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: false,
|
||||||
},
|
},
|
||||||
modelType: {
|
modelType: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -73,33 +81,58 @@ const props = defineProps({
|
|||||||
const container = ref(null)
|
const container = ref(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const rotationSpeed = ref(0.02)
|
const loadingProgress = ref(0)
|
||||||
const scale = ref(1)
|
const scale = ref(1)
|
||||||
const autoRotate = ref(true)
|
const modelColor = ref('#00ff88')
|
||||||
|
const predefineColors = ref([
|
||||||
|
'#00ff88', // 亮绿色
|
||||||
|
'#ff6b6b', // 亮红色
|
||||||
|
'#4ecdc4', // 亮青色
|
||||||
|
'#45b7d1', // 亮蓝色
|
||||||
|
'#f9ca24', // 亮黄色
|
||||||
|
'#f0932b', // 亮橙色
|
||||||
|
'#eb4d4b', // 亮粉红
|
||||||
|
'#6c5ce7', // 亮紫色
|
||||||
|
'#a29bfe', // 亮靛蓝
|
||||||
|
'#fd79a8', // 亮玫瑰
|
||||||
|
])
|
||||||
|
|
||||||
// Three.js 相关变量
|
// Three.js 相关变量
|
||||||
let scene, camera, renderer, controls, model, mixer, clock
|
let scene, camera, renderer, controls, model, mixer, clock
|
||||||
let animationId
|
let animationId
|
||||||
|
let baseScale = 1 // 存储基础缩放值
|
||||||
|
|
||||||
// 初始化Three.js场景
|
// 初始化Three.js场景
|
||||||
const initThreeJS = () => {
|
const initThreeJS = () => {
|
||||||
if (!container.value) return
|
if (!container.value) {
|
||||||
|
console.error('ThreeDPreview: 容器元素不存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 创建场景
|
// 创建场景
|
||||||
scene = new THREE.Scene()
|
scene = new THREE.Scene()
|
||||||
scene.background = new THREE.Color(0xf0f0f0)
|
scene.background = new THREE.Color(0x2a2a2a) // 深灰色背景,类似截图
|
||||||
|
|
||||||
// 创建相机
|
// 获取容器尺寸,确保有最小尺寸
|
||||||
const containerRect = container.value.getBoundingClientRect()
|
const containerRect = container.value.getBoundingClientRect()
|
||||||
camera = new THREE.PerspectiveCamera(75, containerRect.width / containerRect.height, 0.1, 1000)
|
const width = Math.max(containerRect.width || 400, 400)
|
||||||
camera.position.set(0, 0, 5)
|
const height = Math.max(containerRect.height || 300, 300)
|
||||||
|
|
||||||
|
// 创建相机 - 参考截图的视角(稍微俯视,从左上角观察)
|
||||||
|
camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
|
||||||
|
camera.position.set(3, 3, 3) // 从左上角俯视角度
|
||||||
|
|
||||||
// 创建渲染器
|
// 创建渲染器
|
||||||
renderer = new THREE.WebGLRenderer({ antialias: true })
|
renderer = new THREE.WebGLRenderer({
|
||||||
renderer.setSize(containerRect.width, containerRect.height)
|
antialias: true,
|
||||||
renderer.setPixelRatio(window.devicePixelRatio)
|
alpha: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
|
})
|
||||||
|
renderer.setSize(width, height)
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||||
renderer.shadowMap.enabled = true
|
renderer.shadowMap.enabled = true
|
||||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
renderer.shadowMap.type = THREE.PCFSoftShadowMap
|
||||||
|
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||||
|
|
||||||
// 添加到容器
|
// 添加到容器
|
||||||
container.value.appendChild(renderer.domElement)
|
container.value.appendChild(renderer.domElement)
|
||||||
@@ -108,8 +141,6 @@ const initThreeJS = () => {
|
|||||||
controls = new OrbitControls(camera, renderer.domElement)
|
controls = new OrbitControls(camera, renderer.domElement)
|
||||||
controls.enableDamping = true
|
controls.enableDamping = true
|
||||||
controls.dampingFactor = 0.05
|
controls.dampingFactor = 0.05
|
||||||
controls.autoRotate = autoRotate.value
|
|
||||||
controls.autoRotateSpeed = rotationSpeed.value
|
|
||||||
|
|
||||||
// 添加光源
|
// 添加光源
|
||||||
addLights()
|
addLights()
|
||||||
@@ -117,6 +148,9 @@ const initThreeJS = () => {
|
|||||||
// 添加地面
|
// 添加地面
|
||||||
addGround()
|
addGround()
|
||||||
|
|
||||||
|
// 添加坐标轴辅助线
|
||||||
|
addAxesHelper()
|
||||||
|
|
||||||
// 创建时钟
|
// 创建时钟
|
||||||
clock = new THREE.Clock()
|
clock = new THREE.Clock()
|
||||||
|
|
||||||
@@ -127,31 +161,50 @@ const initThreeJS = () => {
|
|||||||
window.addEventListener('resize', onWindowResize)
|
window.addEventListener('resize', onWindowResize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加光源
|
//
|
||||||
|
|
||||||
|
// 添加光源 - 参考截图的柔和光照效果
|
||||||
const addLights = () => {
|
const addLights = () => {
|
||||||
// 环境光
|
// 环境光 - 提供基础照明,参考截图的柔和效果
|
||||||
const ambientLight = new THREE.AmbientLight(0x404040, 0.6)
|
const ambientLight = new THREE.AmbientLight(0x404040, 0.6)
|
||||||
scene.add(ambientLight)
|
scene.add(ambientLight)
|
||||||
|
|
||||||
// 方向光
|
// 主方向光 - 从左上角照射,模拟截图中的光照方向
|
||||||
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||||
directionalLight.position.set(10, 10, 5)
|
directionalLight.position.set(5, 5, 3)
|
||||||
directionalLight.castShadow = true
|
directionalLight.castShadow = true
|
||||||
directionalLight.shadow.mapSize.width = 2048
|
directionalLight.shadow.mapSize.width = 2048
|
||||||
directionalLight.shadow.mapSize.height = 2048
|
directionalLight.shadow.mapSize.height = 2048
|
||||||
|
directionalLight.shadow.camera.near = 0.5
|
||||||
|
directionalLight.shadow.camera.far = 50
|
||||||
|
directionalLight.shadow.camera.left = -10
|
||||||
|
directionalLight.shadow.camera.right = 10
|
||||||
|
directionalLight.shadow.camera.top = 10
|
||||||
|
directionalLight.shadow.camera.bottom = -10
|
||||||
scene.add(directionalLight)
|
scene.add(directionalLight)
|
||||||
|
|
||||||
// 点光源
|
// 补充光源 - 从右侧照射,提供更均匀的光照
|
||||||
const pointLight = new THREE.PointLight(0xffffff, 0.5)
|
const fillLight = new THREE.DirectionalLight(0xffffff, 0.4)
|
||||||
pointLight.position.set(-10, 10, -5)
|
fillLight.position.set(-3, 3, 3)
|
||||||
scene.add(pointLight)
|
scene.add(fillLight)
|
||||||
|
|
||||||
|
// 背光 - 增加轮廓,但强度较低
|
||||||
|
const backLight = new THREE.DirectionalLight(0xffffff, 0.15)
|
||||||
|
backLight.position.set(0, 2, -5)
|
||||||
|
scene.add(backLight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加地面
|
// 添加地面网格 - 参考截图的深色背景和浅色网格线
|
||||||
const addGround = () => {
|
const addGround = () => {
|
||||||
|
// 创建网格辅助线 - 使用深色背景配浅色网格线,增加网格密度
|
||||||
|
const gridHelper = new THREE.GridHelper(20, 40, 0x666666, 0x666666)
|
||||||
|
gridHelper.position.y = -0.01 // 稍微向下一点,避免z-fighting
|
||||||
|
scene.add(gridHelper)
|
||||||
|
|
||||||
|
// 添加半透明地面 - 使用更深的颜色
|
||||||
const groundGeometry = new THREE.PlaneGeometry(20, 20)
|
const groundGeometry = new THREE.PlaneGeometry(20, 20)
|
||||||
const groundMaterial = new THREE.MeshLambertMaterial({
|
const groundMaterial = new THREE.MeshLambertMaterial({
|
||||||
color: 0xcccccc,
|
color: 0x1a1a1a, // 更深的背景色
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.3,
|
opacity: 0.3,
|
||||||
})
|
})
|
||||||
@@ -161,12 +214,26 @@ const addGround = () => {
|
|||||||
scene.add(ground)
|
scene.add(ground)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 添加坐标轴辅助线 - 参考截图的样式
|
||||||
|
const addAxesHelper = () => {
|
||||||
|
const axesHelper = new THREE.AxesHelper(3) // 稍微小一点的坐标轴
|
||||||
|
scene.add(axesHelper)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
// 加载3D模型
|
// 加载3D模型
|
||||||
const loadModel = async () => {
|
const loadModel = async () => {
|
||||||
if (!props.modelUrl) return
|
if (!props.modelUrl) {
|
||||||
|
console.warn('ThreeDPreview: 没有提供模型URL')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
loadingProgress.value = 0
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
// 清除现有模型
|
// 清除现有模型
|
||||||
@@ -196,24 +263,69 @@ const loadModel = async () => {
|
|||||||
model = loadedModel
|
model = loadedModel
|
||||||
scene.add(model)
|
scene.add(model)
|
||||||
|
|
||||||
// 调整模型位置和大小
|
// 计算模型边界并调整相机位置
|
||||||
centerModel()
|
const box = new THREE.Box3().setFromObject(model)
|
||||||
fitCameraToModel()
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
|
||||||
// 设置阴影
|
// 调整模型位置到原点
|
||||||
|
model.position.sub(center)
|
||||||
|
|
||||||
|
// 计算并保存基础缩放值
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
baseScale = maxDim > 0 ? 2 / maxDim : 1
|
||||||
|
|
||||||
|
// 应用初始缩放
|
||||||
|
model.scale.setScalar(baseScale * scale.value)
|
||||||
|
|
||||||
|
// 根据模型大小调整相机距离 - 保持截图中的俯视角度
|
||||||
|
const cameraDistance = maxDim > 0 ? maxDim * 2 : 5
|
||||||
|
|
||||||
|
// 设置相机位置为左上角俯视角度
|
||||||
|
camera.position.set(cameraDistance * 0.6, cameraDistance * 0.6, cameraDistance * 0.6)
|
||||||
|
camera.lookAt(0, 0, 0)
|
||||||
|
|
||||||
|
if (controls) {
|
||||||
|
controls.target.set(0, 0, 0)
|
||||||
|
controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置阴影和材质
|
||||||
model.traverse((child) => {
|
model.traverse((child) => {
|
||||||
if (child.isMesh) {
|
if (child.isMesh) {
|
||||||
child.castShadow = true
|
child.castShadow = true
|
||||||
child.receiveShadow = true
|
child.receiveShadow = true
|
||||||
|
|
||||||
|
// 将模型材质改为亮色
|
||||||
|
if (child.material) {
|
||||||
|
const colorHex = modelColor.value.replace('#', '0x')
|
||||||
|
// 如果是数组材质
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((mat) => {
|
||||||
|
if (mat.color) {
|
||||||
|
mat.color.setHex(colorHex)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// 单个材质
|
||||||
|
if (child.material.color) {
|
||||||
|
child.material.color.setHex(colorHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
console.warn('ThreeDPreview: 模型加载返回空值')
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
loadingProgress.value = 100
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('加载3D模型失败:', err)
|
console.error('ThreeDPreview: 加载3D模型失败:', err)
|
||||||
error.value = `加载模型失败: ${err.message}`
|
error.value = `加载模型失败: ${err.message}`
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
loadingProgress.value = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +347,16 @@ const loadGLTF = (url) => {
|
|||||||
|
|
||||||
resolve(model)
|
resolve(model)
|
||||||
},
|
},
|
||||||
undefined,
|
(xhr) => {
|
||||||
reject
|
if (xhr.total > 0) {
|
||||||
|
const percent = (xhr.loaded / xhr.total) * 100
|
||||||
|
loadingProgress.value = percent
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('ThreeDPreview: GLTF模型加载失败', error)
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -283,74 +403,52 @@ const loadSTL = (url) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 居中模型
|
// 放大
|
||||||
const centerModel = () => {
|
const zoomIn = () => {
|
||||||
if (!model) return
|
if (scale.value < 3) {
|
||||||
|
scale.value = Math.min(scale.value + 0.1, 3)
|
||||||
const box = new THREE.Box3().setFromObject(model)
|
updateScale(scale.value)
|
||||||
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 = () => {
|
const zoomOut = () => {
|
||||||
if (!model) return
|
if (scale.value > 0.1) {
|
||||||
|
scale.value = Math.max(scale.value - 0.1, 0.1)
|
||||||
const box = new THREE.Box3().setFromObject(model)
|
updateScale(scale.value)
|
||||||
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) => {
|
const updateScale = (value) => {
|
||||||
if (model) {
|
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)
|
model.scale.setScalar(baseScale * value)
|
||||||
|
console.log('ThreeDPreview: 更新缩放', { value, baseScale, finalScale: baseScale * value })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置相机
|
// 更新模型颜色
|
||||||
const resetCamera = () => {
|
const updateModelColor = (color) => {
|
||||||
if (camera && model) {
|
if (model && color) {
|
||||||
fitCameraToModel()
|
model.traverse((child) => {
|
||||||
|
if (child.isMesh && child.material) {
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material.forEach((mat) => {
|
||||||
|
if (mat.color) {
|
||||||
|
mat.color.setHex(color.replace('#', '0x'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if (child.material.color) {
|
||||||
|
child.material.color.setHex(color.replace('#', '0x'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换自动旋转
|
//
|
||||||
const toggleAutoRotate = () => {
|
|
||||||
autoRotate.value = !autoRotate.value
|
|
||||||
if (controls) {
|
|
||||||
controls.autoRotate = autoRotate.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重试加载
|
// 重试加载
|
||||||
const retryLoad = () => {
|
const retryLoad = () => {
|
||||||
@@ -362,9 +460,12 @@ const onWindowResize = () => {
|
|||||||
if (!container.value || !camera || !renderer) return
|
if (!container.value || !camera || !renderer) return
|
||||||
|
|
||||||
const containerRect = container.value.getBoundingClientRect()
|
const containerRect = container.value.getBoundingClientRect()
|
||||||
camera.aspect = containerRect.width / containerRect.height
|
const width = Math.max(containerRect.width || 400, 400)
|
||||||
|
const height = Math.max(containerRect.height || 300, 300)
|
||||||
|
|
||||||
|
camera.aspect = width / height
|
||||||
camera.updateProjectionMatrix()
|
camera.updateProjectionMatrix()
|
||||||
renderer.setSize(containerRect.width, containerRect.height)
|
renderer.setSize(width, height)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 渲染循环
|
// 渲染循环
|
||||||
@@ -429,10 +530,16 @@ watch(
|
|||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 使用nextTick确保DOM完全渲染
|
||||||
|
nextTick(() => {
|
||||||
|
// 延迟初始化,确保容器有正确的尺寸
|
||||||
|
setTimeout(() => {
|
||||||
initThreeJS()
|
initThreeJS()
|
||||||
if (props.modelUrl) {
|
if (props.modelUrl) {
|
||||||
loadModel()
|
loadModel()
|
||||||
}
|
}
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -440,7 +547,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss">
|
||||||
.three-d-preview {
|
.three-d-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -450,7 +557,11 @@ onUnmounted(() => {
|
|||||||
.preview-container {
|
.preview-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
min-height: 400px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-panel {
|
.control-panel {
|
||||||
@@ -475,12 +586,27 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-buttons {
|
.color-picker {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.el-color-picker--small {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scale-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|
||||||
.el-button {
|
.scale-value {
|
||||||
flex: 1;
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -519,6 +645,32 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loading-progress {
|
||||||
|
width: 200px;
|
||||||
|
margin-top: 16px;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: #409eff;
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.error-content {
|
.error-content {
|
||||||
.el-icon {
|
.el-icon {
|
||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
|
|||||||
237
web/src/components/ui/CustomSwitch.vue
Normal file
237
web/src/components/ui/CustomSwitch.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<template>
|
||||||
|
<div class="custom-switch" :class="{ 'is-active': modelValue }" @click="toggleSwitch">
|
||||||
|
<div class="switch-track" :style="trackStyle" ref="trackRef">
|
||||||
|
<div class="switch-thumb" :style="thumbStyle"></div>
|
||||||
|
<div class="switch-text inactive-text">
|
||||||
|
<slot name="inactive-text"></slot>
|
||||||
|
</div>
|
||||||
|
<div class="switch-text active-text">
|
||||||
|
<slot name="active-text"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, defineEmits, defineProps, nextTick, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
activeColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#67c23a', // 默认绿色
|
||||||
|
},
|
||||||
|
inactiveColor: {
|
||||||
|
type: String,
|
||||||
|
default: '#f56c6c', // 默认红色
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: [String, Number],
|
||||||
|
default: null, // 默认不设置固定宽度,使用min-width
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: 'default', // small, default, large
|
||||||
|
validator: (value) => ['small', 'default', 'large'].includes(value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
|
// 轨道宽度引用
|
||||||
|
const trackRef = ref(null)
|
||||||
|
const trackWidth = ref(120) // 默认宽度
|
||||||
|
|
||||||
|
// 获取轨道实际宽度
|
||||||
|
const updateTrackWidth = () => {
|
||||||
|
if (trackRef.value) {
|
||||||
|
trackWidth.value = trackRef.value.offsetWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算尺寸相关样式
|
||||||
|
const sizeConfig = computed(() => {
|
||||||
|
const configs = {
|
||||||
|
small: {
|
||||||
|
height: 24,
|
||||||
|
thumbSize: 20,
|
||||||
|
thumbMargin: 2,
|
||||||
|
fontSize: 12,
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
height: 28,
|
||||||
|
thumbSize: 24,
|
||||||
|
thumbMargin: 2,
|
||||||
|
fontSize: 13,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
large: {
|
||||||
|
height: 32,
|
||||||
|
thumbSize: 28,
|
||||||
|
thumbMargin: 2,
|
||||||
|
fontSize: 14,
|
||||||
|
padding: 16,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return configs[props.size]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算轨道样式
|
||||||
|
const trackStyle = computed(() => {
|
||||||
|
const backgroundColor = props.modelValue ? props.activeColor : props.inactiveColor
|
||||||
|
const config = sizeConfig.value
|
||||||
|
const style = {
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
height: `${config.height}px`,
|
||||||
|
padding: `0 ${config.padding}px`,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果传入了width属性,则设置固定宽度
|
||||||
|
if (props.width !== null) {
|
||||||
|
const widthValue = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||||
|
style.width = widthValue
|
||||||
|
style.minWidth = widthValue
|
||||||
|
}
|
||||||
|
|
||||||
|
return style
|
||||||
|
})
|
||||||
|
|
||||||
|
// 计算滑块样式 - 使用像素值而不是calc()
|
||||||
|
const thumbStyle = computed(() => {
|
||||||
|
const config = sizeConfig.value
|
||||||
|
const thumbWidth = config.thumbSize
|
||||||
|
const thumbMargin = config.thumbMargin
|
||||||
|
const maxTranslateX = trackWidth.value - thumbWidth - thumbMargin * 2
|
||||||
|
|
||||||
|
const transform = props.modelValue ? `translateX(${maxTranslateX}px)` : 'translateX(0px)'
|
||||||
|
console.log('Track width:', trackWidth.value, 'Transform:', transform)
|
||||||
|
|
||||||
|
return {
|
||||||
|
transform: transform,
|
||||||
|
width: `${thumbWidth}px`,
|
||||||
|
height: `${thumbWidth}px`,
|
||||||
|
top: `${thumbMargin}px`,
|
||||||
|
left: `${thumbMargin}px`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleSwitch = () => {
|
||||||
|
if (props.disabled) return
|
||||||
|
|
||||||
|
const newValue = !props.modelValue
|
||||||
|
emit('update:modelValue', newValue)
|
||||||
|
emit('change', newValue)
|
||||||
|
|
||||||
|
// 切换后更新宽度
|
||||||
|
nextTick(() => {
|
||||||
|
updateTrackWidth()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件挂载后获取轨道宽度
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
updateTrackWidth()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.custom-switch {
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
.switch-track {
|
||||||
|
.inactive-text {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-text {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.is-active) {
|
||||||
|
.switch-track {
|
||||||
|
.inactive-text {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-text {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-track {
|
||||||
|
position: relative;
|
||||||
|
min-width: 120px; // 最小宽度
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-thumb {
|
||||||
|
position: absolute;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-text {
|
||||||
|
position: relative; // 改为相对定位
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1; // 让文字占据剩余空间
|
||||||
|
text-align: center; // 文字居中
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-text {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-text {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
position: absolute; // 绝对定位,避免影响布局
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应式设计
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.switch-track {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -98,6 +98,12 @@ const routes = [
|
|||||||
meta: { title: 'AI3D模型生成' },
|
meta: { title: 'AI3D模型生成' },
|
||||||
component: () => import('@/views/AIThreeDCreate.vue'),
|
component: () => import('@/views/AIThreeDCreate.vue'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'test3d',
|
||||||
|
path: '/test3d',
|
||||||
|
meta: { title: '3D预览测试' },
|
||||||
|
component: () => import('@/views/test/Test3D.vue'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ExternalLink',
|
name: 'ExternalLink',
|
||||||
path: '/external',
|
path: '/external',
|
||||||
@@ -476,7 +482,7 @@ const routes = [
|
|||||||
path: '/mobile/3d',
|
path: '/mobile/3d',
|
||||||
name: 'mobile-3d',
|
name: 'mobile-3d',
|
||||||
meta: { title: '3D模型生成' },
|
meta: { title: '3D模型生成' },
|
||||||
component: () => import('@/views/mobile/ThreeDCreate.vue'),
|
component: () => import('@/views/AIThreeDCreate.vue'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -485,7 +491,7 @@ const routes = [
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
path: '/test',
|
path: '/test',
|
||||||
meta: { title: '测试页面' },
|
meta: { title: '测试页面' },
|
||||||
component: () => import('@/views/Test.vue'),
|
component: () => import('@/views/test/Test.vue'),
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
|||||||
import { replaceImg, substr } from '@/utils/libs'
|
import { replaceImg, substr } from '@/utils/libs'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, nextTick, reactive, ref } from 'vue'
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
|
||||||
export const useJimengStore = defineStore('jimeng', () => {
|
export const useJimengStore = defineStore('jimeng', () => {
|
||||||
// 当前激活的功能分类和具体功能
|
// 当前激活的功能分类和具体功能
|
||||||
@@ -431,7 +431,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
|||||||
|
|
||||||
const downloadFile = async (item) => {
|
const downloadFile = async (item) => {
|
||||||
const url = replaceImg(item.video_url || item.img_url)
|
const url = replaceImg(item.video_url || item.img_url)
|
||||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
const downloadURL = `/api/download?url=${url}`
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
const fileName = urlObj.pathname.split('/').pop()
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
|
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export const useJimengStore = defineStore('mobile-jimeng', () => {
|
|||||||
|
|
||||||
const downloadFile = async (item) => {
|
const downloadFile = async (item) => {
|
||||||
const url = replaceImg(item.video_url || item.img_url)
|
const url = replaceImg(item.video_url || item.img_url)
|
||||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
const downloadURL = `/api/download?url=${url}`
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
const fileName = urlObj.pathname.split('/').pop()
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
|
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export const useSunoStore = defineStore('suno', () => {
|
|||||||
}
|
}
|
||||||
const download = (item) => {
|
const download = (item) => {
|
||||||
const url = replaceImg(item.audio_url)
|
const url = replaceImg(item.audio_url)
|
||||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
const downloadURL = `/api/download?url=${url}`
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
const fileName = urlObj.pathname.split('/').pop()
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
item.downloading = true
|
item.downloading = true
|
||||||
|
|||||||
@@ -179,7 +179,7 @@ export const useSunoStore = defineStore('suno', () => {
|
|||||||
|
|
||||||
const download = async (item) => {
|
const download = async (item) => {
|
||||||
const url = replaceImg(item.audio_url)
|
const url = replaceImg(item.audio_url)
|
||||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
const downloadURL = `/api/download?url=${url}`
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
const fileName = urlObj.pathname.split('/').pop()
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
|
|
||||||
|
|||||||
@@ -464,7 +464,7 @@ export const useVideoStore = defineStore('video', () => {
|
|||||||
// 视频下载
|
// 视频下载
|
||||||
const downloadVideo = async (item) => {
|
const downloadVideo = async (item) => {
|
||||||
const url = replaceImg(item.video_url)
|
const url = replaceImg(item.video_url)
|
||||||
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
|
const downloadURL = `/api/download?url=${url}`
|
||||||
const urlObj = new URL(url)
|
const urlObj = new URL(url)
|
||||||
const fileName = urlObj.pathname.split('/').pop()
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
|
|
||||||
|
|||||||
@@ -16,35 +16,20 @@
|
|||||||
<div class="params-container">
|
<div class="params-container">
|
||||||
<!-- 图片上传区域 -->
|
<!-- 图片上传区域 -->
|
||||||
<div class="param-line pt">
|
<div class="param-line pt">
|
||||||
<span class="label">上传图片:</span>
|
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<ImageUpload v-model="giteeForm.image_url" :max-count="1" :multiple="false" />
|
<ImageUpload v-model="giteeForm.image_url" :max-count="1" :multiple="false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文本提示词 -->
|
|
||||||
<div class="param-line pt">
|
|
||||||
<span class="label">提示词:</span>
|
|
||||||
</div>
|
|
||||||
<div class="param-line">
|
|
||||||
<el-input
|
|
||||||
v-model="giteeForm.prompt"
|
|
||||||
type="textarea"
|
|
||||||
:autosize="{ minRows: 3, maxRows: 5 }"
|
|
||||||
placeholder="请输入3D模型描述,越详细越好"
|
|
||||||
maxlength="2000"
|
|
||||||
show-word-limit
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 模型选择 -->
|
<!-- 模型选择 -->
|
||||||
<div class="param-line pt">
|
<div class="param-line pt">
|
||||||
<span class="label">输出格式:</span>
|
<span class="label"><span class="text-red-500 mr-1">*</span>模型选择:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="giteeForm.model"
|
v-model="giteeForm.model"
|
||||||
placeholder="选择输出格式"
|
placeholder="选择模型"
|
||||||
@change="handleModelChange"
|
@change="handleModelChange"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
@@ -55,6 +40,30 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="param-line">
|
||||||
|
<el-alert v-if="giteeForm.model_desc" type="info" :closable="false">
|
||||||
|
{{ giteeForm.model_desc }}
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件格式选择 -->
|
||||||
|
<div class="param-line">
|
||||||
|
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式:</span>
|
||||||
|
<el-select v-model="giteeForm.file_format" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="format in giteeSupportedFormats"
|
||||||
|
:key="format"
|
||||||
|
:label="format"
|
||||||
|
:value="format"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 纹理开关 -->
|
||||||
|
<div class="flex justify-between param-line">
|
||||||
|
<span class="label">生成纹理:</span>
|
||||||
|
<el-switch v-model="giteeForm.texture" size="large" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 高级参数 -->
|
<!-- 高级参数 -->
|
||||||
<div class="param-line pt">
|
<div class="param-line pt">
|
||||||
@@ -64,9 +73,7 @@
|
|||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
:class="
|
:class="
|
||||||
giteeAdvancedVisible
|
giteeAdvancedVisible ? 'iconfont icon-arrow-up' : 'iconfont icon-arrow-down'
|
||||||
? 'iconfont icon-arrow-down'
|
|
||||||
: 'iconfont icon-arrow-right'
|
|
||||||
"
|
"
|
||||||
></i>
|
></i>
|
||||||
<span>高级参数设置</span>
|
<span>高级参数设置</span>
|
||||||
@@ -75,14 +82,9 @@
|
|||||||
|
|
||||||
<!-- 高级参数内容 -->
|
<!-- 高级参数内容 -->
|
||||||
<div v-show="giteeAdvancedVisible" class="advanced-params">
|
<div v-show="giteeAdvancedVisible" class="advanced-params">
|
||||||
<!-- 纹理开关 -->
|
|
||||||
<div class="param-line">
|
|
||||||
<el-checkbox v-model="giteeForm.texture">启用纹理</el-checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 随机种子 -->
|
<!-- 随机种子 -->
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<span class="label">随机种子:</span>
|
<span class="label mb-3">随机种子:</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="giteeForm.seed"
|
v-model="giteeForm.seed"
|
||||||
:min="1"
|
:min="1"
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
|
|
||||||
<!-- 迭代次数 -->
|
<!-- 迭代次数 -->
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<span class="label">迭代次数:</span>
|
<span class="label mb-3">迭代次数:</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="giteeForm.num_inference_steps"
|
v-model="giteeForm.num_inference_steps"
|
||||||
:min="1"
|
:min="1"
|
||||||
@@ -106,7 +108,7 @@
|
|||||||
|
|
||||||
<!-- 引导系数 -->
|
<!-- 引导系数 -->
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<span class="label">引导系数:</span>
|
<span class="label mb-3">引导系数:</span>
|
||||||
<el-input-number
|
<el-input-number
|
||||||
v-model="giteeForm.guidance_scale"
|
v-model="giteeForm.guidance_scale"
|
||||||
:min="1"
|
:min="1"
|
||||||
@@ -119,7 +121,7 @@
|
|||||||
|
|
||||||
<!-- 3D渲染精度 -->
|
<!-- 3D渲染精度 -->
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<span class="label">3D渲染精度:</span>
|
<span class="label mb-3">3D渲染精度:</span>
|
||||||
<el-select v-model="giteeForm.octree_resolution" style="width: 100%">
|
<el-select v-model="giteeForm.octree_resolution" style="width: 100%">
|
||||||
<el-option label="64 (低精度)" :value="64" />
|
<el-option label="64 (低精度)" :value="64" />
|
||||||
<el-option label="128 (中精度)" :value="128" />
|
<el-option label="128 (中精度)" :value="128" />
|
||||||
@@ -138,17 +140,32 @@
|
|||||||
</template>
|
</template>
|
||||||
<!-- 参数容器 -->
|
<!-- 参数容器 -->
|
||||||
<div class="params-container">
|
<div class="params-container">
|
||||||
<!-- 图片上传区域 -->
|
<div class="param-line pt flex justify-between items-center">
|
||||||
<div class="param-line pt">
|
<span class="label">生成模式:</span>
|
||||||
<span class="label">上传图片:</span>
|
<custom-switch
|
||||||
|
v-model="tencentForm.text3d"
|
||||||
|
active-color="#9c27b0"
|
||||||
|
inactive-color="#409eff"
|
||||||
|
:width="120"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
<template #active-text>
|
||||||
|
<div class="flex items-center justify-start pl-4 text-sm">
|
||||||
|
<i class="iconfont icon-image mr-1"></i> <span>文生3D</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-line">
|
</template>
|
||||||
<ImageUpload v-model="tencentForm.image_url" :max-count="1" :multiple="false" />
|
<template #inactive-text>
|
||||||
|
<div class="flex items-center justify-end pl-4 text-sm">
|
||||||
|
<i class="iconfont icon-doc mr-1"></i> <span>图生3D</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</custom-switch>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文本提示词 -->
|
<!-- 文本提示词 -->
|
||||||
|
<div v-if="tencentForm.text3d">
|
||||||
<div class="param-line pt">
|
<div class="param-line pt">
|
||||||
<span class="label">提示词:</span>
|
<span class="label"><span class="text-red-500 mr-1">*</span>提示词:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -160,16 +177,26 @@
|
|||||||
show-word-limit
|
show-word-limit
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- 图片上传区域 -->
|
||||||
|
<div class="param-line pt">
|
||||||
|
<span class="label"><span class="text-red-500 mr-1">*</span>上传图片:</span>
|
||||||
|
</div>
|
||||||
|
<div class="param-line">
|
||||||
|
<ImageUpload v-model="tencentForm.image_url" :max-count="1" :multiple="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 模型选择 -->
|
<!-- 模型选择 -->
|
||||||
<div class="param-line pt">
|
<div class="param-line pt">
|
||||||
<span class="label">输出格式:</span>
|
<span class="label mb-2"><span class="text-red-500 mr-1">*</span>模型选择:</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="tencentForm.model"
|
v-model="tencentForm.model"
|
||||||
@change="handleModelChange"
|
@change="handleModelChange"
|
||||||
placeholder="选择输出格式"
|
placeholder="选择模型"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="model in configs.tencent.models"
|
v-for="model in configs.tencent.models"
|
||||||
@@ -179,27 +206,30 @@
|
|||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 高级参数 -->
|
|
||||||
<div class="param-line pt">
|
|
||||||
<span class="label">高级参数:</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- PBR材质开关 -->
|
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<el-checkbox v-model="tencentForm.enable_pbr">启用PBR材质</el-checkbox>
|
<el-alert v-if="tencentForm.model_desc" type="info" :closable="false">
|
||||||
|
{{ tencentForm.model_desc }}
|
||||||
|
</el-alert>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 文件格式选择 -->
|
<!-- 文件格式选择 -->
|
||||||
<div class="param-line">
|
<div class="param-line">
|
||||||
<span class="label">文件格式:</span>
|
<span class="label mb-3"><span class="text-red-500 mr-1">*</span>输出格式:</span>
|
||||||
<el-select v-model="tencentForm.file_format" style="width: 100%">
|
<el-select v-model="tencentForm.file_format" style="width: 100%">
|
||||||
<el-option label="GLB" value="glb" />
|
<el-option
|
||||||
<el-option label="GLTF" value="gltf" />
|
v-for="format in tencentSupportedFormats"
|
||||||
<el-option label="OBJ" value="obj" />
|
:key="format"
|
||||||
<el-option label="FBX" value="fbx" />
|
:label="format"
|
||||||
|
:value="format"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- PBR材质开关 -->
|
||||||
|
<div class="flex justify-between param-line">
|
||||||
|
<span class="label">启用PBR材质:</span>
|
||||||
|
<el-switch v-model="tencentForm.enable_pbr" size="large" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CustomTabPane>
|
</CustomTabPane>
|
||||||
<!-- 生成按钮 -->
|
<!-- 生成按钮 -->
|
||||||
@@ -232,33 +262,139 @@
|
|||||||
<div
|
<div
|
||||||
v-for="task in taskList"
|
v-for="task in taskList"
|
||||||
:key="task.id"
|
:key="task.id"
|
||||||
class="task-item"
|
class="task-card"
|
||||||
:class="{ completed: task.status === 'completed' }"
|
:class="getTaskCardClass(task.status)"
|
||||||
>
|
>
|
||||||
<div class="task-header">
|
<!-- 任务卡片头部 -->
|
||||||
<span class="task-id">#{{ task.id }}</span>
|
<div class="task-card-header">
|
||||||
<span class="task-status" :class="task.status">
|
<div class="task-info">
|
||||||
|
<div class="task-id">
|
||||||
|
<i class="iconfont icon-renwu mr-2"></i>
|
||||||
|
#{{ task.id }}
|
||||||
|
</div>
|
||||||
|
<div class="task-platform">
|
||||||
|
<i :class="getPlatformIcon(task.type)" class="mr-1"></i>
|
||||||
|
{{ getPlatformName(task.type) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="task-status-wrapper">
|
||||||
|
<div class="task-status" :class="task.status">
|
||||||
|
<i :class="getStatusIcon(task.status)" class="mr-1"></i>
|
||||||
{{ getStatusText(task.status) }}
|
{{ getStatusText(task.status) }}
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="task-power">
|
||||||
<div class="task-content">
|
<i class="iconfont icon-suanli mr-1"></i>
|
||||||
<div class="task-prompt">
|
{{ task.power }}
|
||||||
{{ task.params?.prompt }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="task-progress" v-if="task.status === 'processing'">
|
|
||||||
<el-progress :percentage="task.progress" :stroke-width="4" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="task-actions" v-if="task.status === 'completed'">
|
<!-- 任务卡片内容 -->
|
||||||
<el-button size="small" @click="preview3D(task)">预览</el-button>
|
<div class="task-card-content">
|
||||||
<el-button size="small" type="primary" @click="download(task)">下载</el-button>
|
<!-- 左侧预览图 -->
|
||||||
|
<div class="task-preview">
|
||||||
|
<div v-if="task.status === 'completed' && task.preview_url" class="preview-image">
|
||||||
|
<img :src="task.preview_url" :alt="getTaskPrompt(task)" />
|
||||||
|
<div class="preview-overlay">
|
||||||
|
<i class="iconfont icon-yulan"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="getTaskImageUrl(task)" class="input-image">
|
||||||
|
<img :src="getTaskImageUrl(task)" :alt="getTaskPrompt(task)" />
|
||||||
|
<div class="input-overlay">
|
||||||
|
<i class="iconfont icon-tupian"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="prompt-placeholder">
|
||||||
|
<i class="iconfont icon-wenzi"></i>
|
||||||
|
<span>{{ getTaskPrompt(task) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="task-actions" v-else>
|
<!-- 右侧任务详情 -->
|
||||||
<el-button size="small" @click="deleteTask(task.id)">删除</el-button>
|
<div class="task-details">
|
||||||
|
<div class="task-model">
|
||||||
|
<i class="iconfont icon-moxing mr-1"></i>
|
||||||
|
{{ task.model }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="task-prompt" v-if="getTaskPrompt(task)">
|
||||||
|
<i class="iconfont icon-tishi mr-1"></i>
|
||||||
|
<span>{{ getTaskPrompt(task) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-params" v-if="getTaskParams(task)">
|
||||||
|
<i class="iconfont icon-shezhi mr-1"></i>
|
||||||
|
<span>{{ getTaskParams(task) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-time">
|
||||||
|
<i class="iconfont icon-shijian mr-1"></i>
|
||||||
|
{{ formatTime(task.created_at) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-error" v-if="task.status === 'failed' && task.err_msg">
|
||||||
|
<i class="iconfont icon-cuowu mr-1"></i>
|
||||||
|
<span>{{ task.err_msg }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 任务卡片底部操作 -->
|
||||||
|
<div class="task-card-footer">
|
||||||
|
<div class="task-actions">
|
||||||
|
<el-button
|
||||||
|
v-if="task.status === 'completed'"
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
@click="preview3D(task)"
|
||||||
|
class="action-btn preview-btn"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-eye-open mr-1"></i>
|
||||||
|
预览
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="task.status === 'completed'"
|
||||||
|
size="small"
|
||||||
|
type="success"
|
||||||
|
@click="downloadFile(task)"
|
||||||
|
:loading="task.downloading"
|
||||||
|
class="action-btn download-btn"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-download mr-1" v-if="!task.downloading"></i>
|
||||||
|
<span v-if="task.downloading">下载中...</span>
|
||||||
|
<span v-else>下载</span>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="danger"
|
||||||
|
@click="deleteTask(task.id)"
|
||||||
|
class="action-btn delete-btn"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-remove mr-1"></i>
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<el-button
|
||||||
|
v-if="task.status === 'processing'"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
disabled
|
||||||
|
class="action-btn processing-btn"
|
||||||
|
>
|
||||||
|
<i class="iconfont icon-loading animate-spin mr-1"></i>
|
||||||
|
处理中...
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="taskList.length === 0" class="empty-state">
|
||||||
|
<i class="iconfont icon-kong"></i>
|
||||||
|
<p>暂无任务,开始创建你的第一个3D模型吧!</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,9 +417,8 @@
|
|||||||
<el-dialog v-model="previewVisible" title="3D模型预览" width="80%" :before-close="closePreview">
|
<el-dialog v-model="previewVisible" title="3D模型预览" width="80%" :before-close="closePreview">
|
||||||
<div class="preview-container">
|
<div class="preview-container">
|
||||||
<ThreeDPreview
|
<ThreeDPreview
|
||||||
v-if="currentPreviewTask && currentPreviewTask.img_url"
|
v-if="currentPreviewTask && currentPreviewTask.file_url"
|
||||||
:model-url="currentPreviewTask.img_url"
|
:model-url="currentPreviewTask.file_url"
|
||||||
:model-type="currentPreviewTask.model"
|
|
||||||
/>
|
/>
|
||||||
<div v-else class="preview-placeholder">
|
<div v-else class="preview-placeholder">
|
||||||
<i class="iconfont icon-3d"></i>
|
<i class="iconfont icon-3d"></i>
|
||||||
@@ -293,7 +428,14 @@
|
|||||||
<template #footer>
|
<template #footer>
|
||||||
<span class="dialog-footer">
|
<span class="dialog-footer">
|
||||||
<el-button @click="closePreview">关闭</el-button>
|
<el-button @click="closePreview">关闭</el-button>
|
||||||
<el-button type="primary" @click="downloadCurrentModel">下载模型</el-button>
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
@click="downloadCurrentModel"
|
||||||
|
:loading="currentPreviewTask.downloading"
|
||||||
|
>
|
||||||
|
<span v-if="!currentPreviewTask.downloading">下载模型</span>
|
||||||
|
<span v-else>下载中...</span>
|
||||||
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -303,12 +445,15 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import ImageUpload from '@/components/ImageUpload.vue'
|
import ImageUpload from '@/components/ImageUpload.vue'
|
||||||
import ThreeDPreview from '@/components/ThreeDPreview.vue'
|
import ThreeDPreview from '@/components/ThreeDPreview.vue'
|
||||||
|
import CustomSwitch from '@/components/ui/CustomSwitch.vue'
|
||||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||||
import { httpGet, httpPost } from '@/utils/http'
|
import { checkSession } from '@/store/cache'
|
||||||
|
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { checkSession } from '@/store/cache'
|
import { showMessageError } from '../utils/dialog'
|
||||||
|
import { replaceImg } from '../utils/libs'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const activePlatform = ref('gitee')
|
const activePlatform = ref('gitee')
|
||||||
@@ -320,27 +465,34 @@ const total = ref(0)
|
|||||||
const taskList = ref([])
|
const taskList = ref([])
|
||||||
const currentPreviewTask = ref(null)
|
const currentPreviewTask = ref(null)
|
||||||
const giteeAdvancedVisible = ref(false) // 控制Gitee高级参数显示状态
|
const giteeAdvancedVisible = ref(false) // 控制Gitee高级参数显示状态
|
||||||
const tencentForm = ref({
|
const tencentDefaultForm = {
|
||||||
|
text3d: false,
|
||||||
prompt: '',
|
prompt: '',
|
||||||
image_url: '',
|
image_url: '',
|
||||||
model: '',
|
model: '',
|
||||||
power: 0,
|
|
||||||
file_format: '', // 输出文件格式
|
file_format: '', // 输出文件格式
|
||||||
enable_pbr: false, // 是否开启PBR材质
|
enable_pbr: false, // 是否开启PBR材质
|
||||||
})
|
model_desc: '', // 模型描述
|
||||||
const giteeForm = ref({
|
power: 0, // 算力消耗
|
||||||
|
}
|
||||||
|
const giteeDefaultForm = {
|
||||||
prompt: '',
|
prompt: '',
|
||||||
image_url: '',
|
image_url: '',
|
||||||
model: '',
|
model: '',
|
||||||
power: 0,
|
|
||||||
file_format: '', // 输出文件格式
|
file_format: '', // 输出文件格式
|
||||||
texture: false, // 是否开启纹理
|
texture: false, // 是否开启纹理
|
||||||
seed: 1234, // 随机种子
|
seed: 1234, // 随机种子
|
||||||
num_inference_steps: 5, //迭代次数
|
num_inference_steps: 5, //迭代次数
|
||||||
guidance_scale: 7.5, //引导系数
|
guidance_scale: 7.5, //引导系数
|
||||||
octree_resolution: 128, // 3D 渲染精度,越高3D 细节越丰富
|
octree_resolution: 128, // 3D 渲染精度,越高3D 细节越丰富
|
||||||
})
|
model_desc: '', // 模型描述
|
||||||
|
power: 0, // 算力消耗
|
||||||
|
}
|
||||||
|
const tencentForm = ref(tencentDefaultForm)
|
||||||
|
const giteeForm = ref(giteeDefaultForm)
|
||||||
const currentPower = ref(0)
|
const currentPower = ref(0)
|
||||||
|
const tencentSupportedFormats = ref([])
|
||||||
|
const giteeSupportedFormats = ref([])
|
||||||
|
|
||||||
// 计算属性:获取当前活跃平台的表单数据
|
// 计算属性:获取当前活跃平台的表单数据
|
||||||
const currentForm = computed(() => {
|
const currentForm = computed(() => {
|
||||||
@@ -374,10 +526,16 @@ const handleModelChange = (value) => {
|
|||||||
const model = configs.value.tencent.models.find((model) => model.name === value)
|
const model = configs.value.tencent.models.find((model) => model.name === value)
|
||||||
currentPower.value = model.power
|
currentPower.value = model.power
|
||||||
tencentForm.value.power = model.power
|
tencentForm.value.power = model.power
|
||||||
|
tencentForm.value.model_desc = model.desc
|
||||||
|
tencentForm.value.file_format = model.formats[0]
|
||||||
|
tencentSupportedFormats.value = model.formats
|
||||||
} else {
|
} else {
|
||||||
const model = configs.value.gitee.models.find((model) => model.name === value)
|
const model = configs.value.gitee.models.find((model) => model.name === value)
|
||||||
currentPower.value = model.power
|
currentPower.value = model.power
|
||||||
giteeForm.value.power = model.power
|
giteeForm.value.power = model.power
|
||||||
|
giteeForm.value.model_desc = model.desc
|
||||||
|
giteeForm.value.file_format = model.formats[0]
|
||||||
|
giteeSupportedFormats.value = model.formats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,17 +544,14 @@ const handlePlatformChange = (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const generate3D = async () => {
|
const generate3D = async () => {
|
||||||
if (currentPower.value === 0) {
|
const requestData = {
|
||||||
ElMessage.warning('请完善生成参数')
|
...(activePlatform.value === 'tencent' ? tencentForm.value : giteeForm.value),
|
||||||
|
}
|
||||||
|
if (requestData.model === '') {
|
||||||
|
ElMessage.warning('请选择模型')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (requestData.file_format === '') {
|
||||||
if (!currentPrompt.value.trim()) {
|
|
||||||
ElMessage.warning('请输入提示词')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedModel.value) {
|
|
||||||
ElMessage.warning('请选择输出格式')
|
ElMessage.warning('请选择输出格式')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -404,13 +559,9 @@ const generate3D = async () => {
|
|||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|
||||||
const requestData = {
|
requestData.type = activePlatform.value
|
||||||
type: activePlatform.value,
|
if (requestData.image_url !== '') {
|
||||||
model: selectedModel.value,
|
requestData.image_url = replaceImg(requestData.image_url[0].url)
|
||||||
prompt: currentPrompt.value,
|
|
||||||
image_url: currentImage.value[0]?.url || '',
|
|
||||||
power: currentPower.value,
|
|
||||||
...currentForm.value, // 包含所有表单参数
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await httpPost('/api/ai3d/generate', requestData)
|
const response = await httpPost('/api/ai3d/generate', requestData)
|
||||||
@@ -418,26 +569,8 @@ const generate3D = async () => {
|
|||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
ElMessage.success('任务创建成功')
|
ElMessage.success('任务创建成功')
|
||||||
// 清空表单
|
// 清空表单
|
||||||
tencentForm.value = {
|
tencentForm.value = tencentDefaultForm
|
||||||
prompt: '',
|
giteeForm.value = giteeDefaultForm
|
||||||
image_url: '',
|
|
||||||
model: '',
|
|
||||||
power: 0,
|
|
||||||
file_format: '',
|
|
||||||
enable_pbr: false,
|
|
||||||
}
|
|
||||||
giteeForm.value = {
|
|
||||||
prompt: '',
|
|
||||||
image_url: '',
|
|
||||||
model: '',
|
|
||||||
power: 0,
|
|
||||||
file_format: '',
|
|
||||||
texture: false,
|
|
||||||
seed: 1234,
|
|
||||||
num_inference_steps: 5,
|
|
||||||
guidance_scale: 7.5,
|
|
||||||
octree_resolution: 128,
|
|
||||||
}
|
|
||||||
currentPower.value = 0
|
currentPower.value = 0
|
||||||
// 刷新任务列表
|
// 刷新任务列表
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -453,13 +586,13 @@ const generate3D = async () => {
|
|||||||
|
|
||||||
const loadTasks = async () => {
|
const loadTasks = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await httpGet('/api/ai3d/jobs', {
|
const response = await httpGet('/api/ai3d/jobs/mock', {
|
||||||
page: currentPage.value,
|
page: currentPage.value,
|
||||||
page_size: pageSize.value,
|
page_size: pageSize.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
taskList.value = response.data.list
|
taskList.value = response.data.items
|
||||||
total.value = response.data.total
|
total.value = response.data.total
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -490,7 +623,7 @@ const deleteTask = async (taskId) => {
|
|||||||
type: 'warning',
|
type: 'warning',
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await httpGet(`/api/ai3d/job/${taskId}/delete`)
|
const response = await httpGet(`/api/ai3d/job/delete?id=${taskId}`)
|
||||||
if (response.code === 0) {
|
if (response.code === 0) {
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
loadTasks()
|
loadTasks()
|
||||||
@@ -511,35 +644,36 @@ const preview3D = (task) => {
|
|||||||
|
|
||||||
const closePreview = () => {
|
const closePreview = () => {
|
||||||
previewVisible.value = false
|
previewVisible.value = false
|
||||||
currentPreviewTask.value = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const download = async (task) => {
|
const downloadFile = async (item) => {
|
||||||
if (!task.img_url) {
|
const url = replaceImg(item.file_url)
|
||||||
ElMessage.warning('模型文件不存在')
|
const downloadURL = `/api/download?url=${url}`
|
||||||
return
|
const urlObj = new URL(url)
|
||||||
}
|
const fileName = urlObj.pathname.split('/').pop()
|
||||||
|
|
||||||
|
item.downloading = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 创建一个隐藏的a标签来下载文件
|
const response = await httpDownload(downloadURL)
|
||||||
|
const blob = new Blob([response.data])
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = task.img_url
|
link.href = URL.createObjectURL(blob)
|
||||||
link.download = `3d_model_${task.id}.${task.model}`
|
link.download = fileName
|
||||||
link.style.display = 'none'
|
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
ElMessage.success('开始下载3D模型')
|
item.downloading = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('下载失败:', error)
|
showMessageError('下载失败')
|
||||||
ElMessage.error('下载失败,请重试')
|
item.downloading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadCurrentModel = () => {
|
const downloadCurrentModel = () => {
|
||||||
if (currentPreviewTask.value) {
|
if (currentPreviewTask.value) {
|
||||||
download(currentPreviewTask.value)
|
downloadFile(currentPreviewTask.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +687,116 @@ const getStatusText = (status) => {
|
|||||||
return statusMap[status] || status
|
return statusMap[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTaskCardClass = (status) => {
|
||||||
|
if (status === 'completed') {
|
||||||
|
return 'task-card-completed'
|
||||||
|
} else if (status === 'processing') {
|
||||||
|
return 'task-card-processing'
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
return 'task-card-failed'
|
||||||
|
} else {
|
||||||
|
return 'task-card-default'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlatformIcon = (type) => {
|
||||||
|
if (type === 'gitee') {
|
||||||
|
return 'iconfont icon-gitee'
|
||||||
|
} else if (type === 'tencent') {
|
||||||
|
return 'iconfont icon-tencent'
|
||||||
|
}
|
||||||
|
return 'iconfont icon-question'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPlatformName = (type) => {
|
||||||
|
if (type === 'gitee') {
|
||||||
|
return 'Gitee 模力方舟'
|
||||||
|
} else if (type === 'tencent') {
|
||||||
|
return '腾讯云混元3D'
|
||||||
|
}
|
||||||
|
return '未知平台'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStatusIcon = (status) => {
|
||||||
|
if (status === 'pending') {
|
||||||
|
return 'iconfont icon-pending'
|
||||||
|
} else if (status === 'processing') {
|
||||||
|
return 'iconfont icon-processing'
|
||||||
|
} else if (status === 'completed') {
|
||||||
|
return 'iconfont icon-completed'
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
return 'iconfont icon-failed'
|
||||||
|
}
|
||||||
|
return 'iconfont icon-question'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskPrompt = (task) => {
|
||||||
|
try {
|
||||||
|
if (task.params) {
|
||||||
|
const parsedParams = JSON.parse(task.params)
|
||||||
|
return parsedParams.prompt || '文生3D任务'
|
||||||
|
}
|
||||||
|
return '文生3D任务'
|
||||||
|
} catch (e) {
|
||||||
|
return '文生3D任务'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskImageUrl = (task) => {
|
||||||
|
try {
|
||||||
|
if (task.params) {
|
||||||
|
const parsedParams = JSON.parse(task.params)
|
||||||
|
return parsedParams.image_url || null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskParams = (task) => {
|
||||||
|
try {
|
||||||
|
if (task.params) {
|
||||||
|
const parsedParams = JSON.parse(task.params)
|
||||||
|
const params = []
|
||||||
|
|
||||||
|
if (parsedParams.texture) {
|
||||||
|
params.push('纹理')
|
||||||
|
}
|
||||||
|
if (parsedParams.enable_pbr) {
|
||||||
|
params.push('PBR材质')
|
||||||
|
}
|
||||||
|
if (parsedParams.num_inference_steps && parsedParams.num_inference_steps !== 5) {
|
||||||
|
params.push(`迭代次数: ${parsedParams.num_inference_steps}`)
|
||||||
|
}
|
||||||
|
if (parsedParams.guidance_scale && parsedParams.guidance_scale !== 7.5) {
|
||||||
|
params.push(`引导系数: ${parsedParams.guidance_scale}`)
|
||||||
|
}
|
||||||
|
if (parsedParams.octree_resolution && parsedParams.octree_resolution !== 128) {
|
||||||
|
params.push(`精度: ${parsedParams.octree_resolution}`)
|
||||||
|
}
|
||||||
|
if (parsedParams.seed && parsedParams.seed !== 1234) {
|
||||||
|
params.push(`种子: ${parsedParams.seed}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.join(',')
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
} catch (e) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (timestamp) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0')
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||||||
|
}
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadConfigs()
|
loadConfigs()
|
||||||
@@ -565,249 +809,5 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.page-threed {
|
@use '@/assets/css/ai3d.scss' as ai3d;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-toggle-btn {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #409eff;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #66b1ff;
|
|
||||||
background: #f0f9ff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
font-size: 12px;
|
|
||||||
transition: transform 0.3s ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.advanced-params {
|
|
||||||
margin-left: 16px;
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-left: 3px solid #e4e7ed;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,765 +0,0 @@
|
|||||||
<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>
|
|
||||||
66
web/src/views/test/Test3D.vue
Normal file
66
web/src/views/test/Test3D.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<template>
|
||||||
|
<div class="test-3d">
|
||||||
|
<h1>3D预览功能测试</h1>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>测试1: 默认立方体(无模型URL)</h2>
|
||||||
|
<div class="preview-wrapper">
|
||||||
|
<ThreeDPreview />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h2>测试2: 带模型URL(如果有的话)</h2>
|
||||||
|
<div class="preview-wrapper">
|
||||||
|
<ThreeDPreview v-if="testModelUrl" :model-url="testModelUrl" :model-type="testModelType" />
|
||||||
|
<div v-else class="no-model">
|
||||||
|
<p>没有测试模型URL</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ThreeDPreview from '@/components/ThreeDPreview.vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 测试用的模型URL(可以替换为实际的模型文件)
|
||||||
|
const testModelUrl = ref('https://img.r9it.com/R03TQZ7PZ386RGL7PTMNGFOHAJW15WYF.glb')
|
||||||
|
const testModelType = ref('glb')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.test-3d {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-container {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-model {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user