mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-04-21 18:44:24 +08:00
文生视频和图生视频功能完成
This commit is contained in:
@@ -290,7 +290,7 @@ func (h *JimengHandler) Jobs(c *gin.Context) {
|
||||
|
||||
// 分页查询
|
||||
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())
|
||||
return
|
||||
}
|
||||
@@ -338,22 +338,32 @@ func (h *JimengHandler) Remove(c *gin.Context) {
|
||||
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)
|
||||
resp.ERROR(c, "删除任务失败")
|
||||
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{})
|
||||
}
|
||||
|
||||
// Retry 重试任务
|
||||
func (h *JimengHandler) Retry(c *gin.Context) {
|
||||
user, err := h.GetLoginUser(c)
|
||||
if err != nil {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
userId := h.GetLoginUserId(c)
|
||||
|
||||
jobId := h.GetInt(c, "id", 0)
|
||||
if jobId == 0 {
|
||||
@@ -368,7 +378,7 @@ func (h *JimengHandler) Retry(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if job.UserId != user.Id {
|
||||
if job.UserId != userId {
|
||||
resp.ERROR(c, "无权限操作")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -144,7 +144,15 @@ func (h *NetHandler) Download(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 使用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 {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
@@ -157,6 +165,5 @@ func (h *NetHandler) Download(c *gin.Context) {
|
||||
}
|
||||
|
||||
c.Status(http.StatusOK)
|
||||
// 将下载的文件内容写入响应
|
||||
_, _ = io.Copy(c.Writer, r.Body)
|
||||
}
|
||||
|
||||
@@ -293,7 +293,7 @@ func (s *Service) DownloadImages() {
|
||||
|
||||
func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) {
|
||||
// sava image
|
||||
imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, false)
|
||||
imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, ".png", false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
logger2 "geekai/logger"
|
||||
"geekai/service/oss"
|
||||
"geekai/store"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
@@ -31,10 +32,11 @@ type Service struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
running bool
|
||||
uploader *oss.UploaderManager
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 从数据库加载配置
|
||||
var config model.Config
|
||||
@@ -54,6 +56,7 @@ func NewService(db *gorm.DB, redisCli *redis.Client) *Service {
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
running: false,
|
||||
uploader: uploader,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +68,7 @@ func (s *Service) Start() {
|
||||
logger.Info("Starting Jimeng service and task consumer...")
|
||||
s.running = true
|
||||
go s.consumeTasks()
|
||||
go s.pollTaskStatus()
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
|
||||
logger.Infof("提交即梦任务: %+v", req)
|
||||
|
||||
// 提交异步任务
|
||||
resp, err := s.client.SubmitTask(req)
|
||||
if err != nil {
|
||||
@@ -186,8 +192,7 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
logger.Errorf("update jimeng job task_id failed: %v", err)
|
||||
}
|
||||
|
||||
// 开始轮询任务状态
|
||||
return s.pollTaskStatus(job.Id, resp.Data.TaskId, job.ReqKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildTaskRequest 构建任务请求(统一的参数解析)
|
||||
@@ -360,78 +365,100 @@ func (s *Service) setCommonParams(req *SubmitTaskRequest, params map[string]any)
|
||||
}
|
||||
|
||||
// pollTaskStatus 轮询任务状态
|
||||
func (s *Service) pollTaskStatus(jobId uint, taskId, reqKey string) error {
|
||||
maxRetries := 60 // 最大重试次数,60次 * 5秒 = 5分钟
|
||||
retryCount := 0
|
||||
func (s *Service) pollTaskStatus() {
|
||||
|
||||
for retryCount < maxRetries {
|
||||
time.Sleep(5 * time.Second) // 等待5秒
|
||||
|
||||
// 查询任务状态
|
||||
resp, err := s.client.QueryTask(&QueryTaskRequest{
|
||||
ReqKey: reqKey,
|
||||
TaskId: taskId,
|
||||
ReqJson: `{"return_url":true}`,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("query jimeng task status failed: %v", err)
|
||||
retryCount++
|
||||
for {
|
||||
var jobs []model.JimengJob
|
||||
s.db.Where("status IN (?)", []model.JMTaskStatus{model.JMTaskStatusGenerating, model.JMTaskStatusInQueue}).Find(&jobs)
|
||||
if len(jobs) == 0 {
|
||||
logger.Debugf("no jimeng task to poll, sleep 10s")
|
||||
time.Sleep(10 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// 更新原始数据
|
||||
rawData, _ := json.Marshal(resp)
|
||||
s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Update("raw_data", string(rawData))
|
||||
for _, job := range jobs {
|
||||
// 任务超时处理
|
||||
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 {
|
||||
case model.JMTaskStatusDone:
|
||||
// 判断任务是否成功
|
||||
if resp.Message != "Success" {
|
||||
return s.handleTaskError(jobId, fmt.Sprintf("task failed: %s", resp.Data.AlgorithmBaseResp.StatusMessage))
|
||||
}
|
||||
time.Sleep(5 * time.Second)
|
||||
|
||||
// 任务完成,更新结果
|
||||
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 更新任务状态
|
||||
@@ -498,11 +525,6 @@ func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
|
||||
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连接
|
||||
func (s *Service) testConnection(accessKey, secretKey string) error {
|
||||
testClient := NewClient(accessKey, secretKey)
|
||||
|
||||
@@ -191,7 +191,7 @@ func (s *Service) DownloadImages() {
|
||||
if strings.HasPrefix(v.OrgURL, "https://cdn.discordapp.com") {
|
||||
proxy = true
|
||||
}
|
||||
imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, proxy)
|
||||
imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, ".png", proxy)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("error with download image %s, %v", v.OrgURL, err)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (s AliYunOss) PutFile(ctx *gin.Context, name string) (File, error) {
|
||||
}, 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 err error
|
||||
if useProxy {
|
||||
@@ -99,8 +99,10 @@ func (s AliYunOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with parse image URL: %v", err)
|
||||
}
|
||||
fileExt := utils.GetImgExt(parse.Path)
|
||||
objectKey := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt)
|
||||
if ext == "" {
|
||||
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))
|
||||
if err != nil {
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
path, err := utils.GenUploadPath(s.config.BasePath, file.Filename, false)
|
||||
path, err := utils.GenUploadPath(s.config.BasePath, file.Filename, "")
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with parse image URL: %v", err)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing to file:%v", err)
|
||||
|
||||
@@ -44,7 +44,7 @@ func NewMiniOss(appConfig *types.AppConfig) (MiniOss, error) {
|
||||
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 err error
|
||||
if useProxy {
|
||||
@@ -59,8 +59,10 @@ func (s MiniOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with parse image URL: %v", err)
|
||||
}
|
||||
fileExt := filepath.Ext(parse.Path)
|
||||
filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt)
|
||||
if ext == "" {
|
||||
ext = filepath.Ext(parse.Path)
|
||||
}
|
||||
filename := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
|
||||
info, err := s.client.PutObject(
|
||||
context.Background(),
|
||||
s.config.Bucket,
|
||||
@@ -86,7 +88,7 @@ func (s MiniOss) PutFile(ctx *gin.Context, name string) (File, error) {
|
||||
}
|
||||
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)
|
||||
info, err := s.client.PutObject(ctx, s.config.Bucket, filename, fileReader, file.Size, minio.PutObjectOptions{
|
||||
ContentType: file.Header.Get("Body-Type"),
|
||||
|
||||
@@ -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 err error
|
||||
if useProxy {
|
||||
@@ -108,8 +108,10 @@ func (s QinNiuOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with parse image URL: %v", err)
|
||||
}
|
||||
fileExt := utils.GetImgExt(parse.Path)
|
||||
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt)
|
||||
if ext == "" {
|
||||
ext = filepath.Ext(parse.Path)
|
||||
}
|
||||
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
|
||||
ret := storage.PutRet{}
|
||||
extra := storage.PutExtra{}
|
||||
// 上传文件字节数据
|
||||
|
||||
@@ -23,7 +23,7 @@ type File struct {
|
||||
}
|
||||
type Uploader interface {
|
||||
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)
|
||||
Delete(fileURL string) error
|
||||
}
|
||||
|
||||
@@ -272,14 +272,14 @@ func (s *Service) DownloadFiles() {
|
||||
for _, v := range items {
|
||||
// 下载图片和音频
|
||||
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 {
|
||||
logger.Errorf("download image with error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.Errorf("download audio with error: %v", err)
|
||||
continue
|
||||
|
||||
@@ -164,7 +164,7 @@ func (s *Service) DownloadFiles() {
|
||||
}
|
||||
|
||||
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 {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
@@ -174,7 +174,7 @@ func (s *Service) DownloadFiles() {
|
||||
|
||||
if 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 {
|
||||
logger.Errorf("download video with error: %v", err)
|
||||
continue
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
JMTaskStatusNotFound = JMTaskStatus("not_found") // 任务未找到
|
||||
JMTaskStatusSuccess = JMTaskStatus("success") // 任务成功
|
||||
JMTaskStatusFailed = JMTaskStatus("failed") // 任务失败
|
||||
JMTaskStatusExpired = JMTaskStatus("expired") // 任务过期
|
||||
)
|
||||
|
||||
// JMTaskType 任务类型
|
||||
|
||||
16
api/test/app_test.go
Normal file
16
api/test/app_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -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 "测试完成"
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
// GenUploadPath 生成上传文件路径
|
||||
func GenUploadPath(basePath, filename string, isImg bool) (string, error) {
|
||||
func GenUploadPath(basePath, filename string, ext string) (string, error) {
|
||||
now := time.Now()
|
||||
dir := fmt.Sprintf("%s/%d/%d", basePath, now.Year(), now.Month())
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
var fileExt string
|
||||
if isImg {
|
||||
fileExt = GetImgExt(filename)
|
||||
} else {
|
||||
fileExt = filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
ext = 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
|
||||
@@ -80,14 +78,6 @@ func DownloadFile(fileURL string, filepath string, proxy string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetImgExt(filename string) string {
|
||||
ext := filepath.Ext(filename)
|
||||
if ext == "" {
|
||||
return ".png"
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func ExtractImgURLs(text string) []string {
|
||||
re := regexp.MustCompile(`(http[s]?:\/\/.*?\.(?:png|jpg|jpeg|gif))`)
|
||||
matches := re.FindAllStringSubmatch(text, 10)
|
||||
|
||||
@@ -1,61 +1,97 @@
|
||||
<template>
|
||||
<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">
|
||||
<el-image
|
||||
:src="image"
|
||||
:preview-src-list="imageList"
|
||||
:initial-index="index"
|
||||
fit="cover"
|
||||
class="upload-image"
|
||||
/>
|
||||
<div class="upload-overlay">
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
size="small"
|
||||
circle
|
||||
@click="removeImage(index)"
|
||||
class="remove-btn"
|
||||
/>
|
||||
<!-- 单图模式 -->
|
||||
<template v-if="props.maxCount === 1">
|
||||
<div class="single-upload">
|
||||
<div v-if="imageList.length === 0" class="upload-btn">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="true"
|
||||
:show-file-list="false"
|
||||
:http-request="handleUpload"
|
||||
:multiple="false"
|
||||
accept="image/*"
|
||||
class="uploader"
|
||||
>
|
||||
<div class="upload-placeholder">
|
||||
<el-icon :size="20"><UploadFilled /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</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>
|
||||
</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
|
||||
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"><Plus /></el-icon>
|
||||
<span>上传图片</span>
|
||||
</div>
|
||||
<el-icon :size="40" class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">拖拽图片到此处,或 <em>点击上传</em></div>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-center">
|
||||
支持 JPG、PNG 格式,最多上传 {{ maxCount }} 张,单张最大 5MB
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 初始上传区域 -->
|
||||
<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">支持 JPG、PNG 格式,最大 10MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 上传进度 -->
|
||||
<el-progress
|
||||
@@ -69,7 +105,8 @@
|
||||
|
||||
<script setup>
|
||||
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 { computed, ref } from 'vue'
|
||||
|
||||
@@ -97,14 +134,14 @@ const uploadProgress = ref(0)
|
||||
// 图片列表
|
||||
const imageList = computed({
|
||||
get() {
|
||||
if (props.multiple) {
|
||||
if (props.multiple || props.maxCount > 1) {
|
||||
return Array.isArray(props.modelValue) ? props.modelValue : []
|
||||
} else {
|
||||
return props.modelValue ? [props.modelValue] : []
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (props.multiple) {
|
||||
if (props.multiple || props.maxCount > 1) {
|
||||
emit('update:modelValue', value)
|
||||
} else {
|
||||
emit('update:modelValue', value[0] || '')
|
||||
@@ -112,6 +149,7 @@ const imageList = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const uploadCount = ref(1)
|
||||
// 处理上传
|
||||
const handleUpload = async (uploadFile) => {
|
||||
const file = uploadFile.file
|
||||
@@ -122,17 +160,18 @@ const handleUpload = async (uploadFile) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 检查文件大小 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
ElMessage.error('图片大小不能超过 10MB')
|
||||
// 检查文件大小 (5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
ElMessage.error('图片大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查数量限制
|
||||
if (props.multiple && imageList.value.length >= props.maxCount) {
|
||||
if (uploadCount.value > props.maxCount) {
|
||||
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
|
||||
return
|
||||
}
|
||||
uploadCount.value++
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
@@ -153,10 +192,10 @@ const handleUpload = async (uploadFile) => {
|
||||
clearInterval(progressTimer)
|
||||
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]
|
||||
imageList.value = newList
|
||||
} else {
|
||||
@@ -178,114 +217,114 @@ const removeImage = (index) => {
|
||||
const newList = [...imageList.value]
|
||||
newList.splice(index, 1)
|
||||
imageList.value = newList
|
||||
uploadCount.value--
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
.image-upload
|
||||
width 100%
|
||||
<style lang="stylus">
|
||||
.image-upload {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.upload-list
|
||||
display flex
|
||||
flex-wrap wrap
|
||||
gap 10px
|
||||
.single-upload {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.upload-item
|
||||
position relative
|
||||
width 100px
|
||||
height 100px
|
||||
border-radius 6px
|
||||
overflow hidden
|
||||
border 1px solid #dcdfe6
|
||||
.single-image-item {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
}
|
||||
|
||||
.upload-image
|
||||
width 100%
|
||||
height 100%
|
||||
.upload-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.upload-overlay
|
||||
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-item {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #dcdfe6;
|
||||
|
||||
.remove-btn
|
||||
background rgba(245, 108, 108, 0.8)
|
||||
border none
|
||||
color white
|
||||
.upload-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover .upload-overlay
|
||||
opacity 1
|
||||
.upload-overlay {
|
||||
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
|
||||
width 100px
|
||||
height 100px
|
||||
border 2px dashed #dcdfe6
|
||||
border-radius 6px
|
||||
display flex
|
||||
align-items center
|
||||
justify-content center
|
||||
cursor pointer
|
||||
transition all 0.3s
|
||||
.remove-btn {
|
||||
background: rgba(245, 108, 108, 0.8);
|
||||
border: none;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover
|
||||
border-color #409eff
|
||||
color #409eff
|
||||
&:hover .upload-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.uploader
|
||||
width 100%
|
||||
height 100%
|
||||
.upload-btn {
|
||||
.uploader {
|
||||
width: 100%;
|
||||
|
||||
.upload-placeholder
|
||||
display flex
|
||||
flex-direction column
|
||||
align-items center
|
||||
gap 5px
|
||||
font-size 12px
|
||||
color #8c939d
|
||||
.el-upload-dragger {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-area
|
||||
border 2px dashed #dcdfe6
|
||||
border-radius 6px
|
||||
padding 40px
|
||||
text-align center
|
||||
cursor pointer
|
||||
transition all 0.3s
|
||||
.upload-area {
|
||||
.el-upload-dragger {
|
||||
width: 100%;
|
||||
}
|
||||
.uploader {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover
|
||||
border-color #409eff
|
||||
.upload-progress {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.uploader
|
||||
width 100%
|
||||
|
||||
.upload-placeholder
|
||||
display flex
|
||||
flex-direction column
|
||||
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
|
||||
:deep(.el-upload) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { checkSession } from '@/store/cache'
|
||||
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 { ElMessageBox } from 'element-plus'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -233,29 +233,32 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
// 获取任务状态文本
|
||||
const getTaskStatusText = (status) => {
|
||||
const statusMap = {
|
||||
in_queue: '排队中',
|
||||
generating: '处理中',
|
||||
success: '成功',
|
||||
failed: '失败',
|
||||
canceled: '已取消',
|
||||
in_queue: '任务排队中',
|
||||
generating: '任务执行中',
|
||||
success: '任务成功',
|
||||
failed: '任务失败',
|
||||
canceled: '任务已取消',
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
// 获取状态类型
|
||||
const getStatusType = (status) => {
|
||||
const getTaskType = (type) => {
|
||||
const typeMap = {
|
||||
pending: 'info',
|
||||
processing: 'warning',
|
||||
completed: 'success',
|
||||
failed: 'danger',
|
||||
text_to_image: 'primary',
|
||||
image_to_image: 'primary',
|
||||
image_edit: 'primary',
|
||||
image_effects: 'primary',
|
||||
text_to_video: 'success',
|
||||
image_to_video: 'success',
|
||||
}
|
||||
return typeMap[status] || 'info'
|
||||
return typeMap[type] || 'primary'
|
||||
}
|
||||
|
||||
// 切换任务筛选
|
||||
const switchTaskFilter = (filter) => {
|
||||
taskFilter.value = filter
|
||||
isOver.value = false
|
||||
fetchData(1)
|
||||
}
|
||||
|
||||
@@ -272,10 +275,13 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
page_size: pageSize.value,
|
||||
filter: taskFilter.value,
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
if (data.total === 0) {
|
||||
if (!data.items || data.items.length === 0) {
|
||||
isOver.value = true
|
||||
currentList.value = []
|
||||
if (pageNum === 1) {
|
||||
currentList.value = []
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -297,7 +303,9 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
|
||||
// 简单轮询逻辑
|
||||
const startPolling = () => {
|
||||
if (pollHandler) return
|
||||
if (pollHandler) {
|
||||
clearInterval(pollHandler)
|
||||
}
|
||||
pollHandler = setInterval(async () => {
|
||||
const response = await httpPost('/api/jimeng/jobs', {
|
||||
page: 1,
|
||||
@@ -322,7 +330,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
if (todoList.length === 0) {
|
||||
stopPolling()
|
||||
}
|
||||
}, 5000)
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -404,7 +412,9 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
const response = await httpPost('/api/jimeng/task', requestData)
|
||||
if (response.data) {
|
||||
showMessageOK('任务提交成功')
|
||||
isOver.value = false
|
||||
await fetchData(1)
|
||||
startPolling()
|
||||
}
|
||||
} catch (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) => {
|
||||
try {
|
||||
const response = await httpPost(`/api/jimeng/retry/${taskId}`)
|
||||
const response = await httpGet(`/api/jimeng/retry?id=${taskId}`)
|
||||
if (response.data) {
|
||||
showMessageOK('重试任务已提交')
|
||||
await fetchData(page.value)
|
||||
isOver.value = false
|
||||
await fetchData(1)
|
||||
startPolling()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重试任务失败:', error)
|
||||
@@ -440,7 +477,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
const response = await httpGet('/api/jimeng/remove', { id: item.id })
|
||||
if (response.data) {
|
||||
showMessageOK('删除成功')
|
||||
await fetchData(page.value)
|
||||
await fetchData(1)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
@@ -456,17 +493,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
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) => {
|
||||
// 联动功能开关
|
||||
@@ -576,14 +602,14 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
getCurrentPowerCost,
|
||||
getFunctionName,
|
||||
getTaskStatusText,
|
||||
getStatusType,
|
||||
getTaskType,
|
||||
switchTaskFilter,
|
||||
fetchData,
|
||||
submitTask,
|
||||
downloadFile,
|
||||
retryTask,
|
||||
removeJob,
|
||||
playVideo,
|
||||
downloadFile,
|
||||
cleanup,
|
||||
drawSame,
|
||||
|
||||
|
||||
@@ -95,7 +95,11 @@
|
||||
<span class="label">上传图片:</span>
|
||||
</div>
|
||||
<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 class="param-line pt">
|
||||
@@ -133,7 +137,11 @@
|
||||
<span class="label">上传图片:</span>
|
||||
</div>
|
||||
<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 class="param-line pt">
|
||||
@@ -162,7 +170,11 @@
|
||||
<span class="label">上传图片:</span>
|
||||
</div>
|
||||
<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 class="param-line pt">
|
||||
@@ -228,7 +240,11 @@
|
||||
<span class="label">上传图片:</span>
|
||||
</div>
|
||||
<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 class="param-line pt">
|
||||
@@ -313,6 +329,7 @@
|
||||
v-bind="waterfallOptions"
|
||||
:is-loading="store.loading"
|
||||
:is-over="store.isOver"
|
||||
:lazyload="true"
|
||||
@afterRender="onWaterfallAfterRender"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
@@ -323,32 +340,82 @@
|
||||
<el-image
|
||||
v-if="item.img_url"
|
||||
:src="item.img_url"
|
||||
:preview-src-list="[item.img_url]"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
class="preview-image"
|
||||
/>
|
||||
<video
|
||||
v-else-if="item.video_url"
|
||||
:src="item.video_url"
|
||||
class="preview-video"
|
||||
preload="metadata"
|
||||
/>
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<img :src="loadingIcon" class="max-w-[50px] max-h-[50px]" />
|
||||
</div>
|
||||
</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">
|
||||
<i
|
||||
class="iconfont icon-video text-2xl"
|
||||
v-if="item.type.includes('video')"
|
||||
></i>
|
||||
<i class="iconfont icon-dalle text-2xl" v-else></i>
|
||||
<span>{{ store.getTaskStatusText(item.status) }}</span>
|
||||
<div
|
||||
v-if="item.status === 'in_queue'"
|
||||
class="flex flex-col items-center gap-1"
|
||||
>
|
||||
<i class="iconfont icon-video" v-if="item.type.includes('video')"></i>
|
||||
<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 class="task-center">
|
||||
<div class="task-info flex justify-between">
|
||||
<div class="flex gap-2">
|
||||
<el-tag size="small" :type="store.getStatusType(item.status)">
|
||||
{{ store.getTaskStatusText(item.status) }}
|
||||
<el-tag size="small" :type="store.getTaskType(item.type)">
|
||||
{{ store.getFunctionName(item.type) }}
|
||||
</el-tag>
|
||||
<el-tag size="small">{{ store.getFunctionName(item.type) }}</el-tag>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span>
|
||||
@@ -368,6 +435,37 @@
|
||||
></i>
|
||||
</el-tooltip>
|
||||
</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
|
||||
@@ -380,42 +478,6 @@
|
||||
<span v-if="item.power">{{ item.power }}算力</span>
|
||||
</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>
|
||||
</template>
|
||||
</Waterfall>
|
||||
@@ -423,7 +485,7 @@
|
||||
<img
|
||||
:src="waterfallOptions.loadProps.loading"
|
||||
class="max-w-[50px] max-h-[50px]"
|
||||
v-if="store.loading"
|
||||
v-if="!waterfallRendered"
|
||||
/>
|
||||
<div v-else>
|
||||
<div class="no-more-data" v-if="store.isOver">
|
||||
@@ -439,7 +501,14 @@
|
||||
|
||||
<!-- 视频预览对话框 -->
|
||||
<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>
|
||||
</el-dialog>
|
||||
@@ -448,13 +517,15 @@
|
||||
|
||||
<script setup>
|
||||
import '@/assets/css/jimeng.styl'
|
||||
import loadingIcon from '@/assets/img/loading.gif'
|
||||
import ImageUpload from '@/components/ImageUpload.vue'
|
||||
import Generating from '@/components/ui/Generating.vue'
|
||||
import { imageSizeOptions, useJimengStore, videoAspectRatioOptions } from '@/store/jimeng'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { dateFormat } from '@/utils/libs'
|
||||
import { Switch } from '@element-plus/icons-vue'
|
||||
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 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
@@ -474,6 +545,9 @@ const getCategoryIcon = (category) => {
|
||||
|
||||
const store = useJimengStore()
|
||||
|
||||
// 新增:瀑布流渲染完成状态
|
||||
const waterfallRendered = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
})
|
||||
@@ -482,7 +556,27 @@ onUnmounted(() => {
|
||||
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() {
|
||||
waterfallRendered.value = true
|
||||
if (!store.loading && !store.isOver) {
|
||||
store.fetchData(store.page + 1)
|
||||
}
|
||||
@@ -498,6 +592,17 @@ function copyPrompt(prompt) {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
function copyErrorMsg(msg) {
|
||||
navigator.clipboard
|
||||
.writeText(msg)
|
||||
.then(() => {
|
||||
ElMessage.success('错误信息已复制')
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="stylus" scoped>
|
||||
@@ -508,6 +613,23 @@ function copyPrompt(prompt) {
|
||||
gap: 20px;
|
||||
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) {
|
||||
.task-list .task-grid {
|
||||
@@ -519,4 +641,49 @@ function copyPrompt(prompt) {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user