merge v4.2.5

This commit is contained in:
RockYang
2026-04-05 21:35:30 +08:00
86 changed files with 7732 additions and 2716 deletions

View File

@@ -1,5 +1,14 @@
# 更新日志 # 更新日志
## v4.2.5
- 功能优化:在代码右下角增加复制代码功能按钮,增加收起和展开代码功能
- Bug 修复:修复 Shift + Enter 不换行的 Bug
- Bug 修复:修复管理后台菜单添加页面的文本错误
- Bug 修复:解决聊天页面异常退出不断重连的 bug
- 功能优化:把 Luma 和可灵视频生成页面整合成一个视频创作中心页面,统一管理视频任务
- 功能新增:增加即梦 AI 专题页面,支持即梦官方原生 API 的图片和视频生成 🎉🎉🎉
## v4.2.4 ## v4.2.4
- 功能优化:更改前端构建技术选型,使用 Vite 构建,提升构建速度和兼容性 - 功能优化:更改前端构建技术选型,使用 Vite 构建,提升构建速度和兼容性

66
CLAUDE.md Normal file
View File

@@ -0,0 +1,66 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build Commands
### Go Backend (api/)
- **Development**: `cd api && go run main.go` (uses config.toml)
- **Build**: `cd api && make` (builds both amd64 and arm64 binaries)
- **Individual builds**: `make amd64` or `make arm64`
- **Clean**: `make clean`
- **Config**: Copy `config.sample.toml` to `config.toml` and configure
### Web Frontend (web/)
- **Development**: `cd web && npm run dev` (runs on Vite dev server with --host)
- **Build**: `cd web && npm run build`
- **Lint**: `cd web && npm run lint` (ESLint with auto-fix)
### Testing
- Backend tests: `cd api/test && bash run_crawler_test.sh`
- No specific frontend test configuration found
## Project Architecture
### Backend (Go)
- **Framework**: Gin web framework with dependency injection via uber-go/fx
- **Database**: GORM with MySQL, Redis for caching, LevelDB for local storage
- **Authentication**: JWT tokens with Redis session storage
- **Middleware**: CORS, authorization, parameter handling, static resource serving
- **Structure**:
- `handler/`: HTTP request handlers (REST API endpoints)
- `service/`: Business logic services (AI integrations, payments, etc.)
- `store/`: Database models and data access layer
- `core/`: Application server and middleware configuration
- `utils/`: Utility functions and helpers
### Frontend (Vue.js)
- **Framework**: Vue 3 with Composition API
- **UI Components**: Element Plus + Vant (mobile components)
- **State Management**: Pinia
- **Routing**: Vue Router with nested routes
- **Build Tool**: Vite
- **CSS**: Stylus preprocessor with Tailwind CSS utilities
- **Features**: Responsive design (desktop/mobile views), theme switching (dark/light)
### Key Features
- **AI Chat**: Multiple chat models and conversation management
- **Image Generation**: MidJourney, Stable Diffusion, DALL-E integration
- **Audio/Video**: Suno music creation, Luma/KeLing video generation
- **User Management**: Authentication, payments, power logs, invitations
- **Admin Panel**: Comprehensive management interface
### Database Models
Key entities: User, ChatItem, ChatMessage, ChatRole, ChatModel, Order, Product, AdminUser, and various job types for AI services.
### API Structure
- User APIs: `/api/user/*` (auth, profile, settings)
- Chat APIs: `/api/chat/*` (conversations, messages)
- AI Service APIs: `/api/mj/*`, `/api/sd/*`, `/api/dall/*`, `/api/suno/*`, `/api/video/*`
- Admin APIs: `/api/admin/*` (management functions)
### Configuration
- Backend: TOML configuration file (`config.toml`)
- Database: MySQL with automatic migrations
- Services: Redis, various AI API integrations
- File Storage: Local, Aliyun OSS, MinIO, Qiniu options

195
JIMENG_CONFIG_README.md Normal file
View File

@@ -0,0 +1,195 @@
# 即梦 AI 配置功能说明
## 功能概述
即梦 AI 配置功能允许管理员通过 Web 界面配置即梦 AI 的 API 密钥和算力消耗设置,支持动态配置更新,无需重启服务。
## 功能特性
### 1. 秘钥配置
- AccessKey 和 SecretKey 配置
- 支持密码显示/隐藏
- 连接测试功能
### 2. 算力配置
- 文生图算力消耗
- 图生图算力消耗
- 图片编辑算力消耗
- 图片特效算力消耗
- 文生视频算力消耗
- 图生视频算力消耗
### 3. 动态配置
- 配置实时生效
- 无需重启服务
- 支持配置验证
## API 接口
### 获取配置
```
GET /api/admin/jimeng/config
```
### 更新配置
```
POST /api/admin/jimeng/config
Content-Type: application/json
{
"config": {
"access_key": "your_access_key",
"secret_key": "your_secret_key",
"power": {
"text_to_image": 10,
"image_to_image": 15,
"image_edit": 20,
"image_effects": 25,
"text_to_video": 30,
"image_to_video": 35
}
}
}
```
### 测试连接
```
POST /api/admin/jimeng/config/test
Content-Type: application/json
{
"config": {
"access_key": "your_access_key",
"secret_key": "your_secret_key"
}
}
```
## 前端页面
### 访问路径
管理后台 -> 即梦 AI -> 配置设置
### 页面功能
1. **秘钥配置标签页**
- AccessKey 输入框(密码模式)
- SecretKey 输入框(密码模式)
- 测试连接按钮
2. **算力配置标签页**
- 各种任务类型的算力消耗配置
- 数字输入框,支持 1-100 范围
- 提示信息说明
3. **操作按钮**
- 保存配置
- 重置配置
## 配置存储
配置存储在数据库的`config`表中:
- 配置键:`jimeng`
- 配置值JSON 格式的即梦 AI 配置
## 默认配置
如果配置不存在,系统会使用以下默认值:
```json
{
"access_key": "",
"secret_key": "",
"power": {
"text_to_image": 10,
"image_to_image": 15,
"image_edit": 20,
"image_effects": 25,
"text_to_video": 30,
"image_to_video": 35
}
}
```
## 使用流程
1. **初始配置**
- 访问管理后台即梦 AI 配置页面
- 填写 AccessKey 和 SecretKey
- 点击"测试连接"验证配置
- 调整各功能算力消耗
- 保存配置
2. **配置更新**
- 修改需要更新的配置项
- 保存配置
- 配置立即生效
3. **故障排查**
- 使用"测试连接"功能验证 API 密钥
- 检查配置是否正确保存
- 查看服务日志
## 注意事项
1. **权限要求**
- 只有管理员可以访问配置页面
- 需要有效的管理员登录会话
2. **配置验证**
- AccessKey 和 SecretKey 不能为空
- 算力消耗必须大于 0
- 建议先测试连接再保存配置
3. **服务影响**
- 配置更新不会影响正在进行的任务
- 新任务会使用更新后的配置
- 客户端配置会在下次请求时更新
## 错误处理
1. **配置加载失败**
- 使用默认配置
- 记录错误日志
2. **连接测试失败**
- 显示具体错误信息
- 建议检查 API 密钥
3. **配置保存失败**
- 显示错误信息
- 保留原有配置
## 开发说明
### 后端文件
- `api/handler/admin/jimeng_handler.go` - 配置管理 API
- `api/service/jimeng/service.go` - 配置服务逻辑
- `api/core/types/jimeng.go` - 配置类型定义
### 前端文件
- `web/src/views/admin/jimeng/JimengSetting.vue` - 配置页面
### 数据库
- `config`表存储配置信息
- 配置键:`jimeng`
- 配置值JSON 格式

View File

@@ -34,6 +34,50 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// AuthConfig 定义授权配置
type AuthConfig struct {
ExactPaths map[string]bool // 精确匹配的路径
PrefixPaths map[string]bool // 前缀匹配的路径
}
var authConfig = &AuthConfig{
ExactPaths: map[string]bool{
"/api/user/login": false,
"/api/user/logout": false,
"/api/user/resetPass": false,
"/api/user/register": false,
"/api/admin/login": false,
"/api/admin/logout": false,
"/api/admin/login/captcha": false,
"/api/app/list": false,
"/api/app/type/list": false,
"/api/app/list/user": false,
"/api/model/list": false,
"/api/mj/imgWall": false,
"/api/mj/notify": false,
"/api/invite/hits": false,
"/api/sd/imgWall": false,
"/api/dall/imgWall": false,
"/api/product/list": false,
"/api/menu/list": false,
"/api/markMap/client": false,
"/api/payment/doPay": false,
"/api/payment/payWays": false,
"/api/download": false,
"/api/dall/models": false,
},
PrefixPaths: map[string]bool{
"/api/test/": false,
"/api/payment/notify/": false,
"/api/user/clogin": false,
"/api/config/": false,
"/api/function/": false,
"/api/sms/": false,
"/api/captcha/": false,
"/static/": false,
},
}
type AppServer struct { type AppServer struct {
Config *types.AppConfig Config *types.AppConfig
Engine *gin.Engine Engine *gin.Engine
@@ -61,13 +105,28 @@ func (s *AppServer) Init(debug bool, client *redis.Client) {
} }
func (s *AppServer) Run(db *gorm.DB) error { func (s *AppServer) Run(db *gorm.DB) error {
// 重命名 config 表字段
if db.Migrator().HasColumn(&model.Config{}, "config_json") {
db.Migrator().RenameColumn(&model.Config{}, "config_json", "value")
}
if db.Migrator().HasColumn(&model.Config{}, "marker") {
db.Migrator().RenameColumn(&model.Config{}, "marker", "name")
}
if db.Migrator().HasIndex(&model.Config{}, "idx_chatgpt_configs_key") {
db.Migrator().DropIndex(&model.Config{}, "idx_chatgpt_configs_key")
}
if db.Migrator().HasIndex(&model.Config{}, "marker") {
db.Migrator().DropIndex(&model.Config{}, "marker")
}
// load system configs // load system configs
var sysConfig model.Config var sysConfig model.Config
err := db.Where("marker", "system").First(&sysConfig).Error err := db.Where("name", "system").First(&sysConfig).Error
if err != nil { if err != nil {
return fmt.Errorf("failed to load system config: %v", err) return fmt.Errorf("failed to load system config: %v", err)
} }
err = utils.JsonDecode(sysConfig.Config, &s.SysConfig) err = utils.JsonDecode(sysConfig.Value, &s.SysConfig)
if err != nil { if err != nil {
return fmt.Errorf("failed to decode system config: %v", err) return fmt.Errorf("failed to decode system config: %v", err)
} }
@@ -99,6 +158,7 @@ func (s *AppServer) Run(db *gorm.DB) error {
&model.MidJourneyJob{}, &model.MidJourneyJob{},
&model.UserLoginLog{}, &model.UserLoginLog{},
&model.DallJob{}, &model.DallJob{},
&model.JimengJob{},
) )
// 手动删除字段 // 手动删除字段
if db.Migrator().HasColumn(&model.Order{}, "deleted_at") { if db.Migrator().HasColumn(&model.Order{}, "deleted_at") {
@@ -216,6 +276,11 @@ func corsMiddleware() gin.HandlerFunc {
// 用户授权验证 // 用户授权验证
func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc { func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
if !needLogin(c) {
c.Next()
return
}
clientProtocols := c.GetHeader("Sec-WebSocket-Protocol") clientProtocols := c.GetHeader("Sec-WebSocket-Protocol")
var tokenString string var tokenString string
isAdminApi := strings.Contains(c.Request.URL.Path, "/api/admin/") isAdminApi := strings.Contains(c.Request.URL.Path, "/api/admin/")
@@ -234,18 +299,13 @@ func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
} }
if tokenString == "" { if tokenString == "" {
if needLogin(c) { resp.NotAuth(c, "You should put Authorization in request headers")
resp.NotAuth(c, "You should put Authorization in request headers") c.Abort()
c.Abort() return
return
} else { // 直接放行
c.Next()
return
}
} }
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok && needLogin(c) { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
} }
if isAdminApi { if isAdminApi {
@@ -256,21 +316,21 @@ func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
}) })
if err != nil && needLogin(c) { if err != nil {
resp.NotAuth(c, fmt.Sprintf("Error with parse auth token: %v", err)) resp.NotAuth(c, fmt.Sprintf("Error with parse auth token: %v", err))
c.Abort() c.Abort()
return return
} }
claims, ok := token.Claims.(jwt.MapClaims) claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid && needLogin(c) { if !ok || !token.Valid {
resp.NotAuth(c, "Token is invalid") resp.NotAuth(c, "Token is invalid")
c.Abort() c.Abort()
return return
} }
expr := utils.IntValue(utils.InterfaceToString(claims["expired"]), 0) expr := utils.IntValue(utils.InterfaceToString(claims["expired"]), 0)
if expr > 0 && int64(expr) < time.Now().Unix() && needLogin(c) { if expr > 0 && int64(expr) < time.Now().Unix() {
resp.NotAuth(c, "Token is expired") resp.NotAuth(c, "Token is expired")
c.Abort() c.Abort()
return return
@@ -280,57 +340,48 @@ func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
if isAdminApi { if isAdminApi {
key = fmt.Sprintf("admin/%v", claims["user_id"]) key = fmt.Sprintf("admin/%v", claims["user_id"])
} }
if _, err := client.Get(context.Background(), key).Result(); err != nil && needLogin(c) { if _, err := client.Get(context.Background(), key).Result(); err != nil {
resp.NotAuth(c, "Token is not found in redis") resp.NotAuth(c, "Token is not found in redis")
c.Abort() c.Abort()
return return
} }
c.Set(types.LoginUserID, claims["user_id"]) c.Set(types.LoginUserID, claims["user_id"])
c.Next()
} }
} }
func needLogin(c *gin.Context) bool { func needLogin(c *gin.Context) bool {
if c.Request.URL.Path == "/api/user/login" || path := c.Request.URL.Path
c.Request.URL.Path == "/api/user/logout" ||
c.Request.URL.Path == "/api/user/resetPass" || // 如果不是 API 路径,不需要登录
c.Request.URL.Path == "/api/admin/login" || if !strings.HasPrefix(path, "/api") {
c.Request.URL.Path == "/api/admin/logout" ||
c.Request.URL.Path == "/api/admin/login/captcha" ||
c.Request.URL.Path == "/api/user/register" ||
c.Request.URL.Path == "/api/chat/history" ||
c.Request.URL.Path == "/api/chat/detail" ||
c.Request.URL.Path == "/api/chat/list" ||
c.Request.URL.Path == "/api/app/list" ||
c.Request.URL.Path == "/api/app/type/list" ||
c.Request.URL.Path == "/api/app/list/user" ||
c.Request.URL.Path == "/api/model/list" ||
c.Request.URL.Path == "/api/mj/imgWall" ||
c.Request.URL.Path == "/api/mj/notify" ||
c.Request.URL.Path == "/api/invite/hits" ||
c.Request.URL.Path == "/api/sd/imgWall" ||
c.Request.URL.Path == "/api/dall/imgWall" ||
c.Request.URL.Path == "/api/product/list" ||
c.Request.URL.Path == "/api/menu/list" ||
c.Request.URL.Path == "/api/markMap/client" ||
c.Request.URL.Path == "/api/payment/doPay" ||
c.Request.URL.Path == "/api/payment/payWays" ||
c.Request.URL.Path == "/api/suno/detail" ||
c.Request.URL.Path == "/api/suno/play" ||
c.Request.URL.Path == "/api/download" ||
c.Request.URL.Path == "/api/dall/models" ||
strings.HasPrefix(c.Request.URL.Path, "/api/test") ||
strings.HasPrefix(c.Request.URL.Path, "/api/payment/notify/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/user/clogin") ||
strings.HasPrefix(c.Request.URL.Path, "/api/config/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/function/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/sms/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/captcha/") ||
strings.HasPrefix(c.Request.URL.Path, "/static/") {
return false return false
} }
// 检查精确匹配的路径
if skip, exists := authConfig.ExactPaths[path]; exists {
return skip
}
// 检查前缀匹配的路径
for prefix, skip := range authConfig.PrefixPaths {
if strings.HasPrefix(path, prefix) {
return skip
}
}
return true return true
} }
// 跳过授权
func (s *AppServer) SkipAuth(url string, prefix bool) {
if prefix {
authConfig.PrefixPaths[url] = false
} else {
authConfig.ExactPaths[url] = false
}
}
// 统一参数处理 // 统一参数处理
func parameterHandlerMiddleware() gin.HandlerFunc { func parameterHandlerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {

View File

@@ -43,9 +43,10 @@ type SmtpConfig struct {
} }
type ApiConfig struct { type ApiConfig struct {
ApiURL string ApiURL string
AppId string AppId string
Token string Token string
JimengConfig JimengConfig // 即梦AI配置
} }
type AlipayConfig struct { type AlipayConfig struct {
@@ -170,7 +171,7 @@ type SystemConfig struct {
EnabledVerify bool `json:"enabled_verify"` // 是否启用验证码 EnabledVerify bool `json:"enabled_verify"` // 是否启用验证码
EmailWhiteList []string `json:"email_white_list"` // 邮箱白名单列表 EmailWhiteList []string `json:"email_white_list"` // 邮箱白名单列表
TranslateModelId int `json:"translate_model_id"` // 用来做提示词翻译的模型 id AssistantModelId int `json:"assistant_model_id"` // 用来做提示词,翻译的AI模型 id
MaxFileSize int `json:"max_file_size"` // 最大文件大小,单位MB MaxFileSize int `json:"max_file_size"` // 最大文件大小,单位MB
} }

18
api/core/types/jimeng.go Normal file
View File

@@ -0,0 +1,18 @@
package types
// JimengConfig 即梦AI配置
type JimengConfig struct {
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Power JimengPower `json:"power"`
}
// JimengPower 即梦AI算力配置
type JimengPower struct {
TextToImage int `json:"text_to_image"`
ImageToImage int `json:"image_to_image"`
ImageEdit int `json:"image_edit"`
ImageEffects int `json:"image_effects"`
TextToVideo int `json:"text_to_video"`
ImageToVideo int `json:"image_to_video"`
}

View File

@@ -18,6 +18,7 @@ require (
github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480 github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480
github.com/qiniu/go-sdk/v7 v7.17.1 github.com/qiniu/go-sdk/v7 v7.17.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/volcengine/volc-sdk-golang v1.0.23
go.uber.org/zap v1.23.0 go.uber.org/zap v1.23.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.4.7 gorm.io/driver/mysql v1.4.7
@@ -45,7 +46,7 @@ require (
github.com/go-pay/util v0.0.2 // indirect github.com/go-pay/util v0.0.2 // indirect
github.com/go-pay/xlog v0.0.2 // indirect github.com/go-pay/xlog v0.0.2 // indirect
github.com/go-pay/xtime v0.0.2 // indirect github.com/go-pay/xtime v0.0.2 // indirect
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/css v1.0.0 // indirect
github.com/tklauser/go-sysconf v0.3.13 // indirect github.com/tklauser/go-sysconf v0.3.13 // indirect
github.com/tklauser/numcpus v0.7.0 // indirect github.com/tklauser/numcpus v0.7.0 // indirect
@@ -78,7 +79,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect github.com/klauspost/compress v1.16.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/minio/md5-simd v1.1.2 // indirect github.com/minio/md5-simd v1.1.2 // indirect
@@ -120,7 +121,7 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/atomic v1.9.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/fx v1.19.3 go.uber.org/fx v1.19.3
go.uber.org/multierr v1.6.0 // indirect go.uber.org/multierr v1.7.0 // indirect
golang.org/x/crypto v0.23.0 golang.org/x/crypto v0.23.0
golang.org/x/sys v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect
gorm.io/gorm v1.25.1 gorm.io/gorm v1.25.1

View File

@@ -1,3 +1,5 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/aliyun/alibaba-cloud-sdk-go v1.62.405 h1:cKNFQmeCQFN0WNfjScKoVrGi7vXxTVbkCvCqSrOf+P4= github.com/aliyun/alibaba-cloud-sdk-go v1.62.405 h1:cKNFQmeCQFN0WNfjScKoVrGi7vXxTVbkCvCqSrOf+P4=
@@ -6,6 +8,7 @@ github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible h1:Sg/2xHwDrioHpxTN6WMiw
github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/aliyun/aliyun-oss-go-sdk v2.2.9+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY= github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
@@ -13,11 +16,13 @@ github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZx
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -28,6 +33,8 @@ github.com/dlclark/regexp2 v1.8.1 h1:6Lcdwya6GjPUNsBct8Lg/yRPwMhABj269AAzdGSiR+0
github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dlclark/regexp2 v1.8.1/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
@@ -84,11 +91,27 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A= github.com/goji/httpauth v0.0.0-20160601135302-2da839ab0f4d/go.mod h1:nnjvkQ9ptGaCkuDUx6wNykzzlUixGxvkme+H/lnzb+A=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-tika v0.3.1 h1:l+jr10hDhZjcgxFRfcQChRLo1bPXQeLFluMyvDhXTTA= github.com/google/go-tika v0.3.1 h1:l+jr10hDhZjcgxFRfcQChRLo1bPXQeLFluMyvDhXTTA=
@@ -115,8 +138,11 @@ github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkr
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5imkbgOkpRUYLnmbU7UEFbjtDA2hxJ1ichM=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -127,6 +153,7 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -179,6 +206,7 @@ github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480 h1:IFhPCcB0/H
github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480/go.mod h1:BijIqAP84FMYC4XbdJgjyMpiSjusU8x0Y0W9K2t0QtU= github.com/pkoukk/tiktoken-go v0.1.1-0.20230418101013-cae809389480/go.mod h1:BijIqAP84FMYC4XbdJgjyMpiSjusU8x0Y0W9K2t0QtU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk= github.com/qiniu/dyn v1.3.0/go.mod h1:E8oERcm8TtwJiZvkQPbcAh0RL8jO1G0VXJMW3FAWdkk=
github.com/qiniu/go-sdk/v7 v7.17.1 h1:UoQv7fBKtzAiD1qZPIvTy62Se48YLKxcCYP9nAwWMa0= github.com/qiniu/go-sdk/v7 v7.17.1 h1:UoQv7fBKtzAiD1qZPIvTy62Se48YLKxcCYP9nAwWMa0=
github.com/qiniu/go-sdk/v7 v7.17.1/go.mod h1:nqoYCNo53ZlGA521RvRethvxUDvXKt4gtYXOwye868w= github.com/qiniu/go-sdk/v7 v7.17.1/go.mod h1:nqoYCNo53ZlGA521RvRethvxUDvXKt4gtYXOwye868w=
@@ -208,6 +236,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -230,6 +259,8 @@ github.com/uber/jaeger-lib v2.4.1+incompatible h1:td4jdvLcExb4cBISKIpHuGoVXh+dVK
github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U= github.com/uber/jaeger-lib v2.4.1+incompatible/go.mod h1:ComeNDZlWwrWnDv8aPp0Ba6+uUTzImX/AauajbLI56U=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/volcengine/volc-sdk-golang v1.0.23 h1:anOslb2Qp6ywnsbyq9jqR0ljuO63kg9PY+4OehIk5R8=
github.com/volcengine/volc-sdk-golang v1.0.23/go.mod h1:AfG/PZRUkHJ9inETvbjNifTDgut25Wbkm2QoYBTbvyU=
github.com/xxl-job/xxl-job-executor-go v1.2.0 h1:MTl2DpwrK2+hNjRRks2k7vB3oy+3onqm9OaSarneeLQ= github.com/xxl-job/xxl-job-executor-go v1.2.0 h1:MTl2DpwrK2+hNjRRks2k7vB3oy+3onqm9OaSarneeLQ=
github.com/xxl-job/xxl-job-executor-go v1.2.0/go.mod h1:bUFhz/5Irp9zkdYk5MxhQcDDT6LlZrI8+rv5mHtQ1mo= github.com/xxl-job/xxl-job-executor-go v1.2.0/go.mod h1:bUFhz/5Irp9zkdYk5MxhQcDDT6LlZrI8+rv5mHtQ1mo=
github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8= github.com/ysmood/fetchup v0.3.0 h1:UhYz9xnLEVn2ukSuK3KCgcznWpHMdrmbsPpllcylyu8=
@@ -260,8 +291,8 @@ go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4= go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.7.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY= go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY= go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -275,15 +306,23 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
@@ -294,12 +333,15 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -338,16 +380,39 @@ golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
@@ -360,6 +425,10 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -369,4 +438,6 @@ gorm.io/driver/mysql v1.4.7/go.mod h1:SxzItlnT1cb6e1e4ZRpgJN2VYtcqJgqnHxWr4wsP8o
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64= gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@@ -66,15 +66,15 @@ func (h *ConfigHandler) Update(c *gin.Context) {
} }
value := utils.JsonEncode(&data.Config) value := utils.JsonEncode(&data.Config)
config := model.Config{Key: data.Key, Config: value} config := model.Config{Name: data.Key, Value: value}
res := h.DB.FirstOrCreate(&config, model.Config{Key: data.Key}) res := h.DB.FirstOrCreate(&config, model.Config{Name: data.Key})
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, res.Error.Error()) resp.ERROR(c, res.Error.Error())
return return
} }
if config.Id > 0 { if config.Id > 0 {
config.Config = value config.Value = value
res := h.DB.Updates(&config) res := h.DB.Updates(&config)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, res.Error.Error()) resp.ERROR(c, res.Error.Error())
@@ -83,16 +83,16 @@ func (h *ConfigHandler) Update(c *gin.Context) {
// update config cache for AppServer // update config cache for AppServer
var cfg model.Config var cfg model.Config
h.DB.Where("marker", data.Key).First(&cfg) h.DB.Where("name", data.Key).First(&cfg)
var err error var err error
if data.Key == "system" { if data.Key == "system" {
err = utils.JsonDecode(cfg.Config, &h.App.SysConfig) err = utils.JsonDecode(cfg.Value, &h.App.SysConfig)
} }
if err != nil { if err != nil {
resp.ERROR(c, "Failed to update config cache: "+err.Error()) resp.ERROR(c, "Failed to update config cache: "+err.Error())
return return
} }
logger.Infof("Update AppServer's config successfully: %v", config.Config) logger.Infof("Update AppServer's config successfully: %v", config.Value)
} }
resp.SUCCESS(c, config) resp.SUCCESS(c, config)
@@ -102,14 +102,14 @@ func (h *ConfigHandler) Update(c *gin.Context) {
func (h *ConfigHandler) Get(c *gin.Context) { func (h *ConfigHandler) Get(c *gin.Context) {
key := c.Query("key") key := c.Query("key")
var config model.Config var config model.Config
res := h.DB.Where("marker", key).First(&config) res := h.DB.Where("name", key).First(&config)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, res.Error.Error()) resp.ERROR(c, res.Error.Error())
return return
} }
var value map[string]interface{} var value map[string]interface{}
err := utils.JsonDecode(config.Config, &value) err := utils.JsonDecode(config.Value, &value)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return

View File

@@ -194,7 +194,6 @@ func (h *ImageHandler) Remove(c *gin.Context) {
remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg) remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
progress = job.Progress progress = job.Progress
imgURL = job.ImgURL imgURL = job.ImgURL
break
case "sd": case "sd":
var job model.SdJob var job model.SdJob
if res := h.DB.Where("id", id).First(&job); res.Error != nil { if res := h.DB.Where("id", id).First(&job); res.Error != nil {
@@ -210,7 +209,6 @@ func (h *ImageHandler) Remove(c *gin.Context) {
remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg) remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
progress = job.Progress progress = job.Progress
imgURL = job.ImgURL imgURL = job.ImgURL
break
case "dall": case "dall":
var job model.DallJob var job model.DallJob
if res := h.DB.Where("id", id).First(&job); res.Error != nil { if res := h.DB.Where("id", id).First(&job); res.Error != nil {
@@ -226,7 +224,6 @@ func (h *ImageHandler) Remove(c *gin.Context) {
remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg) remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
progress = job.Progress progress = job.Progress
imgURL = job.ImgURL imgURL = job.ImgURL
break
default: default:
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return

View File

@@ -0,0 +1,296 @@
package admin
import (
"fmt"
"strconv"
"geekai/core"
"geekai/core/types"
"geekai/handler"
"geekai/service"
"geekai/service/jimeng"
"geekai/service/oss"
"geekai/store/model"
"geekai/utils"
"geekai/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminJimengHandler 管理后台即梦AI处理器
type AdminJimengHandler struct {
handler.BaseHandler
jimengService *jimeng.Service
userService *service.UserService
uploader *oss.UploaderManager
}
// NewAdminJimengHandler 创建管理后台即梦AI处理器
func NewAdminJimengHandler(app *core.AppServer, db *gorm.DB, jimengService *jimeng.Service, userService *service.UserService, uploader *oss.UploaderManager) *AdminJimengHandler {
return &AdminJimengHandler{
BaseHandler: handler.BaseHandler{App: app, DB: db},
jimengService: jimengService,
userService: userService,
uploader: uploader,
}
}
// RegisterRoutes 注册即梦AI管理后台路由
func (h *AdminJimengHandler) RegisterRoutes() {
rg := h.App.Engine.Group("/api/admin/jimeng/")
rg.GET("/jobs", h.Jobs)
rg.GET("/jobs/:id", h.JobDetail)
rg.POST("/jobs/remove", h.BatchRemove)
rg.GET("/stats", h.Stats)
rg.GET("/config", h.GetConfig)
rg.POST("/config/update", h.UpdateConfig)
}
// Jobs 获取任务列表
func (h *AdminJimengHandler) Jobs(c *gin.Context) {
page := h.GetInt(c, "page", 1)
pageSize := h.GetInt(c, "page_size", 20)
userId := h.GetInt(c, "user_id", 0)
taskType := h.GetTrim(c, "type")
status := h.GetTrim(c, "status")
var tasks []model.JimengJob
var total int64
session := h.DB.Model(&model.JimengJob{})
// 构建查询条件
if userId > 0 {
session = session.Where("user_id = ?", userId)
}
if taskType != "" {
session = session.Where("type = ?", taskType)
}
if status != "" {
session = session.Where("status = ?", status)
}
// 获取总数
err := session.Count(&total).Error
if err != nil {
resp.ERROR(c, "获取任务数量失败")
return
}
// 获取数据
offset := (page - 1) * pageSize
err = session.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&tasks).Error
if err != nil {
resp.ERROR(c, "获取任务列表失败")
return
}
resp.SUCCESS(c, gin.H{
"jobs": tasks,
"total": total,
"page": page,
"page_size": pageSize,
})
}
// JobDetail 获取任务详情
func (h *AdminJimengHandler) JobDetail(c *gin.Context) {
idStr := c.Param("id")
jobId, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
resp.ERROR(c, "参数错误")
return
}
var job model.JimengJob
err = h.DB.Where("id = ?", jobId).First(&job).Error
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
resp.SUCCESS(c, job)
}
// BatchRemove 批量删除任务
func (h *AdminJimengHandler) BatchRemove(c *gin.Context) {
var req struct {
JobIds []uint `json:"job_ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, "参数错误")
return
}
var deletedCount int64 = 0
for _, jobId := range req.JobIds {
var job model.JimengJob
err := h.DB.Where("id = ?", jobId).First(&job).Error
if err != nil {
continue // 跳过不存在的
}
tx := h.DB.Begin()
if job.Status != model.JMTaskStatusSuccess && job.Power > 0 {
remark := fmt.Sprintf("任务未成功退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
err = h.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: "jimeng",
Remark: remark,
})
if err != nil {
tx.Rollback()
continue
}
}
err = tx.Where("id = ?", jobId).Delete(&model.JimengJob{}).Error
if err != nil {
tx.Rollback()
continue
}
tx.Commit()
deletedCount++
if job.ImgURL != "" {
err = h.uploader.GetUploadHandler().Delete(job.ImgURL)
if err != nil {
logger.Error("remove image failed: ", err)
}
}
if job.VideoURL != "" {
err = h.uploader.GetUploadHandler().Delete(job.VideoURL)
if err != nil {
logger.Error("remove video failed: ", err)
}
}
}
resp.SUCCESS(c, gin.H{
"message": "批量删除成功",
"deleted_count": deletedCount,
})
}
// Stats 获取统计信息
func (h *AdminJimengHandler) Stats(c *gin.Context) {
type StatResult struct {
Status model.JMTaskStatus `json:"status"`
Count int64 `json:"count"`
}
var stats []StatResult
err := h.DB.Model(&model.JimengJob{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&stats).Error
if err != nil {
resp.ERROR(c, "获取统计信息失败")
return
}
// 整理统计数据
result := gin.H{
"totalTasks": int64(0),
"completedTasks": int64(0),
"processingTasks": int64(0),
"failedTasks": int64(0),
"pendingTasks": int64(0),
}
for _, stat := range stats {
result["totalTasks"] = result["totalTasks"].(int64) + stat.Count
switch stat.Status {
case model.JMTaskStatusInQueue:
result["pendingTasks"] = stat.Count
case model.JMTaskStatusSuccess:
result["completedTasks"] = stat.Count
case model.JMTaskStatusGenerating:
result["processingTasks"] = stat.Count
case model.JMTaskStatusFailed:
result["failedTasks"] = stat.Count
}
}
resp.SUCCESS(c, result)
}
// GetConfig 获取即梦AI配置
func (h *AdminJimengHandler) GetConfig(c *gin.Context) {
jimengConfig := h.jimengService.GetConfig()
resp.SUCCESS(c, jimengConfig)
}
// UpdateConfig 更新即梦AI配置
func (h *AdminJimengHandler) UpdateConfig(c *gin.Context) {
var req types.JimengConfig
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, "参数错误")
return
}
// 验证必填字段
if req.AccessKey == "" {
resp.ERROR(c, "AccessKey不能为空")
return
}
if req.SecretKey == "" {
resp.ERROR(c, "SecretKey不能为空")
return
}
// 验证算力配置
if req.Power.TextToImage <= 0 {
resp.ERROR(c, "文生图算力必须大于0")
return
}
if req.Power.ImageToImage <= 0 {
resp.ERROR(c, "图生图算力必须大于0")
return
}
if req.Power.ImageEdit <= 0 {
resp.ERROR(c, "图片编辑算力必须大于0")
return
}
if req.Power.ImageEffects <= 0 {
resp.ERROR(c, "图片特效算力必须大于0")
return
}
if req.Power.TextToVideo <= 0 {
resp.ERROR(c, "文生视频算力必须大于0")
return
}
if req.Power.ImageToVideo <= 0 {
resp.ERROR(c, "图生视频算力必须大于0")
return
}
// 保存配置
tx := h.DB.Begin()
value := utils.JsonEncode(&req)
config := model.Config{Name: "jimeng", Value: value}
err := tx.FirstOrCreate(&config, model.Config{Name: "jimeng"}).Error
if err != nil {
resp.ERROR(c, "保存配置失败: "+err.Error())
return
}
if config.Id > 0 {
config.Value = value
err = tx.Updates(&config).Error
if err != nil {
resp.ERROR(c, "更新配置失败: "+err.Error())
return
}
}
// 更新服务中的客户端配置
updateErr := h.jimengService.UpdateClientConfig(req.AccessKey, req.SecretKey)
if updateErr != nil {
resp.ERROR(c, updateErr.Error())
tx.Rollback()
return
}
tx.Commit()
resp.SUCCESS(c, gin.H{"message": "配置更新成功"})
}

View File

@@ -154,7 +154,6 @@ func (h *MediaHandler) Remove(c *gin.Context) {
remark = fmt.Sprintf("SUNO 任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg) remark = fmt.Sprintf("SUNO 任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
progress = job.Progress progress = job.Progress
fileURL = job.AudioURL fileURL = job.AudioURL
break
case "luma": case "luma":
case "keling": case "keling":
var job model.VideoJob var job model.VideoJob
@@ -174,7 +173,6 @@ func (h *MediaHandler) Remove(c *gin.Context) {
if fileURL == "" { if fileURL == "" {
fileURL = job.WaterURL fileURL = job.WaterURL
} }
break
default: default:
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return

View File

@@ -95,6 +95,15 @@ func (h *ChatHandler) Chat(c *gin.Context) {
ctx, cancel := context.WithCancel(c.Request.Context()) ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel() defer cancel()
// 这里做个全局的异常处理,防止整个请求异常,导致 SSE 连接断开
defer func() {
if err := recover(); err != nil {
logger.Errorf("chat handler error: %v", err)
pushMessage(c, ChatEventError, err)
c.Abort()
}
}()
// 使用旧的聊天数据覆盖模型和角色ID // 使用旧的聊天数据覆盖模型和角色ID
var chat model.ChatItem var chat model.ChatItem
h.DB.Where("chat_id", input.ChatId).First(&chat) h.DB.Where("chat_id", input.ChatId).First(&chat)

View File

@@ -20,6 +20,7 @@ import (
// List 获取会话列表 // List 获取会话列表
func (h *ChatHandler) List(c *gin.Context) { func (h *ChatHandler) List(c *gin.Context) {
logger.Info(h.GetLoginUserId(c))
if !h.IsLogin(c) { if !h.IsLogin(c) {
resp.SUCCESS(c) resp.SUCCESS(c)
return return
@@ -28,7 +29,7 @@ func (h *ChatHandler) List(c *gin.Context) {
userId := h.GetLoginUserId(c) userId := h.GetLoginUserId(c)
var items = make([]vo.ChatItem, 0) var items = make([]vo.ChatItem, 0)
var chats []model.ChatItem var chats []model.ChatItem
h.DB.Where("user_id", userId).Order("id DESC").Find(&chats) h.DB.Debug().Where("user_id", userId).Order("id DESC").Find(&chats)
if len(chats) == 0 { if len(chats) == 0 {
resp.SUCCESS(c, items) resp.SUCCESS(c, items)
return return

View File

@@ -31,14 +31,14 @@ func NewConfigHandler(app *core.AppServer, db *gorm.DB, licenseService *service.
func (h *ConfigHandler) Get(c *gin.Context) { func (h *ConfigHandler) Get(c *gin.Context) {
key := c.Query("key") key := c.Query("key")
var config model.Config var config model.Config
res := h.DB.Where("marker", key).First(&config) res := h.DB.Where("name", key).First(&config)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, res.Error.Error()) resp.ERROR(c, res.Error.Error())
return return
} }
var value map[string]interface{} var value map[string]any
err := utils.JsonDecode(config.Config, &value) err := utils.JsonDecode(config.Value, &value)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return

View File

@@ -77,7 +77,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
Quality: data.Quality, Quality: data.Quality,
Size: data.Size, Size: data.Size,
Style: data.Style, Style: data.Style,
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
Power: chatModel.Power, Power: chatModel.Power,
} }
job := model.DallJob{ job := model.DallJob{

View File

@@ -213,7 +213,7 @@ func (h *FunctionHandler) Dall3(c *gin.Context) {
Prompt: prompt, Prompt: prompt,
ModelId: 0, ModelId: 0,
ModelName: "dall-e-3", ModelName: "dall-e-3",
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
N: 1, N: 1,
Quality: "standard", Quality: "standard",
Size: "1024x1024", Size: "1024x1024",
@@ -265,27 +265,27 @@ func (h *FunctionHandler) WebSearch(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return
} }
// 从参数中获取搜索关键词 // 从参数中获取搜索关键词
keyword, ok := params["keyword"].(string) keyword, ok := params["keyword"].(string)
if !ok || keyword == "" { if !ok || keyword == "" {
resp.ERROR(c, "搜索关键词不能为空") resp.ERROR(c, "搜索关键词不能为空")
return return
} }
// 从参数中获取最大页数默认为1页 // 从参数中获取最大页数默认为1页
maxPages := 1 maxPages := 1
if pages, ok := params["max_pages"].(float64); ok { if pages, ok := params["max_pages"].(float64); ok {
maxPages = int(pages) maxPages = int(pages)
} }
// 获取用户ID // 获取用户ID
userID, ok := params["user_id"].(float64) userID, ok := params["user_id"].(float64)
if !ok { if !ok {
resp.ERROR(c, "用户ID不能为空") resp.ERROR(c, "用户ID不能为空")
return return
} }
// 查询用户信息 // 查询用户信息
var user model.User var user model.User
res := h.DB.Where("id = ?", int(userID)).First(&user) res := h.DB.Where("id = ?", int(userID)).First(&user)
@@ -293,21 +293,21 @@ func (h *FunctionHandler) WebSearch(c *gin.Context) {
resp.ERROR(c, "用户不存在") resp.ERROR(c, "用户不存在")
return return
} }
// 检查用户算力是否足够 // 检查用户算力是否足够
searchPower := 1 // 每次搜索消耗1点算力 searchPower := 1 // 每次搜索消耗1点算力
if user.Power < searchPower { if user.Power < searchPower {
resp.ERROR(c, "算力不足,无法执行网络搜索") resp.ERROR(c, "算力不足,无法执行网络搜索")
return return
} }
// 执行网络搜索 // 执行网络搜索
searchResults, err := crawler.SearchWeb(keyword, maxPages) searchResults, err := crawler.SearchWeb(keyword, maxPages)
if err != nil { if err != nil {
resp.ERROR(c, fmt.Sprintf("搜索失败: %v", err)) resp.ERROR(c, fmt.Sprintf("搜索失败: %v", err))
return return
} }
// 扣减用户算力 // 扣减用户算力
err = h.userService.DecreasePower(user.Id, searchPower, model.PowerLog{ err = h.userService.DecreasePower(user.Id, searchPower, model.PowerLog{
Type: types.PowerConsume, Type: types.PowerConsume,
@@ -318,7 +318,7 @@ func (h *FunctionHandler) WebSearch(c *gin.Context) {
resp.ERROR(c, "扣减算力失败:"+err.Error()) resp.ERROR(c, "扣减算力失败:"+err.Error())
return return
} }
// 返回搜索结果 // 返回搜索结果
resp.SUCCESS(c, searchResults) resp.SUCCESS(c, searchResults)
} }

View File

@@ -0,0 +1,442 @@
package handler
import (
"fmt"
"geekai/core"
"geekai/core/types"
"geekai/service"
"geekai/service/jimeng"
"geekai/store/model"
"geekai/store/vo"
"geekai/utils"
"geekai/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// JimengHandler 即梦AI处理器
type JimengHandler struct {
BaseHandler
jimengService *jimeng.Service
userService *service.UserService
}
// NewJimengHandler 创建即梦AI处理器
func NewJimengHandler(app *core.AppServer, jimengService *jimeng.Service, db *gorm.DB, userService *service.UserService) *JimengHandler {
return &JimengHandler{
BaseHandler: BaseHandler{App: app, DB: db},
jimengService: jimengService,
userService: userService,
}
}
// RegisterRoutes 注册路由,新增统一任务接口
func (h *JimengHandler) RegisterRoutes() {
rg := h.App.Engine.Group("/api/jimeng")
rg.POST("task", h.CreateTask) // 只保留统一任务接口
rg.GET("power-config", h.GetPowerConfig) // 新增算力配置接口
rg.POST("jobs", h.Jobs)
rg.GET("remove", h.Remove)
rg.GET("retry", h.Retry)
}
// JimengTaskRequest 统一任务请求结构体
// 支持所有生图和生成视频类型
type JimengTaskRequest struct {
TaskType string `json:"task_type" binding:"required"`
Prompt string `json:"prompt"`
ImageInput string `json:"image_input"`
ImageUrls []string `json:"image_urls"`
BinaryDataBase64 []string `json:"binary_data_base64"`
Scale float64 `json:"scale"`
Width int `json:"width"`
Height int `json:"height"`
Gpen float64 `json:"gpen"`
Skin float64 `json:"skin"`
SkinUnifi float64 `json:"skin_unifi"`
GenMode string `json:"gen_mode"`
Seed int64 `json:"seed"`
UsePreLLM bool `json:"use_pre_llm"`
TemplateId string `json:"template_id"`
AspectRatio string `json:"aspect_ratio"`
}
// CreateTask 统一任务创建接口
func (h *JimengHandler) CreateTask(c *gin.Context) {
var req JimengTaskRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
// 新增:除图像特效外,其他任务类型必须有提示词
if req.TaskType != "image_effects" && req.Prompt == "" {
resp.ERROR(c, "提示词不能为空")
return
}
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
if req.Width == 0 {
req.Width = 1328
}
if req.Height == 0 {
req.Height = 1328
}
if req.Seed == 0 {
req.Seed = -1
}
var powerCost int
var taskType model.JMTaskType
var params map[string]any
var reqKey string
var modelName string
switch req.TaskType {
case "text_to_image":
powerCost = h.getPowerFromConfig(model.JMTaskTypeTextToImage)
taskType = model.JMTaskTypeTextToImage
reqKey = jimeng.ReqKeyTextToImage
modelName = "即梦文生图"
if req.Scale == 0 {
req.Scale = 2.5
}
params = map[string]any{
"seed": req.Seed,
"scale": req.Scale,
"width": req.Width,
"height": req.Height,
"use_pre_llm": req.UsePreLLM,
}
case "image_to_image":
powerCost = h.getPowerFromConfig(model.JMTaskTypeImageToImage)
taskType = model.JMTaskTypeImageToImage
reqKey = jimeng.ReqKeyImageToImagePortrait
modelName = "即梦图生图"
if req.Gpen == 0 {
req.Gpen = 0.4
}
if req.Skin == 0 {
req.Skin = 0.3
}
if req.GenMode == "" {
if req.Prompt != "" {
req.GenMode = jimeng.GenModeCreative
} else {
req.GenMode = jimeng.GenModeReference
}
}
params = map[string]any{
"image_input": req.ImageInput,
"width": req.Width,
"height": req.Height,
"gpen": req.Gpen,
"skin": req.Skin,
"skin_unifi": req.SkinUnifi,
"gen_mode": req.GenMode,
"seed": req.Seed,
}
case "image_edit":
powerCost = h.getPowerFromConfig(model.JMTaskTypeImageEdit)
taskType = model.JMTaskTypeImageEdit
reqKey = jimeng.ReqKeyImageEdit
modelName = "即梦图像编辑"
if req.Scale == 0 {
req.Scale = 0.5
}
params = map[string]any{
"seed": req.Seed,
"scale": req.Scale,
}
if len(req.ImageUrls) > 0 {
params["image_urls"] = req.ImageUrls
}
if len(req.BinaryDataBase64) > 0 {
params["binary_data_base64"] = req.BinaryDataBase64
}
case "image_effects":
powerCost = h.getPowerFromConfig(model.JMTaskTypeImageEffects)
taskType = model.JMTaskTypeImageEffects
reqKey = jimeng.ReqKeyImageEffects
modelName = "即梦图像特效"
if req.Width == 0 {
req.Width = 1328
}
if req.Height == 0 {
req.Height = 1328
}
params = map[string]any{
"image_input1": req.ImageInput,
"template_id": req.TemplateId,
"width": req.Width,
"height": req.Height,
}
case "text_to_video":
powerCost = h.getPowerFromConfig(model.JMTaskTypeTextToVideo)
taskType = model.JMTaskTypeTextToVideo
reqKey = jimeng.ReqKeyTextToVideo
modelName = "即梦文生视频"
if req.Seed == 0 {
req.Seed = -1
}
if req.AspectRatio == "" {
req.AspectRatio = jimeng.AspectRatio16_9
}
params = map[string]any{
"seed": req.Seed,
"aspect_ratio": req.AspectRatio,
}
case "image_to_video":
powerCost = h.getPowerFromConfig(model.JMTaskTypeImageToVideo)
taskType = model.JMTaskTypeImageToVideo
reqKey = jimeng.ReqKeyImageToVideo
modelName = "即梦图生视频"
if req.Seed == 0 {
req.Seed = -1
}
params = map[string]any{
"seed": req.Seed,
"aspect_ratio": req.AspectRatio,
}
if len(req.ImageUrls) > 0 {
params["image_urls"] = req.ImageUrls
}
if len(req.BinaryDataBase64) > 0 {
params["binary_data_base64"] = req.BinaryDataBase64
}
default:
resp.ERROR(c, "不支持的任务类型")
return
}
if user.Power < powerCost {
resp.ERROR(c, fmt.Sprintf("算力不足,需要%d算力", powerCost))
return
}
taskReq := &jimeng.CreateTaskRequest{
Type: taskType,
Prompt: req.Prompt,
Params: params,
ReqKey: reqKey,
Power: powerCost,
}
job, err := h.jimengService.CreateTask(user.Id, taskReq)
if err != nil {
logger.Errorf("create jimeng task failed: %v", err)
resp.ERROR(c, "创建任务失败")
return
}
h.userService.DecreasePower(user.Id, powerCost, model.PowerLog{
Type: types.PowerConsume,
Model: "jimeng",
Remark: fmt.Sprintf("%s任务ID%d", modelName, job.Id),
})
resp.SUCCESS(c, job)
}
// Jobs 获取任务列表
func (h *JimengHandler) Jobs(c *gin.Context) {
userId := h.GetLoginUserId(c)
var req struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Filter string `json:"filter"`
Ids []uint `json:"ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
var jobs []model.JimengJob
var total int64
query := h.DB.Model(&model.JimengJob{}).Where("user_id = ?", userId)
switch req.Filter {
case "image":
query = query.Where("type IN (?)", []model.JMTaskType{
model.JMTaskTypeTextToImage,
model.JMTaskTypeImageToImage,
model.JMTaskTypeImageEdit,
model.JMTaskTypeImageEffects,
})
case "video":
query = query.Where("type IN (?)", []model.JMTaskType{
model.JMTaskTypeTextToVideo,
model.JMTaskTypeImageToVideo,
})
}
if len(req.Ids) > 0 {
query = query.Where("id IN (?)", req.Ids)
}
// 统计总数
if err := query.Count(&total).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
// 分页查询
offset := (req.Page - 1) * req.PageSize
if err := query.Order("updated_at DESC").Offset(offset).Limit(req.PageSize).Find(&jobs).Error; err != nil {
resp.ERROR(c, err.Error())
return
}
// 填充 VO
var jobVos []vo.JimengJob
for _, job := range jobs {
var jobVo vo.JimengJob
err := utils.CopyObject(job, &jobVo)
if err != nil {
continue
}
jobVo.CreatedAt = job.CreatedAt.Unix()
jobVos = append(jobVos, jobVo)
}
resp.SUCCESS(c, vo.NewPage(total, req.Page, req.PageSize, jobVos))
}
// Remove 删除任务
func (h *JimengHandler) Remove(c *gin.Context) {
user, err := h.GetLoginUser(c)
if err != nil {
resp.NotAuth(c)
return
}
jobId := h.GetInt(c, "id", 0)
if jobId == 0 {
resp.ERROR(c, "参数错误")
return
}
// 获取任务,判断状态
job, err := h.jimengService.GetJob(uint(jobId))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
if job.UserId != user.Id {
resp.ERROR(c, "无权限操作")
return
}
if job.Status != model.JMTaskStatusFailed {
resp.ERROR(c, "只有失败的任务才能删除")
return
}
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) {
userId := h.GetLoginUserId(c)
jobId := h.GetInt(c, "id", 0)
if jobId == 0 {
resp.ERROR(c, "参数错误")
return
}
// 检查任务是否存在且属于当前用户
job, err := h.jimengService.GetJob(uint(jobId))
if err != nil {
resp.ERROR(c, "任务不存在")
return
}
if job.UserId != userId {
resp.ERROR(c, "无权限操作")
return
}
// 只有失败的任务才能重试
if job.Status != model.JMTaskStatusFailed {
resp.ERROR(c, "只有失败的任务才能重试")
return
}
// 重置任务状态
if err := h.jimengService.UpdateJobStatus(uint(jobId), model.JMTaskStatusInQueue, ""); err != nil {
logger.Errorf("reset job status failed: %v", err)
resp.ERROR(c, "重置任务状态失败")
return
}
// 重新推送到队列
if err := h.jimengService.PushTaskToQueue(uint(jobId)); err != nil {
logger.Errorf("push retry task to queue failed: %v", err)
resp.ERROR(c, "推送重试任务失败")
return
}
resp.SUCCESS(c, gin.H{"message": "重试任务已提交"})
}
// getPowerFromConfig 从配置中获取指定类型的算力消耗
func (h *JimengHandler) getPowerFromConfig(taskType model.JMTaskType) int {
config := h.jimengService.GetConfig()
switch taskType {
case model.JMTaskTypeTextToImage:
return config.Power.TextToImage
case model.JMTaskTypeImageToImage:
return config.Power.ImageToImage
case model.JMTaskTypeImageEdit:
return config.Power.ImageEdit
case model.JMTaskTypeImageEffects:
return config.Power.ImageEffects
case model.JMTaskTypeTextToVideo:
return config.Power.TextToVideo
case model.JMTaskTypeImageToVideo:
return config.Power.ImageToVideo
default:
return 10
}
}
// GetPowerConfig 获取即梦各任务类型算力消耗配置
func (h *JimengHandler) GetPowerConfig(c *gin.Context) {
config := h.jimengService.GetConfig()
resp.SUCCESS(c, gin.H{
"text_to_image": config.Power.TextToImage,
"image_to_image": config.Power.ImageToImage,
"image_edit": config.Power.ImageEdit,
"image_effects": config.Power.ImageEffects,
"text_to_video": config.Power.TextToVideo,
"image_to_video": config.Power.ImageToVideo,
})
}

View File

@@ -160,7 +160,7 @@ func (h *MidJourneyHandler) Image(c *gin.Context) {
UserId: userId, UserId: userId,
ImgArr: data.ImgArr, ImgArr: data.ImgArr,
Mode: h.App.SysConfig.MjMode, Mode: h.App.SysConfig.MjMode,
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
} }
job := model.MidJourneyJob{ job := model.MidJourneyJob{
Type: data.TaskType, Type: data.TaskType,

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

@@ -48,7 +48,7 @@ func (h *PromptHandler) Lyric(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return
} }
content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.LyricPromptTemplate, data.Prompt), h.App.SysConfig.TranslateModelId) content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.LyricPromptTemplate, data.Prompt), h.App.SysConfig.AssistantModelId)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return
@@ -79,7 +79,7 @@ func (h *PromptHandler) Image(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return
} }
content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.ImagePromptOptimizeTemplate, data.Prompt), h.App.SysConfig.TranslateModelId) content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.ImagePromptOptimizeTemplate, data.Prompt), h.App.SysConfig.AssistantModelId)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return
@@ -108,7 +108,7 @@ func (h *PromptHandler) Video(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs) resp.ERROR(c, types.InvalidArgs)
return return
} }
content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.VideoPromptTemplate, data.Prompt), h.App.SysConfig.TranslateModelId) content, err := utils.OpenAIRequest(h.DB, fmt.Sprintf(service.VideoPromptTemplate, data.Prompt), h.App.SysConfig.AssistantModelId)
if err != nil { if err != nil {
resp.ERROR(c, err.Error()) resp.ERROR(c, err.Error())
return return
@@ -158,9 +158,9 @@ func (h *PromptHandler) MetaPrompt(c *gin.Context) {
} }
func (h *PromptHandler) getPromptModel() string { func (h *PromptHandler) getPromptModel() string {
if h.App.SysConfig.TranslateModelId > 0 { if h.App.SysConfig.AssistantModelId > 0 {
var chatModel model.ChatModel var chatModel model.ChatModel
h.DB.Where("id", h.App.SysConfig.TranslateModelId).First(&chatModel) h.DB.Where("id", h.App.SysConfig.AssistantModelId).First(&chatModel)
return chatModel.Value return chatModel.Value
} }
return "gpt-4o" return "gpt-4o"

View File

@@ -131,7 +131,7 @@ func (h *SdJobHandler) Image(c *gin.Context) {
HdSteps: data.HdSteps, HdSteps: data.HdSteps,
}, },
UserId: userId, UserId: userId,
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
} }
job := model.SdJob{ job := model.SdJob{

View File

@@ -85,7 +85,7 @@ func (h *VideoHandler) LumaCreate(c *gin.Context) {
Type: types.VideoLuma, Type: types.VideoLuma,
Prompt: data.Prompt, Prompt: data.Prompt,
Params: params, Params: params,
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
} }
// 插入数据库 // 插入数据库
job := model.VideoJob{ job := model.VideoJob{
@@ -181,7 +181,7 @@ func (h *VideoHandler) KeLingCreate(c *gin.Context) {
Type: types.VideoKeLing, Type: types.VideoKeLing,
Prompt: data.Prompt, Prompt: data.Prompt,
Params: params, Params: params,
TranslateModelId: h.App.SysConfig.TranslateModelId, TranslateModelId: h.App.SysConfig.AssistantModelId,
Channel: data.Channel, Channel: data.Channel,
} }
// 插入数据库 // 插入数据库

View File

@@ -17,6 +17,7 @@ import (
logger2 "geekai/logger" logger2 "geekai/logger"
"geekai/service" "geekai/service"
"geekai/service/dalle" "geekai/service/dalle"
"geekai/service/jimeng"
"geekai/service/mj" "geekai/service/mj"
"geekai/service/oss" "geekai/service/oss"
"geekai/service/payment" "geekai/service/payment"
@@ -140,6 +141,7 @@ func main() {
fx.Provide(handler.NewProductHandler), fx.Provide(handler.NewProductHandler),
fx.Provide(handler.NewConfigHandler), fx.Provide(handler.NewConfigHandler),
fx.Provide(handler.NewPowerLogHandler), fx.Provide(handler.NewPowerLogHandler),
fx.Provide(handler.NewJimengHandler),
fx.Provide(admin.NewConfigHandler), fx.Provide(admin.NewConfigHandler),
fx.Provide(admin.NewAdminHandler), fx.Provide(admin.NewAdminHandler),
@@ -153,6 +155,7 @@ func main() {
fx.Provide(admin.NewOrderHandler), fx.Provide(admin.NewOrderHandler),
fx.Provide(admin.NewChatHandler), fx.Provide(admin.NewChatHandler),
fx.Provide(admin.NewPowerLogHandler), fx.Provide(admin.NewPowerLogHandler),
fx.Provide(admin.NewAdminJimengHandler),
// 创建服务 // 创建服务
fx.Provide(sms.NewSendServiceManager), fx.Provide(sms.NewSendServiceManager),
@@ -208,6 +211,12 @@ func main() {
s.SyncTaskProgress() s.SyncTaskProgress()
s.DownloadFiles() s.DownloadFiles()
}), }),
// 即梦AI 服务
fx.Provide(jimeng.NewService),
fx.Invoke(func(service *jimeng.Service) {
service.Start()
}),
fx.Provide(service.NewUserService), fx.Provide(service.NewUserService),
fx.Provide(payment.NewAlipayService), fx.Provide(payment.NewAlipayService),
fx.Provide(payment.NewHuPiPay), fx.Provide(payment.NewHuPiPay),
@@ -501,6 +510,14 @@ func main() {
group.GET("remove", h.Remove) group.GET("remove", h.Remove)
group.GET("publish", h.Publish) group.GET("publish", h.Publish)
}), }),
// 即梦AI 路由
fx.Invoke(func(s *core.AppServer, h *handler.JimengHandler) {
h.RegisterRoutes()
}),
fx.Invoke(func(s *core.AppServer, h *admin.AdminJimengHandler) {
h.RegisterRoutes()
}),
fx.Provide(admin.NewChatAppTypeHandler), fx.Provide(admin.NewChatAppTypeHandler),
fx.Invoke(func(s *core.AppServer, h *admin.ChatAppTypeHandler) { fx.Invoke(func(s *core.AppServer, h *admin.ChatAppTypeHandler) {
group := s.Engine.Group("/api/admin/app/type") group := s.Engine.Group("/api/admin/app/type")

View File

@@ -49,7 +49,9 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
// PushTask push a new mj task in to task queue // PushTask push a new mj task in to task queue
func (s *Service) PushTask(task types.DallTask) { func (s *Service) PushTask(task types.DallTask) {
logger.Infof("add a new DALL-E task to the task list: %+v", task) logger.Infof("add a new DALL-E task to the task list: %+v", task)
s.taskQueue.RPush(task) if err := s.taskQueue.RPush(task); err != nil {
logger.Errorf("push dall-e task to queue failed: %v", err)
}
} }
func (s *Service) Run() { func (s *Service) Run() {
@@ -291,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

@@ -0,0 +1,139 @@
package jimeng
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/volcengine/volc-sdk-golang/base"
"github.com/volcengine/volc-sdk-golang/service/visual"
)
// Client 即梦API客户端
type Client struct {
visual *visual.Visual
}
// NewClient 创建即梦API客户端
func NewClient(accessKey, secretKey string) *Client {
// 使用官方SDK的visual实例
visualInstance := visual.NewInstance()
visualInstance.Client.SetAccessKey(accessKey)
visualInstance.Client.SetSecretKey(secretKey)
// 添加即梦AI专有的API配置
jimengApis := map[string]*base.ApiInfo{
"CVSync2AsyncSubmitTask": {
Method: http.MethodPost,
Path: "/",
Query: url.Values{
"Action": []string{"CVSync2AsyncSubmitTask"},
"Version": []string{"2022-08-31"},
},
},
"CVSync2AsyncGetResult": {
Method: http.MethodPost,
Path: "/",
Query: url.Values{
"Action": []string{"CVSync2AsyncGetResult"},
"Version": []string{"2022-08-31"},
},
},
"CVProcess": {
Method: http.MethodPost,
Path: "/",
Query: url.Values{
"Action": []string{"CVProcess"},
"Version": []string{"2022-08-31"},
},
},
}
// 将即梦API添加到现有的ApiInfoList中
for name, info := range jimengApis {
visualInstance.Client.ApiInfoList[name] = info
}
return &Client{
visual: visualInstance,
}
}
// SubmitTask 提交异步任务
func (c *Client) SubmitTask(req *SubmitTaskRequest) (*SubmitTaskResponse, error) {
// 直接将请求转为map[string]interface{}
reqBodyBytes, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
// 直接使用序列化后的字节
jsonBody := reqBodyBytes
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncSubmitTask", nil, string(jsonBody))
if err != nil {
return nil, fmt.Errorf("submit task failed (status: %d): %w", statusCode, err)
}
logger.Infof("Jimeng SubmitTask Response: %s", string(respBody))
// 解析响应
var result SubmitTaskResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w", err)
}
return &result, nil
}
// QueryTask 查询任务结果
func (c *Client) QueryTask(req *QueryTaskRequest) (*QueryTaskResponse, error) {
// 序列化请求
jsonBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json("CVSync2AsyncGetResult", nil, string(jsonBody))
if err != nil {
return nil, fmt.Errorf("query task failed (status: %d): %w", statusCode, err)
}
logger.Infof("Jimeng QueryTask Response: %s", string(respBody))
// 解析响应
var result QueryTaskResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w", err)
}
return &result, nil
}
// SubmitSyncTask 提交同步任务(仅用于文生图)
func (c *Client) SubmitSyncTask(req *SubmitTaskRequest) (*QueryTaskResponse, error) {
// 序列化请求
jsonBody, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal request failed: %w", err)
}
// 调用SDK的JSON方法
respBody, statusCode, err := c.visual.Client.Json("CVProcess", nil, string(jsonBody))
if err != nil {
return nil, fmt.Errorf("submit sync task failed (status: %d): %w", statusCode, err)
}
logger.Infof("Jimeng SubmitSyncTask Response: %s", string(respBody))
// 解析响应,同步任务直接返回结果
var result QueryTaskResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("unmarshal response failed: %w", err)
}
return &result, nil
}

View File

@@ -0,0 +1,600 @@
package jimeng
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
logger2 "geekai/logger"
"geekai/service/oss"
"geekai/store"
"geekai/store/model"
"geekai/utils"
"geekai/core/types"
"github.com/go-redis/redis/v8"
)
var logger = logger2.GetLogger()
// Service 即梦服务(合并了消费者功能)
type Service struct {
db *gorm.DB
redis *redis.Client
taskQueue *store.RedisQueue
client *Client
ctx context.Context
cancel context.CancelFunc
running bool
uploader *oss.UploaderManager
}
// NewService 创建即梦服务
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager) *Service {
taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli)
// 从数据库加载配置
var config model.Config
db.Where("name = ?", "Jimeng").First(&config)
var jimengConfig types.JimengConfig
if config.Id > 0 {
_ = utils.JsonDecode(config.Value, &jimengConfig)
}
client := NewClient(jimengConfig.AccessKey, jimengConfig.SecretKey)
ctx, cancel := context.WithCancel(context.Background())
return &Service{
db: db,
redis: redisCli,
taskQueue: taskQueue,
client: client,
ctx: ctx,
cancel: cancel,
running: false,
uploader: uploader,
}
}
// Start 启动服务(包含消费者)
func (s *Service) Start() {
if s.running {
return
}
logger.Info("Starting Jimeng service and task consumer...")
s.running = true
go s.consumeTasks()
go s.pollTaskStatus()
}
// Stop 停止服务
func (s *Service) Stop() {
if !s.running {
return
}
logger.Info("Stopping Jimeng service and task consumer...")
s.running = false
s.cancel()
}
// consumeTasks 消费任务
func (s *Service) consumeTasks() {
for {
select {
case <-s.ctx.Done():
logger.Info("Jimeng task consumer stopped")
return
default:
s.processNextTask()
}
}
}
// processNextTask 处理下一个任务
func (s *Service) processNextTask() {
var jobId uint
if err := s.taskQueue.LPop(&jobId); err != nil {
// 队列为空等待1秒后重试
time.Sleep(time.Second)
return
}
logger.Infof("Processing Jimeng task: job_id=%d", jobId)
if err := s.ProcessTask(jobId); err != nil {
logger.Errorf("process jimeng task failed: job_id=%d, error=%v", jobId, err)
s.UpdateJobStatus(jobId, model.JMTaskStatusFailed, err.Error())
} else {
logger.Infof("Jimeng task processed successfully: job_id=%d", jobId)
}
}
// CreateTask 创建任务
func (s *Service) CreateTask(userId uint, req *CreateTaskRequest) (*model.JimengJob, error) {
// 生成任务ID
taskId := utils.RandString(20)
// 序列化任务参数
paramsJson, err := json.Marshal(req.Params)
if err != nil {
return nil, fmt.Errorf("marshal task params failed: %w", err)
}
// 创建任务记录
job := &model.JimengJob{
UserId: userId,
TaskId: taskId,
Type: req.Type,
ReqKey: req.ReqKey,
Prompt: req.Prompt,
TaskParams: string(paramsJson),
Status: model.JMTaskStatusInQueue,
Power: req.Power,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 保存到数据库
if err := s.db.Create(job).Error; err != nil {
return nil, fmt.Errorf("create jimeng job failed: %w", err)
}
// 推送到任务队列
if err := s.taskQueue.RPush(job.Id); err != nil {
return nil, fmt.Errorf("push jimeng task to queue failed: %w", err)
}
return job, nil
}
// ProcessTask 处理任务
func (s *Service) ProcessTask(jobId uint) error {
// 获取任务记录
var job model.JimengJob
if err := s.db.First(&job, jobId).Error; err != nil {
return fmt.Errorf("get jimeng job failed: %w", err)
}
// 更新任务状态为处理中
if err := s.UpdateJobStatus(job.Id, model.JMTaskStatusGenerating, ""); err != nil {
return fmt.Errorf("update job status failed: %w", err)
}
// 构建请求并提交任务
req, err := s.buildTaskRequest(&job)
if err != nil {
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 {
return s.handleTaskError(job.Id, fmt.Sprintf("submit task failed: %v", err))
}
if resp.Code != 10000 {
return s.handleTaskError(job.Id, fmt.Sprintf("submit task failed: %s", resp.Message))
}
// 更新任务ID和原始数据
rawData, _ := json.Marshal(resp)
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(map[string]any{
"task_id": resp.Data.TaskId,
"raw_data": string(rawData),
"updated_at": time.Now(),
}).Error; err != nil {
logger.Errorf("update jimeng job task_id failed: %v", err)
}
return nil
}
// buildTaskRequest 构建任务请求(统一的参数解析)
func (s *Service) buildTaskRequest(job *model.JimengJob) (*SubmitTaskRequest, error) {
// 解析任务参数
var params map[string]any
if err := json.Unmarshal([]byte(job.TaskParams), &params); err != nil {
return nil, fmt.Errorf("parse task params failed: %w", err)
}
// 构建基础请求
req := &SubmitTaskRequest{
ReqKey: job.ReqKey,
Prompt: job.Prompt,
}
// 根据任务类型设置特定参数
switch job.Type {
case model.JMTaskTypeTextToImage:
s.setTextToImageParams(req, params)
case model.JMTaskTypeImageToImage:
s.setImageToImageParams(req, params)
case model.JMTaskTypeImageEdit:
s.setImageEditParams(req, params)
case model.JMTaskTypeImageEffects:
s.setImageEffectsParams(req, params)
case model.JMTaskTypeTextToVideo:
s.setTextToVideoParams(req, params)
case model.JMTaskTypeImageToVideo:
s.setImageToVideoParams(req, params)
default:
return nil, fmt.Errorf("unsupported task type: %s", job.Type)
}
return req, nil
}
// setTextToImageParams 设置文生图参数
func (s *Service) setTextToImageParams(req *SubmitTaskRequest, params map[string]any) {
if seed, ok := params["seed"]; ok {
if seedVal, err := strconv.ParseInt(fmt.Sprintf("%.0f", seed), 10, 64); err == nil {
req.Seed = seedVal
}
}
if scale, ok := params["scale"]; ok {
if scaleVal, ok := scale.(float64); ok {
req.Scale = scaleVal
}
}
if width, ok := params["width"]; ok {
if widthVal, ok := width.(float64); ok {
req.Width = int(widthVal)
}
}
if height, ok := params["height"]; ok {
if heightVal, ok := height.(float64); ok {
req.Height = int(heightVal)
}
}
if usePreLlm, ok := params["use_pre_llm"]; ok {
if usePreLlmVal, ok := usePreLlm.(bool); ok {
req.UsePreLLM = usePreLlmVal
}
}
}
// setImageToImageParams 设置图生图参数
func (s *Service) setImageToImageParams(req *SubmitTaskRequest, params map[string]any) {
if imageInput, ok := params["image_input"].(string); ok {
req.ImageInput = imageInput
}
if gpen, ok := params["gpen"]; ok {
if gpenVal, ok := gpen.(float64); ok {
req.Gpen = gpenVal
}
}
if skin, ok := params["skin"]; ok {
if skinVal, ok := skin.(float64); ok {
req.Skin = skinVal
}
}
if skinUnifi, ok := params["skin_unifi"]; ok {
if skinUnifiVal, ok := skinUnifi.(float64); ok {
req.SkinUnifi = skinUnifiVal
}
}
if genMode, ok := params["gen_mode"].(string); ok {
req.GenMode = genMode
}
s.setCommonParams(req, params) // 复用通用参数
}
// setImageEditParams 设置图像编辑参数
func (s *Service) setImageEditParams(req *SubmitTaskRequest, params map[string]any) {
if imageUrls, ok := params["image_urls"].([]any); ok {
for _, url := range imageUrls {
if urlStr, ok := url.(string); ok {
req.ImageUrls = append(req.ImageUrls, urlStr)
}
}
}
if binaryData, ok := params["binary_data_base64"].([]any); ok {
for _, data := range binaryData {
if dataStr, ok := data.(string); ok {
req.BinaryDataBase64 = append(req.BinaryDataBase64, dataStr)
}
}
}
if scale, ok := params["scale"]; ok {
if scaleVal, ok := scale.(float64); ok {
req.Scale = scaleVal
}
}
s.setCommonParams(req, params)
}
// setImageEffectsParams 设置图像特效参数
func (s *Service) setImageEffectsParams(req *SubmitTaskRequest, params map[string]any) {
if imageInput1, ok := params["image_input1"].(string); ok {
req.ImageInput1 = imageInput1
}
if templateId, ok := params["template_id"].(string); ok {
req.TemplateId = templateId
}
if width, ok := params["width"]; ok {
if widthVal, ok := width.(float64); ok {
req.Width = int(widthVal)
}
}
if height, ok := params["height"]; ok {
if heightVal, ok := height.(float64); ok {
req.Height = int(heightVal)
}
}
}
// setTextToVideoParams 设置文生视频参数
func (s *Service) setTextToVideoParams(req *SubmitTaskRequest, params map[string]any) {
if aspectRatio, ok := params["aspect_ratio"].(string); ok {
req.AspectRatio = aspectRatio
}
s.setCommonParams(req, params)
}
// setImageToVideoParams 设置图生视频参数
func (s *Service) setImageToVideoParams(req *SubmitTaskRequest, params map[string]any) {
s.setImageEditParams(req, params) // 复用图像编辑的参数设置
if aspectRatio, ok := params["aspect_ratio"].(string); ok {
req.AspectRatio = aspectRatio
}
}
// setCommonParams 设置通用参数seed, width, height等
func (s *Service) setCommonParams(req *SubmitTaskRequest, params map[string]any) {
if seed, ok := params["seed"]; ok {
if seedVal, err := strconv.ParseInt(fmt.Sprintf("%.0f", seed), 10, 64); err == nil {
req.Seed = seedVal
}
}
if width, ok := params["width"]; ok {
if widthVal, ok := width.(float64); ok {
req.Width = int(widthVal)
}
}
if height, ok := params["height"]; ok {
if heightVal, ok := height.(float64); ok {
req.Height = int(heightVal)
}
}
}
// pollTaskStatus 轮询任务状态
func (s *Service) pollTaskStatus() {
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
}
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)
}
}
time.Sleep(5 * time.Second)
}
}
// UpdateJobStatus 更新任务状态
func (s *Service) UpdateJobStatus(jobId uint, status model.JMTaskStatus, errMsg string) error {
updates := map[string]any{
"status": status,
"updated_at": time.Now(),
}
if errMsg != "" {
updates["err_msg"] = errMsg
}
return s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Updates(updates).Error
}
// handleTaskError 处理任务错误
func (s *Service) handleTaskError(jobId uint, errMsg string) error {
logger.Errorf("Jimeng task error (job_id: %d): %s", jobId, errMsg)
return s.UpdateJobStatus(jobId, model.JMTaskStatusFailed, errMsg)
}
// PushTaskToQueue 推送任务到队列(用于手动重试)
func (s *Service) PushTaskToQueue(jobId uint) error {
return s.taskQueue.RPush(jobId)
}
// GetTaskStats 获取任务统计信息
func (s *Service) GetTaskStats() (map[string]any, error) {
type StatResult struct {
Status string `json:"status"`
Count int64 `json:"count"`
}
var stats []StatResult
err := s.db.Model(&model.JimengJob{}).
Select("status, COUNT(*) as count").
Group("status").
Find(&stats).Error
if err != nil {
return nil, err
}
result := map[string]any{
"total": int64(0),
"completed": int64(0),
"processing": int64(0),
"failed": int64(0),
"pending": int64(0),
}
for _, stat := range stats {
result["total"] = result["total"].(int64) + stat.Count
result[stat.Status] = stat.Count
}
return result, nil
}
// GetJob 获取任务
func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
var job model.JimengJob
if err := s.db.First(&job, jobId).Error; err != nil {
return nil, err
}
return &job, nil
}
// testConnection 测试即梦AI连接
func (s *Service) testConnection(accessKey, secretKey string) error {
testClient := NewClient(accessKey, secretKey)
// 使用一个简单的查询任务来测试连接
testReq := &QueryTaskRequest{
ReqKey: "test_connection",
TaskId: "test_task_id_12345",
}
_, err := testClient.QueryTask(testReq)
// 即使任务不存在,只要不是认证错误就说明连接正常
if err != nil {
// 检查是否是认证错误
if strings.Contains(err.Error(), "InvalidAccessKey") {
return fmt.Errorf("认证失败请检查AccessKey和SecretKey是否正确")
}
// 其他错误(如任务不存在)说明连接正常
return nil
}
return nil
}
// UpdateClientConfig 更新客户端配置
func (s *Service) UpdateClientConfig(accessKey, secretKey string) error {
// 创建新的客户端
newClient := NewClient(accessKey, secretKey)
// 测试新客户端是否可用
err := s.testConnection(accessKey, secretKey)
if err != nil {
return err
}
// 更新客户端
s.client = newClient
return nil
}
var defaultPower = types.JimengPower{
TextToImage: 20,
ImageToImage: 20,
ImageEdit: 20,
ImageEffects: 20,
TextToVideo: 300,
ImageToVideo: 300,
}
// GetConfig 获取即梦AI配置
func (s *Service) GetConfig() *types.JimengConfig {
var config model.Config
err := s.db.Where("name", "jimeng").First(&config).Error
if err != nil {
// 如果配置不存在,返回默认配置
return &types.JimengConfig{
AccessKey: "",
SecretKey: "",
Power: defaultPower,
}
}
var jimengConfig types.JimengConfig
err = utils.JsonDecode(config.Value, &jimengConfig)
if err != nil {
return &types.JimengConfig{
AccessKey: "",
SecretKey: "",
Power: defaultPower,
}
}
return &jimengConfig
}

145
api/service/jimeng/types.go Normal file
View File

@@ -0,0 +1,145 @@
package jimeng
import "geekai/store/model"
// ReqKey 常量定义
const (
ReqKeyTextToImage = "high_aes_general_v30l_zt2i" // 文生图
ReqKeyImageToImagePortrait = "i2i_portrait_photo" // 图生图人像写真
ReqKeyImageEdit = "seededit_v3.0" // 图像编辑
ReqKeyImageEffects = "i2i_multi_style_zx2x" // 图像特效
ReqKeyTextToVideo = "jimeng_vgfm_t2v_l20" // 文生视频
ReqKeyImageToVideo = "jimeng_vgfm_i2v_l20" // 图生视频
)
// SubmitTaskRequest 提交任务请求
type SubmitTaskRequest struct {
ReqKey string `json:"req_key"`
// 文生图参数
Prompt string `json:"prompt,omitempty"`
Seed int64 `json:"seed,omitempty"`
Scale float64 `json:"scale,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
UsePreLLM bool `json:"use_pre_llm,omitempty"`
// 图生图参数
ImageInput string `json:"image_input,omitempty"`
ImageUrls []string `json:"image_urls,omitempty"`
BinaryDataBase64 []string `json:"binary_data_base64,omitempty"`
Gpen float64 `json:"gpen,omitempty"`
Skin float64 `json:"skin,omitempty"`
SkinUnifi float64 `json:"skin_unifi,omitempty"`
GenMode string `json:"gen_mode,omitempty"`
// 图像编辑参数
// 图像特效参数
ImageInput1 string `json:"image_input1,omitempty"`
TemplateId string `json:"template_id,omitempty"`
// 视频生成参数
AspectRatio string `json:"aspect_ratio,omitempty"`
}
// SubmitTaskResponse 提交任务响应
type SubmitTaskResponse struct {
Code int `json:"code"`
Message string `json:"message"`
RequestId string `json:"request_id"`
Status int `json:"status"`
TimeElapsed string `json:"time_elapsed"`
Data struct {
TaskId string `json:"task_id"`
} `json:"data"`
}
// QueryTaskRequest 查询任务请求
type QueryTaskRequest struct {
ReqKey string `json:"req_key"`
TaskId string `json:"task_id"`
ReqJson string `json:"req_json,omitempty"`
}
// QueryTaskResponse 查询任务响应
type QueryTaskResponse struct {
Code int `json:"code"`
Message string `json:"message"`
RequestId string `json:"request_id"`
Status int `json:"status"`
TimeElapsed string `json:"time_elapsed"`
Data struct {
AlgorithmBaseResp struct {
StatusCode int `json:"status_code"`
StatusMessage string `json:"status_message"`
} `json:"algorithm_base_resp"`
BinaryDataBase64 []string `json:"binary_data_base64"`
ImageUrls []string `json:"image_urls"`
VideoUrl string `json:"video_url"`
RespData string `json:"resp_data"`
Status model.JMTaskStatus `json:"status"`
LlmResult string `json:"llm_result"`
PeResult string `json:"pe_result"`
PredictTagsResult string `json:"predict_tags_result"`
RephraserResult string `json:"rephraser_result"`
VlmResult string `json:"vlm_result"`
InferCtx any `json:"infer_ctx"`
} `json:"data"`
}
// CreateTaskRequest 创建任务请求
type CreateTaskRequest struct {
Type model.JMTaskType `json:"type"`
Prompt string `json:"prompt"`
Params map[string]any `json:"params"`
ReqKey string `json:"req_key"`
ImageUrls []string `json:"image_urls,omitempty"`
Power int `json:"power,omitempty"`
}
// LogoInfo 水印信息
type LogoInfo struct {
AddLogo bool `json:"add_logo"`
Position int `json:"position"`
Language int `json:"language"`
Opacity float64 `json:"opacity"`
LogoTextContent string `json:"logo_text_content"`
}
// ReqJsonConfig 查询配置
type ReqJsonConfig struct {
ReturnUrl bool `json:"return_url"`
LogoInfo *LogoInfo `json:"logo_info,omitempty"`
}
// ImageEffectTemplate 图像特效模板
const (
TemplateIdFelt3DPolaroid = "felt_3d_polaroid" // 毛毡3d拍立得风格
TemplateIdMyWorld = "my_world" // 像素世界风
TemplateIdMyWorldUniversal = "my_world_universal" // 像素世界-万物通用版
TemplateIdPlasticBubbleFigure = "plastic_bubble_figure" // 盲盒玩偶风
TemplateIdPlasticBubbleFigureCartoon = "plastic_bubble_figure_cartoon_text" // 塑料泡罩人偶-文字卡头版
TemplateIdFurryDreamDoll = "furry_dream_doll" // 毛绒玩偶风
TemplateIdMicroLandscapeMiniWorld = "micro_landscape_mini_world" // 迷你世界玩偶风
TemplateIdMicroLandscapeProfessional = "micro_landscape_mini_world_professional" // 微型景观小世界-职业版
TemplateIdAcrylicOrnaments = "acrylic_ornaments" // 亚克力挂饰
TemplateIdFeltKeychain = "felt_keychain" // 毛毡钥匙扣
TemplateIdLofiPixelCharacter = "lofi_pixel_character_mini_card" // Lofi像素人物小卡
TemplateIdAngelFigurine = "angel_figurine" // 天使形象手办
TemplateIdLyingInFluffyBelly = "lying_in_fluffy_belly" // 躺在毛茸茸肚皮里
TemplateIdGlassBall = "glass_ball" // 玻璃球
)
// AspectRatio 视频宽高比
const (
AspectRatio16_9 = "16:9" // 1280×720
AspectRatio9_16 = "9:16" // 720×1280
AspectRatio1_1 = "1:1" // 960×960
AspectRatio4_3 = "4:3" // 960×720
AspectRatio3_4 = "3:4" // 720×960
AspectRatio21_9 = "21:9" // 1680×720
AspectRatio9_21 = "9:21" // 720×1680
)
// GenMode 生成模式
const (
GenModeCreative = "creative" // 提示词模式
GenModeReference = "reference" // 全参考模式
GenModeReferenceChar = "reference_char" // 人物参考模式
)

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)
@@ -212,7 +212,9 @@ func (s *Service) DownloadImages() {
// PushTask push a new mj task in to task queue // PushTask push a new mj task in to task queue
func (s *Service) PushTask(task types.MjTask) { func (s *Service) PushTask(task types.MjTask) {
logger.Debugf("add a new MidJourney task to the task list: %+v", task) logger.Debugf("add a new MidJourney task to the task list: %+v", task)
s.taskQueue.RPush(task) if err := s.taskQueue.RPush(task); err != nil {
logger.Errorf("push mj task to queue failed: %v", err)
}
} }
// SyncTaskProgress 异步拉取任务 // SyncTaskProgress 异步拉取任务

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

@@ -253,7 +253,9 @@ func (s *Service) checkTaskProgress(apiKey model.ApiKey) (*TaskProgressResp, err
func (s *Service) PushTask(task types.SdTask) { func (s *Service) PushTask(task types.SdTask) {
logger.Debugf("add a new MidJourney task to the task list: %+v", task) logger.Debugf("add a new MidJourney task to the task list: %+v", task)
s.taskQueue.RPush(task) if err := s.taskQueue.RPush(task); err != nil {
logger.Errorf("push sd task to queue failed: %v", err)
}
} }
// CheckTaskStatus 检查任务状态,自动删除过期或者失败的任务 // CheckTaskStatus 检查任务状态,自动删除过期或者失败的任务

View File

@@ -51,7 +51,9 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
func (s *Service) PushTask(task types.SunoTask) { func (s *Service) PushTask(task types.SunoTask) {
logger.Infof("add a new Suno task to the task list: %+v", task) logger.Infof("add a new Suno task to the task list: %+v", task)
s.taskQueue.RPush(task) if err := s.taskQueue.RPush(task); err != nil {
logger.Errorf("push suno task to queue failed: %v", err)
}
} }
func (s *Service) Run() { func (s *Service) Run() {
@@ -270,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

@@ -51,7 +51,9 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
func (s *Service) PushTask(task types.VideoTask) { func (s *Service) PushTask(task types.VideoTask) {
logger.Infof("add a new Video task to the task list: %+v", task) logger.Infof("add a new Video task to the task list: %+v", task)
s.taskQueue.RPush(task) if err := s.taskQueue.RPush(task); err != nil {
logger.Errorf("push video task to queue failed: %v", err)
}
} }
func (s *Service) Run() { func (s *Service) Run() {
@@ -162,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
@@ -172,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

@@ -1,9 +1,9 @@
package model package model
type Config struct { type Config struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Id uint `gorm:"column:id;primaryKey;autoIncrement"`
Key string `gorm:"column:marker;type:varchar(20);uniqueIndex;not null;comment:标识" json:"marker"` Name string `gorm:"column:name;type:varchar(20);uniqueIndex;not null;comment:配置名称"`
Config string `gorm:"column:config_json;type:text;not null" json:"config_json"` Value string `gorm:"column:value;type:text;not null"`
} }
func (m *Config) TableName() string { func (m *Config) TableName() string {

View File

@@ -0,0 +1,55 @@
package model
import (
"time"
)
// JimengJob 即梦AI任务模型
type JimengJob struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserId uint `gorm:"column:user_id;type:int;not null;index;comment:用户ID" json:"user_id"`
TaskId string `gorm:"column:task_id;type:varchar(100);not null;index;comment:任务ID" json:"task_id"`
Type JMTaskType `gorm:"column:type;type:varchar(50);not null;comment:任务类型" json:"type"`
ReqKey string `gorm:"column:req_key;type:varchar(100);comment:请求Key" json:"req_key"`
Prompt string `gorm:"column:prompt;type:text;comment:提示词" json:"prompt"`
TaskParams string `gorm:"column:task_params;type:text;comment:任务参数JSON" json:"task_params"`
ImgURL string `gorm:"column:img_url;type:varchar(1024);comment:图片或封面URL" json:"img_url"`
VideoURL string `gorm:"column:video_url;type:varchar(1024);comment:视频URL" json:"video_url"`
RawData string `gorm:"column:raw_data;type:text;comment:原始API响应" json:"raw_data"`
Progress int `gorm:"column:progress;type:int;default:0;comment:进度百分比" json:"progress"`
Status JMTaskStatus `gorm:"column:status;type:varchar(20);default:'pending';comment:任务状态" json:"status"`
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);comment:错误信息" json:"err_msg"`
Power int `gorm:"column:power;type:int(11);default:0;comment:消耗算力" json:"power"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null;comment:更新时间" json:"updated_at"`
}
// JMTaskStatus 任务状态
type JMTaskStatus string
const (
JMTaskStatusInQueue = JMTaskStatus("in_queue") // 任务已提交
JMTaskStatusGenerating = JMTaskStatus("generating") // 任务处理中
JMTaskStatusDone = JMTaskStatus("done") // 处理完成
JMTaskStatusNotFound = JMTaskStatus("not_found") // 任务未找到
JMTaskStatusSuccess = JMTaskStatus("success") // 任务成功
JMTaskStatusFailed = JMTaskStatus("failed") // 任务失败
JMTaskStatusExpired = JMTaskStatus("expired") // 任务过期
)
// JMTaskType 任务类型
type JMTaskType string
const (
JMTaskTypeTextToImage = JMTaskType("text_to_image") // 文生图
JMTaskTypeImageToImage = JMTaskType("image_to_image") // 图生图
JMTaskTypeImageEdit = JMTaskType("image_edit") // 图像编辑
JMTaskTypeImageEffects = JMTaskType("image_effects") // 图像特效
JMTaskTypeTextToVideo = JMTaskType("text_to_video") // 文生视频
JMTaskTypeImageToVideo = JMTaskType("image_to_video") // 图生视频
)
// TableName 返回数据表名称
func (JimengJob) TableName() string {
return "chatgpt_jimeng_jobs"
}

View File

@@ -10,6 +10,7 @@ package store
import ( import (
"context" "context"
"geekai/utils" "geekai/utils"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
) )
@@ -23,15 +24,15 @@ func NewRedisQueue(name string, client *redis.Client) *RedisQueue {
return &RedisQueue{name: name, client: client, ctx: context.Background()} return &RedisQueue{name: name, client: client, ctx: context.Background()}
} }
func (q *RedisQueue) RPush(value interface{}) { func (q *RedisQueue) RPush(value any) error {
q.client.RPush(q.ctx, q.name, utils.JsonEncode(value)) return q.client.RPush(q.ctx, q.name, utils.JsonEncode(value)).Err()
} }
func (q *RedisQueue) LPush(value interface{}) { func (q *RedisQueue) LPush(value any) error {
q.client.LPush(q.ctx, q.name, utils.JsonEncode(value)) return q.client.LPush(q.ctx, q.name, utils.JsonEncode(value)).Err()
} }
func (q *RedisQueue) LPop(value interface{}) error { func (q *RedisQueue) LPop(value any) error {
result, err := q.client.BLPop(q.ctx, 0, q.name).Result() result, err := q.client.BLPop(q.ctx, 0, q.name).Result()
if err != nil { if err != nil {
return err return err
@@ -39,10 +40,18 @@ func (q *RedisQueue) LPop(value interface{}) error {
return utils.JsonDecode(result[1], value) return utils.JsonDecode(result[1], value)
} }
func (q *RedisQueue) RPop(value interface{}) error { func (q *RedisQueue) RPop(value any) error {
result, err := q.client.BRPop(q.ctx, 0, q.name).Result() result, err := q.client.BRPop(q.ctx, 0, q.name).Result()
if err != nil { if err != nil {
return err return err
} }
return utils.JsonDecode(result[1], value) return utils.JsonDecode(result[1], value)
} }
func (q *RedisQueue) Size() (int64, error) {
return q.client.LLen(q.ctx, q.name).Result()
}
func (q *RedisQueue) Clear() error {
return q.client.Del(q.ctx, q.name).Err()
}

View File

@@ -1,9 +0,0 @@
package vo
import "geekai/core/types"
type Config struct {
Id uint `json:"id"`
Key string `json:"key"`
SystemConfig types.SystemConfig `json:"system_config"`
}

View File

@@ -0,0 +1,23 @@
package vo
import "geekai/store/model"
// JimengJob 即梦AI任务VO
type JimengJob struct {
Id uint `json:"id"`
UserId uint `json:"user_id"`
TaskId string `json:"task_id"`
Type model.JMTaskType `json:"type"`
ReqKey string `json:"req_key"`
Prompt string `json:"prompt"`
TaskParams string `json:"task_params"`
ImgURL string `json:"img_url"`
VideoURL string `json:"video_url"`
RawData string `json:"raw_data"`
Progress int `json:"progress"`
Status model.JMTaskStatus `json:"status"`
ErrMsg string `json:"err_msg"`
Power int `json:"power"`
CreatedAt int64 `json:"created_at"` // 时间戳
UpdatedAt int64 `json:"updated_at"` // 时间戳
}

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

@@ -1,55 +0,0 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
)
const (
codeLength = 32 // 兑换码长度
)
var (
codeMap = make(map[string]bool)
mapMutex = &sync.Mutex{}
)
// GenerateUniqueCode 生成唯一兑换码
func GenerateUniqueCode() (string, error) {
for {
code, err := generateCode()
if err != nil {
return "", err
}
mapMutex.Lock()
if !codeMap[code] {
codeMap[code] = true
mapMutex.Unlock()
return code, nil
}
mapMutex.Unlock()
}
}
// generateCode 生成兑换码
func generateCode() (string, error) {
bytes := make([]byte, codeLength/2) // 因为 hex 编码会使长度翻倍
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func main() {
for i := 0; i < 10; i++ {
code, err := GenerateUniqueCode()
if err != nil {
fmt.Println("Error generating code:", err)
return
}
fmt.Println("Generated code:", code)
}
}

View File

@@ -11,9 +11,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"github.com/nfnt/resize"
"github.com/skip2/go-qrcode"
"image" "image"
"image/color" "image/color"
"image/draw" "image/draw"
@@ -22,11 +19,22 @@ import (
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"github.com/nfnt/resize"
"github.com/skip2/go-qrcode"
) )
// CopyObject 拷贝对象 // CopyObject 拷贝对象
func CopyObject(src interface{}, dst interface{}) error { func CopyObject(src interface{}, dst interface{}) error {
// 这里做异常处理
defer func() {
if r := recover(); r != nil {
logger.Errorf("copy object failed: %v", r)
}
}()
srcType := reflect.TypeOf(src) srcType := reflect.TypeOf(src)
srcValue := reflect.ValueOf(src) srcValue := reflect.ValueOf(src)
dstValue := reflect.ValueOf(dst).Elem() dstValue := reflect.ValueOf(dst).Elem()

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

@@ -6,7 +6,7 @@ VITE_ADMIN_USER=admin
VITE_ADMIN_PASS=admin123 VITE_ADMIN_PASS=admin123
VITE_KEY_PREFIX=GeekAI_DEV_ VITE_KEY_PREFIX=GeekAI_DEV_
VITE_TITLE="Geek-AI 创作系统" VITE_TITLE="Geek-AI 创作系统"
VITE_VERSION=v4.2.4 VITE_VERSION=v4.2.5
VITE_DOCS_URL=https://docs.geekai.me VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
VITE_GITEE_URL=https://gitee.com/blackfox/geekai VITE_GITEE_URL=https://gitee.com/blackfox/geekai

View File

@@ -1,7 +1,7 @@
VITE_API_HOST= VITE_API_HOST=
VITE_WS_HOST= VITE_WS_HOST=
VITE_KEY_PREFIX=GeekAI_ VITE_KEY_PREFIX=GeekAI_
VITE_VERSION=v4.2.4 VITE_VERSION=v4.2.5
VUE_APP_TITLE="Geek-AI 创作系统" VUE_APP_TITLE="Geek-AI 创作系统"
VITE_DOCS_URL=https://docs.geekai.me VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai VITE_GITHUB_URL=https://github.com/yangjian102621/geekai

View File

@@ -91,7 +91,7 @@ html, body {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
--primary-color: #21aa93 // --primary-color: #21aa93
h1 { font-size: 2em; } /* 通常是 2em */ h1 { font-size: 2em; } /* 通常是 2em */
h2 { font-size: 1.5em; } /* 通常是 1.5em */ h2 { font-size: 1.5em; } /* 通常是 1.5em */
@@ -118,6 +118,18 @@ html, body {
} }
} }
.el-popper.is-customized {
/* 设置内边距以保证高度为32px */
padding: 6px 12px;
background: linear-gradient(180deg, #e1bee7, #7e57c2);
color #fff
}
.el-popper.is-customized .el-popper__arrow::before {
background: linear-gradient(180deg, #b39ddb, #7e57c2);
right: 0;
}
/* 省略显示 */ /* 省略显示 */
.ellipsis { .ellipsis {
overflow: hidden; overflow: hidden;

View File

@@ -0,0 +1,349 @@
.page-jimeng {
display: flex;
min-height: 100vh;
background: var(--chat-bg);
//
.params-panel {
min-width: 380px;
max-width: 380px;
margin: 10px;
padding: 20px;
border-radius: 12px;
background: var(--card-bg);
box-shadow: var(--card-shadow, 0 8px 24px rgba(0,0,0,0.12));
color: var(--text-theme-color);
font-size: 14px;
overflow: auto;
h2 {
font-weight: bold;
font-size: 20px;
text-align: center;
color: var(--text-theme-color);
margin-bottom: 30px;
}
//
.category-buttons {
margin-bottom: 25px;
.category-label {
display: flex;
align-items: center;
margin-bottom: 15px;
font-size: 16px;
font-weight: 600;
color: var(--text-theme-color);
.el-icon {
margin-right: 8px;
color: var(--primary-color, #5865f2);
}
}
.category-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
.category-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 15px 10px;
border: 2px solid var(--border-color, #f0f0f0);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
background: var(--card-bg-secondary, #fafafa);
/* */
[data-theme="dark"] & {
background: var(--card-bg-secondary-dark, #23242a);
border-color: var(--border-color-dark, #33343a);
}
&:hover {
border-color: var(--primary-color, #5865f2);
background: var(--card-bg-hover, #f8f9ff);
[data-theme="dark"] & {
background: var(--card-bg-hover-dark, #2a2b31);
}
transform: translateY(-2px);
}
&.active {
border-color: var(--primary-color, #5865f2);
background: var(--primary-gradient, linear-gradient(135deg, #5865f2 0%, #7289da 100%));
color: var(--primary-text-on-primary, #fff);
[data-theme="dark"] & {
background: var(--primary-gradient-dark, linear-gradient(135deg, #23242a 0%, #2a2b31 100%));
color: var(--primary-text-on-primary-dark, #fff);
}
transform: translateY(-2px);
box-shadow: var(--primary-shadow, 0 4px 12px rgba(88,101,242,0.3));
}
.category-icon {
font-size: 20px;
margin-bottom: 8px;
}
.category-name {
font-size: 12px;
font-weight: 500;
}
}
}
}
//
.function-switch {
margin-bottom: 25px;
.switch-label {
display: flex;
align-items: center;
margin-bottom: 15px;
font-size: 16px;
font-weight: 600;
color: var(--text-theme-color);
.el-icon {
margin-right: 8px;
color: var(--primary-color, #5865f2);
}
}
.switch-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 15px;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 10px;
background: var(--card-bg-secondary, #f9f9f9);
[data-theme="dark"] & {
background: var(--card-bg-secondary-dark, #23242a);
border-color: var(--border-color-dark, #33343a);
}
.switch-info {
flex: 1;
.switch-title {
font-size: 14px;
font-weight: 600;
color: var(--text-theme-color);
margin-bottom: 4px;
}
.switch-desc {
font-size: 12px;
color: var(--text-sub-color, #666);
}
}
}
}
//
.params-container {
.function-panel {
.param-line {
margin-bottom: 15px;
&.pt {
margin-top: 20px;
}
.label {
display: flex;
align-items: center;
margin-bottom: 8px;
font-weight: 600;
color: var(--text-theme-color);
}
}
.item-group {
display: flex;
align-items: center;
margin-bottom: 15px;
.label {
margin-right: 15px;
font-weight: 600;
color: var(--text-theme-color);
min-width: 80px;
}
}
.text-info {
margin: 20px 0;
padding: 15px;
background: var(--info-bg, #f0f8ff);
border-radius: 8px;
border-left: 4px solid var(--primary-color, #5865f2);
}
.submit-btn {
margin-top: 30px;
.el-button {
width: 100%;
height: 50px;
font-size: 16px;
font-weight: 600;
}
}
}
}
}
//
.main-content {
flex: 1;
padding: 20px;
background: var(--chat-bg);
color: var(--text-theme-color);
.works-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.h-title {
font-size: 24px;
font-weight: 600;
color: var(--text-theme-color);
margin: 0;
}
}
.task-list {
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
padding: 10px 0;
}
.task-item {
display: flex;
flex-direction: column;
background: var(--card-bg);
border-radius: 12px;
box-shadow: var(--card-shadow, 0 2px 8px rgba(0,0,0,0.1));
overflow: hidden;
transition: box-shadow 0.2s;
&:hover {
box-shadow: 0 4px 24px rgba(88,101,242,0.12);
}
.task-left {
width: 100%;
flex: none;
.task-preview {
width: 100%;
aspect-ratio: 1.2/1;
min-height: 220px;
max-height: 320px;
background: var(--card-bg-secondary, #f0f0f0);
border-radius: 0;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
.preview-image, .preview-video {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
color: var(--text-disabled-color, #999);
font-size: 16px;
.el-icon, .iconfont {
font-size: 32px;
margin-bottom: 5px;
}
}
}
}
.task-center {
flex: none;
padding: 18px 18px 8px 18px;
.task-info {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
.task-prompt {
font-size: 14px;
color: var(--text-theme-color);
margin-bottom: 8px;
line-height: 1.4;
word-break: break-all;
}
.task-meta {
display: flex;
gap: 15px;
font-size: 12px;
color: var(--text-disabled-color, #999);
}
}
.task-right {
flex: none;
padding: 0 18px 16px 18px;
.task-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
}
}
.pagination {
margin-top: 30px;
display: flex;
justify-content: center;
}
}
}
}
//
@media (max-width: 768px) {
.page-jimeng {
flex-direction: column;
.params-panel {
min-width: 100%;
max-width: 100%;
margin: 10px 0;
}
.main-content {
padding: 15px;
}
}
}
@media (max-width: 1200px) {
.task-list .task-grid {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
}
@media (max-width: 768px) {
.task-list .task-grid {
grid-template-columns: 1fr;
}
.task-list .task-item {
min-height: 320px;
.task-left .task-preview {
min-height: 160px;
max-height: 220px;
}
}
}

View File

@@ -1,363 +0,0 @@
.page-keling
display flex
min-height 100vh
:deep(.el-form-item__label)
color var(--text-theme-color)
.grid-content
// background-color #383838
background var(--card-bg)
border-radius 8px
padding 8px 14px
display flex
cursor pointer
margin-bottom 10px
// border 1px solid #383838
border 1px solid var(--chat-bg)
&:hover
border 1px solid var(--theme-border-hover)
.icon
width 20px
height 20px
margin-bottom 5px
.texts
margin-left 5px
margin-top 2px
color var(--text-theme-color)
.param-line.pt
padding-top 5px
padding-bottom 5px
.grid-content.active
// color #47fff1
// background-color #585858
border 1px solid var(--theme-border-hover)
.h-20
height 4rem !important
.main-content
padding-right 1.5rem
padding-left 1.5rem
padding-bottom 1rem
flex 1
background var(--chat-bg)
// width: 100%;
// padding 0 10px 10px 10px
color var(--text-theme-color)
overflow-x hidden
.camera-control
padding 10px
border-radius 4px
background var(--card-bg)
:deep(.el-form-item:last-child)
margin-bottom 0 !important
.title-tabs
:deep(.el-tabs__item.is-active)
color var(--theme-textcolor-normal)
font-size 18px
:deep(.el-tabs__item)
color var(--text-theme-color)
font-size 18px
.el-tabs
--el-tabs-header-height 55px
.el-tabs__item
color var(--text-theme-color)
font-size 18px
.el-tabs__item.is-active, .title-tabs .el-tabs__item.is-active
.title-tabs .el-tabs__active-bar
background-color var(--theme-textcolor-normal)
:deep(.el-textarea)
--el-input-focus-border-color var(--el-color-primary)
:deep(.el-textarea__inner)
background transparent
color var(--text-theme-color)
.el-input__wrapper
background transparent
padding 5px
.text
margin-bottom 10px
color #6b778c
font-size 15px
.param-line.pt
padding-top 5px
padding-bottom 5px
.form-item-inner
display flex
align-items center
.el-icon
margin-left 10px
.el-form-item__label
color var(--text-theme-color)
//
.img-inline
display flex
gap 20px
align-items center
.img-uploader
text-align center
:deep(.el-upload)
border 1px dashed var(--el-border-color)
border-radius 6px
cursor pointer
position relative
overflow hidden
width 120px
height 120px
line-height 120px
transition var(--el-transition-duration-fast)
margin-bottom 20px
&:hover
border-color var(--el-color-primary)
.el-icon.uploader-icon
font-size 28px
color #8c939d
width 100%
height 120px
text-align center
.img-list-box
display flex
.img-item
width 120px
position relative
margin-right 10px
.el-image
width 120px
height 120px
border-radius 5px
.el-button
position absolute
right 5px
top 5px
width 20px
height 20px
.el-row.text-info
width 100%
padding 10px 0
.el-tag
margin-right 10px
//
.submit-btn
display flex
margin 20px 0
.el-button
width 200px
.video-list
.btn
margin-right 10px
border none
border-radius 5px
padding 5px 10px
cursor pointer
color var(--theme-text-color-primary)
background-color var(--btn-bg)
&:hover
opacity 0.7
.list-box
padding 0
.item
display flex
flex-flow row
align-items center
min-height 100px
padding 10px 15px
border-radius 10px
cursor pointer
margin-bottom 20px
background var(--chat-bg)
.left
.container
width 160px
position relative
max-height 120px
overflow hidden
display flex
justify-content center
align-items center
.video
width 160px
border-radius 5px
.el-image
width 160px
height 90px
border-radius 5px
.duration
position absolute
bottom 0
right 0
background-color rgba(14, 8, 8, 0.7)
padding 0 3px
font-family 'Input Sans'
font-size 14px
font-weight 700
border-radius 0.125rem
.play
position absolute
width 100%
height 100%
top 0
left 50%
border none
border-radius 5px
background rgba(100, 100, 100, 0.3)
cursor pointer
color var(--text-theme-color)
opacity 0
transform translate(-50%, 0px)
transition opacity 0.3s ease 0s
&:hover
.play
opacity 1
// display block
.center
width 100%
// border 1px solid saddlebrown
display flex
justify-content center
align-items flex-start
flex-flow column
padding 0 20px
.prompt, .failed
padding 0
font-size 16px
max-height 60px
line-height 28px
overflow hidden
text-overflow ellipsis
.prompt
color var(--text-fb)
cursor text
.failed
color #E4696B
.right
display flex
justify-content right
min-width 200px
font-size 14px
padding 0
.tools
display flex
justify-content left
align-items center
flex-flow row
height 90px
.btn-publish
padding 2px 10px
.text
margin-right 10px
.btn-icon
background none
padding 6px
transition background 0.6s ease 0s
color #919191
&:hover
// background #5f5958
// color #e1e1e1
color var(--el-color-primary)
.downloading
width 16px
.pagination
margin-top 20px
display flex
justify-content center
.inner
display flex
width 100%
.mj-box
margin 10px
// background-color #262626
// border 1px solid #454545
// height: calc(100vh - 50px)
// overflow: scroll
min-width 300px
max-width 300px
padding 20px
border-radius 10px
color var(--text-theme-color)
font-size 14px
overflow auto
h2
font-weight bold
font-size 20px
text-align center
color var(--theme-textcolor-normal)
//
::-webkit-scrollbar
width 0
height 0
background-color transparent
.mj-params
margin-top 10px
overflow auto
.param-line
padding 0 10px
.el-icon
position relative
.model
background var(--card-bg)
// border 1px solid #454545
border-radius 8px
padding 5px
margin-bottom 10px
display flex
flex-flow column
align-items center
cursor pointer
border 1px solid var(--chat-bg)
&:hover
border 1px solid var(--theme-border-hover)
.el-image
height 40px
width 100%
.text
margin-top 4px
font-size 12px
.model.active
// color #47fff1
// background-color #585858
border 1px solid var(--theme-border-hover)
.form-item-inner
display flex
align-items center
.el-select
--el-select-input-focus-border-color var(--el-color-primary)
--el-input-focus-border-color var(--el-color-primary)
.el-input__wrapper
background var(--chat-bg)
.el-input__inner
color var(--text-theme-color)
.el-icon
margin-left 10px
.img-uploader
.el-upload
border 1px dashed var(--el-border-color)
border-radius 6px
cursor pointer
position relative
overflow hidden
width 100%
transition var(--el-transition-duration-fast)
&:hover
border-color var(--el-color-primary)
.el-icon.uploader-icon
font-size 28px
color #8c939d
width 100%
height 120px
text-align center
.param-line.pt
display flex
align-items center
padding-top 5px
padding-bottom 5px
.el-form
.el-form-item__label
color var(--text-theme-color)
.el-input, .el-slider
width 180px
.uploader-icon
font-size 24px
position relative
top 3px
.no-more-data
text-align center
padding 30px
.generate-btn
.iconfont
margin-right 5px

View File

@@ -1,142 +0,0 @@
.page-luma {
display: flex;
height: 100%;
background-color: #0e0808;
overflow: auto;
flex-flow: column;
align-items: center;
background: linear-gradient(180deg, rgba(75,62,53,0.8), rgba(144,50,181,0.3));
}
.page-luma .prompt-box {
display: flex;
max-width: 56rem;
width: 100%;
padding: 20px;
flex-flow: column;
}
.page-luma .prompt-box .images {
display: flex;
flex-flow: row;
padding-bottom: 10px;
justify-content: center;
}
.page-luma .prompt-box .images .item {
position: relative;
}
.page-luma .prompt-box .images .item .el-image {
width: 100px;
height: 100px;
border-radius: 6px;
margin-right: 10px;
}
.page-luma .prompt-box .images .item .el-icon {
position: absolute;
cursor: pointer;
font-size: 20px;
color: #545454;
right: 10px;
top: 0;
}
.page-luma .prompt-box .images .item .el-icon:hover {
color: #888;
}
.page-luma .prompt-box .prompt-container {
width: 100%;
}
.page-luma .prompt-box .prompt-container .input-container {
background: linear-gradient(90deg, rgba(75,62,53,0.8), rgba(144,50,181,0.3));
border-radius: 28px;
padding: 10px 20px;
display: flex;
align-items: center;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
}
.page-luma .prompt-box .prompt-container .input-container .prompt-input {
background: transparent;
border: none;
outline: none;
color: #fff;
font-size: 14px;
width: 100%;
padding: 10px;
resize: none;
white-space: pre-wrap;
word-wrap: break-word;
line-height: 24px;
overflow-wrap: break-word;
scrollbar-width: none; /* 隐藏滚动条 */
}
.page-luma .prompt-box .prompt-container .input-container .prompt-input::placeholder {
color: rgba(255,255,255,0.6);
}
.page-luma .prompt-box .prompt-container .input-container .prompt-input::-webkit-scrollbar {
display: none;
}
.page-luma .prompt-box .prompt-container .input-container .upload-icon,
.page-luma .prompt-box .prompt-container .input-container .send-icon {
color: #e1e1e1;
}
.page-luma .prompt-box .prompt-container .input-container .upload-icon .iconfont,
.page-luma .prompt-box .prompt-container .input-container .send-icon .iconfont {
font-size: 20px;
cursor: pointer;
}
.page-luma .prompt-box .prompt-container .input-container .upload-icon {
position: relative;
}
.page-luma .video-container {
display: flex;
flex-flow: column;
width: 100%;
padding: 0 40px;
}
.page-luma .video-container .h-title {
color: #fff;
width: 100%;
font-size: 36px;
text-align: left;
}
.page-luma .video-container .videos .item {
margin-bottom: 20px;
}
.page-luma .video-container .videos .item .video-box {
width: 100%;
border-radius: 10px;
}
.page-luma .video-container .videos .item .video-box video,
.page-luma .video-container .videos .item .video-box img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 10px;
cursor: pointer;
}
.page-luma .video-container .videos .item .video-name {
color: #e1e1e1;
font-size: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 6px 0;
text-align: center;
}
.page-luma .video-container .videos .item .opts {
display: flex;
justify-content: center;
}
.page-luma .video-container .videos .item .opts .btn {
margin-right: 10px;
background-color: rgba(255,255,255,0.15);
border: none;
border-radius: 20px;
padding: 3px 15px;
cursor: pointer;
color: #fff;
font-size: 14px;
}
.page-luma .video-container .videos .item .opts .btn .iconfont {
font-size: 12px;
}
.page-luma .video-container .videos .item .opts .btn:hover {
background-color: rgba(255,255,255,0.2);
}

View File

@@ -1,362 +0,0 @@
.page-luma {
display flex
height 100%
// background-color #0E0808
// background: var(--chat-bg);
overflow auto
//justify-content center
flex-flow column
align-items center
// background: linear-gradient(180deg, rgba(75, 62, 53, 0.8), rgba(144, 50, 181, 0.3));
.prompt-box {
display flex
max-width 56rem
width 100%
padding 20px
flex-flow column
.images {
display flex
flex-flow row
padding-bottom 10px
justify-content center
align-items center
.item {
position relative
.el-image {
width 100px
height 100px
border-radius 6px
margin-right 10px
}
.el-icon {
position absolute
cursor pointer
font-size 20px
color #545454
right 10px
top 0
&:hover {
color #888888
}
}
}
.btn-swap {
margin-right 10px
.icon-exchange{
color var(--text-theme-color)
cursor pointer
}
}
}
.prompt-container {
width: 100%;
.input-container {
background: var(--chat-bg);
// background: linear-gradient(90deg, rgba(75, 62, 53, 0.8), rgba(144, 50, 181, 0.3));
border-radius: 28px;
padding: 10px 20px;
display: flex;
align-items: center;
margin-bottom: 16px;
// box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
.prompt-input {
background: transparent;
border: none;
outline: none;
color var(--text-theme-color);
font-size: 14px;
width: 100%;
padding: 10px;
resize: none;
white-space: pre-wrap;
word-wrap: break-word;
line-height 24px
overflow-wrap: break-word;
scrollbar-width: none; /* */
&::-webkit-scrollbar {
display: none;
}
}
.upload-icon, .send-icon {
color var( --el-color-primary)
.iconfont {
font-size 20px
cursor pointer
}
}
.upload-icon {
position relative
}
}
.params {
display flex
justify-content right
color var(--text-theme-color);
font-size 14px
padding 10px 30px
.item-group {
margin-left 20px
.label {
margin-right 5px
position relative
top 1px
}
}
}
}
}
.video-container {
display flex
flex-flow column
width 100%
padding 0 40px
.h-title {
color var(--text-theme-color)
width 100%
// font-size 36px
text-align left
}
.list-box {
padding 0
.item {
display flex
flex-flow row
align-items center
min-height 100px
padding 10px 15px
border-radius 10px
cursor pointer
margin-bottom 20px
background: var(--chat-bg);
.left {
.container {
width 160px
position relative
.video{
width 160px
border-radius 5px
}
.el-image {
width 160px
height 90px
border-radius 5px
}
.duration {
position absolute
bottom 0
right 0
background-color rgba(14,8,8,.7)
padding 0 3px
font-family 'Input Sans'
font-size 14px
font-weight 700
border-radius .125rem
}
.play {
position absolute
width: 100%
height 100%
top: 0;
left: 50%;
border none
border-radius 5px
background rgba(100, 100, 100, 0.3)
cursor pointer
color var(--text-theme-color)
opacity 0
transform: translate(-50%, 0px);
transition opacity 0.3s ease 0s
}
&:hover {
.play {
opacity 1
//display block
}
}
}
}
.center {
width 100%
//border 1px solid saddlebrown
display flex
justify-content center
align-items flex-start
flex-flow column
padding 0 20px
.prompt,.failed {
padding 0
font-size 16px
max-height 80px
line-height 28px
overflow hidden
text-overflow ellipsis
}
.prompt {
color var( --text-fb)
cursor: text
}
.failed {
color #E4696B
}
}
.right {
display flex
justify-content right
min-width 200px;
font-size 14px
padding 0
.tools {
display flex
justify-content left
align-items center
flex-flow row
height 90px
.btn-publish {
padding 2px 10px
.text {
margin-right 10px
}
}
.btn-icon {
background none
padding 6px
transition background 0.6s ease 0s
color #919191
&:hover {
// background #5f5958
// color #e1e1e1
color:var(--el-color-primary)
}
.downloading {
width 16px
}
}
}
}
}
}
.pagination {
margin-top 20px
display flex
justify-content center
}
//.videos {
// .item {
// margin-bottom 20px
//
// .video-box {
// width 100%
// aspect-ratio: 16/9;
// border-radius 10px
// video,img {
// width: 100%;
// height: 100%;
// object-fit: cover;
// border-radius 10px
// cursor pointer
// }
// }
//
//
// .video-name {
// color #e1e1e1
// font-size 16px
// white-space nowrap
// overflow hidden
// text-overflow ellipsis
// padding 6px 0
// text-align center
// }
//
// .opts {
// display flex
// justify-content center
// .btn {
// margin-right 10px
// background-color hsla(0,0%,100%,.15)
// border none
// border-radius 20px
// padding 3px 15px
// cursor pointer
// color var(--text-theme-color)
// font-size 14px
//
// .iconfont {
// font-size 11px
// position relative
// margin-right 5px
// top -2px
// }
//
// .el-image {
// width 14px
// height 14px
// margin-right 5px
// }
//
// &:hover {
// background-color hsla(0,0%,100%,.2)
// }
// }
// }
// }
//}
}
.btn {
margin-right 10px
border none
border-radius 5px
padding 5px 10px
cursor pointer
color: var(--theme-text-color-primary)
background-color var(--btn-bg)
&:hover {
opacity: 0.7
}
}
}

View File

@@ -184,21 +184,7 @@ body {
.w-100 { .w-100 {
width 100% width 100%
} }
.mr-1 {
margin-right 0.5rem
}
.mr-2 {
margin-right 1rem
}
.ml-1 {
margin-left 0.5rem
}
.ml-2 {
margin-left 1rem
}
.d-flex { .d-flex {
display flex !important display flex !important
@@ -218,21 +204,3 @@ body {
.align-center { .align-center {
align-items center align-items center
} }
.p-1 {
padding 0.5rem
}
.p-2 {
padding 1rem
}
.m-1 {
margin 0.5rem
}
.m-2 {
margin 1rem
}

View File

@@ -1,5 +1,4 @@
.member { .member {
// background-color: #282c34;
height 100% height 100%
.title { .title {
@@ -13,36 +12,79 @@
.inner { .inner {
color var(--text-theme-color) color var(--text-theme-color)
padding 15px 0 15px 15px; padding 15px 0 15px 15px
overflow-x hidden overflow-x hidden
overflow-y visible overflow-y visible
display flex display flex
flex-flow row flex-flow row
.user-profile { .profile-card {
padding 10px 20px 20px 20px max-width 300px
width 300px border-radius 18px
background-color var(--chat-bg) box-shadow 0 4px 8px rgba(0,0,0,0.08)
color var(--text-theme-color) padding 24px 16px
border-radius 10px background var(--panel-bg)
//height 100vh position relative
z-index 1
.el-form-item__label { margin-bottom 24px
color var(--text-theme-color) }
justify-content start .profile-title {
font-size 18px
font-weight bold
margin-bottom 18px
color #2d8cf0
letter-spacing 2px
text-align center
}
.profile-btn {
width 100%
margin-bottom 12px
font-size 16px
font-weight 500
display flex
align-items center
justify-content center
border none
border-radius 8px
background linear-gradient(90deg, #6dd5ed 0%, #2193b0 100%)
color #fff
transition all 0.3s
i {
margin-right 8px
font-size 20px
} }
&:hover {
.user-opt { box-shadow 0 2px 12px #2193b0aa
.el-col { transform translateY(-2px) scale(1.03)
padding 10px background linear-gradient(90deg, #2193b0 0%, #6dd5ed 100%)
.el-button {
width 100%
}
}
} }
} }
.profile-btn.email {
background linear-gradient(90deg, #f7971e 0%, #ffd200 100%)
}
.profile-btn.mobile {
background linear-gradient(90deg, #43cea2 0%, #185a9d 100%)
}
.profile-btn.third {
background linear-gradient(90deg, #ff512f 0%, #dd2476 100%)
}
.profile-btn.password {
background linear-gradient(90deg, #1d4350 0%, #a43931 100%)
}
.profile-btn.redeem {
background linear-gradient(90deg, #00c6ff 0%, #0072ff 100%)
}
.profile-bg {
position absolute
left 0
top 0
width 100%
height 100%
z-index 0
background url('data:image/svg+xml;utf8,<svg width="100%25" height="100%25" viewBox="0 0 400 200" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="100" cy="100" r="80" fill="%23e0eaff"/><circle cx="300" cy="60" r="40" fill="%23f0f7ff"/><circle cx="320" cy="180" r="30" fill="%23e0eaff"/></svg>') no-repeat center/cover
opacity 0.08
pointer-events none
}
.product-box { .product-box {
padding 0 20px padding 0 20px

View File

@@ -96,4 +96,7 @@
// el-dialog // el-dialog
--el-box-shadow: 0 0 15px rgba(107, 80, 225, 0.8); --el-box-shadow: 0 0 15px rgba(107, 80, 225, 0.8);
//
--panel-bg: linear-gradient(135deg, #252d58 0%, #1f243f 100%);
} }

View File

@@ -5,6 +5,7 @@
--text-fb:#000; --text-fb:#000;
--text-color: #5b62ce; // --text-color: #5b62ce; //
--normal-color: rgba(43, 54, 116, 1); // --normal-color: rgba(43, 54, 116, 1); //
--theme-textcolor-normal:#5b62ce;;
p, h1, h2, h3, h4, h5, h6, article { p, h1, h2, h3, h4, h5, h6, article {
font-family: $font-regular; font-family: $font-regular;
} }
@@ -56,6 +57,8 @@
// //
--quote-bg-color: #e0dfff; --quote-bg-color: #e0dfff;
--quote-text-color: #333; --quote-text-color: #333;
//
--panel-bg: linear-gradient(135deg, #f5eafe 0%, #e9e6fc 100%);
} }

View File

@@ -0,0 +1,567 @@
//
.page-video {
display: flex;
min-height: 100vh;
background: var(--chat-bg);
// Element Plus
:deep(.el-form-item__label) {
color: var(--text-theme-color);
}
:deep(.el-textarea) {
--el-input-focus-border-color: var(--el-color-primary);
}
:deep(.el-textarea__inner) {
background: transparent;
color: var(--text-theme-color);
}
.el-input__wrapper {
background: transparent;
padding: 5px;
}
//
.params-panel {
min-width: 320px;
max-width: 320px;
margin: 10px;
padding: 0 15px 20px 15px;
border-radius: 10px;
color: var(--text-theme-color);
font-size: 14px;
overflow: auto;
background: var(--card-bg);
h2 {
font-weight: bold;
font-size: 20px;
text-align: center;
color: var(--theme-textcolor-normal);
margin-bottom: 20px;
}
//
::-webkit-scrollbar {
width: 0;
height: 0;
background-color: transparent;
}
//
.video-type-tabs {
margin-bottom: 20px;
:deep(.el-tabs__item.is-active) {
color: var(--theme-textcolor-normal);
font-size: 16px;
}
:deep(.el-tabs__item) {
color: var(--text-theme-color);
font-size: 16px;
}
:deep(.el-tabs__active-bar) {
background-color: var(--theme-textcolor-normal);
}
.el-tabs {
--el-tabs-header-height: 45px;
}
}
//
.param-line {
padding: 5px 0;
&.pt {
padding-top: 10px;
padding-bottom: 10px;
}
.label {
margin-right: 5px;
position: relative;
top: 1px;
}
.form-item-inner {
display: flex;
align-items: center;
.el-icon {
margin-left: 10px;
}
}
}
//
.el-form {
.el-form-item__label {
color: var(--text-theme-color);
}
.el-input, .el-slider {
width: 100%;
}
.el-select {
width: 100%;
--el-select-input-focus-border-color: var(--el-color-primary);
--el-input-focus-border-color: var(--el-color-primary);
.el-input__wrapper {
background: var(--chat-bg);
}
.el-input__inner {
color: var(--text-theme-color);
}
}
}
//
.grid-content {
background: var(--card-bg);
border-radius: 8px;
padding: 8px 14px;
display: flex;
cursor: pointer;
margin-bottom: 10px;
border: 1px solid var(--chat-bg);
&:hover {
border: 1px solid var(--theme-border-hover);
}
&.active {
border: 1px solid var(--theme-border-hover);
}
.icon {
width: 20px;
height: 20px;
margin-bottom: 5px;
&.proportion {
width: 20px;
height: 20px;
}
}
.texts {
margin-left: 5px;
margin-top: 2px;
color: var(--text-theme-color);
}
}
//
.camera-control {
padding: 10px;
border-radius: 4px;
background: var(--card-bg);
:deep(.el-form-item:last-child) {
margin-bottom: 0 !important;
}
}
//
.generate-btn {
.iconfont {
margin-right: 5px;
}
}
//
.item-group {
display: flex;
align-items: center;
margin-bottom: 15px;
.label {
margin-right: 10px;
}
}
//
.image-mode-toggle {
display: flex;
align-items: center;
justify-content: space-between;
.label {
margin-right: 10px;
color: var(--text-theme-color);
}
}
//
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s ease-in;
}
.slide-fade-enter-from {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
}
// KeLing
.params-container {
//
.task-type-tabs {
margin-bottom: 20px;
.text {
margin-bottom: 10px;
color: #6b778c;
font-size: 15px;
}
}
//
.img-inline {
display: flex;
flex-wrap: wrap;
.img-uploader {
text-align: center;
position: relative;
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
width: 120px;
height: 120px;
transition: var(--el-transition-duration-fast);
margin-bottom: 20px;
&:hover {
border-color: var(--el-color-primary);
}
}
.removeimg {
position: absolute;
right: -5px;
top: -5px;
z-index: 10;
cursor: pointer;
color: #f56c6c;
font-size: 20px;
}
}
.btn-swap {
.icon-exchange {
color: var(--text-theme-color);
cursor: pointer;
font-size: 20px;
}
}
}
//
.submit-btn {
display: flex;
margin: 20px 0;
.el-button {
width: 100%;
}
}
//
.text-info {
width: 100%;
padding: 10px 0;
.el-tag {
margin-right: 10px;
}
}
}
//
.main-content {
padding: 1.5rem;
flex: 1;
background: var(--chat-bg);
color: var(--text-theme-color);
overflow-x: hidden;
//
.works-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.h-title {
color: var(--text-theme-color);
margin: 0;
}
// .filter-buttons {
// .el-button-group {
// .el-button {
// --el-button-bg-color: var(--card-bg);
// --el-button-border-color: var(--chat-bg);
// --el-button-text-color: var(--text-theme-color);
// --el-button-hover-bg-color: var(--theme-border-hover);
// --el-button-hover-border-color: var(--theme-border-hover);
// --el-button-active-bg-color: var(--el-color-primary);
// --el-button-active-border-color: var(--el-color-primary);
// &.is-type-primary {
// --el-button-bg-color: var(--el-color-primary);
// --el-button-border-color: var(--el-color-primary);
// --el-button-text-color: #ffffff;
// }
// }
// }
// }
}
//
.video-list {
.list-box {
padding: 0;
.item {
display: flex;
flex-flow: row;
align-items: center;
min-height: 100px;
padding: 10px 15px;
border-radius: 10px;
cursor: pointer;
margin-bottom: 20px;
background: var(--card-bg);
.left {
.container {
width: 160px;
position: relative;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
.video {
width: 160px;
height: 120px;
border-radius: 5px;
background-color: var(--el-fill-color-light);
}
.el-image {
width: 160px;
height: 90px;
border-radius: 5px;
}
.duration {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(14, 8, 8, 0.7);
padding: 0 3px;
font-family: 'Input Sans';
font-size: 14px;
font-weight: 700;
border-radius: 0.125rem;
}
.play {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 50%;
border: none;
border-radius: 5px;
background: rgba(100, 100, 100, 0.3);
cursor: pointer;
color: var(--text-theme-color);
opacity: 0;
transform: translate(-50%, 0px);
transition: opacity 0.3s ease 0s;
}
&:hover {
.play {
opacity: 1;
}
}
}
}
.center {
width: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
flex-flow: column;
padding: 0 20px;
.prompt, .failed {
padding: 0;
font-size: 16px;
max-height: 80px;
line-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
}
.prompt {
color: var(--text-fb);
cursor: text;
}
.failed {
color: #E4696B;
}
.pb-2 {
padding-bottom: 8px;
.el-tag {
margin-right: 4px;
margin-bottom: 4px;
}
}
}
.right {
display: flex;
justify-content: right;
min-width: 200px;
font-size: 14px;
padding: 0;
.tools {
display: flex;
justify-content: left;
align-items: center;
flex-flow: row;
height: 90px;
.btn-publish {
padding: 2px 10px;
.text {
margin-right: 10px;
}
}
.btn-icon {
background: none;
padding: 6px;
transition: background 0.6s ease 0s;
color: #919191;
&:hover {
color: var(--el-color-primary);
}
.downloading {
width: 16px;
}
}
}
}
.right-error {
display: flex;
justify-content: center;
align-items: center;
}
}
}
//
.pagination {
margin-top: 20px;
display: flex;
justify-content: center;
}
}
}
//
.btn {
margin-right: 10px;
border: none;
border-radius: 5px;
padding: 5px 10px;
cursor: pointer;
color: var(--theme-text-color-primary);
background-color: var(--btn-bg);
&:hover {
opacity: 0.7;
}
}
//
.no-data {
text-align: center;
padding: 40px;
color: var(--text-theme-color);
}
}
//
@media (max-width: 768px) {
.page-video {
flex-direction: column;
.params-panel {
min-width: 100%;
max-width: 100%;
margin: 10px 0;
}
.main-content {
padding: 1rem;
.video-list .list-box .item {
.left .container {
width: 120px;
.video, .el-image {
width: 120px;
}
}
.center {
padding: 0 10px;
}
.right {
min-width: 120px;
}
}
}
}
}

View File

@@ -1,8 +1,8 @@
@font-face { @font-face {
font-family: "iconfont"; /* Project id 4125778 */ font-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1740279975534') format('woff2'), src: url('iconfont.woff2?t=1752831319382') format('woff2'),
url('iconfont.woff?t=1740279975534') format('woff'), url('iconfont.woff?t=1752831319382') format('woff'),
url('iconfont.ttf?t=1740279975534') format('truetype'); url('iconfont.ttf?t=1752831319382') format('truetype');
} }
.iconfont { .iconfont {
@@ -13,6 +13,150 @@
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.icon-jimeng2:before {
content: "\eabc";
}
.icon-jimeng:before {
content: "\eabb";
}
.icon-video:before {
content: "\e63f";
}
.icon-empty-box:before {
content: "\e638";
}
.icon-check2:before {
content: "\e7e2";
}
.icon-creator:before {
content: "\e6a1";
}
.icon-withdraw:before {
content: "\e689";
}
.icon-withdraw-log:before {
content: "\e635";
}
.icon-money:before {
content: "\e831";
}
.icon-doller:before {
content: "\e633";
}
.icon-wallet:before {
content: "\e64d";
}
.icon-check:before {
content: "\e810";
}
.icon-refuse:before {
content: "\e629";
}
.icon-Reject:before {
content: "\e70d";
}
.icon-clock:before {
content: "\e65d";
}
.icon-eye-close:before {
content: "\e7aa";
}
.icon-eye-open:before {
content: "\e7ab";
}
.icon-list:before {
content: "\e650";
}
.icon-categroy:before {
content: "\e620";
}
.icon-zhankai:before {
content: "\e632";
}
.icon-wechat-mini:before {
content: "\e63d";
}
.icon-niutou:before {
content: "\e64c";
}
.icon-qiniu:before {
content: "\e62c";
}
.icon-storage:before {
content: "\e69a";
}
.icon-localstorage:before {
content: "\ea8d";
}
.icon-minio:before {
content: "\e855";
}
.icon-aliyun:before {
content: "\e672";
}
.icon-sms:before {
content: "\e82c";
}
.icon-duanxin:before {
content: "\e65c";
}
.icon-yanzm:before {
content: "\e625";
}
.icon-yaoqm:before {
content: "\e66e";
}
.icon-epay:before {
content: "\e628";
}
.icon-coze:before {
content: "\e61b";
}
.icon-token:before {
content: "\e68e";
}
.icon-reset:before {
content: "\e649";
}
.icon-stats:before {
content: "\e878";
}
.icon-keling:before { .icon-keling:before {
content: "\eab7"; content: "\eab7";
} }
@@ -289,7 +433,7 @@
content: "\e6c4"; content: "\e6c4";
} }
.icon-mp1:before { .icon-mp4:before {
content: "\e647"; content: "\e647";
} }

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,258 @@
"css_prefix_text": "icon-", "css_prefix_text": "icon-",
"description": "", "description": "",
"glyphs": [ "glyphs": [
{
"icon_id": "42693930",
"name": "即梦AI-02",
"font_class": "jimeng2",
"unicode": "eabc",
"unicode_decimal": 60092
},
{
"icon_id": "42693927",
"name": "即梦AI-01",
"font_class": "jimeng",
"unicode": "eabb",
"unicode_decimal": 60091
},
{
"icon_id": "1283",
"name": "视频",
"font_class": "video",
"unicode": "e63f",
"unicode_decimal": 58943
},
{
"icon_id": "35224131",
"name": "empty-box",
"font_class": "empty-box",
"unicode": "e638",
"unicode_decimal": 58936
},
{
"icon_id": "9143175",
"name": "审核",
"font_class": "check2",
"unicode": "e7e2",
"unicode_decimal": 59362
},
{
"icon_id": "15450788",
"name": "创作者中心",
"font_class": "creator",
"unicode": "e6a1",
"unicode_decimal": 59041
},
{
"icon_id": "1134341",
"name": "提现",
"font_class": "withdraw",
"unicode": "e689",
"unicode_decimal": 59017
},
{
"icon_id": "10887127",
"name": "提现记录",
"font_class": "withdraw-log",
"unicode": "e635",
"unicode_decimal": 58933
},
{
"icon_id": "34452904",
"name": "money-rmb",
"font_class": "money",
"unicode": "e831",
"unicode_decimal": 59441
},
{
"icon_id": "34467697",
"name": "doller",
"font_class": "doller",
"unicode": "e633",
"unicode_decimal": 58931
},
{
"icon_id": "9512709",
"name": "钱包¥",
"font_class": "wallet",
"unicode": "e64d",
"unicode_decimal": 58957
},
{
"icon_id": "8365142",
"name": "check",
"font_class": "check",
"unicode": "e810",
"unicode_decimal": 59408
},
{
"icon_id": "10213506",
"name": "refuse",
"font_class": "refuse",
"unicode": "e629",
"unicode_decimal": 58921
},
{
"icon_id": "19393806",
"name": "Reject",
"font_class": "Reject",
"unicode": "e70d",
"unicode_decimal": 59149
},
{
"icon_id": "248916",
"name": "clock",
"font_class": "clock",
"unicode": "e65d",
"unicode_decimal": 58973
},
{
"icon_id": "6151096",
"name": "eye-close",
"font_class": "eye-close",
"unicode": "e7aa",
"unicode_decimal": 59306
},
{
"icon_id": "6151097",
"name": "eye-open",
"font_class": "eye-open",
"unicode": "e7ab",
"unicode_decimal": 59307
},
{
"icon_id": "6145570",
"name": "list",
"font_class": "list",
"unicode": "e650",
"unicode_decimal": 58960
},
{
"icon_id": "13127646",
"name": "categroy",
"font_class": "categroy",
"unicode": "e620",
"unicode_decimal": 58912
},
{
"icon_id": "1613505",
"name": "展开",
"font_class": "zhankai",
"unicode": "e632",
"unicode_decimal": 58930
},
{
"icon_id": "10905663",
"name": "微信小程序",
"font_class": "wechat-mini",
"unicode": "e63d",
"unicode_decimal": 58941
},
{
"icon_id": "21530643",
"name": "牛头",
"font_class": "niutou",
"unicode": "e64c",
"unicode_decimal": 58956
},
{
"icon_id": "24877229",
"name": "七牛云",
"font_class": "qiniu",
"unicode": "e62c",
"unicode_decimal": 58924
},
{
"icon_id": "3717493",
"name": "存储服务",
"font_class": "storage",
"unicode": "e69a",
"unicode_decimal": 59034
},
{
"icon_id": "7133059",
"name": "本地存储",
"font_class": "localstorage",
"unicode": "ea8d",
"unicode_decimal": 60045
},
{
"icon_id": "9360420",
"name": "minio",
"font_class": "minio",
"unicode": "e855",
"unicode_decimal": 59477
},
{
"icon_id": "21053628",
"name": "阿里云",
"font_class": "aliyun",
"unicode": "e672",
"unicode_decimal": 58994
},
{
"icon_id": "30046100",
"name": "comment-sms",
"font_class": "sms",
"unicode": "e82c",
"unicode_decimal": 59436
},
{
"icon_id": "4893414",
"name": "短信",
"font_class": "duanxin",
"unicode": "e65c",
"unicode_decimal": 58972
},
{
"icon_id": "553324",
"name": "验证码",
"font_class": "yanzm",
"unicode": "e625",
"unicode_decimal": 58917
},
{
"icon_id": "1264836",
"name": "邀请码",
"font_class": "yaoqm",
"unicode": "e66e",
"unicode_decimal": 58990
},
{
"icon_id": "24827618",
"name": "网易支付",
"font_class": "epay",
"unicode": "e628",
"unicode_decimal": 58920
},
{
"icon_id": "43863501",
"name": "Coze",
"font_class": "coze",
"unicode": "e61b",
"unicode_decimal": 58907
},
{
"icon_id": "11551884",
"name": "token",
"font_class": "token",
"unicode": "e68e",
"unicode_decimal": 59022
},
{
"icon_id": "38795534",
"name": "reset",
"font_class": "reset",
"unicode": "e649",
"unicode_decimal": 58953
},
{
"icon_id": "5838820",
"name": "统计",
"font_class": "stats",
"unicode": "e878",
"unicode_decimal": 59512
},
{ {
"icon_id": "42692844", "icon_id": "42692844",
"name": "可灵大模型", "name": "可灵大模型",
@@ -491,7 +743,7 @@
{ {
"icon_id": "12600802", "icon_id": "12600802",
"name": "mp4", "name": "mp4",
"font_class": "mp1", "font_class": "mp4",
"unicode": "e647", "unicode": "e647",
"unicode_decimal": 58951 "unicode_decimal": 58951
}, },

Binary file not shown.

View File

@@ -124,7 +124,7 @@ import hl from 'highlight.js'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji' import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3' import mathjaxPlugin from 'markdown-it-mathjax3'
import { ref } from 'vue' import { nextTick, onMounted, reactive, ref, watchEffect } from 'vue'
import Thinking from './Thinking.vue' import Thinking from './Thinking.vue'
// eslint-disable-next-line no-undef,no-unused-vars // eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({ const props = defineProps({
@@ -155,6 +155,9 @@ const isPlaying = ref(false)
const playIcon = ref('/images/voice.gif') const playIcon = ref('/images/voice.gif')
const store = useSharedStore() const store = useSharedStore()
// 添加代码块展开/收起状态管理
const codeBlockStates = reactive({})
const md = new MarkdownIt({ const md = new MarkdownIt({
breaks: true, breaks: true,
html: true, html: true,
@@ -162,24 +165,29 @@ const md = new MarkdownIt({
typographer: true, typographer: true,
highlight: function (str, lang) { highlight: function (str, lang) {
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000) const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮 // 显示复制代码按钮和展开/收起按钮
const copyBtn = `<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span> const copyBtn = `<div class="flex">
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace( <span class="text-[12px] mr-2 text-[#00e0e0] cursor-pointer expand-btn" data-code-id="${codeIndex}">展开</span>
<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
</div><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g, /<\/textarea>/g,
'&lt;/textarea>' '&lt;/textarea>'
)}</textarea>` )}</textarea>`
let langHtml = ''
let preCode = ''
// 处理代码高亮
if (lang && hl.getLanguage(lang)) { if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>` langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮 preCode = hl.highlight(str, { language: lang }).value
const preCode = hl.highlight(str, { language: lang }).value } else {
// 将代码包裹在 pre 中 preCode = md.utils.escapeHtml(str)
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
} }
// 处理代码高亮 // 将代码包裹在 pre 中,添加收起状态的类
const preCode = md.utils.escapeHtml(str) return `<pre class="code-container flex flex-col code-collapsed" data-code-id="${codeIndex}">
// 将代码包裹在 pre 中 <div class="flex justify-between bg-[#50505a] w-full rounded-tl-[10px] rounded-tr-[10px] px-3 py-1">${langHtml}${copyBtn}</div>
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>` <code class="language-${lang} hljs">${preCode}</code>
<span class="copy-code-btn absolute right-3 bottom-3" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span></pre>`
}, },
}) })
md.use(mathjaxPlugin) md.use(mathjaxPlugin)
@@ -226,6 +234,81 @@ const stopSynthesis = () => {
const reGenerate = (messageId) => { const reGenerate = (messageId) => {
emits('regen', messageId) emits('regen', messageId)
} }
// 添加代码块展开/收起功能
const toggleCodeBlock = (codeId) => {
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const expandBtn = document.querySelector(`.expand-btn[data-code-id="${codeId}"]`)
if (codeContainer && expandBtn) {
if (codeContainer.classList.contains('code-collapsed')) {
codeContainer.classList.remove('code-collapsed')
codeContainer.classList.add('code-expanded')
expandBtn.textContent = '收起'
} else {
codeContainer.classList.remove('code-expanded')
codeContainer.classList.add('code-collapsed')
expandBtn.textContent = '展开'
}
}
}
// 添加事件监听
onMounted(() => {
nextTick(() => {
setupCodeBlockEvents()
})
})
// 监听内容变化,重新绑定事件
watchEffect(() => {
if (props.data.content.text) {
nextTick(() => {
setupCodeBlockEvents()
})
}
})
const setupCodeBlockEvents = () => {
// 移除旧的事件监听器
const oldBtns = document.querySelectorAll('.expand-btn')
oldBtns.forEach((btn) => {
btn.removeEventListener('click', handleExpandClick)
})
// 为展开按钮添加点击事件
const expandBtns = document.querySelectorAll('.expand-btn')
expandBtns.forEach((btn) => {
btn.addEventListener('click', handleExpandClick)
// 检查对应的代码块是否需要展开功能
const codeId = btn.getAttribute('data-code-id')
const codeContainer = document.querySelector(`pre[data-code-id="${codeId}"]`)
const codeElement = codeContainer?.querySelector('.hljs')
if (codeElement) {
// 临时移除高度限制来获取真实高度
const originalMaxHeight = codeElement.style.maxHeight
codeElement.style.maxHeight = 'none'
const realHeight = codeElement.scrollHeight
codeElement.style.maxHeight = originalMaxHeight
// 如果代码块高度小于等于200px隐藏展开按钮
if (realHeight <= 200) {
btn.style.display = 'none'
// 移除收起状态的类,让短代码块完全展示
codeContainer.classList.remove('code-collapsed')
} else {
btn.style.display = 'inline'
}
}
})
}
const handleExpandClick = (e) => {
const codeId = e.target.getAttribute('data-code-id')
toggleCodeBlock(codeId)
}
</script> </script>
<style lang="stylus"> <style lang="stylus">
@@ -266,8 +349,9 @@ const reGenerate = (messageId) => {
} }
.code-container { .code-container {
background-color #2b2b2b
border-radius 10px
position relative position relative
display flex
.hljs { .hljs {
border-radius 10px border-radius 10px
@@ -275,9 +359,6 @@ const reGenerate = (messageId) => {
} }
.copy-code-btn { .copy-code-btn {
position: absolute;
right 10px
top 10px
cursor pointer cursor pointer
font-size 12px font-size 12px
color #c1c1c1 color #c1c1c1
@@ -289,16 +370,50 @@ const reGenerate = (messageId) => {
} }
.lang-name { // 添加代码块展开/收起样式
position absolute; .code-collapsed {
right 10px .hljs {
bottom 20px max-height 200px
padding 2px 6px 4px 6px overflow hidden
background-color #444444 position relative
border-radius 10px transition max-height 0.3s ease
color #00e0e0
&::after {
content ''
position absolute
bottom 0
left 0
right 0
height 30px
background linear-gradient(transparent, #2b2b2b)
pointer-events none
}
}
} }
.code-expanded {
.hljs {
max-height none
overflow auto
transition max-height 0.3s ease
&::after {
display none
}
}
}
.expand-btn {
transition color 0.2s ease
&:hover {
color #20a0ff !important
}
}
.lang-name {
color #00e0e0
}
// 设置表格边框 // 设置表格边框

View File

@@ -0,0 +1,330 @@
<template>
<div class="image-upload">
<!-- 单图模式 -->
<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>
<!-- 多图模式 -->
<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"
>
<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">
支持 JPGPNG 格式最多上传 {{ maxCount }} 单张最大 5MB
</div>
</template>
</el-upload>
</div>
</template>
<!-- 上传进度 -->
<el-progress
v-if="uploading"
:percentage="uploadProgress"
:stroke-width="4"
class="upload-progress"
/>
</div>
</template>
<script setup>
import { httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { Delete, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
const props = defineProps({
modelValue: {
type: [String, Array],
default: '',
},
multiple: {
type: Boolean,
default: false,
},
maxCount: {
type: Number,
default: 1,
},
})
const emit = defineEmits(['update:modelValue', 'upload-success'])
// 上传状态
const uploading = ref(false)
const uploadProgress = ref(0)
// 图片列表
const imageList = computed({
get() {
if (props.multiple || props.maxCount > 1) {
return Array.isArray(props.modelValue) ? props.modelValue : []
} else {
return props.modelValue ? [props.modelValue] : []
}
},
set(value) {
if (props.multiple || props.maxCount > 1) {
emit('update:modelValue', value)
} else {
emit('update:modelValue', value[0] || '')
}
},
})
const uploadCount = ref(1)
// 处理上传
const handleUpload = async (uploadFile) => {
const file = uploadFile.file
// 检查文件类型
if (!file.type.startsWith('image/')) {
ElMessage.error('请选择图片文件')
return
}
// 检查文件大小 (5MB)
if (file.size > 5 * 1024 * 1024) {
ElMessage.error('图片大小不能超过 5MB')
return
}
// 检查数量限制
if (uploadCount.value > props.maxCount) {
ElMessage.error(`最多只能上传 ${props.maxCount} 张图片`)
return
}
uploadCount.value++
uploading.value = true
uploadProgress.value = 0
try {
const formData = new FormData()
formData.append('file', file)
// 模拟上传进度
const progressTimer = setInterval(() => {
if (uploadProgress.value < 90) {
uploadProgress.value += 10
}
}, 100)
const response = await httpPost('/api/upload', formData)
clearInterval(progressTimer)
uploadProgress.value = 100
const imageUrl = replaceImg(response.data.url)
// 更新图片列表
if (props.multiple || props.maxCount > 1) {
const newList = [...imageList.value, imageUrl]
imageList.value = newList
} else {
imageList.value = [imageUrl]
}
emit('upload-success', imageUrl)
ElMessage.success('上传成功')
} catch (error) {
ElMessage.error('上传失败: ' + (error.message || '网络错误'))
} finally {
uploading.value = false
uploadProgress.value = 0
}
}
// 移除图片
const removeImage = (index) => {
const newList = [...imageList.value]
newList.splice(index, 1)
imageList.value = newList
uploadCount.value--
}
</script>
<style lang="stylus">
.image-upload {
width: 100%;
}
.single-upload {
width: 100px;
height: 100px;
position: relative;
}
.single-image-item {
width: 100px;
height: 100px;
position: relative;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
}
.upload-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.upload-item {
position: relative;
width: 100px;
height: 100px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #dcdfe6;
.upload-image {
width: 100%;
height: 100%;
}
.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;
.remove-btn {
background: rgba(245, 108, 108, 0.8);
border: none;
color: white;
}
}
&:hover .upload-overlay {
opacity: 1;
}
}
.upload-btn {
.uploader {
width: 100%;
.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 {
.el-upload-dragger {
width: 100%;
}
.uploader {
width: 100%;
}
}
.upload-progress {
margin-top: 10px;
}
:deep(.el-upload) {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -161,6 +161,24 @@ const items = [
}, },
], ],
}, },
{
icon: 'jimeng',
index: '/admin/jimeng',
title: '即梦AI',
subs: [
{
icon: 'list',
index: '/admin/jimeng/jobs',
title: '任务列表',
},
{
icon: 'config',
index: '/admin/jimeng/config',
title: '即梦设置',
},
],
},
{ {
icon: 'role', icon: 'role',

View File

@@ -104,16 +104,16 @@ const routes = [
component: () => import('@/views/Song.vue'), component: () => import('@/views/Song.vue'),
}, },
{ {
name: 'luma', name: 'video',
path: '/luma', path: '/video',
meta: { title: 'Luma视频创作' }, meta: { title: '视频创作中心' },
component: () => import('@/views/Luma.vue'), component: () => import('@/views/Video.vue'),
}, },
{ {
name: 'keling', name: 'jimeng',
path: '/keling', path: '/jimeng',
meta: { title: 'KeLing视频创作' }, meta: { title: '即梦AI' },
component: () => import('@/views/KeLing.vue'), component: () => import('@/views/Jimeng.vue'),
}, },
], ],
}, },
@@ -258,6 +258,18 @@ const routes = [
meta: { title: '音视频管理' }, meta: { title: '音视频管理' },
component: () => import('@/views/admin/records/Medias.vue'), component: () => import('@/views/admin/records/Medias.vue'),
}, },
{
path: '/admin/jimeng/jobs',
name: 'admin-jimeng-jobs',
meta: { title: '即梦AI任务' },
component: () => import('@/views/admin/jimeng/JimengJobs.vue'),
},
{
path: '/admin/jimeng/config',
name: 'admin-jimeng-config',
meta: { title: '即梦设置' },
component: () => import('@/views/admin/jimeng/JimengSetting.vue'),
},
{ {
path: '/admin/powerLog', path: '/admin/powerLog',
name: 'admin-power-log', name: 'admin-power-log',
@@ -358,4 +370,4 @@ router.beforeEach((to, from, next) => {
next() next()
}) })
export { router, prevRoute } export { prevRoute, router }

642
web/src/store/jimeng.js Normal file
View File

@@ -0,0 +1,642 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import { checkSession } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import { ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
import { computed, nextTick, reactive, ref } from 'vue'
export const useJimengStore = defineStore('jimeng', () => {
// 当前激活的功能分类和具体功能
const activeCategory = ref('image_generation')
const activeFunction = ref('text_to_image')
const useImageInput = ref(false)
// 新增:全局提示词
const currentPrompt = ref('')
// 共同状态
const loading = ref(false)
const submitting = ref(false)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskFilter = ref('all')
const currentList = ref([])
const isOver = ref(false)
// 用户信息
const isLogin = ref(false)
const userPower = ref(100)
// 视频预览
const showDialog = ref(false)
const currentVideoUrl = ref('')
// 登录弹窗
const shareStore = useSharedStore()
// 功能分类配置
const categories = [
{ key: 'image_generation', name: '图片生成' },
{ key: 'image_editing', name: 'AI修图' },
{ key: 'image_effects', name: '图像特效' },
{ key: 'video_generation', name: '视频生成' },
]
// 新增:动态获取算力消耗配置
const powerConfig = reactive({})
// 功能配置
const functions = reactive([
{
key: 'text_to_image',
name: '文生图',
category: 'image_generation',
needsPrompt: true,
needsImage: false,
power: 20,
},
{
key: 'image_to_image',
name: '图生图',
category: 'image_generation',
needsPrompt: true,
needsImage: true,
power: 30,
},
{
key: 'image_edit',
name: '图像编辑',
category: 'image_editing',
needsPrompt: true,
needsImage: true,
multiple: true,
power: 25,
},
{
key: 'image_effects',
name: '图像特效',
category: 'image_effects',
needsPrompt: false,
needsImage: true,
power: 15,
},
{
key: 'text_to_video',
name: '文生视频',
category: 'video_generation',
needsPrompt: true,
needsImage: false,
power: 100,
},
{
key: 'image_to_video',
name: '图生视频',
category: 'video_generation',
needsPrompt: true,
needsImage: true,
multiple: true,
power: 120,
},
])
// 动态设置算力消耗
const setFunctionPowers = (config) => {
functions.forEach((f) => {
if (config[f.key] !== undefined) {
f.power = config[f.key]
}
})
}
// 各功能的参数
const textToImageParams = reactive({
size: '1328x1328',
scale: 2.5,
seed: -1,
use_pre_llm: true,
})
const imageToImageParams = reactive({
image_input: '',
size: '1328x1328',
gpen: 0.4,
skin: 0.3,
skin_unifi: 0,
gen_mode: 'creative',
seed: -1,
})
const imageEditParams = reactive({
image_urls: '',
scale: 0.5,
seed: -1,
})
const imageEffectsParams = reactive({
image_input1: '',
template_id: '',
size: '1328x1328',
})
const textToVideoParams = reactive({
aspect_ratio: '16:9',
seed: -1,
})
const imageToVideoParams = reactive({
image_urls: [],
aspect_ratio: '16:9',
seed: -1,
})
// 计算属性
const currentFunction = computed(() => {
return functions.find((f) => f.key === activeFunction.value) || functions[0]
})
const currentFunctions = computed(() => {
return functions.filter((f) => f.category === activeCategory.value)
})
const needsPrompt = computed(() => currentFunction.value.needsPrompt)
const needsImage = computed(() => currentFunction.value.needsImage)
const needsMultipleImages = computed(() => currentFunction.value.multiple)
const currentPowerCost = computed(() => currentFunction.value.power)
// 初始化方法
const init = async () => {
try {
// 获取算力消耗配置
const powerRes = await httpGet('/api/jimeng/power-config')
if (powerRes.data) {
Object.assign(powerConfig, powerRes.data)
setFunctionPowers(powerRes.data)
}
const user = await checkSession()
isLogin.value = true
userPower.value = user.power
// 获取任务列表
await fetchData(1)
// 开始轮询
startPolling()
} catch (error) {
console.error('初始化失败:', error)
}
}
// 切换功能分类
const switchCategory = (category) => {
activeCategory.value = category
const categoryFunctions = functions.filter((f) => f.category === category)
if (categoryFunctions.length > 0) {
if (category === 'image_generation') {
activeFunction.value = useImageInput.value ? 'image_to_image' : 'text_to_image'
} else if (category === 'video_generation') {
activeFunction.value = useImageInput.value ? 'image_to_video' : 'text_to_video'
} else {
activeFunction.value = categoryFunctions[0].key
}
}
}
// 切换输入模式
const switchInputMode = () => {
if (activeCategory.value === 'image_generation') {
activeFunction.value = useImageInput.value ? 'image_to_image' : 'text_to_image'
} else if (activeCategory.value === 'video_generation') {
activeFunction.value = useImageInput.value ? 'image_to_video' : 'text_to_video'
}
}
// 切换功能
const switchFunction = (functionKey) => {
activeFunction.value = functionKey
}
// 获取当前算力消耗
const getCurrentPowerCost = () => {
return currentFunction.value.power
}
// 获取功能名称
const getFunctionName = (type) => {
const func = functions.find((f) => f.key === type)
return func ? func.name : type
}
// 获取任务状态文本
const getTaskStatusText = (status) => {
const statusMap = {
in_queue: '任务排队中',
generating: '任务执行中',
success: '任务成功',
failed: '任务失败',
canceled: '任务已取消',
}
return statusMap[status] || status
}
// 获取状态类型
const getTaskType = (type) => {
const typeMap = {
text_to_image: 'primary',
image_to_image: 'primary',
image_edit: 'primary',
image_effects: 'primary',
text_to_video: 'success',
image_to_video: 'success',
}
return typeMap[type] || 'primary'
}
// 切换任务筛选
const switchTaskFilter = (filter) => {
taskFilter.value = filter
isOver.value = false
fetchData(1)
}
// 轮询定时器
let pollHandler = null
// 获取任务列表
const fetchData = async (pageNum = 1) => {
try {
loading.value = true
page.value = pageNum
const response = await httpPost('/api/jimeng/jobs', {
page: pageNum,
page_size: pageSize.value,
filter: taskFilter.value,
})
const data = response.data
if (!data.items || data.items.length === 0) {
isOver.value = true
if (pageNum === 1) {
currentList.value = []
}
return
}
total.value = data.total || 0
if (data.items.length < pageSize.value) {
isOver.value = true
}
if (pageNum === 1) {
currentList.value = data.items
} else {
currentList.value = currentList.value.concat(data.items)
}
} catch (error) {
showMessageError('获取任务列表失败:' + error.message)
} finally {
loading.value = false
}
}
// 简单轮询逻辑
const startPolling = () => {
if (pollHandler) {
clearInterval(pollHandler)
}
pollHandler = setInterval(async () => {
const response = await httpPost('/api/jimeng/jobs', {
page: 1,
page_size: 20,
})
const data = response.data
if (data.items.length === 0) {
stopPolling()
return
}
const todoList = data.items.filter(
(item) => item.status === 'in_queue' || item.status === 'generating'
)
// 更新当前列表
currentList.value.forEach((item) => {
const index = data.items.findIndex((i) => i.id === item.id)
if (index !== -1) {
Object.assign(item, data.items[index])
}
})
if (todoList.length === 0) {
stopPolling()
}
}, 3000)
}
const stopPolling = () => {
if (pollHandler) {
clearInterval(pollHandler)
pollHandler = null
}
}
// 提交任务
const submitTask = async () => {
if (!isLogin.value) {
shareStore.setShowLoginDialog(true)
return
}
if (userPower.value < currentPowerCost.value) {
showMessageError('算力不足')
return
}
// 新增:除图像特效外,其他任务类型必须有提示词
if (activeFunction.value !== 'image_effects' && !currentPrompt.value) {
showMessageError('提示词不能为空')
return
}
try {
submitting.value = true
let requestData = { task_type: activeFunction.value, prompt: currentPrompt.value }
switch (activeFunction.value) {
case 'text_to_image':
Object.assign(requestData, {
width: parseInt(textToImageParams.size.split('x')[0]),
height: parseInt(textToImageParams.size.split('x')[1]),
scale: textToImageParams.scale,
seed: textToImageParams.seed,
use_pre_llm: textToImageParams.use_pre_llm,
})
break
case 'image_to_image':
Object.assign(requestData, {
image_input: imageToImageParams.image_input,
width: parseInt(imageToImageParams.size.split('x')[0]),
height: parseInt(imageToImageParams.size.split('x')[1]),
gpen: imageToImageParams.gpen,
skin: imageToImageParams.skin,
skin_unifi: imageToImageParams.skin_unifi,
gen_mode: imageToImageParams.gen_mode,
seed: imageToImageParams.seed,
})
break
case 'image_edit':
Object.assign(requestData, {
image_urls: imageEditParams.image_urls,
scale: imageEditParams.scale,
seed: imageEditParams.seed,
})
break
case 'image_effects':
Object.assign(requestData, {
image_input: imageEffectsParams.image_input1,
template_id: imageEffectsParams.template_id,
width: parseInt(imageEffectsParams.size.split('x')[0]),
height: parseInt(imageEffectsParams.size.split('x')[1]),
})
break
case 'text_to_video':
Object.assign(requestData, {
aspect_ratio: textToVideoParams.aspect_ratio,
seed: textToVideoParams.seed,
})
break
case 'image_to_video':
Object.assign(requestData, {
image_urls: imageToVideoParams.image_urls,
aspect_ratio: imageToVideoParams.aspect_ratio,
seed: imageToVideoParams.seed,
})
break
}
const response = await httpPost('/api/jimeng/task', requestData)
if (response.data) {
showMessageOK('任务提交成功')
isOver.value = false
await fetchData(1)
startPolling()
}
} catch (error) {
console.error('提交任务失败:', error)
showMessageError(error.message || '提交任务失败')
} finally {
submitting.value = false
}
}
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 httpGet(`/api/jimeng/retry?id=${taskId}`)
if (response.data) {
showMessageOK('重试任务已提交')
isOver.value = false
await fetchData(1)
startPolling()
}
} catch (error) {
console.error('重试任务失败:', error)
showMessageError(error.message || '重试任务失败')
}
}
// 删除任务
const removeJob = async (item) => {
try {
await ElMessageBox.confirm('确定要删除这个任务吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
const response = await httpGet('/api/jimeng/remove', { id: item.id })
if (response.data) {
showMessageOK('删除成功')
await fetchData(1)
}
} catch (error) {
if (error !== 'cancel') {
console.error('删除任务失败:', error)
showMessageError(error.message || '删除任务失败')
}
}
}
// 播放视频
const playVideo = (item) => {
currentVideoUrl.value = item.video_url
showDialog.value = true
}
// 画同款功能
const drawSame = (item) => {
// 联动功能开关
if (item.type === 'text_to_image' || item.type === 'image_to_image') {
activeCategory.value = 'image_generation'
useImageInput.value = item.type === 'image_to_image'
} else if (item.type === 'text_to_video' || item.type === 'image_to_video') {
activeCategory.value = 'video_generation'
useImageInput.value = item.type === 'image_to_video'
} else if (item.type === 'image_edit') {
activeCategory.value = 'image_editing'
} else if (item.type === 'image_effects') {
activeCategory.value = 'image_effects'
}
switchFunction(item.type)
nextTick(() => {
currentPrompt.value = item.prompt
})
if (item.type === 'text_to_image') {
if (item.width && item.height) {
textToImageParams.size = `${item.width}x${item.height}`
}
if (item.scale) textToImageParams.scale = item.scale
if (item.seed) textToImageParams.seed = item.seed
if (item.use_pre_llm !== undefined) textToImageParams.use_pre_llm = item.use_pre_llm
} else if (item.type === 'image_to_image') {
if (item.image_input) imageToImageParams.image_input = item.image_input
if (item.width && item.height) {
imageToImageParams.size = `${item.width}x${item.height}`
}
if (item.gpen) imageToImageParams.gpen = item.gpen
if (item.skin) imageToImageParams.skin = item.skin
if (item.skin_unifi) imageToImageParams.skin_unifi = item.skin_unifi
if (item.gen_mode) imageToImageParams.gen_mode = item.gen_mode
if (item.seed) imageToImageParams.seed = item.seed
} else if (item.type === 'image_edit') {
if (item.image_urls) imageEditParams.image_urls = item.image_urls
if (item.scale) imageEditParams.scale = item.scale
if (item.seed) imageEditParams.seed = item.seed
} else if (item.type === 'image_effects') {
if (item.image_input1) imageEffectsParams.image_input1 = item.image_input1
if (item.template_id) imageEffectsParams.template_id = item.template_id
if (item.width && item.height) {
imageEffectsParams.size = `${item.width}x${item.height}`
}
} else if (item.type === 'text_to_video') {
if (item.aspect_ratio) textToVideoParams.aspect_ratio = item.aspect_ratio
if (item.seed) textToVideoParams.seed = item.seed
} else if (item.type === 'image_to_video') {
if (item.image_urls) imageToVideoParams.image_urls = item.image_urls
if (item.aspect_ratio) imageToVideoParams.aspect_ratio = item.aspect_ratio
if (item.seed) imageToVideoParams.seed = item.seed
}
showMessageOK('已填入全部参数,可直接生成同款')
}
// 页面卸载时清理轮询
const cleanup = () => {
stopPolling()
}
// 返回所有状态和方法
return {
// 状态
activeCategory,
activeFunction,
useImageInput,
loading,
submitting,
page,
pageSize,
total,
taskFilter,
currentList,
isOver,
isLogin,
userPower,
showDialog,
currentVideoUrl,
// 配置
categories,
functions,
currentFunctions,
// 参数
currentPrompt,
textToImageParams,
imageToImageParams,
imageEditParams,
imageEffectsParams,
textToVideoParams,
imageToVideoParams,
// 计算属性
currentFunction,
needsPrompt,
needsImage,
needsMultipleImages,
currentPowerCost,
// 方法
init,
switchCategory,
switchFunction,
switchInputMode,
getCurrentPowerCost,
getFunctionName,
getTaskStatusText,
getTaskType,
switchTaskFilter,
fetchData,
submitTask,
downloadFile,
retryTask,
removeJob,
playVideo,
cleanup,
drawSame,
// 工具函数
substr,
replaceImg,
}
})
export const imageSizeOptions = [
{ label: '1:1 (1328x1328)', value: '1328x1328' },
{ label: '3:2 (1584x1056)', value: '1584x1056' },
{ label: '2:3 (1056x1584)', value: '1056x1584' },
{ label: '4:3 (1472x1104)', value: '1472x1104' },
{ label: '3:4 (1104x1472)', value: '1104x1472' },
{ label: '16:9 (1664x936)', value: '1664x936' },
{ label: '9:16 (936x1664)', value: '936x1664' },
{ label: '21:9 (2016x864)', value: '2016x864' },
{ label: '9:21 (864x2016)', value: '864x2016' },
]
export const videoAspectRatioOptions = [
{ label: '1:1 (正方形)', value: '1:1' },
{ label: '16:9 (横版)', value: '16:9' },
{ label: '9:16 (竖版)', value: '9:16' },
]

602
web/src/store/video.js Normal file
View File

@@ -0,0 +1,602 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import nodata from '@/assets/img/no-data.png'
import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { defineStore } from 'pinia'
import { computed, reactive, ref } from 'vue'
export const useVideoStore = defineStore('video', () => {
// 当前活跃的视频类型
const activeVideoType = ref('luma')
// 共同状态
const loading = ref(false)
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const pullHandler = ref(null)
const clipboard = ref(null)
// 视频预览
const showDialog = ref(false)
const currentVideoUrl = ref('')
// 用户信息
const isLogin = ref(false)
const availablePower = ref(100)
const shareStore = useSharedStore()
// 任务筛选
const taskFilter = ref('all') // 'all', 'luma', 'keling'
// Luma 相关状态
const lumaUseImageMode = ref(false) // 是否使用图片辅助生成
const lumaParams = reactive({
prompt: '',
expand_prompt: false,
loop: false,
image: '', // 起始帧
image_tail: '', // 结束帧
})
// KeLing 相关状态
const isGenerating = ref(false)
const generating = ref(false)
const kelingPowerCost = ref(10)
const lumaPowerCost = ref(10)
const showCameraControl = ref(false)
const keLingPowers = ref({})
const models = ref([
{ text: '可灵 1.6', value: 'kling-v1-6' },
{ text: '可灵 1.5', value: 'kling-v1-5' },
{ text: '可灵 1.0', value: 'kling-v1' },
])
const rates = [
{ css: 'square', value: '1:1', text: '1:1', img: '/images/mj/rate_1_1.png' },
{ css: 'size16-9', value: '16:9', text: '16:9', img: '/images/mj/rate_16_9.png' },
{ css: 'size9-16', value: '9:16', text: '9:16', img: '/images/mj/rate_9_16.png' },
]
// KeLing 相关状态
const kelingUseImageMode = ref(false) // 是否使用图片辅助生成
const kelingParams = reactive({
model: 'kling-v1-6',
prompt: '',
negative_prompt: '',
cfg_scale: 0.7,
mode: 'std',
aspect_ratio: '16:9',
duration: '5',
camera_control: {
type: '',
config: {
horizontal: 0,
vertical: 0,
pan: 0,
tilt: 0,
roll: 0,
zoom: 0,
},
},
image: '',
image_tail: '',
})
// 计算属性
const currentList = computed(() => {
return list.value.filter((item) => {
if (taskFilter.value === 'all') {
return true
} else if (taskFilter.value === 'luma') {
return item.type === 'luma' || !item.type // 兼容旧数据
} else if (taskFilter.value === 'keling') {
return item.type === 'keling'
}
return true
})
})
// 初始化方法
const init = async () => {
try {
const user = await checkSession()
isLogin.value = true
availablePower.value = user.power
// 初始化剪贴板
if (clipboard.value) {
clipboard.value.destroy()
}
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
clipboard.value.on('error', () => {
ElMessage.error('复制失败!')
})
// 获取系统信息
const sysInfo = await getSystemInfo()
lumaPowerCost.value = sysInfo.data.luma_power
keLingPowers.value = sysInfo.data.keling_powers
updateModelPower()
// 获取数据并开始轮询
await fetchData(1)
startPolling()
} catch (error) {
console.error('初始化失败:', error)
}
}
// 清理方法
const cleanup = () => {
if (clipboard.value) {
clipboard.value.destroy()
}
stopPolling()
}
// 开始轮询
const startPolling = () => {
if (pullHandler.value) {
clearInterval(pullHandler.value)
}
pullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(page.value)
}
}, 5000)
}
// 停止轮询
const stopPolling = () => {
if (pullHandler.value) {
clearInterval(pullHandler.value)
pullHandler.value = null
}
}
// 获取任务列表
const fetchData = async (_page) => {
if (_page) {
page.value = _page
}
try {
const res = await httpGet('/api/video/list', {
page: page.value,
page_size: pageSize.value,
type: taskFilter.value === 'all' ? '' : taskFilter.value,
})
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push({
...v,
downloading: false,
})
}
loading.value = false
taskPulling.value = needPull
if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items
}
noData.value = list.value.length === 0
} catch (error) {
loading.value = false
noData.value = true
console.error('获取任务列表失败:', error)
}
}
// Luma 相关方法
const uploadLumaStartImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
showLoading('图片上传中...')
const res = await httpPost('/api/upload', formData)
lumaParams.image = res.data.url
ElMessage.success('上传成功')
closeLoading()
} catch (error) {
showMessageError('上传失败: ' + error.message)
closeLoading()
}
}
const uploadLumaEndImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
showLoading('图片上传中...')
const res = await httpPost('/api/upload', formData)
lumaParams.image_tail = res.data.url
ElMessage.success('上传成功')
} catch (error) {
showMessageError('上传失败: ' + error.message)
} finally {
closeLoading()
}
}
const removeLumaImage = (type) => {
if (type === 'start') {
lumaParams.image = ''
} else if (type === 'end') {
lumaParams.image_tail = ''
}
}
const switchLumaImages = () => {
;[lumaParams.image, lumaParams.image_tail] = [lumaParams.image_tail, lumaParams.image]
}
const toggleLumaImageMode = (enabled) => {
lumaUseImageMode.value = enabled
// 关闭时清空图片
if (!enabled) {
lumaParams.image = ''
lumaParams.image_tail = ''
}
}
const createLumaVideo = async () => {
if (!isLogin.value) {
shareStore.setShowLoginDialog(true)
return
}
if (!lumaParams.prompt?.trim()) {
return ElMessage.error('请输入视频描述')
}
if (lumaUseImageMode.value && !lumaParams.image) {
return ElMessage.error('请上传起始帧图片')
}
// 处理参数
const requestData = {
...lumaParams,
task_type: lumaUseImageMode.value ? 'image2video' : 'text2video',
}
// 处理图片链接
if (requestData.image) {
requestData.first_frame_img = replaceImg(requestData.image)
}
if (requestData.image_tail) {
requestData.end_frame_img = replaceImg(requestData.image_tail)
}
try {
await httpPost('/api/video/luma/create', requestData)
await fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
} catch (error) {
showMessageError('创建任务失败:' + error.message)
}
}
// KeLing 相关方法
const changeRate = (item) => {
kelingParams.aspect_ratio = item.value
}
const updateModelPower = () => {
showCameraControl.value = kelingParams.model === 'kling-v1-5' && kelingParams.mode === 'pro'
kelingPowerCost.value =
keLingPowers.value[`${kelingParams.model}_${kelingParams.mode}_${kelingParams.duration}`] ||
10
}
const toggleKelingImageMode = (enabled) => {
kelingUseImageMode.value = enabled
// 关闭时清空图片
if (!enabled) {
kelingParams.image = ''
kelingParams.image_tail = ''
}
}
const uploadKelingStartImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
showLoading('图片上传中...')
const res = await httpPost('/api/upload', formData)
kelingParams.image = res.data.url
ElMessage.success('上传成功')
closeLoading()
} catch (error) {
showMessageError('上传失败: ' + error.message)
closeLoading()
}
}
const uploadKelingEndImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
showLoading('图片上传中...')
const res = await httpPost('/api/upload', formData)
kelingParams.image_tail = res.data.url
ElMessage.success('上传成功')
} catch (error) {
showMessageError('上传失败: ' + error.message)
} finally {
closeLoading()
}
}
const removeKelingImage = (type) => {
if (type === 'start') {
kelingParams.image = ''
} else if (type === 'end') {
kelingParams.image_tail = ''
}
}
const switchKelingImages = () => {
;[kelingParams.image, kelingParams.image_tail] = [kelingParams.image_tail, kelingParams.image]
}
const createKelingVideo = async () => {
if (!isLogin.value) {
shareStore.setShowLoginDialog(true)
return
}
if (generating.value) return
if (!kelingParams.prompt?.trim()) {
return ElMessage.error('请输入视频描述')
}
if (kelingParams.prompt.length > 500) {
return ElMessage.error('视频描述不能超过 500 个字符')
}
if (kelingUseImageMode.value && !kelingParams.image) {
return ElMessage.error('请上传起始帧图片')
}
generating.value = true
// 处理参数
const requestData = {
...kelingParams,
task_type: kelingUseImageMode.value ? 'image2video' : 'text2video',
}
// 处理图片链接
if (requestData.image) {
requestData.image = replaceImg(requestData.image)
}
if (requestData.image_tail) {
requestData.image_tail = replaceImg(requestData.image_tail)
}
try {
await httpPost('/api/video/keling/create', requestData)
showMessageOK('任务创建成功')
// 新增重置
page.value = 1
list.value.unshift({
progress: 0,
prompt: requestData.prompt,
raw_data: {
task_type: requestData.task_type,
model: requestData.model,
duration: requestData.duration,
mode: requestData.mode,
},
})
taskPulling.value = true
} catch (error) {
showMessageError('创建失败: ' + error.message)
} finally {
generating.value = false
}
}
// 提示词生成
const generatePrompt = async () => {
if (isGenerating.value) return
const prompt = activeVideoType.value === 'luma' ? lumaParams.prompt : kelingParams.prompt
if (!prompt) {
return showMessageError('请输入原始提示词')
}
isGenerating.value = true
showLoading('正在生成视频脚本...')
try {
const res = await httpPost('/api/prompt/video', { prompt })
if (activeVideoType.value === 'luma') {
lumaParams.prompt = res.data
} else {
kelingParams.prompt = res.data
}
closeLoading()
} catch (error) {
showMessageError('生成提示词失败:' + error.message)
closeLoading()
} finally {
isGenerating.value = false
}
}
// 视频预览
const playVideo = (item) => {
currentVideoUrl.value = replaceImg(item.video_url)
showDialog.value = true
}
// 视频下载
const downloadVideo = async (item) => {
const url = replaceImg(item.video_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 removeJob = async (item) => {
try {
await ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
await httpGet('/api/video/remove', { id: item.id })
ElMessage.success('任务删除成功')
await fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('任务删除失败:' + error.message)
}
}
}
// 发布任务
const publishJob = async (item) => {
try {
await httpGet('/api/video/publish', { id: item.id, publish: item.publish })
ElMessage.success('操作成功')
} catch (error) {
ElMessage.error('操作失败:' + error.message)
}
}
// 切换视频类型
const switchVideoType = (type) => {
activeVideoType.value = type
}
// 切换任务筛选
const switchTaskFilter = (filter) => {
taskFilter.value = filter
page.value = 1
fetchData(1)
}
return {
// 状态
activeVideoType,
loading,
list,
currentList,
noData,
page,
pageSize,
total,
taskPulling,
showDialog,
currentVideoUrl,
isLogin,
availablePower,
nodata,
taskFilter,
// Luma 状态
lumaUseImageMode,
lumaParams,
lumaPowerCost,
// KeLing 状态
kelingUseImageMode,
isGenerating,
generating,
kelingPowerCost,
showCameraControl,
keLingPowers,
models,
rates,
kelingParams,
// 方法
init,
cleanup,
fetchData,
switchVideoType,
switchTaskFilter,
// Luma 方法
toggleLumaImageMode,
uploadLumaStartImage,
uploadLumaEndImage,
removeLumaImage,
switchLumaImages,
createLumaVideo,
// KeLing 方法
toggleKelingImageMode,
changeRate,
updateModelPower,
uploadKelingStartImage,
uploadKelingEndImage,
removeKelingImage,
switchKelingImages,
createKelingVideo,
// 共同方法
generatePrompt,
playVideo,
downloadVideo,
removeJob,
publishJob,
substr,
replaceImg,
}
})

View File

@@ -255,3 +255,8 @@ export function isChrome() {
const userAgent = navigator.userAgent.toLowerCase() const userAgent = navigator.userAgent.toLowerCase()
return /chrome/.test(userAgent) && !/edg/.test(userAgent) return /chrome/.test(userAgent) && !/edg/.test(userAgent)
} }
// 格式化日期时间
export function formatDateTime(timestamp, format = 'yyyy-MM-dd HH:mm:ss') {
return dateFormat(timestamp, format)
}

View File

@@ -219,8 +219,8 @@
{{ model.power > 0 ? `${model.power}算力` : '免费' }} {{ model.power > 0 ? `${model.power}算力` : '免费' }}
</el-tag> </el-tag>
</div> </div>
<div class="model-description" :title="model.description || '暂无描述'"> <div class="model-description" :title="model.desc || '暂无描述'">
{{ model.description || '暂无描述' }} {{ model.desc || '暂无描述' }}
</div> </div>
<!-- 暂时屏蔽此信息展示或许用户不想展示此信息 --> <!-- 暂时屏蔽此信息展示或许用户不想展示此信息 -->
<div class="model-metadata"> <div class="model-metadata">
@@ -299,7 +299,7 @@
v-model="prompt" v-model="prompt"
@keydown="onInput" @keydown="onInput"
@input="onInput" @input="onInput"
placeholder="按 Enter 键发送消息,使用 Ctrl + Enter 换行" placeholder="按 Enter 键发送消息,使用 Shift + Enter 换行"
autofocus autofocus
> >
</textarea> </textarea>
@@ -488,7 +488,7 @@ const filteredModels = computed(() => {
model.description.toLowerCase().includes(modelSearchKeyword.value.toLowerCase())) model.description.toLowerCase().includes(modelSearchKeyword.value.toLowerCase()))
// 分类匹配 // 分类匹配
const matchesCategory = !activeCategory.value || model.category === activeCategory.value const matchesCategory = !activeCategory.value || model.tag === activeCategory.value
// 免费模型匹配 // 免费模型匹配
const matchesFree = !showFreeModelsOnly.value || model.power <= 0 const matchesFree = !showFreeModelsOnly.value || model.power <= 0
@@ -514,8 +514,8 @@ const toggleFreeModels = () => {
const updateModelCategories = () => { const updateModelCategories = () => {
const categories = new Set() const categories = new Set()
models.value.forEach((model) => { models.value.forEach((model) => {
if (model.category) { if (model.tag) {
categories.add(model.category) categories.add(model.tag)
} }
}) })
modelCategories.value = Array.from(categories) modelCategories.value = Array.from(categories)
@@ -539,7 +539,7 @@ const updateGroupedModels = () => {
// 否则按分类分组展示 // 否则按分类分组展示
const groups = {} const groups = {}
filtered.forEach((model) => { filtered.forEach((model) => {
const category = model.category || '未分类' const category = model.tag || '未分类'
if (!groups[category]) { if (!groups[category]) {
groups[category] = [] groups[category] = []
} }
@@ -1092,9 +1092,8 @@ const onInput = (e) => {
// 输入回车自动提交 // 输入回车自动提交
if (e.keyCode === 13) { if (e.keyCode === 13) {
if (e.ctrlKey) { // Shift + Enter 换行
// Ctrl + Enter 换行 if (e.shiftKey) {
prompt.value += '\n'
return return
} }
e.preventDefault() e.preventDefault()

View File

@@ -69,7 +69,7 @@
<el-popover placement="right-end" trigger="hover" v-if="loginUser.id"> <el-popover placement="right-end" trigger="hover" v-if="loginUser.id">
<template #reference> <template #reference>
<li class="menu-list-item flex-center-col"> <li class="menu-list-item flex-center-col">
<i class="iconfont icon-config" /> <i class="iconfont icon-user-circle" />
</li> </li>
</template> </template>
<template #default> <template #default>
@@ -97,6 +97,11 @@
</ul> </ul>
</template> </template>
</el-popover> </el-popover>
<div v-else class="mb-2 flex justify-center">
<el-button @click="store.setShowLoginDialog(true)" type="primary" size="small">
登录
</el-button>
</div>
<div class="menu-bot-item"> <div class="menu-bot-item">
<a @click="router.push('/')" class="link-button"> <a @click="router.push('/')" class="link-button">
<i class="iconfont icon-house"></i> <i class="iconfont icon-house"></i>
@@ -109,14 +114,14 @@
</div> </div>
</div> </div>
<el-scrollbar class="right-main"> <el-scrollbar class="right-main">
<div class="topheader" v-if="loginUser.id === undefined || !loginUser.id"> <!-- <div class="topheader" v-if="loginUser.id === undefined || !loginUser.id">
<el-button <el-button
@click="router.push('/login')" @click="router.push('/login')"
class="btn-go animate__animated animate__pulse animate__infinite" class="btn-go animate__animated animate__pulse animate__infinite"
round round
>登录</el-button >登录</el-button
> >
</div> </div> -->
<div class="content custom-scroll"> <div class="content custom-scroll">
<router-view :key="routerViewKey" v-slot="{ Component }"> <router-view :key="routerViewKey" v-slot="{ Component }">
<transition name="move" mode="out-in"> <transition name="move" mode="out-in">
@@ -209,7 +214,6 @@ watch(
// 监听路由变化; // 监听路由变化;
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
curPath.value = to.path curPath.value = to.path
console.log(curPath.value)
next() next()
}) })
@@ -281,7 +285,9 @@ const logout = function () {
httpGet('/api/user/logout') httpGet('/api/user/logout')
.then(() => { .then(() => {
removeUserToken() removeUserToken()
router.push('/login') // 刷新组件
routerViewKey.value += 1
loginUser.value = {}
}) })
.catch(() => { .catch(() => {
ElMessage.error('注销失败!') ElMessage.error('注销失败!')

View File

@@ -688,87 +688,26 @@
v-if="item.progress === 100" v-if="item.progress === 100"
> >
<div class="opt" v-if="item['can_opt']"> <div class="opt" v-if="item['can_opt']">
<div class="flex flex-row justify-start items-center mb-3"> <el-row :gutter="8" class="mb-3">
<button <el-col :span="6" v-for="i in 4" :key="'u' + i">
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600" <button
@click="upscale(1, item)" class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
> @click="upscale(i, item)"
U1
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="upscale(2, item)"
>
U2
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="upscale(3, item)"
>
U3
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="upscale(4, item)"
>
U4
</button>
<div class="show-prompt ml-2">
<el-popover
placement="left"
title="提示词"
:width="240"
trigger="hover"
> >
<template #reference> U{{ i }}
<i class="iconfont icon-prompt text-white text-xl"></i> </button>
</template> </el-col>
</el-row>
<template #default> <el-row :gutter="8" class="mb-3">
<div class="mj-list-item-prompt"> <el-col :span="6" v-for="i in 4" :key="'v' + i">
<span>{{ item.prompt }}</span> <button
<el-icon class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
class="copy-prompt-mj" @click="variation(i, item)"
:data-clipboard-text="item.prompt" >
> V{{ i }}
<DocumentCopy /> </button>
</el-icon> </el-col>
</div> </el-row>
</template>
</el-popover>
</div>
</div>
<div class="flex flex-row justify-start items-center mb-3">
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
@click="variation(1, item)"
>
V1
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="variation(2, item)"
>
V2
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="variation(3, item)"
>
V3
</button>
<button
class="px-3 h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600 ml-2"
@click="variation(4, item)"
>
V4
</button>
</div>
</div> </div>
<div <div
@@ -797,13 +736,28 @@
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip content="删除" placement="top"> <el-tooltip content="删除" placement="top">
<el-button <el-button type="danger" @click="removeImage(item)" circle>
type="danger" <i class="iconfont icon-remove"></i>
:icon="Delete" </el-button>
@click="removeImage(item)"
circle
/>
</el-tooltip> </el-tooltip>
<el-popover
placement="top"
title="提示词"
:width="240"
trigger="hover"
>
<template #reference>
<el-button type="primary" circle>
<i class="iconfont icon-prompt text-white"></i>
</el-button>
</template>
<template #default>
<div class="mj-list-item-prompt">
<span>{{ item.prompt }}</span>
</div>
</template>
</el-popover>
</div> </div>
</div> </div>
</div> </div>
@@ -866,7 +820,7 @@ import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError } from '@/utils/dialog' import { closeLoading, showLoading, showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http' import { httpGet, httpPost } from '@/utils/http'
import { copyObj, removeArrayItem } from '@/utils/libs' import { copyObj, removeArrayItem } from '@/utils/libs'
import { Delete, DocumentCopy, InfoFilled, Plus, UploadFilled } from '@element-plus/icons-vue' import { Delete, InfoFilled, Plus, UploadFilled } from '@element-plus/icons-vue'
import Clipboard from 'clipboard' import Clipboard from 'clipboard'
import Compressor from 'compressorjs' import Compressor from 'compressorjs'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus' import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'

View File

@@ -69,7 +69,7 @@
class="nav-item-box" class="nav-item-box"
@click="router.push(item.url)" @click="router.push(item.url)"
> >
<i :class="'iconfont ' + iconMap[item.url]"></i> <i :class="'iconfont ' + item.icon"></i>
<div>{{ item.name }}</div> <div>{{ item.name }}</div>
</div> </div>
</el-space> </el-space>
@@ -107,20 +107,6 @@ const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
const giteeURL = ref(import.meta.env.VITE_GITEE_URL) const giteeURL = ref(import.meta.env.VITE_GITEE_URL)
const navs = ref([]) const navs = ref([])
const iconMap = ref({
'/chat': 'icon-chat',
'/mj': 'icon-mj',
'/sd': 'icon-sd',
'/dalle': 'icon-dalle',
'/images-wall': 'icon-image',
'/suno': 'icon-suno',
'/xmind': 'icon-xmind',
'/apps': 'icon-app',
'/member': 'icon-vip-user',
'/invite': 'icon-share',
'/luma': 'icon-luma',
})
const displayedChars = ref([]) const displayedChars = ref([])
const initAnimation = ref('') const initAnimation = ref('')
let timer = null // 定时器句柄 let timer = null // 定时器句柄

688
web/src/views/Jimeng.vue Normal file
View File

@@ -0,0 +1,688 @@
<template>
<div class="page-jimeng">
<!-- 左侧参数设置面板 -->
<div class="params-panel">
<!-- 功能分类按钮组 -->
<div class="category-buttons">
<div class="category-grid">
<div
v-for="category in store.categories"
:key="category.key"
:class="['category-btn', { active: store.activeCategory === category.key }]"
@click="store.switchCategory(category.key)"
>
<div class="category-icon">
<i :class="getCategoryIcon(category.key)"></i>
</div>
<div class="category-name">{{ category.name }}</div>
</div>
</div>
</div>
<!-- 功能开关 -->
<div
class="function-switch"
v-if="
store.activeCategory === 'image_generation' || store.activeCategory === 'video_generation'
"
>
<div class="switch-label">
<el-icon><Switch /></el-icon>
生成模式
</div>
<div class="switch-container">
<div class="switch-info">
<div class="switch-title">
{{ store.activeCategory === 'image_generation' ? '图生图人像写真' : '图生视频' }}
</div>
</div>
<el-switch v-model="store.useImageInput" @change="store.switchInputMode" />
</div>
</div>
<!-- 参数容器 -->
<div class="params-container">
<!-- 文生图 -->
<div v-if="store.activeFunction === 'text_to_image'" class="function-panel">
<div class="param-line pt">
<span class="label">提示词:</span>
</div>
<div class="param-line">
<el-input
v-model="store.currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="请输入图片描述,越详细越好"
maxlength="2000"
show-word-limit
/>
</div>
<div class="param-line pt">
<span class="label">图片尺寸:</span>
</div>
<div class="param-line">
<el-select v-model="store.textToImageParams.size" placeholder="选择尺寸">
<el-option
v-for="opt in imageSizeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
<div class="param-line">
<span class="label"
>创意度
<el-tooltip content="创意度越高,影响文本描述的程度越高" placement="top">
<i class="iconfont icon-info cursor-pointer ml-1"></i> </el-tooltip
></span>
</div>
<div class="item-group">
<el-slider v-model="store.textToImageParams.scale" :min="1" :max="10" :step="0.5" />
</div>
<div class="item-group flex justify-between">
<span class="label">智能优化提示词</span>
<el-switch v-model="store.textToImageParams.use_pre_llm" />
</div>
</div>
<!-- 图生图 -->
<div v-if="store.activeFunction === 'image_to_image'" class="function-panel">
<div class="param-line pt">
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload
v-model="store.imageToImageParams.image_input"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
<span class="label">提示词:</span>
</div>
<div class="param-line">
<el-input
v-model="store.currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="描述你想要的图片效果"
maxlength="2000"
show-word-limit
/>
</div>
<div class="param-line pt">
<span class="label">图片尺寸:</span>
</div>
<div class="param-line">
<el-select v-model="store.imageToImageParams.size" placeholder="选择尺寸">
<el-option
v-for="opt in imageSizeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<!-- 图像编辑 -->
<div v-if="store.activeFunction === 'image_edit'" class="function-panel">
<div class="param-line pt">
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload
v-model="store.imageEditParams.image_urls"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
<span class="label">编辑提示词:</span>
</div>
<div class="param-line">
<el-input
v-model="store.currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="描述你想要的编辑效果"
maxlength="2000"
show-word-limit
/>
</div>
<div class="item-group">
<span class="label">编辑强度:</span>
<el-slider v-model="store.imageEditParams.scale" :min="0" :max="1" :step="0.1" />
</div>
</div>
<!-- 图像特效 -->
<div v-if="store.activeFunction === 'image_effects'" class="function-panel">
<div class="param-line pt">
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload
v-model="store.imageEffectsParams.image_input1"
:max-count="1"
:multiple="false"
/>
</div>
<div class="param-line pt">
<span class="label">特效模板:</span>
</div>
<div class="param-line">
<el-select v-model="store.imageEffectsParams.template_id" placeholder="选择特效模板">
<el-option label="经典特效" value="classic" />
<el-option label="艺术风格" value="artistic" />
<el-option label="现代科技" value="modern" />
</el-select>
</div>
<div class="param-line pt">
<span class="label">输出尺寸:</span>
</div>
<div class="param-line">
<el-select v-model="store.imageEffectsParams.size" placeholder="选择尺寸">
<el-option
v-for="opt in imageSizeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<!-- 文生视频 -->
<div v-if="store.activeFunction === 'text_to_video'" class="function-panel">
<div class="param-line pt">
<span class="label">提示词:</span>
</div>
<div class="param-line">
<el-input
v-model="store.currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="描述你想要的视频内容"
maxlength="2000"
show-word-limit
/>
</div>
<div class="param-line pt">
<span class="label">视频比例:</span>
</div>
<div class="param-line">
<el-select v-model="store.textToVideoParams.aspect_ratio" placeholder="选择比例">
<el-option
v-for="opt in videoAspectRatioOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<!-- 图生视频 -->
<div v-if="store.activeFunction === 'image_to_video'" class="function-panel">
<div class="param-line pt">
<span class="label">上传图片:</span>
</div>
<div class="param-line">
<ImageUpload
v-model="store.imageToVideoParams.image_urls"
:max-count="2"
:multiple="true"
/>
</div>
<div class="param-line pt">
<span class="label">提示词:</span>
</div>
<div class="param-line">
<el-input
v-model="store.currentPrompt"
type="textarea"
:autosize="{ minRows: 3, maxRows: 5 }"
placeholder="描述你想要的视频效果"
maxlength="2000"
show-word-limit
/>
</div>
<div class="param-line pt">
<span class="label">视频比例:</span>
</div>
<div class="param-line">
<el-select v-model="store.imageToVideoParams.aspect_ratio" placeholder="选择比例">
<el-option
v-for="opt in videoAspectRatioOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<!-- 提交按钮 -->
<div class="submit-btn flex justify-center pt-4">
<el-button
type="primary"
@click="store.submitTask"
:loading="store.submitting"
size="large"
>
立即生成 ({{ store.currentPowerCost }}<i class="iconfont icon-vip2"></i>)
</el-button>
</div>
</div>
</div>
<!-- 右侧任务列表 -->
<div class="main-content">
<div class="works-header">
<h2 class="h-title">你的作品</h2>
<div class="filter-buttons">
<el-button-group>
<el-button
:type="store.taskFilter === 'all' ? 'primary' : 'default'"
@click="store.switchTaskFilter('all')"
size="small"
>
全部
</el-button>
<el-button
:type="store.taskFilter === 'image' ? 'primary' : 'default'"
@click="store.switchTaskFilter('image')"
size="small"
>
图片
</el-button>
<el-button
:type="store.taskFilter === 'video' ? 'primary' : 'default'"
@click="store.switchTaskFilter('video')"
size="small"
>
视频
</el-button>
</el-button-group>
</div>
</div>
<div class="task-list" v-loading="store.loading">
<div v-if="store.currentList.length > 0">
<Waterfall
:list="store.currentList"
v-bind="waterfallOptions"
:is-loading="store.loading"
:is-over="store.isOver"
:lazyload="true"
@afterRender="onWaterfallAfterRender"
>
<template #default="{ item }">
<div class="task-item">
<!-- 保持原有内容 -->
<div class="task-left">
<div class="task-preview">
<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"
>
<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">
<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.getTaskType(item.type)">
{{ store.getFunctionName(item.type) }}
</el-tag>
</div>
<div class="flex gap-2">
<span>
<el-tooltip content="复制提示词" placement="top">
<i
class="iconfont icon-copy cursor-pointer"
@click="copyPrompt(item.prompt)"
></i>
</el-tooltip>
</span>
<span class="ml-1">
<el-tooltip content="画同款" placement="top">
<i
class="iconfont icon-image-list cursor-pointer"
@click="store.drawSame(item)"
></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
class="task-prompt line-clamp-2 min-h-[40px] text-[14px] text-theme mb-2 leading-snug break-all"
>
{{ store.substr(item.prompt, 200) }}
</div>
<div class="task-meta">
<span>{{ dateFormat(item.created_at) }}</span>
<span v-if="item.power">{{ item.power }}算力</span>
</div>
</div>
</div>
</template>
</Waterfall>
<div class="flex justify-center py-10">
<img
:src="waterfallOptions.loadProps.loading"
class="max-w-[50px] max-h-[50px]"
v-if="!waterfallRendered"
/>
<div v-else>
<div class="no-more-data" v-if="store.isOver">
<span class="text-gray-500 mr-2">没有更多数据了</span>
<i class="iconfont icon-face"></i>
</div>
</div>
</div>
</div>
<el-empty v-else :image-size="100" description="暂无记录" />
</div>
</div>
<!-- 视频预览对话框 -->
<el-dialog v-model="store.showDialog" title="视频预览" width="70%" center>
<video
:src="store.currentVideoUrl"
autoplay="true"
controls
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
</el-dialog>
</div>
</template>
<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, ref, watch } from 'vue'
import { Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
const sharedStore = useSharedStore()
const waterfallOptions = sharedStore.waterfallOptions
// 获取分类图标
const getCategoryIcon = (category) => {
const iconMap = {
image_generation: 'iconfont icon-image',
image_editing: 'iconfont icon-edit',
image_effects: 'iconfont icon-chuangzuo',
video_generation: 'iconfont icon-video',
}
return iconMap[category] || 'iconfont icon-image'
}
const store = useJimengStore()
// 新增:瀑布流渲染完成状态
const waterfallRendered = ref(false)
onMounted(() => {
store.init()
})
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)
}
}
function copyPrompt(prompt) {
navigator.clipboard
.writeText(prompt)
.then(() => {
ElMessage.success('提示词已复制')
})
.catch(() => {
ElMessage.error('复制失败')
})
}
function copyErrorMsg(msg) {
navigator.clipboard
.writeText(msg)
.then(() => {
ElMessage.success('错误信息已复制')
})
.catch(() => {
ElMessage.error('复制失败')
})
}
</script>
<style lang="stylus" scoped>
.task-list {
.task-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
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 {
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
}
@media (max-width: 768px) {
.task-list .task-grid {
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>

View File

@@ -1,745 +0,0 @@
<template>
<div class="page-keling">
<div class="inner custom-scroll">
<!-- 左侧参数设置面板 -->
<el-scrollbar max-height="100vh">
<div class="mj-box">
<h2>视频参数设置</h2>
<el-form :model="params" label-width="80px" label-position="left">
<!-- 画面比例 -->
<div class="param-line">
<div class="param-line pt">
<span>画面比例</span>
<el-tooltip content="生成画面的尺寸比例" placement="right">
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
<div class="param-line pt">
<el-row :gutter="10">
<el-col :span="8" v-for="item in rates" :key="item.value">
<div
class="flex-col items-center"
:class="
item.value === params.aspect_ratio ? 'grid-content active' : 'grid-content'
"
@click="changeRate(item)"
>
<el-image class="icon proportion" :src="item.img" fit="cover"></el-image>
<div class="texts">{{ item.text }}</div>
</div>
</el-col>
</el-row>
</div>
</div>
<!-- 模型选择 -->
<div class="param-line">
<el-form-item label="模型选择">
<el-select
v-model="params.model"
placeholder="请选择模型"
@change="updateModelPower"
>
<el-option
v-for="item in models"
:key="item.value"
:label="item.text"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<!-- 视频时长 -->
<div class="param-line">
<el-form-item label="视频时长">
<el-select
v-model="params.duration"
placeholder="请选择时长"
@change="updateModelPower"
>
<el-option label="5秒" value="5" />
<el-option label="10秒" value="10" />
</el-select>
</el-form-item>
</div>
<!-- 生成模式 -->
<div class="param-line">
<el-form-item label="生成模式">
<el-select
v-model="params.mode"
placeholder="请选择模式"
@change="updateModelPower"
>
<el-option label="标准模式" value="std" />
<el-option label="专业模式" value="pro" />
</el-select>
</el-form-item>
</div>
<!-- 创意程度 -->
<div class="param-line">
<el-form-item label="创意程度">
<el-slider v-model="params.cfg_scale" :min="0" :max="1" :step="0.1" />
</el-form-item>
</div>
<!-- 运镜控制 -->
<div class="param-line" v-if="showCameraControl">
<div class="param-line pt">
<span>运镜控制</span>
<el-tooltip content="生成画面的运镜效果,仅 1.5的高级模式可用" placement="right">
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
<!-- 添加运镜类型选择 -->
<div class="param-line">
<el-select v-model="params.camera_control.type" placeholder="请选择运镜类型">
<el-option label="请选择" value="" />
<el-option label="简单运镜" value="simple" />
<el-option label="下移拉远" value="down_back" />
<el-option label="推进上移" value="forward_up" />
<el-option label="右旋推进" value="right_turn_forward" />
<el-option label="左旋推进" value="left_turn_forward" />
</el-select>
</div>
<!-- 仅在simple模式下显示详细配置 -->
<div class="camera-control mt-2" v-if="params.camera_control.type === 'simple'">
<el-form-item label="水平移动">
<el-slider
v-model="params.camera_control.config.horizontal"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="垂直移动">
<el-slider v-model="params.camera_control.config.vertical" :min="-10" :max="10" />
</el-form-item>
<el-form-item label="左右旋转">
<el-slider v-model="params.camera_control.config.pan" :min="-10" :max="10" />
</el-form-item>
<el-form-item label="上下旋转">
<el-slider v-model="params.camera_control.config.tilt" :min="-10" :max="10" />
</el-form-item>
<el-form-item label="横向翻转">
<el-slider v-model="params.camera_control.config.roll" :min="-10" :max="10" />
</el-form-item>
<el-form-item label="镜头缩放">
<el-slider v-model="params.camera_control.config.zoom" :min="-10" :max="10" />
</el-form-item>
</div>
</div>
</el-form>
</div>
</el-scrollbar>
<!-- 右侧主内容区 -->
<div class="main-content task-list-inner">
<!-- 任务类型选择 -->
<div class="param-line">
<el-tabs v-model="params.task_type" @tab-change="tabChange" class="title-tabs">
<el-tab-pane label="文生视频" name="text2video">
<div class="text">使用文字描述想要生成视频的内容</div>
</el-tab-pane>
<el-tab-pane label="图生视频" name="image2video">
<div class="text">
以某张图片为底稿参考来创作视频生成类似风格或类型视频支持 PNG /JPG/JPEG
格式图片
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 生成操作区 -->
<div class="generation-area">
<div v-if="params.task_type === 'text2video'" class="text2video">
<el-input
v-model="params.prompt"
type="textarea"
maxlength="500"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="请在此输入视频提示词,您也可以点击下面的提示词助手生成视频提示词"
/>
<el-row class="text-info">
<el-button
class="generate-btn"
@click="generatePrompt"
:loading="isGenerating"
size="small"
color="#5865f2"
>
<i class="iconfont icon-chuangzuo"></i>
生成专业视频提示词
</el-button>
</el-row>
</div>
<div v-else class="image2video">
<div class="image-upload img-inline">
<div class="upload-box img-uploader video-img-box">
<el-icon v-if="params.image" @click="removeImage('start')" class="removeimg"
><CircleCloseFilled
/></el-icon>
<h4>起始帧</h4>
<el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadStartImage"
accept=".jpg,.png,.jpeg"
>
<img v-if="params.image" :src="params.image" class="preview" />
<el-icon v-else class="upload-icon"><Plus /></el-icon>
</el-upload>
</div>
<div class="btn-swap" v-if="params.image && params.image_tail">
<i class="iconfont icon-exchange" @click="switchReverse"></i>
</div>
<div class="upload-box img-uploader video-img-box">
<el-icon v-if="params.image_tail" @click="removeImage('end')" class="removeimg"
><CircleCloseFilled
/></el-icon>
<h4>结束帧</h4>
<el-upload
class="uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadEndImage"
accept=".jpg,.png,.jpeg"
>
<img v-if="params.image_tail" :src="params.image_tail" class="preview" />
<el-icon v-else class="upload-icon"><Plus /></el-icon>
</el-upload>
</div>
</div>
<div class="param-line pt">
<div class="flex-row justify-between items-center">
<div class="flex-row justify-start items-center">
<span>提示词</span>
<el-tooltip content="输入你想要的内容,用逗号分割" placement="right">
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</div>
</div>
<div class="param-line pt">
<el-input
v-model="params.prompt"
type="textarea"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="描述视频画面细节"
/>
</div>
</div>
<!-- 排除内容 -->
<div class="param-line pt">
<div class="flex-row justify-between items-center">
<div class="flex-row justify-start items-center">
<span>不希望出现的内容可选</span>
<el-tooltip content="不想出现在图片上的元素(例如:树,建筑)" placement="right">
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</div>
</div>
<div class="param-line pt">
<el-input
v-model="params.negative_prompt"
type="textarea"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="请在此输入你不希望出现在视频上的内容"
/>
</div>
<!-- 算力显示 -->
<el-row class="text-info">
<el-text type="primary"
>每次生成视频消耗 <el-text type="warning">{{ powerCost }}算力;</el-text> </el-text
>&nbsp;&nbsp;
<el-text type="primary"
>当前可用算力<el-text type="warning">{{ availablePower }}</el-text></el-text
>
</el-row>
<!-- 生成按钮 -->
<div class="submit-btn">
<el-button type="primary" :dark="false" @click="generate" round>立即生成</el-button>
</div>
</div>
<!-- 任务列表区域 -->
<div class="video-list">
<h2 class="text-xl p-3">你的作品</h2>
<div class="list-box" v-if="!noData">
<div v-for="item in list" :key="item.id">
<div class="item">
<div class="left">
<div class="container">
<div v-if="item.progress === 100">
<video
class="video"
:src="item.video_url"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button
class="play flex justify-center items-center"
@click="previewVideo(item)"
>
<img src="/images/play.svg" alt="" />
</button>
</div>
<el-image v-else-if="item.progress === 101" :src="item.cover_url" fit="cover" />
<generating message="正在生成视频" v-else />
</div>
</div>
<div class="center">
<div class="pb-2">
<el-tag class="mr-1">{{ item.raw_data.task_type }}</el-tag>
<el-tag class="mr-1">{{ item.raw_data.model }}</el-tag>
<el-tag class="mr-1">{{ item.raw_data.duration }}</el-tag>
<el-tag class="mr-1">{{ item.raw_data.mode }}</el-tag>
</div>
<div class="failed" v-if="item.progress === 101">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else>
{{ substr(item.prompt, 1000) }}
</div>
</div>
<div class="right" v-if="item.progress === 100">
<div class="tools">
<el-tooltip content="复制提示词" placement="top">
<button class="btn btn-icon copy-prompt" :data-clipboard-text="item.prompt">
<i class="iconfont icon-copy"></i>
</button>
</el-tooltip>
<!-- <button class="btn btn-publish">
<span class="text">发布</span>
<black-switch v-model:value="item.publish" @change="publishJob(item)" size="small" />
</button> -->
<el-tooltip content="下载视频" placement="top">
<button
class="btn btn-icon"
@click="downloadVideo(item)"
:disabled="item.downloading"
>
<i class="iconfont icon-download" v-if="!item.downloading"></i>
<el-image
src="/images/loading.gif"
class="downloading"
fit="cover"
v-else
/>
</button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<button class="btn btn-icon" @click="removeJob(item)">
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
</div>
</div>
<div class="right-error" v-else>
<el-button type="danger" @click="removeJob(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</div>
</div>
</div>
</div>
<el-empty
:image-size="100"
:image="nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination">
<el-pagination
v-if="total > pageSize"
background
style="--el-pagination-button-bg-color: rgba(86, 86, 95, 0.2)"
layout="total,prev, pager, next"
:hide-on-single-page="true"
v-model:current-page="page"
v-model:page-size="pageSize"
@current-change="fetchData"
:total="total"
/>
</div>
</div>
</div>
</div>
<!-- 视频预览对话框 -->
<black-dialog
v-model:show="previewVisible"
title="视频预览"
hide-footer
@cancal="previewVisible = false"
width="auto"
>
<video
v-if="currentVideo"
:src="currentVideo"
controls
style="max-width: 90vw; max-height: 90vh"
:autoplay="true"
loop="loop"
muted="muted"
preload="auto"
>
您的浏览器不支持视频播放
</video>
</black-dialog>
</div>
</template>
<script setup>
import nodata from '@/assets/img/no-data.png'
import BlackDialog from '@/components/ui/BlackDialog.vue'
import Generating from '@/components/ui/Generating.vue'
import { checkSession, getSystemInfo } from '@/store/cache'
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg, substr } from '@/utils/libs'
import { CircleCloseFilled, InfoFilled, Plus } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
const models = ref([
{
text: '可灵 1.6',
value: 'kling-v1-6',
},
{
text: '可灵 1.5',
value: 'kling-v1-5',
},
{
text: '可灵 1.0',
value: 'kling-v1',
},
])
// 参数设置
const params = reactive({
task_type: 'text2video',
model: models.value[0].value,
prompt: '',
negative_prompt: '',
cfg_scale: 0.7,
mode: 'std',
aspect_ratio: '16:9',
duration: '5',
camera_control: {
type: '',
config: {
horizontal: 0,
vertical: 0,
pan: 0,
tilt: 0,
roll: 0,
zoom: 0,
},
},
image: '',
image_tail: '',
})
const rates = [
{ css: 'square', value: '1:1', text: '1:1', img: '/images/mj/rate_1_1.png' },
{
css: 'size16-9',
value: '16:9',
text: '16:9',
img: '/images/mj/rate_16_9.png',
},
{
css: 'size9-16',
value: '9:16',
text: '9:16',
img: '/images/mj/rate_9_16.png',
},
]
// 切换图片比例
const changeRate = (item) => {
params.aspect_ratio = item.value
}
const generating = ref(false)
const isGenerating = ref(false)
const powerCost = ref(10)
const availablePower = ref(100)
const taskFilter = ref('all')
const loading = ref(false)
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const pullHandler = ref(null)
const previewVisible = ref(false)
const currentVideo = ref('')
const showCameraControl = ref(false)
const keLingPowers = ref({})
const isLogin = ref(false)
// 动态更新模型消耗的算力
const updateModelPower = () => {
showCameraControl.value = params.model === 'kling-v1-5' && params.mode === 'pro'
powerCost.value = keLingPowers.value[`${params.model}_${params.mode}_${params.duration}`] || {}
}
// tab切换
const tabChange = (tab) => {
params.task_type = tab
}
const uploadStartImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
showLoading('图片上传中...')
const res = await httpPost('/api/upload', formData)
params.image = res.data.url
ElMessage.success('上传成功')
closeLoading()
} catch (e) {
showMessageError('上传失败: ' + e.message)
closeLoading()
}
}
//移除图片
const removeImage = (type) => {
if (type === 'start') {
params.image = ''
} else if (type === 'end') {
params.image_tail = ''
}
}
//图片交换方法
const switchReverse = () => {
;[params.image, params.image_tail] = [params.image_tail, params.image]
}
const uploadEndImage = async (file) => {
const formData = new FormData()
formData.append('file', file.file)
try {
const res = await httpPost('/api/upload', formData)
params.image_tail = res.data.url
ElMessage.success('上传成功')
} catch (e) {
showMessageError('上传失败: ' + e.message)
}
}
const generatePrompt = async () => {
if (isGenerating.value) return
if (!params.prompt) {
return showMessageError('请输入视频描述')
}
isGenerating.value = true
try {
const res = await httpPost('/api/prompt/video', { prompt: params.prompt })
params.prompt = res.data
} catch (e) {
showMessageError('生成失败: ' + e.message)
} finally {
isGenerating.value = false
}
}
const generate = async () => {
//增加防抖
if (generating.value) return
if (!params.prompt?.trim()) {
return ElMessage.error('请输入视频描述')
}
// 提示词长度不能超过 500
if (params.prompt.length > 500) {
return ElMessage.error('视频描述不能超过 500 个字符')
}
if (params.task_type === 'image2video' && !params.image) {
return ElMessage.error('请上传起始帧图片')
}
generating.value = true
// 处理图片链接
if (params.image) {
params.image = replaceImg(params.image)
}
if (params.image_tail) {
params.image_tail = replaceImg(params.image_tail)
}
try {
await httpPost('/api/video/keling/create', params)
showMessageOK('任务创建成功')
// 新增重置
page.value = 1
list.value.unshift({
progress: 0,
prompt: params.prompt,
raw_data: {
task_type: params.task_type,
model: params.model,
duration: params.duration,
mode: params.mode,
},
})
taskPulling.value = true
} catch (e) {
showMessageError('创建失败: ' + e.message)
} finally {
generating.value = false
}
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
httpGet('/api/video/list', {
page: page.value,
page_size: pageSize.value,
type: 'keling',
task_type: taskFilter.value === 'all' ? '' : taskFilter.value,
})
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push({
...v,
downloading: false,
})
}
loading.value = false
taskPulling.value = needPull
if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items
}
noData.value = list.value.length === 0
})
.catch(() => {
loading.value = false
noData.value = true
})
}
const previewVideo = (task) => {
currentVideo.value = task.video_url
previewVisible.value = true
}
const downloadVideo = async (task) => {
try {
const res = await httpDownload(`/api/download?url=${replaceImg(task.video_url)}`)
const blob = new Blob([res.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = `video_${task.id}.mp4`
link.click()
URL.revokeObjectURL(link.href)
} catch (e) {
showMessageError('下载失败: ' + e.message)
}
}
// 删除任务
const removeJob = (item) => {
ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet('/api/video/remove', { id: item.id })
.then(() => {
ElMessage.success('任务删除成功')
fetchData(page.value)
})
.catch((e) => {
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {})
}
const clipboard = ref(null)
// 生命周期钩子
onMounted(() => {
checkSession()
.then((u) => {
isLogin.value = true
availablePower.value = u.power
fetchData(1)
// 设置轮询
pullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(page.value)
}
}, 5000)
})
.catch((e) => {
console.log(e)
})
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
clipboard.value.on('error', () => {
ElMessage.error('复制失败!')
})
getSystemInfo().then((res) => {
keLingPowers.value = res.data.keling_powers
updateModelPower()
})
})
onUnmounted(() => {
clipboard.value.destroy()
if (pullHandler.value) {
clearInterval(pullHandler.value)
}
})
</script>
<style lang="stylus" scoped>
@import '../assets/css/keling.styl'
</style>

View File

@@ -1,387 +0,0 @@
<template>
<div class="page-luma">
<div class="prompt-box">
<div class="images">
<template v-for="(img, index) in images" :key="img">
<div class="item">
<el-image :src="replaceImg(img)" fit="cover" />
<el-icon @click="remove(img)"><CircleCloseFilled /></el-icon>
</div>
<div class="btn-swap" v-if="images.length === 2 && index === 0">
<i class="iconfont icon-exchange" @click="switchReverse"></i>
</div>
</template>
</div>
<div class="prompt-container">
<div class="input-container">
<div class="upload-icon" v-if="images.length < 2">
<el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="upload"
accept=".jpg,.png,.jpeg"
>
<i class="iconfont icon-image"></i>
</el-upload>
</div>
<textarea
class="prompt-input"
:rows="row"
v-model="formData.prompt"
maxlength="2000"
placeholder="请输入提示词或者上传图片"
autofocus
>
</textarea>
<div class="send-icon" @click="create">
<i class="iconfont icon-send"></i>
</div>
</div>
<div class="params">
<div class="item-group">
<el-button class="generate-btn" size="small" @click="generatePrompt" color="#5865f2">
<i class="iconfont icon-chuangzuo" style="margin-right: 5px"></i>
<span>生成AI视频提示词</span>
</el-button>
</div>
<div class="item-group">
<span class="label">循环参考图</span>
<el-switch v-model="formData.loop" size="small" />
</div>
<div class="item-group">
<span class="label">提示词优化</span>
<el-switch v-model="formData.expand_prompt" size="small" />
</div>
</div>
</div>
</div>
<el-container
class="video-container"
v-loading="loading"
element-loading-background="rgba(100,100,100,0.3)"
>
<h2 class="h-title text-2xl mb-5 mt-2">你的作品</h2>
<div class="list-box" v-if="!noData">
<div v-for="item in list" :key="item.id">
<div class="item">
<div class="left">
<div class="container">
<div v-if="item.progress === 100">
<video
class="video"
:src="replaceImg(item.video_url)"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button class="play flex justify-center items-center" @click="play(item)">
<img src="/images/play.svg" alt="" />
</button>
</div>
<el-image :src="item.cover_url" fit="cover" v-else-if="item.progress === 101" />
<generating message="正在生成视频" v-else />
</div>
</div>
<div class="center">
<div class="failed" v-if="item.progress === 101">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else>{{ item.prompt }}</div>
</div>
<div class="right" v-if="item.progress === 100">
<div class="tools">
<!-- <button class="btn btn-publish">
<span class="text">发布</span>
<black-switch v-model:value="item.publish" @change="publishJob(item)" size="small" />
</button> -->
<el-tooltip content="复制提示词" placement="top">
<button class="btn btn-icon copy-prompt" :data-clipboard-text="item.prompt">
<i class="iconfont icon-copy"></i>
</button>
</el-tooltip>
<el-tooltip content="下载视频" placement="top">
<button class="btn btn-icon" @click="download(item)" :disabled="item.downloading">
<i class="iconfont icon-download" v-if="!item.downloading"></i>
<el-image src="/images/loading.gif" class="downloading" fit="cover" v-else />
</button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<button class="btn btn-icon" @click="removeJob(item)">
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
</div>
</div>
<div class="right-error" v-else>
<el-button type="danger" @click="removeJob(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</div>
</div>
</div>
</div>
<el-empty
:image-size="100"
:image="nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination">
<el-pagination
v-if="total > pageSize"
background
style="--el-pagination-button-bg-color: rgba(86, 86, 95, 0.2)"
layout="total,prev, pager, next"
:hide-on-single-page="true"
v-model:current-page="page"
v-model:page-size="pageSize"
@current-change="fetchData(page)"
:total="total"
/>
</div>
</el-container>
<black-dialog
v-model:show="showDialog"
title="预览视频"
hide-footer
@cancal="showDialog = false"
width="auto"
>
<video
style="max-width: 90vw; max-height: 90vh"
:src="currentVideoUrl"
preload="auto"
:autoplay="true"
loop="loop"
muted="muted"
v-show="showDialog"
>
您的浏览器不支持视频播放
</video>
</black-dialog>
</div>
</template>
<script setup>
import nodata from '@/assets/img/no-data.png'
import BlackDialog from '@/components/ui/BlackDialog.vue'
import Generating from '@/components/ui/Generating.vue'
import { checkSession } from '@/store/cache'
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { CircleCloseFilled } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
const showDialog = ref(false)
const currentVideoUrl = ref('')
const row = ref(1)
const images = ref([])
const formData = reactive({
prompt: '',
expand_prompt: false,
loop: false,
first_frame_img: '',
end_frame_img: '',
})
const loading = ref(false)
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const clipboard = ref(null)
const pullHandler = ref(null)
onMounted(() => {
checkSession().then(() => {
fetchData(1)
// 设置轮询
pullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(1)
}
}, 5000)
})
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
})
onUnmounted(() => {
clipboard.value.destroy()
if (pullHandler.value) {
clearInterval(pullHandler.value)
}
})
const download = (item) => {
const url = replaceImg(item.video_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
httpDownload(downloadURL)
.then((response) => {
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(() => {
showMessageError('下载失败')
item.downloading = false
})
}
const play = (item) => {
currentVideoUrl.value = replaceImg(item.video_url)
showDialog.value = true
}
const removeJob = (item) => {
ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet('/api/video/remove', { id: item.id })
.then(() => {
ElMessage.success('任务删除成功')
fetchData()
})
.catch((e) => {
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {})
}
const publishJob = (item) => {
httpGet('/api/video/publish', { id: item.id, publish: item.publish })
.then(() => {
ElMessage.success('操作成功')
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
}
const upload = (file) => {
const formData = new FormData()
formData.append('file', file.file, file.name)
showLoading('正在上传文件...')
httpPost('/api/upload', formData)
.then((res) => {
images.value.push(res.data.url)
ElMessage.success({ message: '上传成功', duration: 500 })
closeLoading()
})
.catch((e) => {
ElMessage.error('图片上传失败:' + e.message)
closeLoading()
})
}
const remove = (img) => {
images.value = images.value.filter((item) => item !== img)
}
const switchReverse = () => {
images.value = images.value.reverse()
}
const fetchData = (_page) => {
if (_page) {
page.value = _page
}
httpGet('/api/video/list', {
page: page.value,
page_size: pageSize.value,
type: 'luma',
})
.then((res) => {
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
needPull = true
}
items.push(v)
}
loading.value = false
taskPulling.value = needPull
if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items
}
noData.value = list.value.length === 0
})
.catch(() => {
loading.value = false
noData.value = true
})
}
const create = () => {
const len = images.value.length
if (len) {
formData.first_frame_img = replaceImg(images.value[0])
if (len === 2) {
formData.end_frame_img = replaceImg(images.value[1])
}
}
httpPost('/api/video/luma/create', formData)
.then(() => {
fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
})
.catch((e) => {
showMessageError('创建任务失败:' + e.message)
})
}
const generatePrompt = () => {
if (formData.prompt === '') {
return showMessageError('请输入原始提示词')
}
showLoading('正在生成视频脚本...')
httpPost('/api/prompt/video', { prompt: formData.prompt })
.then((res) => {
formData.prompt = res.data
closeLoading()
})
.catch((e) => {
showMessageError('生成提示词失败:' + e.message)
closeLoading()
})
}
</script>
<style lang="stylus" scoped>
@import "../assets/css/luma.styl"
</style>

View File

@@ -7,27 +7,37 @@
:element-loading-text="loadingText" :element-loading-text="loadingText"
> >
<div class="inner"> <div class="inner">
<div class="user-profile"> <el-card class="profile-card">
<user-profile :key="profileKey" /> <el-row class="user-opt" :gutter="16">
<el-col :span="24">
<el-row class="user-opt" :gutter="20"> <el-button class="profile-btn email" @click="showBindEmailDialog = true">
<el-col :span="12"> <i class="iconfont icon-email"></i> 绑定邮箱
<el-button type="primary" @click="showBindEmailDialog = true">绑定邮箱</el-button> </el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" @click="showBindMobileDialog = true">绑定手机</el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" @click="showThirdLoginDialog = true">第三方登录</el-button>
</el-col>
<el-col :span="12">
<el-button type="primary" @click="showPasswordDialog = true">修改密码</el-button>
</el-col> </el-col>
<el-col :span="24"> <el-col :span="24">
<el-button type="primary" @click="showRedeemVerifyDialog = true">卡密兑换 </el-button> <el-button class="profile-btn mobile" @click="showBindMobileDialog = true">
<i class="iconfont icon-mobile"></i> 绑定手机
</el-button>
</el-col>
<el-col :span="24">
<el-button class="profile-btn third" @click="showThirdLoginDialog = true">
<i class="iconfont icon-login"></i> 第三方登录
</el-button>
</el-col>
<el-col :span="24">
<el-button class="profile-btn password" @click="showPasswordDialog = true">
<i class="iconfont icon-password"></i> 修改密码
</el-button>
</el-col>
<el-divider />
<el-col :span="24">
<el-button class="profile-btn redeem" @click="showRedeemVerifyDialog = true">
<i class="iconfont icon-redeem"></i> 卡密兑换
</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </el-card>
<div class="profile-bg"></div>
<div class="product-box"> <div class="product-box">
<div class="info" v-if="orderPayInfoText !== ''"> <div class="info" v-if="orderPayInfoText !== ''">
@@ -158,7 +168,6 @@ import PasswordDialog from '@/components/PasswordDialog.vue'
import RedeemVerify from '@/components/RedeemVerify.vue' import RedeemVerify from '@/components/RedeemVerify.vue'
import ThirdLogin from '@/components/ThirdLogin.vue' import ThirdLogin from '@/components/ThirdLogin.vue'
import UserOrder from '@/components/UserOrder.vue' import UserOrder from '@/components/UserOrder.vue'
import UserProfile from '@/components/UserProfile.vue'
import { checkSession, getSystemInfo } from '@/store/cache' import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http' import { httpGet, httpPost } from '@/utils/http'
@@ -185,7 +194,6 @@ const orderPayInfoText = ref('')
const payWays = ref([]) const payWays = ref([])
const vipInfoText = ref('') const vipInfoText = ref('')
const store = useSharedStore() const store = useSharedStore()
const profileKey = ref(0)
const userOrderKey = ref(0) const userOrderKey = ref(0)
const showDialog = ref(false) const showDialog = ref(false)
const qrImg = ref('') const qrImg = ref('')
@@ -276,17 +284,13 @@ const pay = (product, payWay) => {
}) })
} }
const redeemCallback = (success) => { const redeemCallback = () => {
showRedeemVerifyDialog.value = false showRedeemVerifyDialog.value = false
if (success) {
profileKey.value += 1
}
} }
const payCallback = (success) => { const payCallback = (success) => {
showDialog.value = false showDialog.value = false
if (success) { if (success) {
profileKey.value += 1
userOrderKey.value += 1 userOrderKey.value += 1
} }
} }

645
web/src/views/Video.vue Normal file
View File

@@ -0,0 +1,645 @@
<template>
<div class="page-video">
<!-- 左侧参数设置面板 -->
<div class="params-panel">
<!-- 视频类型切换标签页 -->
<el-tabs
v-model="store.activeVideoType"
@tab-change="store.switchVideoType"
class="video-type-tabs"
>
<!-- Luma 视频参数 -->
<el-tab-pane label="Luma视频" name="luma">
<div class="params-container">
<div class="param-line">
<el-input
v-model="store.lumaParams.prompt"
type="textarea"
maxlength="2000"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="请在此输入视频提示词,用逗号分割,您也可以点击下面的提示词助手生成视频提示词"
/>
</div>
<!-- 提示词生成按钮 -->
<div class="param-line pt">
<el-button
class="generate-btn"
@click="store.generatePrompt"
:loading="store.isGenerating"
size="small"
color="#5865f2"
style="width: 100%"
>
<i class="iconfont icon-chuangzuo"></i>
生成AI视频提示词
</el-button>
</div>
<!-- 图片辅助生成开关 -->
<div class="param-line pt">
<div class="image-mode-toggle">
<span class="label">使用图片辅助生成</span>
<el-switch
v-model="store.lumaUseImageMode"
@change="store.toggleLumaImageMode"
size="small"
/>
</div>
</div>
<!-- 图片上传区域可折叠 -->
<div v-if="store.lumaUseImageMode" class="img-inline">
<div class="img-uploader video-img-box mr-2">
<el-icon
v-if="store.lumaParams.image"
@click="store.removeLumaImage('start')"
class="removeimg"
>
<CircleCloseFilled />
</el-icon>
<el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="store.uploadLumaStartImage"
accept=".jpg,.png,.jpeg"
>
<el-image
v-if="store.lumaParams.image"
:src="store.lumaParams.image"
fit="cover"
/>
<div class="flex flex-col" v-else>
<el-icon class="mb-1 text-base"><Plus /></el-icon>
<span>起始帧</span>
</div>
</el-upload>
</div>
<div
class="flex items-center h-[120px] cursor-pointer"
v-if="store.lumaParams.image && store.lumaParams.image_tail"
>
<el-tooltip content="交换图片" placement="top">
<i class="iconfont icon-exchange" @click="store.switchLumaImages"></i>
</el-tooltip>
</div>
<div class="img-uploader video-img-box ml-2">
<el-icon
v-if="store.lumaParams.image_tail"
@click="store.removeLumaImage('end')"
class="removeimg"
>
<CircleCloseFilled />
</el-icon>
<el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="store.uploadLumaEndImage"
accept=".jpg,.png,.jpeg"
>
<el-image
v-if="store.lumaParams.image_tail"
:src="store.lumaParams.image_tail"
fit="cover"
/>
<div class="flex flex-col" v-else>
<el-icon class="mb-1 text-base"><Plus /></el-icon>
<span>结束帧</span>
</div>
</el-upload>
</div>
</div>
<!-- Luma 特有参数设置 -->
<div class="item-group flex justify-between">
<span class="label">循环参考图</span>
<el-switch v-model="store.lumaParams.loop" size="small" />
</div>
<div class="item-group flex justify-between">
<span class="label">提示词优化</span>
<el-switch v-model="store.lumaParams.expand_prompt" size="small" />
</div>
<!-- 算力显示 -->
<el-row class="text-info">
<el-text type="primary"
>当前可用算力<el-text type="warning">{{ store.availablePower }}</el-text></el-text
>
</el-row>
<!-- 生成按钮 -->
<div class="submit-btn">
<el-button type="primary" :dark="false" @click="store.createLumaVideo" round>
立即生成 ({{ store.lumaPowerCost }}<i class="iconfont icon-vip2"></i>)
</el-button>
</div>
</div>
</el-tab-pane>
<!-- KeLing 视频参数 -->
<el-tab-pane label="可灵视频" name="keling">
<div class="params-container">
<el-form :model="store.kelingParams" label-width="80px" label-position="left">
<!-- 画面比例 -->
<div class="param-line">
<div class="param-line pt">
<span>画面比例</span>
<el-tooltip content="生成画面的尺寸比例" placement="right">
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-line pt">
<el-row :gutter="10">
<el-col :span="8" v-for="item in store.rates" :key="item.value">
<div
class="flex-col items-center"
:class="
item.value === store.kelingParams.aspect_ratio
? 'grid-content active'
: 'grid-content'
"
@click="store.changeRate(item)"
>
<el-image class="icon proportion" :src="item.img" fit="cover"></el-image>
<div class="texts">{{ item.text }}</div>
</div>
</el-col>
</el-row>
</div>
</div>
<!-- 模型选择 -->
<div class="param-line">
<el-form-item label="模型选择">
<el-select
v-model="store.kelingParams.model"
placeholder="请选择模型"
@change="store.updateModelPower"
>
<el-option
v-for="item in store.models"
:key="item.value"
:label="item.text"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<!-- 视频时长 -->
<div class="param-line">
<el-form-item label="视频时长">
<el-select
v-model="store.kelingParams.duration"
placeholder="请选择时长"
@change="store.updateModelPower"
>
<el-option label="5秒" value="5" />
<el-option label="10秒" value="10" />
</el-select>
</el-form-item>
</div>
<!-- 生成模式 -->
<div class="param-line">
<el-form-item label="生成模式">
<el-select
v-model="store.kelingParams.mode"
placeholder="请选择模式"
@change="store.updateModelPower"
>
<el-option label="标准模式" value="std" />
<el-option label="专业模式" value="pro" />
</el-select>
</el-form-item>
</div>
<!-- 创意程度 -->
<div class="param-line">
<el-form-item label="创意程度">
<el-slider v-model="store.kelingParams.cfg_scale" :min="0" :max="1" :step="0.1" />
</el-form-item>
</div>
<!-- 运镜控制 -->
<div class="param-line" v-if="store.showCameraControl">
<div class="param-line pt">
<span>运镜控制</span>
<el-tooltip content="生成画面的运镜效果,仅 1.5的高级模式可用" placement="right">
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-line">
<el-select
v-model="store.kelingParams.camera_control.type"
placeholder="请选择运镜类型"
>
<el-option label="请选择" value="" />
<el-option label="简单运镜" value="simple" />
<el-option label="下移拉远" value="down_back" />
<el-option label="推进上移" value="forward_up" />
<el-option label="右旋推进" value="right_turn_forward" />
<el-option label="左旋推进" value="left_turn_forward" />
</el-select>
</div>
<!-- 仅在simple模式下显示详细配置 -->
<div
class="camera-control mt-2"
v-if="store.kelingParams.camera_control.type === 'simple'"
>
<el-form-item label="水平移动">
<el-slider
v-model="store.kelingParams.camera_control.config.horizontal"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="垂直移动">
<el-slider
v-model="store.kelingParams.camera_control.config.vertical"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="左右旋转">
<el-slider
v-model="store.kelingParams.camera_control.config.pan"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="上下旋转">
<el-slider
v-model="store.kelingParams.camera_control.config.tilt"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="横向翻转">
<el-slider
v-model="store.kelingParams.camera_control.config.roll"
:min="-10"
:max="10"
/>
</el-form-item>
<el-form-item label="镜头缩放">
<el-slider
v-model="store.kelingParams.camera_control.config.zoom"
:min="-10"
:max="10"
/>
</el-form-item>
</div>
</div>
</el-form>
<!-- 图片辅助生成开关 -->
<div class="param-line pt">
<div class="image-mode-toggle">
<span class="label">使用图片辅助生成</span>
<el-switch
v-model="store.kelingUseImageMode"
@change="store.toggleKelingImageMode"
size="small"
/>
</div>
</div>
<!-- 图片上传区域可折叠 -->
<div v-if="store.kelingUseImageMode" class="img-inline">
<div class="img-uploader video-img-box mr-2">
<el-icon
v-if="store.kelingParams.image"
@click="store.removeKelingImage('start')"
class="removeimg"
>
<CircleCloseFilled />
</el-icon>
<el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="store.uploadKelingStartImage"
accept=".jpg,.png,.jpeg"
>
<el-image
v-if="store.kelingParams.image"
:src="store.kelingParams.image"
fit="cover"
/>
<div class="flex flex-col" v-else>
<el-icon class="mb-1 text-base"><Plus /></el-icon>
<span>起始帧</span>
</div>
</el-upload>
</div>
<div
class="flex items-center h-[120px] cursor-pointer"
v-if="store.kelingParams.image && store.kelingParams.image_tail"
>
<el-tooltip content="交换图片" placement="top">
<i class="iconfont icon-exchange" @click="store.switchKelingImages"></i>
</el-tooltip>
</div>
<div class="img-uploader video-img-box ml-2">
<el-icon
v-if="store.kelingParams.image_tail"
@click="store.removeKelingImage('end')"
class="removeimg"
>
<CircleCloseFilled />
</el-icon>
<el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="store.uploadKelingEndImage"
accept=".jpg,.png,.jpeg"
>
<el-image
v-if="store.kelingParams.image_tail"
:src="store.kelingParams.image_tail"
fit="cover"
/>
<div class="flex flex-col" v-else>
<el-icon class="mb-1 text-base"><Plus /></el-icon>
<span>结束帧</span>
</div>
</el-upload>
</div>
</div>
<!-- 提示词输入 -->
<div class="param-line pt">
<span>提示词</span>
<el-tooltip content="输入你想要的内容,用逗号分割" placement="right">
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-line pt">
<el-input
v-model="store.kelingParams.prompt"
type="textarea"
maxlength="500"
:autosize="{ minRows: 4, maxRows: 6 }"
:placeholder="
store.kelingUseImageMode
? '描述视频画面细节'
: '请在此输入视频提示词,您也可以点击下面的提示词助手生成视频提示词'
"
/>
</div>
<!-- 提示词生成按钮 -->
<div class="param-line pt">
<el-button
class="generate-btn"
@click="store.generatePrompt"
:loading="store.isGenerating"
size="small"
color="#5865f2"
style="width: 100%"
>
<i class="iconfont icon-chuangzuo"></i>
生成专业视频提示词
</el-button>
</div>
<!-- 排除内容 -->
<div class="param-line pt">
<span>不希望出现的内容可选</span>
<el-tooltip content="不想出现在图片上的元素(例如:树,建筑)" placement="right">
<el-icon><InfoFilled /></el-icon>
</el-tooltip>
</div>
<div class="param-line pt">
<el-input
v-model="store.kelingParams.negative_prompt"
type="textarea"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="请在此输入你不希望出现在视频上的内容"
/>
</div>
<!-- 算力显示 -->
<el-row class="text-info">
<el-text type="primary"
>当前可用算力<el-text type="warning">{{ store.availablePower }}</el-text></el-text
>
</el-row>
<!-- 生成按钮 -->
<div class="submit-btn">
<el-button
type="primary"
:dark="false"
@click="store.createKelingVideo"
round
:loading="store.generating"
>
立即生成 ({{ store.kelingPowerCost }}<i class="iconfont icon-vip2"></i>)
</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
<!-- 右侧任务列表 -->
<div
class="main-content"
v-loading="store.loading"
element-loading-background="rgba(100,100,100,0.3)"
>
<div class="works-header">
<h2 class="h-title text-2xl">你的作品</h2>
<div class="filter-buttons">
<el-button-group>
<el-button
:type="store.taskFilter === 'all' ? 'primary' : 'default'"
@click="store.switchTaskFilter('all')"
size="small"
>
全部
</el-button>
<el-button
:type="store.taskFilter === 'luma' ? 'primary' : 'default'"
@click="store.switchTaskFilter('luma')"
size="small"
>
Luma
</el-button>
<el-button
:type="store.taskFilter === 'keling' ? 'primary' : 'default'"
@click="store.switchTaskFilter('keling')"
size="small"
>
可灵
</el-button>
</el-button-group>
</div>
</div>
<div class="video-list">
<div class="list-box" v-if="!store.noData">
<div v-for="item in store.currentList" :key="item.id">
<div class="item">
<div class="left">
<div class="container">
<div v-if="item.progress === 100">
<video
class="video"
:src="store.replaceImg(item.video_url)"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button
class="play flex justify-center items-center"
@click="store.playVideo(item)"
>
<img src="/images/play.svg" alt="" />
</button>
</div>
<el-image
:src="item.cover_url"
class="border rounded-lg"
fit="cover"
v-else-if="item.progress === 101"
/>
<generating message="正在生成视频" v-else />
</div>
</div>
<div class="center">
<div class="pb-2" v-if="item.raw_data">
<el-tag class="mr-1">{{
item.raw_data.task_type || store.activeVideoType
}}</el-tag>
<el-tag class="mr-1" v-if="item.raw_data.model">{{ item.raw_data.model }}</el-tag>
<el-tag class="mr-1" v-if="item.raw_data.duration"
>{{ item.raw_data.duration }}</el-tag
>
<el-tag class="mr-1" v-if="item.raw_data.mode">{{ item.raw_data.mode }}</el-tag>
</div>
<div class="failed" v-if="item.progress === 101">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else>
{{ store.substr(item.prompt, 1000) }}
</div>
</div>
<div class="right" v-if="item.progress === 100">
<div class="tools">
<el-tooltip content="复制提示词" placement="top">
<button class="btn btn-icon copy-prompt" :data-clipboard-text="item.prompt">
<i class="iconfont icon-copy"></i>
</button>
</el-tooltip>
<el-tooltip content="下载视频" placement="top">
<button
class="btn btn-icon"
@click="store.downloadVideo(item)"
:disabled="item.downloading"
>
<i class="iconfont icon-download" v-if="!item.downloading"></i>
<el-image src="/images/loading.gif" class="downloading" fit="cover" v-else />
</button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<button class="btn btn-icon" @click="store.removeJob(item)">
<i class="iconfont icon-remove"></i>
</button>
</el-tooltip>
</div>
</div>
<div class="right-error" v-else>
<el-button type="danger" @click="store.removeJob(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</div>
</div>
</div>
</div>
<el-empty
:image-size="100"
:image="store.nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination">
<el-pagination
v-if="store.total > store.pageSize"
background
style="--el-pagination-button-bg-color: rgba(86, 86, 95, 0.2)"
layout="total,prev, pager, next"
:hide-on-single-page="true"
:current-page="store.page"
:page-size="store.pageSize"
@current-change="store.fetchData"
:total="store.total"
/>
</div>
</div>
</div>
<!-- 视频预览对话框 -->
<black-dialog
:show="store.showDialog"
title="预览视频"
hide-footer
@cancal="store.showDialog = false"
@update:show="store.showDialog = $event"
width="auto"
>
<video
style="max-width: 90vw; max-height: 90vh"
:src="store.currentVideoUrl"
preload="auto"
:autoplay="true"
loop="loop"
muted="muted"
v-show="store.showDialog"
>
您的浏览器不支持视频播放
</video>
</black-dialog>
</div>
</template>
<script setup>
import BlackDialog from '@/components/ui/BlackDialog.vue'
import Generating from '@/components/ui/Generating.vue'
import { useVideoStore } from '@/store/video'
import { CircleCloseFilled, InfoFilled, Plus } from '@element-plus/icons-vue'
import { onMounted, onUnmounted } from 'vue'
const store = useVideoStore()
onMounted(() => {
store.init()
})
onUnmounted(() => {
store.cleanup()
})
</script>
<style lang="stylus" scoped>
@import "../assets/css/video.styl"
</style>

View File

@@ -56,7 +56,7 @@
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
开放注册 菜单图标
<el-tooltip <el-tooltip
effect="dark" effect="dark"
content="可以填写 iconfont 图标名称也可以自己上传图片" content="可以填写 iconfont 图标名称也可以自己上传图片"

View File

@@ -169,10 +169,10 @@
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
默认翻译模型 系统辅助AI模型
<el-tooltip <el-tooltip
effect="dark" effect="dark"
content="选择一个默认模型来翻译提示词" content="用来辅助用户生成提示词翻译的AI模型默认使用 gpt-4o-mini"
raw-content raw-content
placement="right" placement="right"
> >
@@ -183,9 +183,9 @@
</div> </div>
</template> </template>
<el-select <el-select
v-model.number="system['translate_model_id']" v-model.number="system['assistant_model_id']"
:filterable="true" :filterable="true"
placeholder="选择一个默认模型来翻译提示词" placeholder="选择一个系统辅助AI模型"
style="width: 100%" style="width: 100%"
> >
<el-option <el-option

View File

@@ -0,0 +1,526 @@
<template>
<div class="app-container">
<!-- 统计信息 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number">{{ stats.totalTasks }}</div>
<div class="stat-label">总任务数</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number !text-blue-500">{{ stats.pendingTasks }}</div>
<div class="stat-label">排队中</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number warning">{{ stats.processingTasks }}</div>
<div class="stat-label">处理中</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number success">{{ stats.completedTasks }}</div>
<div class="stat-label">已完成</div>
</div>
</el-card>
</el-col>
<el-col :span="4">
<el-card class="stat-card">
<div class="stat-item">
<div class="stat-number danger">{{ stats.failedTasks }}</div>
<div class="stat-label">失败</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 搜索筛选 -->
<el-card class="filter-card" shadow="never">
<el-form :model="queryForm" ref="queryFormRef" :inline="true" label-width="80px">
<el-form-item label="用户ID">
<el-input
v-model="queryForm.user_id"
placeholder="请输入用户ID"
clearable
style="width: 150px"
/>
</el-form-item>
<el-form-item label="任务类型">
<el-select
v-model="queryForm.type"
placeholder="请选择任务类型"
clearable
style="width: 150px"
@change="handleQuery"
>
<el-option label="文生图" value="text_to_image" />
<el-option label="图生图" value="image_to_image" />
<el-option label="图像编辑" value="image_edit" />
<el-option label="图像特效" value="image_effects" />
<el-option label="文生视频" value="text_to_video" />
<el-option label="图生视频" value="image_to_video" />
</el-select>
</el-form-item>
<el-form-item label="任务状态">
<el-select
v-model="queryForm.status"
placeholder="请选择状态"
clearable
style="width: 120px"
@change="handleQuery"
>
<el-option label="等待中" value="in_queue" />
<el-option label="处理中" value="generating" />
<el-option label="已完成" value="success" />
<el-option label="失败" value="failed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery" :loading="loading">
<i class="iconfont icon-search mr-1" />
搜索
</el-button>
<el-button type="danger" @click="handleBatchDelete" :disabled="!multipleSelection.length">
<i class="iconfont icon-remove mr-1" />
批量删除
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 任务列表 -->
<el-card class="table-card">
<el-table
:data="taskList"
v-loading="loading"
@selection-change="handleSelectionChange"
stripe
border
>
<el-table-column type="selection" width="55" />
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="80" />
<el-table-column prop="type" label="任务类型" width="120">
<template #default="scope">
<el-tag size="small">{{ getTaskTypeName(scope.row.type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="prompt" label="提示词" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusColor(scope.row.status)" size="small">
{{ getStatusName(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="progress" label="进度" width="100">
<template #default="scope">
<el-progress :percentage="scope.row.progress" :stroke-width="4" />
</template>
</el-table-column>
<el-table-column prop="power" label="算力" width="80" />
<el-table-column prop="created_at" label="创建时间" width="180">
<template #default="scope">
{{ formatDateTime(scope.row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="scope">
<el-button type="primary" size="small" text @click="handleViewDetail(scope.row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<!-- 任务详情对话框 -->
<el-dialog
v-model="detailDialog.visible"
:title="`任务详情 - ${detailDialog.data.id}`"
width="800px"
:close-on-click-modal="false"
>
<div class="detail-content" v-if="detailDialog.data">
<el-descriptions :column="2" border>
<el-descriptions-item label="任务ID">{{ detailDialog.data.id }}</el-descriptions-item>
<el-descriptions-item label="用户ID">{{
detailDialog.data.user_id
}}</el-descriptions-item>
<el-descriptions-item label="任务类型">{{
getTaskTypeName(detailDialog.data.type)
}}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getStatusColor(detailDialog.data.status)">
{{ getStatusName(detailDialog.data.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进度"
>{{ detailDialog.data.progress }}%</el-descriptions-item
>
<el-descriptions-item label="算力消耗">{{
detailDialog.data.power
}}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{
formatDateTime(detailDialog.data.created_at)
}}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{
formatDateTime(detailDialog.data.updated_at)
}}</el-descriptions-item>
</el-descriptions>
<div class="detail-section">
<h4 class="text-base pt-2 font-bold">提示词</h4>
<div class="prompt-content">{{ detailDialog.data.prompt || '无' }}</div>
</div>
<div class="detail-section" v-if="detailDialog.data.task_params">
<h4>任务参数</h4>
<el-input
v-model="detailDialog.data.task_params"
type="textarea"
:rows="5"
readonly
class="params-content"
/>
</div>
<div class="detail-section" v-if="detailDialog.data.err_msg">
<h4>错误信息</h4>
<el-alert :title="detailDialog.data.err_msg" type="error" :closable="false" />
</div>
<div class="detail-section" v-if="detailDialog.data.img_url || detailDialog.data.video_url">
<h4>生成结果</h4>
<div class="result-content">
<div v-if="detailDialog.data.img_url" class="result-item">
<label>图片</label>
<el-image
:src="detailDialog.data.img_url"
:preview-src-list="[detailDialog.data.img_url]"
fit="cover"
style="width: 100px; height: 100px; border-radius: 4px"
/>
</div>
<div v-if="detailDialog.data.video_url" class="result-item">
<label>视频</label>
<video
:src="detailDialog.data.video_url"
controls
style="width: 200px; height: 150px; border-radius: 4px"
/>
</div>
</div>
</div>
<div class="detail-section" v-if="detailDialog.data.raw_data">
<h4>原始响应数据</h4>
<el-input
v-model="formattedRawData"
type="textarea"
:rows="10"
readonly
class="raw-data-content"
/>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { httpGet, httpPost } from '@/utils/http'
import { formatDateTime } from '@/utils/libs'
import { ElMessage, ElMessageBox } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
// 查询表单
const queryForm = reactive({
user_id: '',
type: '',
status: '',
})
// 分页信息
const pagination = reactive({
page: 1,
size: 20,
total: 0,
})
// 数据
const taskList = ref([])
const loading = ref(false)
const multipleSelection = ref([])
const queryFormRef = ref(null)
// 统计信息
const stats = reactive({
totalTasks: 0,
completedTasks: 0,
processingTasks: 0,
failedTasks: 0,
})
// 详情对话框
const detailDialog = reactive({
visible: false,
data: {},
})
// 格式化原始数据
const formattedRawData = computed(() => {
if (!detailDialog.data.raw_data) return ''
try {
return JSON.stringify(JSON.parse(detailDialog.data.raw_data), null, 2)
} catch (error) {
return detailDialog.data.raw_data
}
})
// 获取任务类型名称
const getTaskTypeName = (type) => {
const typeMap = {
text_to_image: '文生图',
image_to_image: '图生图',
image_edit: '图像编辑',
image_effects: '图像特效',
text_to_video: '文生视频',
image_to_video: '图生视频',
}
return typeMap[type] || type
}
// 获取状态名称
const getStatusName = (status) => {
const statusMap = {
in_queue: '等待中',
generating: '处理中',
success: '已完成',
failed: '失败',
}
return statusMap[status] || status
}
// 获取状态颜色
const getStatusColor = (status) => {
const colorMap = {
in_queue: '',
generating: 'warning',
success: 'success',
failed: 'danger',
}
return colorMap[status] || ''
}
// 获取任务列表
const getTaskList = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
page_size: pagination.size,
...queryForm,
}
const response = await httpGet('/api/admin/jimeng/jobs', params)
taskList.value = response.data.jobs || []
pagination.total = response.data.total || 0
} catch (error) {
ElMessage.error('获取任务列表失败')
} finally {
loading.value = false
}
}
// 获取统计信息
const getStats = async () => {
try {
const response = await httpGet('/api/admin/jimeng/stats')
Object.assign(stats, response.data)
} catch (error) {
console.error('获取统计信息失败:', error)
}
}
// 查询
const handleQuery = () => {
pagination.page = 1
getTaskList()
}
// 选择变化
const handleSelectionChange = (selection) => {
multipleSelection.value = selection
}
// 查看详情
const handleViewDetail = async (row) => {
try {
const response = await httpGet(`/api/admin/jimeng/jobs/${row.id}`)
detailDialog.data = response.data
detailDialog.visible = true
} catch (error) {
ElMessage.error('获取任务详情失败')
}
}
// 批量删除
const handleBatchDelete = async () => {
if (!multipleSelection.value.length) {
ElMessage.warning('请选择要删除的任务')
return
}
try {
await ElMessageBox.confirm(
`确定要删除选中的 ${multipleSelection.value.length} 个任务吗?`,
'提示',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}
)
const jobIds = multipleSelection.value.map((item) => item.id)
await httpPost('/api/admin/jimeng/jobs/remove', { job_ids: jobIds })
ElMessage.success('批量删除成功')
getTaskList()
getStats()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('批量删除失败')
}
}
}
// 分页大小变化
const handleSizeChange = (size) => {
pagination.size = size
pagination.page = 1
getTaskList()
}
// 当前页变化
const handleCurrentChange = (page) => {
pagination.page = page
getTaskList()
}
// 初始化
onMounted(() => {
getTaskList()
getStats()
})
</script>
<style lang="stylus">
.app-container
padding 20px
.el-form-item
margin-bottom 0
.page-header
margin-bottom 20px
h2
margin 0 0 8px 0
color #303133
p
margin 0
color #606266
font-size 14px
.filter-card
margin-bottom 20px
.stats-row
margin-bottom 20px
.stat-card
.stat-item
text-align center
padding 20px
.stat-number
font-size 28px
font-weight bold
color #303133
margin-bottom 8px
&.success
color #67c23a
&.warning
color #e6a23c
&.danger
color #f56c6c
.stat-label
font-size 14px
color #909399
.table-card
.pagination-container
margin-top 20px
display flex
justify-content center
.detail-content
.detail-section
margin-bottom 20px
h4
margin 0 0 10px 0
color #303133
font-size 16px
.prompt-content
background #f5f7fa
padding 12px
border-radius 4px
color #606266
line-height 1.6
.params-content, .raw-data-content
font-family monospace
.result-content
.result-item
margin-bottom 10px
display flex
align-items center
gap 10px
label
font-weight bold
color #303133
min-width 50px
</style>

View File

@@ -0,0 +1,281 @@
<template>
<div class="system-config form" v-loading="loading">
<div class="container">
<el-form
:model="jimengConfig"
label-width="150px"
label-position="right"
ref="configFormRef"
:rules="rules"
class="py-3 px-5"
>
<!-- 秘钥配置分组 -->
<div class="mb-3">
<h3 class="mb-2">秘钥配置</h3>
<el-form-item label="AccessKey" prop="access_key">
<el-input
v-model="jimengConfig.access_key"
placeholder="请输入即梦AI的AccessKey"
show-password
/>
</el-form-item>
<el-form-item label="SecretKey" prop="secret_key">
<el-input
v-model="jimengConfig.secret_key"
placeholder="请输入即梦AI的SecretKey"
show-password
/>
</el-form-item>
</div>
<el-divider />
<!-- 算力配置分组 -->
<div class="mb-3">
<h3 class="mb-3">算力配置</h3>
<el-form-item>
<template #label>
<div class="label-title">
文生图算力
<el-tooltip
effect="dark"
content="用户使用文生图功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.text_to_image"
:min="1"
placeholder="请输入文生图算力消耗"
/>
</el-form-item>
<el-form-item>
<template #label>
<div class="label-title">
图生图算力
<el-tooltip
effect="dark"
content="用户使用图生图功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.image_to_image"
:min="1"
placeholder="请输入图生图算力消耗"
/>
</el-form-item>
<el-form-item>
<template #label>
<div class="label-title">
图片编辑算力
<el-tooltip
effect="dark"
content="用户使用图片编辑功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.image_edit"
:min="1"
placeholder="请输入图片编辑算力消耗"
/>
</el-form-item>
<el-form-item>
<template #label>
<div class="label-title">
图片特效算力
<el-tooltip
effect="dark"
content="用户使用图片特效功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.image_effects"
:min="1"
placeholder="请输入图片特效算力消耗"
/>
</el-form-item>
<el-form-item>
<template #label>
<div class="label-title">
文生视频算力
<el-tooltip
effect="dark"
content="用户使用文生视频功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.text_to_video"
:min="1"
placeholder="请输入文生视频算力消耗"
/>
</el-form-item>
<el-form-item>
<template #label>
<div class="label-title">
图生视频算力
<el-tooltip
effect="dark"
content="用户使用图生视频功能时消耗的算力"
raw-content
placement="right"
>
<el-icon>
<InfoFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<el-input-number
v-model="jimengConfig.power.image_to_video"
:min="1"
placeholder="请输入图生视频算力消耗"
/>
</el-form-item>
</div>
<div style="padding: 10px">
<el-form-item>
<el-button type="primary" @click="saveConfig" :loading="saving">保存配置</el-button>
<el-button @click="resetConfig">重置</el-button>
</el-form-item>
</div>
</el-form>
</div>
</div>
</template>
<script setup>
import { httpGet, httpPost } from '@/utils/http'
import { InfoFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
const jimengConfig = ref({
access_key: '',
secret_key: '',
power: {
text_to_image: 10,
image_to_image: 15,
image_edit: 20,
image_effects: 25,
text_to_video: 30,
image_to_video: 35,
},
})
const loading = ref(true)
const saving = ref(false)
const testing = ref(false)
const configFormRef = ref()
// 表单验证规则
const rules = {
access_key: [{ required: true, message: '请输入AccessKey', trigger: 'blur' }],
secret_key: [{ required: true, message: '请输入SecretKey', trigger: 'blur' }],
}
onMounted(() => {
loadConfig()
})
// 加载配置
const loadConfig = async () => {
try {
const res = await httpGet('/api/admin/jimeng/config')
jimengConfig.value = res.data
} catch (e) {
ElMessage.error('加载配置失败: ' + e.message)
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
await configFormRef.value.validate()
saving.value = true
await httpPost('/api/admin/jimeng/config/update', jimengConfig.value)
ElMessage.success('配置保存成功!')
} catch (e) {
if (e.message) {
ElMessage.error(e.message)
}
} finally {
saving.value = false
}
}
// 重置配置
const resetConfig = () => {
jimengConfig.value = {
access_key: '',
secret_key: '',
power: {
text_to_image: 10,
image_to_image: 15,
image_edit: 20,
image_effects: 25,
text_to_video: 30,
image_to_video: 35,
},
}
ElMessage.info('配置已重置')
}
</script>
<style lang="stylus" scoped>
@import '../../../assets/css/admin/form.styl'
@import '../../../assets/css/main.styl'
.system-config {
display flex
justify-content center
.container {
width 100%
max-width 800px
}
.label-title {
display flex
align-items center
gap 5px
}
.el-input-number {
width 100%
}
}
</style>