文生视频和图生视频功能完成

This commit is contained in:
GeekMaster
2025-07-23 19:11:30 +08:00
parent 54fe49de5d
commit a3f6a641aa
20 changed files with 640 additions and 610 deletions

View File

@@ -290,7 +290,7 @@ func (h *JimengHandler) Jobs(c *gin.Context) {
// 分页查询 // 分页查询
offset := (req.Page - 1) * req.PageSize offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&jobs).Error; err != nil { if err := query.Order("updated_at DESC").Offset(offset).Limit(req.PageSize).Find(&jobs).Error; err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return
} }
@@ -338,22 +338,32 @@ func (h *JimengHandler) Remove(c *gin.Context) {
return return
} }
if err := h.jimengService.DeleteJob(uint(jobId), user.Id); err != nil { tx := h.DB.Begin()
if err := tx.Where("id = ? AND user_id = ?", jobId, user.Id).Delete(&model.JimengJob{}).Error; err != nil {
logger.Errorf("delete jimeng job failed: %v", err) logger.Errorf("delete jimeng job failed: %v", err)
resp.ERROR(c, "删除任务失败") resp.ERROR(c, "删除任务失败")
return return
} }
// 退回算力
err = h.userService.IncreasePower(user.Id, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: "jimeng",
Remark: fmt.Sprintf("删除任务,退回%d算力", job.Power),
})
if err != nil {
resp.ERROR(c, "退回算力失败")
tx.Rollback()
return
}
tx.Commit()
resp.SUCCESS(c, gin.H{}) resp.SUCCESS(c, gin.H{})
} }
// Retry 重试任务 // Retry 重试任务
func (h *JimengHandler) Retry(c *gin.Context) { func (h *JimengHandler) Retry(c *gin.Context) {
user, err := h.GetLoginUser(c) userId := h.GetLoginUserId(c)
if err != nil {
resp.NotAuth(c)
return
}
jobId := h.GetInt(c, "id", 0) jobId := h.GetInt(c, "id", 0)
if jobId == 0 { if jobId == 0 {
@@ -368,7 +378,7 @@ func (h *JimengHandler) Retry(c *gin.Context) {
return return
} }
if job.UserId != user.Id { if job.UserId != userId {
resp.ERROR(c, "无权限操作") resp.ERROR(c, "无权限操作")
return return
} }

View File

@@ -144,7 +144,15 @@ func (h *NetHandler) Download(c *gin.Context) {
return return
} }
// 使用http.Get下载文件 // 使用http.Get下载文件
r, err := http.Get(fileUrl) req, err := http.NewRequest("GET", fileUrl, nil)
if err != nil {
resp.ERROR(c, err.Error())
return
}
// 模拟浏览器 UA
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
client := &http.Client{}
r, err := client.Do(req)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return
@@ -157,6 +165,5 @@ func (h *NetHandler) Download(c *gin.Context) {
} }
c.Status(http.StatusOK) c.Status(http.StatusOK)
// 将下载的文件内容写入响应
_, _ = io.Copy(c.Writer, r.Body) _, _ = io.Copy(c.Writer, r.Body)
} }

View File

@@ -293,7 +293,7 @@ func (s *Service) DownloadImages() {
func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) { func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) {
// sava image // sava image
imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, false) imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, ".png", false)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
logger2 "geekai/logger" logger2 "geekai/logger"
"geekai/service/oss"
"geekai/store" "geekai/store"
"geekai/store/model" "geekai/store/model"
"geekai/utils" "geekai/utils"
@@ -31,10 +32,11 @@ type Service struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
running bool running bool
uploader *oss.UploaderManager
} }
// NewService 创建即梦服务 // NewService 创建即梦服务
func NewService(db *gorm.DB, redisCli *redis.Client) *Service { func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager) *Service {
taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli) taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli)
// 从数据库加载配置 // 从数据库加载配置
var config model.Config var config model.Config
@@ -54,6 +56,7 @@ func NewService(db *gorm.DB, redisCli *redis.Client) *Service {
ctx: ctx, ctx: ctx,
cancel: cancel, cancel: cancel,
running: false, running: false,
uploader: uploader,
} }
} }
@@ -65,6 +68,7 @@ func (s *Service) Start() {
logger.Info("Starting Jimeng service and task consumer...") logger.Info("Starting Jimeng service and task consumer...")
s.running = true s.running = true
go s.consumeTasks() go s.consumeTasks()
go s.pollTaskStatus()
} }
// Stop 停止服务 // Stop 停止服务
@@ -166,6 +170,8 @@ func (s *Service) ProcessTask(jobId uint) error {
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err)) return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
} }
logger.Infof("提交即梦任务: %+v", req)
// 提交异步任务 // 提交异步任务
resp, err := s.client.SubmitTask(req) resp, err := s.client.SubmitTask(req)
if err != nil { if err != nil {
@@ -186,8 +192,7 @@ func (s *Service) ProcessTask(jobId uint) error {
logger.Errorf("update jimeng job task_id failed: %v", err) logger.Errorf("update jimeng job task_id failed: %v", err)
} }
// 开始轮询任务状态 return nil
return s.pollTaskStatus(job.Id, resp.Data.TaskId, job.ReqKey)
} }
// buildTaskRequest 构建任务请求(统一的参数解析) // buildTaskRequest 构建任务请求(统一的参数解析)
@@ -360,78 +365,100 @@ func (s *Service) setCommonParams(req *SubmitTaskRequest, params map[string]any)
} }
// pollTaskStatus 轮询任务状态 // pollTaskStatus 轮询任务状态
func (s *Service) pollTaskStatus(jobId uint, taskId, reqKey string) error { func (s *Service) pollTaskStatus() {
maxRetries := 60 // 最大重试次数60次 * 5秒 = 5分钟
retryCount := 0
for retryCount < maxRetries { for {
time.Sleep(5 * time.Second) // 等待5秒 var jobs []model.JimengJob
s.db.Where("status IN (?)", []model.JMTaskStatus{model.JMTaskStatusGenerating, model.JMTaskStatusInQueue}).Find(&jobs)
// 查询任务状态 if len(jobs) == 0 {
resp, err := s.client.QueryTask(&QueryTaskRequest{ logger.Debugf("no jimeng task to poll, sleep 10s")
ReqKey: reqKey, time.Sleep(10 * time.Second)
TaskId: taskId,
ReqJson: `{"return_url":true}`,
})
if err != nil {
logger.Errorf("query jimeng task status failed: %v", err)
retryCount++
continue continue
} }
// 更新原始数据 for _, job := range jobs {
rawData, _ := json.Marshal(resp) // 任务超时处理
s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Update("raw_data", string(rawData)) if job.UpdatedAt.Before(time.Now().Add(-5 * time.Minute)) {
s.handleTaskError(job.Id, "task timeout")
continue
}
// 查询任务状态
resp, err := s.client.QueryTask(&QueryTaskRequest{
ReqKey: job.ReqKey,
TaskId: job.TaskId,
ReqJson: `{"return_url":true}`,
})
if err != nil {
logger.Errorf("query jimeng task status failed: %v", err)
continue
}
// 更新原始数据
rawData, _ := json.Marshal(resp)
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", string(rawData))
if resp.Code != 10000 {
s.handleTaskError(job.Id, fmt.Sprintf("query task failed: %s", resp.Message))
continue
}
switch resp.Data.Status {
case model.JMTaskStatusDone:
// 判断任务是否成功
if resp.Message != "Success" {
s.handleTaskError(job.Id, fmt.Sprintf("task failed: %s", resp.Data.AlgorithmBaseResp.StatusMessage))
continue
}
// 任务完成,更新结果
updates := map[string]any{
"status": model.JMTaskStatusSuccess,
"updated_at": time.Now(),
}
// 设置结果URL
if len(resp.Data.ImageUrls) > 0 {
imgUrl, err := s.uploader.GetUploadHandler().PutUrlFile(resp.Data.ImageUrls[0], ".png", false)
if err != nil {
logger.Errorf("upload image failed: %v", err)
imgUrl = resp.Data.ImageUrls[0]
}
updates["img_url"] = imgUrl
}
if resp.Data.VideoUrl != "" {
videoUrl, err := s.uploader.GetUploadHandler().PutUrlFile(resp.Data.VideoUrl, ".mp4", false)
if err != nil {
logger.Errorf("upload video failed: %v", err)
videoUrl = resp.Data.VideoUrl
}
updates["video_url"] = videoUrl
}
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates)
case model.JMTaskStatusInQueue, model.JMTaskStatusGenerating:
// 任务处理中
s.UpdateJobStatus(job.Id, model.JMTaskStatusGenerating, "")
case model.JMTaskStatusNotFound:
// 任务未找到
s.handleTaskError(job.Id, "task not found")
case model.JMTaskStatusExpired:
// 任务过期
s.handleTaskError(job.Id, "task expired")
default:
logger.Warnf("unknown task status: %s", resp.Data.Status)
}
if resp.Code != 10000 {
return s.handleTaskError(jobId, fmt.Sprintf("query task failed: %s", resp.Message))
} }
switch resp.Data.Status { time.Sleep(5 * time.Second)
case model.JMTaskStatusDone:
// 判断任务是否成功
if resp.Message != "Success" {
return s.handleTaskError(jobId, fmt.Sprintf("task failed: %s", resp.Data.AlgorithmBaseResp.StatusMessage))
}
// 任务完成,更新结果
updates := map[string]any{
"status": model.JMTaskStatusSuccess,
"updated_at": time.Now(),
}
// 设置结果URL
if len(resp.Data.ImageUrls) > 0 {
updates["img_url"] = resp.Data.ImageUrls[0]
}
if resp.Data.VideoUrl != "" {
updates["video_url"] = resp.Data.VideoUrl
}
return s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Updates(updates).Error
case model.JMTaskStatusInQueue:
// 任务在队列中
s.UpdateJobStatus(jobId, model.JMTaskStatusGenerating, "")
case model.JMTaskStatusGenerating:
// 任务处理中
s.UpdateJobStatus(jobId, model.JMTaskStatusGenerating, "")
case model.JMTaskStatusNotFound:
// 任务未找到或已过期
return s.handleTaskError(jobId, resp.Message)
default:
logger.Warnf("unknown task status: %s", resp.Data.Status)
}
retryCount++
} }
// 超时处理
return s.handleTaskError(jobId, "task timeout")
} }
// UpdateJobStatus 更新任务状态 // UpdateJobStatus 更新任务状态
@@ -498,11 +525,6 @@ func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
return &job, nil return &job, nil
} }
// DeleteJob 删除任务
func (s *Service) DeleteJob(jobId uint, userId uint) error {
return s.db.Where("id = ? AND user_id = ?", jobId, userId).Delete(&model.JimengJob{}).Error
}
// testConnection 测试即梦AI连接 // testConnection 测试即梦AI连接
func (s *Service) testConnection(accessKey, secretKey string) error { func (s *Service) testConnection(accessKey, secretKey string) error {
testClient := NewClient(accessKey, secretKey) testClient := NewClient(accessKey, secretKey)

View File

@@ -191,7 +191,7 @@ func (s *Service) DownloadImages() {
if strings.HasPrefix(v.OrgURL, "https://cdn.discordapp.com") { if strings.HasPrefix(v.OrgURL, "https://cdn.discordapp.com") {
proxy = true proxy = true
} }
imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, proxy) imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, ".png", proxy)
if err != nil { if err != nil {
logger.Errorf("error with download image %s, %v", v.OrgURL, err) logger.Errorf("error with download image %s, %v", v.OrgURL, err)

View File

@@ -84,7 +84,7 @@ func (s AliYunOss) PutFile(ctx *gin.Context, name string) (File, error) {
}, nil }, nil
} }
func (s AliYunOss) PutUrlFile(fileURL string, useProxy bool) (string, error) { func (s AliYunOss) PutUrlFile(fileURL string, ext string, useProxy bool) (string, error) {
var fileData []byte var fileData []byte
var err error var err error
if useProxy { if useProxy {
@@ -99,8 +99,10 @@ func (s AliYunOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("error with parse image URL: %v", err) return "", fmt.Errorf("error with parse image URL: %v", err)
} }
fileExt := utils.GetImgExt(parse.Path) if ext == "" {
objectKey := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt) ext = filepath.Ext(parse.Path)
}
objectKey := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
// 上传文件字节数据 // 上传文件字节数据
err = s.bucket.PutObject(objectKey, bytes.NewReader(fileData)) err = s.bucket.PutObject(objectKey, bytes.NewReader(fileData))
if err != nil { if err != nil {

View File

@@ -12,11 +12,12 @@ import (
"fmt" "fmt"
"geekai/core/types" "geekai/core/types"
"geekai/utils" "geekai/utils"
"github.com/gin-gonic/gin"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/gin-gonic/gin"
) )
type LocalStorage struct { type LocalStorage struct {
@@ -37,7 +38,7 @@ func (s LocalStorage) PutFile(ctx *gin.Context, name string) (File, error) {
return File{}, fmt.Errorf("error with get form: %v", err) return File{}, fmt.Errorf("error with get form: %v", err)
} }
path, err := utils.GenUploadPath(s.config.BasePath, file.Filename, false) path, err := utils.GenUploadPath(s.config.BasePath, file.Filename, "")
if err != nil { if err != nil {
return File{}, fmt.Errorf("error with generate filename: %s", err.Error()) return File{}, fmt.Errorf("error with generate filename: %s", err.Error())
} }
@@ -57,13 +58,13 @@ func (s LocalStorage) PutFile(ctx *gin.Context, name string) (File, error) {
}, nil }, nil
} }
func (s LocalStorage) PutUrlFile(fileURL string, useProxy bool) (string, error) { func (s LocalStorage) PutUrlFile(fileURL string, ext string, useProxy bool) (string, error) {
parse, err := url.Parse(fileURL) parse, err := url.Parse(fileURL)
if err != nil { if err != nil {
return "", fmt.Errorf("error with parse image URL: %v", err) return "", fmt.Errorf("error with parse image URL: %v", err)
} }
filename := filepath.Base(parse.Path) filename := filepath.Base(parse.Path)
filePath, err := utils.GenUploadPath(s.config.BasePath, filename, true) filePath, err := utils.GenUploadPath(s.config.BasePath, filename, ext)
if err != nil { if err != nil {
return "", fmt.Errorf("error with generate image dir: %v", err) return "", fmt.Errorf("error with generate image dir: %v", err)
} }
@@ -85,7 +86,7 @@ func (s LocalStorage) PutBase64(base64Img string) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("error decoding base64:%v", err) return "", fmt.Errorf("error decoding base64:%v", err)
} }
filePath, err := utils.GenUploadPath(s.config.BasePath, "", true) filePath, _ := utils.GenUploadPath(s.config.BasePath, "", ".png")
err = os.WriteFile(filePath, imageData, 0644) err = os.WriteFile(filePath, imageData, 0644)
if err != nil { if err != nil {
return "", fmt.Errorf("error writing to file:%v", err) return "", fmt.Errorf("error writing to file:%v", err)

View File

@@ -44,7 +44,7 @@ func NewMiniOss(appConfig *types.AppConfig) (MiniOss, error) {
return MiniOss{config: config, client: minioClient, proxyURL: appConfig.ProxyURL}, nil return MiniOss{config: config, client: minioClient, proxyURL: appConfig.ProxyURL}, nil
} }
func (s MiniOss) PutUrlFile(fileURL string, useProxy bool) (string, error) { func (s MiniOss) PutUrlFile(fileURL string, ext string, useProxy bool) (string, error) {
var fileData []byte var fileData []byte
var err error var err error
if useProxy { if useProxy {
@@ -59,8 +59,10 @@ func (s MiniOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("error with parse image URL: %v", err) return "", fmt.Errorf("error with parse image URL: %v", err)
} }
fileExt := filepath.Ext(parse.Path) if ext == "" {
filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt) ext = filepath.Ext(parse.Path)
}
filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
info, err := s.client.PutObject( info, err := s.client.PutObject(
context.Background(), context.Background(),
s.config.Bucket, s.config.Bucket,
@@ -86,7 +88,7 @@ func (s MiniOss) PutFile(ctx *gin.Context, name string) (File, error) {
} }
defer fileReader.Close() defer fileReader.Close()
fileExt := utils.GetImgExt(file.Filename) fileExt := filepath.Ext(file.Filename)
filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt) filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt)
info, err := s.client.PutObject(ctx, s.config.Bucket, filename, fileReader, file.Size, minio.PutObjectOptions{ info, err := s.client.PutObject(ctx, s.config.Bucket, filename, fileReader, file.Size, minio.PutObjectOptions{
ContentType: file.Header.Get("Body-Type"), ContentType: file.Header.Get("Body-Type"),

View File

@@ -93,7 +93,7 @@ func (s QinNiuOss) PutFile(ctx *gin.Context, name string) (File, error) {
} }
func (s QinNiuOss) PutUrlFile(fileURL string, useProxy bool) (string, error) { func (s QinNiuOss) PutUrlFile(fileURL string, ext string, useProxy bool) (string, error) {
var fileData []byte var fileData []byte
var err error var err error
if useProxy { if useProxy {
@@ -108,8 +108,10 @@ func (s QinNiuOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
if err != nil { if err != nil {
return "", fmt.Errorf("error with parse image URL: %v", err) return "", fmt.Errorf("error with parse image URL: %v", err)
} }
fileExt := utils.GetImgExt(parse.Path) if ext == "" {
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt) ext = filepath.Ext(parse.Path)
}
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
ret := storage.PutRet{} ret := storage.PutRet{}
extra := storage.PutExtra{} extra := storage.PutExtra{}
// 上传文件字节数据 // 上传文件字节数据

View File

@@ -23,7 +23,7 @@ type File struct {
} }
type Uploader interface { type Uploader interface {
PutFile(ctx *gin.Context, name string) (File, error) PutFile(ctx *gin.Context, name string) (File, error)
PutUrlFile(url string, useProxy bool) (string, error) PutUrlFile(url string, ext string, useProxy bool) (string, error)
PutBase64(imageData string) (string, error) PutBase64(imageData string) (string, error)
Delete(fileURL string) error Delete(fileURL string) error
} }

View File

@@ -272,14 +272,14 @@ func (s *Service) DownloadFiles() {
for _, v := range items { for _, v := range items {
// 下载图片和音频 // 下载图片和音频
logger.Infof("try download cover image: %s", v.CoverURL) logger.Infof("try download cover image: %s", v.CoverURL)
coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, true) coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, ".png", true)
if err != nil { if err != nil {
logger.Errorf("download image with error: %v", err) logger.Errorf("download image with error: %v", err)
continue continue
} }
logger.Infof("try download audio: %s", v.AudioURL) logger.Infof("try download audio: %s", v.AudioURL)
audioURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.AudioURL, true) audioURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.AudioURL, ".mp3", true)
if err != nil { if err != nil {
logger.Errorf("download audio with error: %v", err) logger.Errorf("download audio with error: %v", err)
continue continue

View File

@@ -164,7 +164,7 @@ func (s *Service) DownloadFiles() {
} }
logger.Infof("try download video: %s", v.WaterURL) logger.Infof("try download video: %s", v.WaterURL)
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, true) videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, ".mp4", true)
if err != nil { if err != nil {
logger.Errorf("download video with error: %v", err) logger.Errorf("download video with error: %v", err)
continue continue
@@ -174,7 +174,7 @@ func (s *Service) DownloadFiles() {
if v.VideoURL != "" { if v.VideoURL != "" {
logger.Infof("try download no water video: %s", v.VideoURL) logger.Infof("try download no water video: %s", v.VideoURL)
videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, true) videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
if err != nil { if err != nil {
logger.Errorf("download video with error: %v", err) logger.Errorf("download video with error: %v", err)
continue continue

View File

@@ -34,6 +34,7 @@ const (
JMTaskStatusNotFound = JMTaskStatus("not_found") // 任务未找到 JMTaskStatusNotFound = JMTaskStatus("not_found") // 任务未找到
JMTaskStatusSuccess = JMTaskStatus("success") // 任务成功 JMTaskStatusSuccess = JMTaskStatus("success") // 任务成功
JMTaskStatusFailed = JMTaskStatus("failed") // 任务失败 JMTaskStatusFailed = JMTaskStatus("failed") // 任务失败
JMTaskStatusExpired = JMTaskStatus("expired") // 任务过期
) )
// JMTaskType 任务类型 // JMTaskType 任务类型

16
api/test/app_test.go Normal file
View File

@@ -0,0 +1,16 @@
package test
import (
"geekai/utils"
"testing"
)
// TestNewService 测试创建爬虫服务
func TestNewService(t *testing.T) {
videoURL := `https://p3-aiop-sign.byteimg.com/tos-cn-i-vuqhorh59i/2025072310444223AAB2C93CE2B9BB8573-6843-0~tplv-vuqhorh59i-image.image?rk3s=7f9e702d&x-expires=1753325083&x-signature=%2F5V3H%2FWPQlOej6VtVZyf%2BNJBWok%3D`
filePath := "test_video.png"
err := utils.DownloadFile(videoURL, filePath, "")
if err != nil {
t.Fatalf("下载视频失败: %v", err)
}
}

View File

@@ -1,214 +0,0 @@
package test
import (
"geekai/service/crawler"
"strings"
"testing"
"time"
)
// TestNewService 测试创建爬虫服务
func TestNewService(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("测试过程中发生崩溃: %v", r)
}
}()
service, err := crawler.NewService()
if err != nil {
t.Logf("注意: 创建爬虫服务失败可能是因为Chrome浏览器未安装: %v", err)
t.Skip("跳过测试 - 浏览器问题")
return
}
defer service.Close()
// 创建服务成功则测试通过
if service == nil {
t.Fatal("创建的爬虫服务为空")
}
}
// TestSearchWeb 测试网络搜索功能
func TestSearchWeb(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("测试过程中发生崩溃: %v", r)
}
}()
// 设置测试超时时间
timeout := time.After(600 * time.Second)
done := make(chan bool)
go func() {
defer func() {
if r := recover(); r != nil {
t.Logf("搜索过程中发生崩溃: %v", r)
done <- false
return
}
}()
keyword := "Golang编程"
maxPages := 1
// 执行搜索
result, err := crawler.SearchWeb(keyword, maxPages)
if err != nil {
t.Logf("搜索失败,可能是网络问题或浏览器未安装: %v", err)
done <- false
return
}
// 验证结果不为空
if result == "" {
t.Log("搜索结果为空")
done <- false
return
}
// 验证结果包含关键字或部分关键字
if !strings.Contains(result, "Golang") && !strings.Contains(result, "golang") {
t.Logf("搜索结果中未包含关键字或部分关键字,获取到的结果: %s", result)
done <- false
return
}
// 验证结果格式,至少应包含"链接:"
if !strings.Contains(result, "链接:") {
t.Log("搜索结果格式不正确,没有找到'链接:'部分")
done <- false
return
}
done <- true
t.Logf("搜索结果: %s", result)
}()
select {
case <-timeout:
t.Log("测试超时 - 这可能是正常的,特别是在网络较慢或资源有限的环境中")
t.Skip("跳过测试 - 超时")
case success := <-done:
if !success {
t.Skip("跳过测试 - 搜索失败")
}
}
}
// 减少测试用例数量,只保留基本测试
// 这样可以减少测试时间和资源消耗
// 以下测试用例被注释掉,可以根据需要启用
/*
// TestSearchWebNoResults 测试搜索无结果的情况
func TestSearchWebNoResults(t *testing.T) {
// 设置测试超时时间
timeout := time.After(60 * time.Second)
done := make(chan bool)
go func() {
// 使用一个极不可能有搜索结果的随机字符串
keyword := "askdjfhalskjdfhas98y234hlakjsdhflakjshdflakjshdfl"
maxPages := 1
// 执行搜索
result, err := crawler.SearchWeb(keyword, maxPages)
if err != nil {
t.Errorf("搜索失败: %v", err)
done <- false
return
}
// 验证结果为"未找到相关搜索结果"
if !strings.Contains(result, "未找到") && !strings.Contains(result, "0 条搜索结果") {
t.Errorf("对于无结果的搜索,预期返回包含'未找到'的信息,实际返回: %s", result)
done <- false
return
}
done <- true
}()
select {
case <-timeout:
t.Fatal("测试超时")
case success := <-done:
if !success {
t.Fatal("测试失败")
}
}
}
// TestSearchWebMultiplePages 测试多页搜索
func TestSearchWebMultiplePages(t *testing.T) {
// 设置测试超时时间
timeout := time.After(120 * time.Second)
done := make(chan bool)
go func() {
keyword := "golang programming"
maxPages := 2
// 执行搜索
result, err := crawler.SearchWeb(keyword, maxPages)
if err != nil {
t.Errorf("搜索失败: %v", err)
done <- false
return
}
// 验证结果不为空
if result == "" {
t.Error("搜索结果为空")
done <- false
return
}
// 计算结果中的条目数
resultCount := strings.Count(result, "链接:")
if resultCount < 10 {
t.Errorf("多页搜索应返回至少10条结果实际返回: %d", resultCount)
done <- false
return
}
done <- true
}()
select {
case <-timeout:
t.Fatal("测试超时")
case success := <-done:
if !success {
t.Fatal("测试失败")
}
}
}
// TestSearchWebWithMaxPageLimit 测试页数限制
func TestSearchWebWithMaxPageLimit(t *testing.T) {
service, err := crawler.NewService()
if err != nil {
t.Fatalf("创建爬虫服务失败: %v", err)
}
defer service.Close()
// 传入一个超过限制的页数
results, err := service.WebSearch("golang", 15)
if err != nil {
t.Fatalf("搜索失败: %v", err)
}
// 验证结果不为空
if len(results) == 0 {
t.Fatal("搜索结果为空")
}
// 因为最大页数限制为10所以结果数量应该小于等于10*10=100
if len(results) > 100 {
t.Errorf("搜索结果超过最大限制预期最多100条实际: %d", len(results))
}
}
*/

View File

@@ -1,41 +0,0 @@
#!/bin/bash
# 显示执行的命令
set -x
# 检查Chrome/Chromium浏览器是否已安装
check_chrome() {
echo "检查Chrome/Chromium浏览器是否安装..."
which chromium-browser || which google-chrome || which chromium
if [ $? -ne 0 ]; then
echo "警告: 未找到Chrome或Chromium浏览器测试可能会失败"
echo "尝试安装必要的依赖..."
sudo apt-get update && sudo apt-get install -y libnss3 libgbm1 libasound2 libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libxdamage1 libxfixes3 libxrandr2 libxcomposite1 libxcursor1 libxi6 libxtst6 libnss3 libnspr4 libpango1.0-0
echo "已安装依赖但仍需安装Chrome/Chromium浏览器以完全支持测试"
else
echo "已找到Chrome/Chromium浏览器"
fi
}
# 切换到项目根目录
cd ..
# 检查环境
check_chrome
# 运行爬虫测试,使用超时限制
echo "开始运行爬虫测试..."
timeout 180s go test -v ./test/crawler_test.go -run "TestNewService|TestSearchWeb"
TEST_RESULT=$?
if [ $TEST_RESULT -eq 124 ]; then
echo "测试超时终止"
exit 1
elif [ $TEST_RESULT -ne 0 ]; then
echo "测试失败,退出码: $TEST_RESULT"
exit $TEST_RESULT
else
echo "测试成功完成"
fi
echo "测试完成"

View File

@@ -20,7 +20,7 @@ import (
) )
// GenUploadPath 生成上传文件路径 // GenUploadPath 生成上传文件路径
func GenUploadPath(basePath, filename string, isImg bool) (string, error) { func GenUploadPath(basePath, filename string, ext string) (string, error) {
now := time.Now() now := time.Now()
dir := fmt.Sprintf("%s/%d/%d", basePath, now.Year(), now.Month()) dir := fmt.Sprintf("%s/%d/%d", basePath, now.Year(), now.Month())
_, err := os.Stat(dir) _, err := os.Stat(dir)
@@ -30,13 +30,11 @@ func GenUploadPath(basePath, filename string, isImg bool) (string, error) {
return "", fmt.Errorf("error with create upload dir%v", err) return "", fmt.Errorf("error with create upload dir%v", err)
} }
} }
var fileExt string if ext == "" {
if isImg { ext = filepath.Ext(filename)
fileExt = GetImgExt(filename)
} else {
fileExt = filepath.Ext(filename)
} }
return fmt.Sprintf("%s/%d%s", dir, now.UnixMicro(), fileExt), nil
return fmt.Sprintf("%s/%d%s", dir, now.UnixMicro(), ext), nil
} }
// GenUploadUrl 生成上传文件 URL // GenUploadUrl 生成上传文件 URL
@@ -80,14 +78,6 @@ func DownloadFile(fileURL string, filepath string, proxy string) error {
return nil return nil
} }
func GetImgExt(filename string) string {
ext := filepath.Ext(filename)
if ext == "" {
return ".png"
}
return ext
}
func ExtractImgURLs(text string) []string { func ExtractImgURLs(text string) []string {
re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:png|jpg|jpeg|gif))`) re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:png|jpg|jpeg|gif))`)
matches := re.FindAllStringSubmatch(text, 10) matches := re.FindAllStringSubmatch(text, 10)

View File

@@ -1,61 +1,97 @@
<template> <template>
<div class="image-upload"> <div class="image-upload">
<div class="upload-list" v-if="imageList.length > 0"> <!-- 单图模式 -->
<div v-for="(image, index) in imageList" :key="index" class="upload-item"> <template v-if="props.maxCount === 1">
<el-image <div class="single-upload">
:src="image" <div v-if="imageList.length === 0" class="upload-btn">
:preview-src-list="imageList" <el-upload
:initial-index="index" drag
fit="cover" :auto-upload="true"
class="upload-image" :show-file-list="false"
/> :http-request="handleUpload"
<div class="upload-overlay"> :multiple="false"
<el-button accept="image/*"
type="danger" class="uploader"
:icon="Delete" >
size="small" <div class="upload-placeholder">
circle <el-icon :size="20"><UploadFilled /></el-icon>
@click="removeImage(index)" <span>上传图片</span>
class="remove-btn" </div>
/> </el-upload>
</div>
<div v-else class="upload-item single-image-item">
<el-image :src="imageList[0]" fit="cover" class="upload-image" />
<div class="upload-overlay" style="opacity: 1">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(0)"
class="remove-btn"
/>
</div>
</div> </div>
</div> </div>
</template>
<!-- 上传按钮 --> <!-- 多图模式 -->
<div v-if="!multiple || imageList.length < maxCount" class="upload-btn"> <template v-else>
<div class="upload-list" v-if="imageList.length > 0">
<div v-for="(image, index) in imageList" :key="index" class="upload-item">
<el-image :src="image" fit="cover" class="upload-image" />
<div class="upload-overlay">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(index)"
class="remove-btn"
/>
</div>
</div>
<!-- 上传按钮 -->
<div v-if="!multiple || imageList.length < maxCount" class="upload-btn">
<el-upload
drag
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:multiple="multiple"
accept="image/*"
class="uploader"
:limit="maxCount"
>
<div class="upload-placeholder">
<el-icon :size="20"><UploadFilled /></el-icon>
<span>上传图片</span>
</div>
</el-upload>
</div>
</div>
<!-- 初始上传区域 -->
<div v-else class="upload-area">
<el-upload <el-upload
drag
:auto-upload="true" :auto-upload="true"
:show-file-list="false" :show-file-list="false"
:http-request="handleUpload" :http-request="handleUpload"
:multiple="multiple"
accept="image/*" accept="image/*"
class="uploader" class="uploader"
:limit="maxCount"
> >
<div class="upload-placeholder"> <el-icon :size="40" class="el-icon--upload"><UploadFilled /></el-icon>
<el-icon :size="20"><Plus /></el-icon> <div class="el-upload__text">拖拽图片到此处 <em>点击上传</em></div>
<span>上传图片</span> <template #tip>
</div> <div class="el-upload__tip text-center">
支持 JPGPNG 格式最多上传 {{ maxCount }} 单张最大 5MB
</div>
</template>
</el-upload> </el-upload>
</div> </div>
</div> </template>
<!-- 初始上传区域 -->
<div v-else class="upload-area">
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
accept="image/*"
class="uploader"
>
<div class="upload-placeholder">
<el-icon :size="40"><Plus /></el-icon>
<div class="upload-text">
<p>点击上传图片</p>
<p class="upload-tip">支持 JPGPNG 格式最大 10MB</p>
</div>
</div>
</el-upload>
</div>
<!-- 上传进度 --> <!-- 上传进度 -->
<el-progress <el-progress
@@ -69,7 +105,8 @@
<script setup> <script setup>
import { httpPost } from '@/utils/http' import { httpPost } from '@/utils/http'
import { Delete, Plus } from '@element-plus/icons-vue' import { replaceImg } from '@/utils/libs'
import { Delete, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -97,14 +134,14 @@ const uploadProgress = ref(0)
// 图片列表 // 图片列表
const imageList = computed({ const imageList = computed({
get() { get() {
if (props.multiple) { if (props.multiple || props.maxCount > 1) {
return Array.isArray(props.modelValue) ? props.modelValue : [] return Array.isArray(props.modelValue) ? props.modelValue : []
} else { } else {
return props.modelValue ? [props.modelValue] : [] return props.modelValue ? [props.modelValue] : []
} }
}, },
set(value) { set(value) {
if (props.multiple) { if (props.multiple || props.maxCount > 1) {
emit('update:modelValue', value) emit('update:modelValue', value)
} else { } else {
emit('update:modelValue', value[0] || '') emit('update:modelValue', value[0] || '')
@@ -112,6 +149,7 @@ const imageList = computed({
}, },
}) })
const uploadCount = ref(1)
// 处理上传 // 处理上传
const handleUpload = async (uploadFile) => { const handleUpload = async (uploadFile) => {
const file = uploadFile.file const file = uploadFile.file
@@ -122,17 +160,18 @@ const handleUpload = async (uploadFile) => {
return return
} }
// 检查文件大小 (10MB) // 检查文件大小 (5MB)
if (file.size > 10 * 1024 * 1024) { if (file.size > 5 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 10MB') ElMessage.error('图片大小不能超过 5MB')
return return
} }
// 检查数量限制 // 检查数量限制
if (props.multiple && imageList.value.length >= props.maxCount) { if (uploadCount.value > props.maxCount) {
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`) ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
return return
} }
uploadCount.value++
uploading.value = true uploading.value = true
uploadProgress.value = 0 uploadProgress.value = 0
@@ -153,10 +192,10 @@ const handleUpload = async (uploadFile) => {
clearInterval(progressTimer) clearInterval(progressTimer)
uploadProgress.value = 100 uploadProgress.value = 100
const imageUrl = response.data.url const imageUrl = replaceImg(response.data.url)
// 更新图片列表 // 更新图片列表
if (props.multiple) { if (props.multiple || props.maxCount > 1) {
const newList = [...imageList.value, imageUrl] const newList = [...imageList.value, imageUrl]
imageList.value = newList imageList.value = newList
} else { } else {
@@ -178,114 +217,114 @@ const removeImage = (index) => {
const newList = [...imageList.value] const newList = [...imageList.value]
newList.splice(index, 1) newList.splice(index, 1)
imageList.value = newList imageList.value = newList
uploadCount.value--
} }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus">
.image-upload .image-upload {
width 100% width: 100%;
}
.upload-list .single-upload {
display flex width: 100px;
flex-wrap wrap height: 100px;
gap 10px position: relative;
}
.upload-item .single-image-item {
position relative width: 100px;
width 100px height: 100px;
height 100px position: relative;
border-radius 6px border-radius: 6px;
overflow hidden overflow: hidden;
border 1px solid #dcdfe6 border: 1px solid #dcdfe6;
}
.upload-image .upload-list {
width 100% display: flex;
height 100% flex-wrap: wrap;
gap: 10px;
}
.upload-overlay .upload-item {
position absolute position: relative;
top 0 width: 100px;
left 0 height: 100px;
right 0 border-radius: 6px;
bottom 0 overflow: hidden;
background rgba(0, 0, 0, 0.5) border: 1px solid #dcdfe6;
display flex
align-items center
justify-content center
opacity 0
transition opacity 0.3s
.remove-btn .upload-image {
background rgba(245, 108, 108, 0.8) width: 100%;
border none height: 100%;
color white }
&:hover .upload-overlay .upload-overlay {
opacity 1 position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s;
.upload-btn .remove-btn {
width 100px background: rgba(245, 108, 108, 0.8);
height 100px border: none;
border 2px dashed #dcdfe6 color: white;
border-radius 6px }
display flex }
align-items center
justify-content center
cursor pointer
transition all 0.3s
&:hover &:hover .upload-overlay {
border-color #409eff opacity: 1;
color #409eff }
}
.uploader .upload-btn {
width 100% .uploader {
height 100% width: 100%;
.upload-placeholder .el-upload-dragger {
display flex width: 100px;
flex-direction column height: 100px;
align-items center display: flex;
gap 5px align-items: center;
font-size 12px justify-content: center;
color #8c939d }
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 12px;
color: #8c939d;
}
}
.upload-area .upload-area {
border 2px dashed #dcdfe6 .el-upload-dragger {
border-radius 6px width: 100%;
padding 40px }
text-align center .uploader {
cursor pointer width: 100%;
transition all 0.3s }
}
&:hover .upload-progress {
border-color #409eff margin-top: 10px;
}
.uploader :deep(.el-upload) {
width 100% width: 100%;
height: 100%;
.upload-placeholder display: flex;
display flex align-items: center;
flex-direction column justify-content: center;
align-items center }
gap 10px
color #8c939d
.upload-text
p
margin 5px 0
.upload-tip
font-size 12px
color #c0c4cc
.upload-progress
margin-top 10px
:deep(.el-upload)
width 100%
height 100%
display flex
align-items center
justify-content center
</style> </style>

View File

@@ -7,7 +7,7 @@
import { checkSession } from '@/store/cache' import { checkSession } from '@/store/cache'
import { showMessageError, showMessageOK } from '@/utils/dialog' import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http' 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'
@@ -233,29 +233,32 @@ export const useJimengStore = defineStore('jimeng', () => {
// 获取任务状态文本 // 获取任务状态文本
const getTaskStatusText = (status) => { const getTaskStatusText = (status) => {
const statusMap = { const statusMap = {
in_queue: '排队中', in_queue: '任务排队中',
generating: '处理中', generating: '任务执行中',
success: '成功', success: '任务成功',
failed: '失败', failed: '任务失败',
canceled: '已取消', canceled: '任务已取消',
} }
return statusMap[status] || status return statusMap[status] || status
} }
// 获取状态类型 // 获取状态类型
const getStatusType = (status) => { const getTaskType = (type) => {
const typeMap = { const typeMap = {
pending: 'info', text_to_image: 'primary',
processing: 'warning', image_to_image: 'primary',
completed: 'success', image_edit: 'primary',
failed: 'danger', image_effects: 'primary',
text_to_video: 'success',
image_to_video: 'success',
} }
return typeMap[status] || 'info' return typeMap[type] || 'primary'
} }
// 切换任务筛选 // 切换任务筛选
const switchTaskFilter = (filter) => { const switchTaskFilter = (filter) => {
taskFilter.value = filter taskFilter.value = filter
isOver.value = false
fetchData(1) fetchData(1)
} }
@@ -272,10 +275,13 @@ export const useJimengStore = defineStore('jimeng', () => {
page_size: pageSize.value, page_size: pageSize.value,
filter: taskFilter.value, filter: taskFilter.value,
}) })
const data = response.data const data = response.data
if (data.total === 0) { if (!data.items || data.items.length === 0) {
isOver.value = true isOver.value = true
currentList.value = [] if (pageNum === 1) {
currentList.value = []
}
return return
} }
@@ -297,7 +303,9 @@ export const useJimengStore = defineStore('jimeng', () => {
// 简单轮询逻辑 // 简单轮询逻辑
const startPolling = () => { const startPolling = () => {
if (pollHandler) return if (pollHandler) {
clearInterval(pollHandler)
}
pollHandler = setInterval(async () => { pollHandler = setInterval(async () => {
const response = await httpPost('/api/jimeng/jobs', { const response = await httpPost('/api/jimeng/jobs', {
page: 1, page: 1,
@@ -322,7 +330,7 @@ export const useJimengStore = defineStore('jimeng', () => {
if (todoList.length === 0) { if (todoList.length === 0) {
stopPolling() stopPolling()
} }
}, 5000) }, 3000)
} }
const stopPolling = () => { const stopPolling = () => {
@@ -404,7 +412,9 @@ export const useJimengStore = defineStore('jimeng', () => {
const response = await httpPost('/api/jimeng/task', requestData) const response = await httpPost('/api/jimeng/task', requestData)
if (response.data) { if (response.data) {
showMessageOK('任务提交成功') showMessageOK('任务提交成功')
isOver.value = false
await fetchData(1) await fetchData(1)
startPolling()
} }
} catch (error) { } catch (error) {
console.error('提交任务失败:', error) console.error('提交任务失败:', error)
@@ -414,13 +424,40 @@ export const useJimengStore = defineStore('jimeng', () => {
} }
} }
const downloadFile = async (item) => {
const url = replaceImg(item.video_url || item.img_url)
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
} catch (error) {
showMessageError('下载失败')
item.downloading = false
}
}
// 重试任务 // 重试任务
const retryTask = async (taskId) => { const retryTask = async (taskId) => {
try { try {
const response = await httpPost(`/api/jimeng/retry/${taskId}`) const response = await httpGet(`/api/jimeng/retry?id=${taskId}`)
if (response.data) { if (response.data) {
showMessageOK('重试任务已提交') showMessageOK('重试任务已提交')
await fetchData(page.value) isOver.value = false
await fetchData(1)
startPolling()
} }
} catch (error) { } catch (error) {
console.error('重试任务失败:', error) console.error('重试任务失败:', error)
@@ -440,7 +477,7 @@ export const useJimengStore = defineStore('jimeng', () => {
const response = await httpGet('/api/jimeng/remove', { id: item.id }) const response = await httpGet('/api/jimeng/remove', { id: item.id })
if (response.data) { if (response.data) {
showMessageOK('删除成功') showMessageOK('删除成功')
await fetchData(page.value) await fetchData(1)
} }
} catch (error) { } catch (error) {
if (error !== 'cancel') { if (error !== 'cancel') {
@@ -456,17 +493,6 @@ export const useJimengStore = defineStore('jimeng', () => {
showDialog.value = true showDialog.value = true
} }
// 下载文件
const downloadFile = (item) => {
const url = item.video_url || item.img_url
if (url) {
const link = document.createElement('a')
link.href = url
link.download = `jimeng_${item.id}.${item.video_url ? 'mp4' : 'jpg'}`
link.click()
}
}
// 画同款功能 // 画同款功能
const drawSame = (item) => { const drawSame = (item) => {
// 联动功能开关 // 联动功能开关
@@ -576,14 +602,14 @@ export const useJimengStore = defineStore('jimeng', () => {
getCurrentPowerCost, getCurrentPowerCost,
getFunctionName, getFunctionName,
getTaskStatusText, getTaskStatusText,
getStatusType, getTaskType,
switchTaskFilter, switchTaskFilter,
fetchData, fetchData,
submitTask, submitTask,
downloadFile,
retryTask, retryTask,
removeJob, removeJob,
playVideo, playVideo,
downloadFile,
cleanup, cleanup,
drawSame, drawSame,

View File

@@ -95,7 +95,11 @@
<span class="label">上传图片:</span> <span class="label">上传图片:</span>
</div> </div>
<div class="param-line"> <div class="param-line">
<ImageUpload v-model="store.imageToImageParams.image_input" /> <ImageUpload
v-model="store.imageToImageParams.image_input"
:max-count="1"
:multiple="false"
/>
</div> </div>
<div class="param-line pt"> <div class="param-line pt">
@@ -133,7 +137,11 @@
<span class="label">上传图片:</span> <span class="label">上传图片:</span>
</div> </div>
<div class="param-line"> <div class="param-line">
<ImageUpload v-model="store.imageEditParams.image_urls" :multiple="true" /> <ImageUpload
v-model="store.imageEditParams.image_urls"
:max-count="1"
:multiple="false"
/>
</div> </div>
<div class="param-line pt"> <div class="param-line pt">
@@ -162,7 +170,11 @@
<span class="label">上传图片:</span> <span class="label">上传图片:</span>
</div> </div>
<div class="param-line"> <div class="param-line">
<ImageUpload v-model="store.imageEffectsParams.image_input1" /> <ImageUpload
v-model="store.imageEffectsParams.image_input1"
:max-count="1"
:multiple="false"
/>
</div> </div>
<div class="param-line pt"> <div class="param-line pt">
@@ -228,7 +240,11 @@
<span class="label">上传图片:</span> <span class="label">上传图片:</span>
</div> </div>
<div class="param-line"> <div class="param-line">
<ImageUpload v-model="store.imageToVideoParams.image_urls" :multiple="true" /> <ImageUpload
v-model="store.imageToVideoParams.image_urls"
:max-count="2"
:multiple="true"
/>
</div> </div>
<div class="param-line pt"> <div class="param-line pt">
@@ -313,6 +329,7 @@
v-bind="waterfallOptions" v-bind="waterfallOptions"
:is-loading="store.loading" :is-loading="store.loading"
:is-over="store.isOver" :is-over="store.isOver"
:lazyload="true"
@afterRender="onWaterfallAfterRender" @afterRender="onWaterfallAfterRender"
> >
<template #default="{ item }"> <template #default="{ item }">
@@ -323,32 +340,82 @@
<el-image <el-image
v-if="item.img_url" v-if="item.img_url"
:src="item.img_url" :src="item.img_url"
:preview-src-list="[item.img_url]"
:preview-teleported="true"
fit="cover" fit="cover"
class="preview-image" class="preview-image"
/> >
<video <template #placeholder>
v-else-if="item.video_url" <div class="w-full h-full flex justify-center items-center">
:src="item.video_url" <img :src="loadingIcon" class="max-w-[50px] max-h-[50px]" />
class="preview-video" </div>
preload="metadata" </template>
/> </el-image>
<div v-else-if="item.video_url" class="w-full h-full preview-video-wrapper">
<video
:src="item.video_url"
preload="auto"
loop="loop"
muted="muted"
class="preview-video w-full h-full"
>
您的浏览器不支持视频播放
</video>
<div class="video-mask" @click="store.playVideo(item)">
<div class="play-btn">
<img src="/images/play.svg" alt="播放" />
</div>
</div>
</div>
<div v-else class="preview-placeholder"> <div v-else class="preview-placeholder">
<i <div
class="iconfont icon-video text-2xl" v-if="item.status === 'in_queue'"
v-if="item.type.includes('video')" class="flex flex-col items-center gap-1"
></i> >
<i class="iconfont icon-dalle text-2xl" v-else></i> <i class="iconfont icon-video" v-if="item.type.includes('video')"></i>
<span>{{ store.getTaskStatusText(item.status) }}</span> <i class="iconfont icon-dalle" v-else></i>
<span>
{{ store.getTaskStatusText(item.status) }}
</span>
</div>
<div
v-else-if="item.status === 'generating'"
class="flex flex-col items-center gap-1"
>
<span>
<Generating>
<div class="text-gray-400 text-base pt-3">
{{ store.getTaskStatusText(item.status) }}
</div></Generating
>
</span>
</div>
<div
v-else-if="item.status === 'failed'"
class="flex flex-col items-center gap-1"
>
<i class="iconfont icon-error text-red-500"></i>
<span class="text text-red-500">
{{ store.getTaskStatusText(item.status) }}
</span>
<span
class="text-sm text-red-400 err-msg-clip cursor-pointer mx-5"
@click="copyErrorMsg(item.err_msg)"
>
{{ item.err_msg }}
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="task-center"> <div class="task-center">
<div class="task-info flex justify-between"> <div class="task-info flex justify-between">
<div class="flex gap-2"> <div class="flex gap-2">
<el-tag size="small" :type="store.getStatusType(item.status)"> <el-tag size="small" :type="store.getTaskType(item.type)">
{{ store.getTaskStatusText(item.status) }} {{ store.getFunctionName(item.type) }}
</el-tag> </el-tag>
<el-tag size="small">{{ store.getFunctionName(item.type) }}</el-tag>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<span> <span>
@@ -368,6 +435,37 @@
></i> ></i>
</el-tooltip> </el-tooltip>
</span> </span>
<template v-if="item.status === 'failed'">
<span class="ml-1" v-if="item.status === 'failed'">
<el-tooltip content="重试" placement="top">
<i
class="iconfont icon-refresh cursor-pointer"
@click="store.retryTask(item.id)"
></i>
</el-tooltip>
</span>
<span class="ml-1" v-if="item.status === 'failed'">
<el-tooltip content="删除" placement="top">
<i
class="iconfont icon-remove cursor-pointer text-red-500"
@click="store.removeJob(item)"
></i>
</el-tooltip>
</span>
</template>
<span class="ml-1" v-if="item.video_url || item.img_url">
<el-tooltip content="下载" placement="top">
<i
v-if="!item.downloading"
class="iconfont icon-download text-sm cursor-pointer"
@click="store.downloadFile(item)"
></i>
<el-image src="/images/loading.gif" class="w-4 h-4" fit="cover" v-else />
</el-tooltip>
</span>
</div> </div>
</div> </div>
<div <div
@@ -380,42 +478,6 @@
<span v-if="item.power">{{ item.power }}算力</span> <span v-if="item.power">{{ item.power }}算力</span>
</div> </div>
</div> </div>
<div class="task-right">
<div class="task-actions">
<el-button
v-if="item.status === 'failed'"
type="primary"
size="small"
@click="store.retryTask(item.id)"
>
重试
</el-button>
<el-button
v-if="item.video_url || item.img_url"
type="default"
size="small"
@click="store.downloadFile(item)"
>
下载
</el-button>
<el-button
v-if="item.video_url"
type="default"
size="small"
@click="store.playVideo(item)"
>
播放
</el-button>
<el-button
type="danger"
v-if="item.status === 'failed'"
size="small"
@click="store.removeJob(item)"
>
删除
</el-button>
</div>
</div>
</div> </div>
</template> </template>
</Waterfall> </Waterfall>
@@ -423,7 +485,7 @@
<img <img
:src="waterfallOptions.loadProps.loading" :src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]" class="max-w-[50px] max-h-[50px]"
v-if="store.loading" v-if="!waterfallRendered"
/> />
<div v-else> <div v-else>
<div class="no-more-data" v-if="store.isOver"> <div class="no-more-data" v-if="store.isOver">
@@ -439,7 +501,14 @@
<!-- 视频预览对话框 --> <!-- 视频预览对话框 -->
<el-dialog v-model="store.showDialog" title="视频预览" width="70%" center> <el-dialog v-model="store.showDialog" title="视频预览" width="70%" center>
<video :src="store.currentVideoUrl" controls style="width: 100%; max-height: 60vh"> <video
:src="store.currentVideoUrl"
autoplay="true"
controls
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放 您的浏览器不支持视频播放
</video> </video>
</el-dialog> </el-dialog>
@@ -448,13 +517,15 @@
<script setup> <script setup>
import '@/assets/css/jimeng.styl' import '@/assets/css/jimeng.styl'
import loadingIcon from '@/assets/img/loading.gif'
import ImageUpload from '@/components/ImageUpload.vue' import ImageUpload from '@/components/ImageUpload.vue'
import Generating from '@/components/ui/Generating.vue'
import { imageSizeOptions, useJimengStore, videoAspectRatioOptions } from '@/store/jimeng' import { imageSizeOptions, useJimengStore, videoAspectRatioOptions } from '@/store/jimeng'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import { dateFormat } from '@/utils/libs' import { dateFormat } from '@/utils/libs'
import { Switch } from '@element-plus/icons-vue' import { Switch } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted, ref, watch } from 'vue'
import { Waterfall } from 'vue-waterfall-plugin-next' import { Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css' import 'vue-waterfall-plugin-next/dist/style.css'
@@ -474,6 +545,9 @@ const getCategoryIcon = (category) => {
const store = useJimengStore() const store = useJimengStore()
// 新增:瀑布流渲染完成状态
const waterfallRendered = ref(false)
onMounted(() => { onMounted(() => {
store.init() store.init()
}) })
@@ -482,7 +556,27 @@ onUnmounted(() => {
store.cleanup() store.cleanup()
}) })
// 监听 loading每次 loading 变为 true 时重置渲染状态
watch(
() => store.loading,
(val) => {
if (val) {
waterfallRendered.value = false
}
}
)
watch(
() => store.isOver,
(val) => {
if (val) {
waterfallRendered.value = true
}
}
)
function onWaterfallAfterRender() { function onWaterfallAfterRender() {
waterfallRendered.value = true
if (!store.loading && !store.isOver) { if (!store.loading && !store.isOver) {
store.fetchData(store.page + 1) store.fetchData(store.page + 1)
} }
@@ -498,6 +592,17 @@ function copyPrompt(prompt) {
ElMessage.error('复制失败') ElMessage.error('复制失败')
}) })
} }
function copyErrorMsg(msg) {
navigator.clipboard
.writeText(msg)
.then(() => {
ElMessage.success('错误信息已复制')
})
.catch(() => {
ElMessage.error('复制失败')
})
}
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@@ -508,6 +613,23 @@ function copyPrompt(prompt) {
gap: 20px; gap: 20px;
padding: 10px 0; padding: 10px 0;
} }
// 新增:增强任务项悬停动画
.task-item {
transition: box-shadow 3s cubic-bezier(0.4,0,0.2,1), transform 0.5s cubic-bezier(0.4,0,0.2,1), border-color 0.5s;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1.5px solid transparent;
border-radius: 12px;
background: #fff;
position: relative;
z-index: 1;
}
.task-item:hover {
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 1.5px 8px rgba(0,0,0,0.10);
border-color: #a259ff;
transform: scale(1.025) translateY(-2px);
z-index: 10;
background: #f7fbff;
}
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.task-list .task-grid { .task-list .task-grid {
@@ -519,4 +641,49 @@ function copyPrompt(prompt) {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
.preview-video-wrapper
position: relative
width: 100%
height: 100%
.video-mask
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: rgba(0,0,0,0.25)
display: flex
justify-content: center
align-items: center
opacity: 0
transition: opacity 0.2s
z-index: 2
&:hover .video-mask
opacity: 1
.play-btn
width: 64px
height: 64px
background: rgba(255,255,255,0.3)
border-radius: 50%
display: flex
justify-content: center
align-items: center
box-shadow: 0 2px 8px rgba(0,0,0,0.15)
cursor: pointer
z-index: 3
transition: background 0.2s
&:hover
background: rgba(255,255,255,0.4)
.play-btn img
width: 36px
height: 36px
.err-msg-clip
display: -webkit-box
-webkit-line-clamp: 2
-webkit-box-orient: vertical
overflow: hidden
text-overflow: ellipsis
word-break: break-all
white-space: normal
cursor: pointer
</style> </style>