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
- 功能优化:更改前端构建技术选型,使用 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"
)
// 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 {
Config *types.AppConfig
Engine *gin.Engine
@@ -61,13 +105,28 @@ func (s *AppServer) Init(debug bool, client *redis.Client) {
}
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
var sysConfig model.Config
err := db.Where("marker", "system").First(&sysConfig).Error
err := db.Where("name", "system").First(&sysConfig).Error
if err != nil {
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 {
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.UserLoginLog{},
&model.DallJob{},
&model.JimengJob{},
)
// 手动删除字段
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 {
return func(c *gin.Context) {
if !needLogin(c) {
c.Next()
return
}
clientProtocols := c.GetHeader("Sec-WebSocket-Protocol")
var tokenString string
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 needLogin(c) {
resp.NotAuth(c, "You should put Authorization in request headers")
c.Abort()
return
} else { // 直接放行
c.Next()
return
}
resp.NotAuth(c, "You should put Authorization in request headers")
c.Abort()
return
}
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"])
}
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))
c.Abort()
return
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok || !token.Valid && needLogin(c) {
if !ok || !token.Valid {
resp.NotAuth(c, "Token is invalid")
c.Abort()
return
}
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")
c.Abort()
return
@@ -280,57 +340,48 @@ func authorizeMiddleware(s *AppServer, client *redis.Client) gin.HandlerFunc {
if isAdminApi {
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")
c.Abort()
return
}
c.Set(types.LoginUserID, claims["user_id"])
c.Next()
}
}
func needLogin(c *gin.Context) bool {
if c.Request.URL.Path == "/api/user/login" ||
c.Request.URL.Path == "/api/user/logout" ||
c.Request.URL.Path == "/api/user/resetPass" ||
c.Request.URL.Path == "/api/admin/login" ||
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/") {
path := c.Request.URL.Path
// 如果不是 API 路径,不需要登录
if !strings.HasPrefix(path, "/api") {
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
}
// 跳过授权
func (s *AppServer) SkipAuth(url string, prefix bool) {
if prefix {
authConfig.PrefixPaths[url] = false
} else {
authConfig.ExactPaths[url] = false
}
}
// 统一参数处理
func parameterHandlerMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {

View File

@@ -43,9 +43,10 @@ type SmtpConfig struct {
}
type ApiConfig struct {
ApiURL string
AppId string
Token string
ApiURL string
AppId string
Token string
JimengConfig JimengConfig // 即梦AI配置
}
type AlipayConfig struct {
@@ -170,7 +171,7 @@ type SystemConfig struct {
EnabledVerify bool `json:"enabled_verify"` // 是否启用验证码
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
}

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/qiniu/go-sdk/v7 v7.17.1
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
gopkg.in/natefinch/lumberjack.v2 v2.2.1
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/xlog 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/tklauser/go-sysconf v0.3.13 // 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/jinzhu/inflection v1.0.0 // 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/cpuid/v2 v2.2.5 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
@@ -120,7 +121,7 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/atomic v1.9.0 // indirect
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/sys v0.20.0 // indirect
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/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
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/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
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/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
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/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-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
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/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=
@@ -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/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/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.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
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/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/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.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/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.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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
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.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.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
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/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
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.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go-sdk/v7 v7.17.1 h1:UoQv7fBKtzAiD1qZPIvTy62Se48YLKxcCYP9nAwWMa0=
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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.7.0/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/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
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/go.mod h1:bUFhz/5Irp9zkdYk5MxhQcDDT6LlZrI8+rv5mHtQ1mo=
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/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
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.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.7.0 h1:zaiO/rmgFjbmCXdSYJWQcdvOCsthmdaHfr3Gm2Kx4Ec=
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/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
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.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
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/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
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/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.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/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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
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-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-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.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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/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-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.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.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw=
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-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/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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-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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
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/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
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-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
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=

View File

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

View File

@@ -194,7 +194,6 @@ func (h *ImageHandler) Remove(c *gin.Context) {
remark = fmt.Sprintf("任务失败退回算力。任务ID%dErr: %s", job.Id, job.ErrMsg)
progress = job.Progress
imgURL = job.ImgURL
break
case "sd":
var job model.SdJob
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)
progress = job.Progress
imgURL = job.ImgURL
break
case "dall":
var job model.DallJob
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)
progress = job.Progress
imgURL = job.ImgURL
break
default:
resp.ERROR(c, types.InvalidArgs)
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)
progress = job.Progress
fileURL = job.AudioURL
break
case "luma":
case "keling":
var job model.VideoJob
@@ -174,7 +173,6 @@ func (h *MediaHandler) Remove(c *gin.Context) {
if fileURL == "" {
fileURL = job.WaterURL
}
break
default:
resp.ERROR(c, types.InvalidArgs)
return

View File

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

View File

@@ -20,6 +20,7 @@ import (
// List 获取会话列表
func (h *ChatHandler) List(c *gin.Context) {
logger.Info(h.GetLoginUserId(c))
if !h.IsLogin(c) {
resp.SUCCESS(c)
return
@@ -28,7 +29,7 @@ func (h *ChatHandler) List(c *gin.Context) {
userId := h.GetLoginUserId(c)
var items = make([]vo.ChatItem, 0)
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 {
resp.SUCCESS(c, items)
return

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ func (h *PromptHandler) Lyric(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs)
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 {
resp.ERROR(c, err.Error())
return
@@ -79,7 +79,7 @@ func (h *PromptHandler) Image(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs)
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 {
resp.ERROR(c, err.Error())
return
@@ -108,7 +108,7 @@ func (h *PromptHandler) Video(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs)
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 {
resp.ERROR(c, err.Error())
return
@@ -158,9 +158,9 @@ func (h *PromptHandler) MetaPrompt(c *gin.Context) {
}
func (h *PromptHandler) getPromptModel() string {
if h.App.SysConfig.TranslateModelId > 0 {
if h.App.SysConfig.AssistantModelId > 0 {
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 "gpt-4o"

View File

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

View File

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

View File

@@ -17,6 +17,7 @@ import (
logger2 "geekai/logger"
"geekai/service"
"geekai/service/dalle"
"geekai/service/jimeng"
"geekai/service/mj"
"geekai/service/oss"
"geekai/service/payment"
@@ -140,6 +141,7 @@ func main() {
fx.Provide(handler.NewProductHandler),
fx.Provide(handler.NewConfigHandler),
fx.Provide(handler.NewPowerLogHandler),
fx.Provide(handler.NewJimengHandler),
fx.Provide(admin.NewConfigHandler),
fx.Provide(admin.NewAdminHandler),
@@ -153,6 +155,7 @@ func main() {
fx.Provide(admin.NewOrderHandler),
fx.Provide(admin.NewChatHandler),
fx.Provide(admin.NewPowerLogHandler),
fx.Provide(admin.NewAdminJimengHandler),
// 创建服务
fx.Provide(sms.NewSendServiceManager),
@@ -208,6 +211,12 @@ func main() {
s.SyncTaskProgress()
s.DownloadFiles()
}),
// 即梦AI 服务
fx.Provide(jimeng.NewService),
fx.Invoke(func(service *jimeng.Service) {
service.Start()
}),
fx.Provide(service.NewUserService),
fx.Provide(payment.NewAlipayService),
fx.Provide(payment.NewHuPiPay),
@@ -501,6 +510,14 @@ func main() {
group.GET("remove", h.Remove)
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.Invoke(func(s *core.AppServer, h *admin.ChatAppTypeHandler) {
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
func (s *Service) PushTask(task types.DallTask) {
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() {
@@ -291,7 +293,7 @@ func (s *Service) DownloadImages() {
func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) {
// sava image
imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, false)
imgURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(orgURL, ".png", false)
if err != nil {
return "", err
}

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") {
proxy = true
}
imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, proxy)
imgURL, err := s.uploaderManager.GetUploadHandler().PutUrlFile(v.OrgURL, ".png", proxy)
if err != nil {
logger.Errorf("error with download image %s, %v", v.OrgURL, err)
@@ -212,7 +212,9 @@ func (s *Service) DownloadImages() {
// PushTask push a new mj task in to task queue
func (s *Service) PushTask(task types.MjTask) {
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 异步拉取任务

View File

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

View File

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

View File

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

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 err error
if useProxy {
@@ -108,8 +108,10 @@ func (s QinNiuOss) PutUrlFile(fileURL string, useProxy bool) (string, error) {
if err != nil {
return "", fmt.Errorf("error with parse image URL: %v", err)
}
fileExt := utils.GetImgExt(parse.Path)
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), fileExt)
if ext == "" {
ext = filepath.Ext(parse.Path)
}
key := fmt.Sprintf("%s/%d%s", s.config.SubDir, time.Now().UnixMicro(), ext)
ret := storage.PutRet{}
extra := storage.PutExtra{}
// 上传文件字节数据

View File

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

View File

@@ -253,7 +253,9 @@ func (s *Service) checkTaskProgress(apiKey model.ApiKey) (*TaskProgressResp, err
func (s *Service) PushTask(task types.SdTask) {
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 检查任务状态,自动删除过期或者失败的任务

View File

@@ -51,7 +51,9 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
func (s *Service) PushTask(task types.SunoTask) {
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() {
@@ -270,14 +272,14 @@ func (s *Service) DownloadFiles() {
for _, v := range items {
// 下载图片和音频
logger.Infof("try download cover image: %s", v.CoverURL)
coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, true)
coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, ".png", true)
if err != nil {
logger.Errorf("download image with error: %v", err)
continue
}
logger.Infof("try download audio: %s", v.AudioURL)
audioURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.AudioURL, true)
audioURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.AudioURL, ".mp3", true)
if err != nil {
logger.Errorf("download audio with error: %v", err)
continue

View File

@@ -51,7 +51,9 @@ func NewService(db *gorm.DB, manager *oss.UploaderManager, redisCli *redis.Clien
func (s *Service) PushTask(task types.VideoTask) {
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() {
@@ -162,7 +164,7 @@ func (s *Service) DownloadFiles() {
}
logger.Infof("try download video: %s", v.WaterURL)
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, true)
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.WaterURL, ".mp4", true)
if err != nil {
logger.Errorf("download video with error: %v", err)
continue
@@ -172,7 +174,7 @@ func (s *Service) DownloadFiles() {
if v.VideoURL != "" {
logger.Infof("try download no water video: %s", v.VideoURL)
videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, true)
videoURL, err = s.uploadManager.GetUploadHandler().PutUrlFile(v.VideoURL, ".mp4", true)
if err != nil {
logger.Errorf("download video with error: %v", err)
continue

View File

@@ -1,9 +1,9 @@
package model
type Config struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Key string `gorm:"column:marker;type:varchar(20);uniqueIndex;not null;comment:标识" json:"marker"`
Config string `gorm:"column:config_json;type:text;not null" json:"config_json"`
Id uint `gorm:"column:id;primaryKey;autoIncrement"`
Name string `gorm:"column:name;type:varchar(20);uniqueIndex;not null;comment:配置名称"`
Value string `gorm:"column:value;type:text;not null"`
}
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 (
"context"
"geekai/utils"
"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()}
}
func (q *RedisQueue) RPush(value interface{}) {
q.client.RPush(q.ctx, q.name, utils.JsonEncode(value))
func (q *RedisQueue) RPush(value any) error {
return q.client.RPush(q.ctx, q.name, utils.JsonEncode(value)).Err()
}
func (q *RedisQueue) LPush(value interface{}) {
q.client.LPush(q.ctx, q.name, utils.JsonEncode(value))
func (q *RedisQueue) LPush(value any) error {
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()
if err != nil {
return err
@@ -39,10 +40,18 @@ func (q *RedisQueue) LPop(value interface{}) error {
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()
if err != nil {
return err
}
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"
"encoding/json"
"fmt"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"github.com/nfnt/resize"
"github.com/skip2/go-qrcode"
"image"
"image/color"
"image/draw"
@@ -22,11 +19,22 @@ import (
"reflect"
"strconv"
"strings"
"github.com/lionsoul2014/ip2region/binding/golang/xdb"
"github.com/nfnt/resize"
"github.com/skip2/go-qrcode"
)
// CopyObject 拷贝对象
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)
srcValue := reflect.ValueOf(src)
dstValue := reflect.ValueOf(dst).Elem()

View File

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

View File

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

View File

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

View File

@@ -91,7 +91,7 @@ html, body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
--primary-color: #21aa93
// --primary-color: #21aa93
h1 { font-size: 2em; } /* 通常是 2em */
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 {
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 {
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 {
display flex !important
@@ -218,21 +204,3 @@ body {
.align-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 {
// background-color: #282c34;
height 100%
.title {
@@ -13,36 +12,79 @@
.inner {
color var(--text-theme-color)
padding 15px 0 15px 15px;
padding 15px 0 15px 15px
overflow-x hidden
overflow-y visible
display flex
flex-flow row
.user-profile {
padding 10px 20px 20px 20px
width 300px
background-color var(--chat-bg)
color var(--text-theme-color)
border-radius 10px
//height 100vh
.el-form-item__label {
color var(--text-theme-color)
justify-content start
.profile-card {
max-width 300px
border-radius 18px
box-shadow 0 4px 8px rgba(0,0,0,0.08)
padding 24px 16px
background var(--panel-bg)
position relative
z-index 1
margin-bottom 24px
}
.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
}
.user-opt {
.el-col {
padding 10px
.el-button {
width 100%
}
}
&:hover {
box-shadow 0 2px 12px #2193b0aa
transform translateY(-2px) scale(1.03)
background linear-gradient(90deg, #2193b0 0%, #6dd5ed 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 {
padding 0 20px

View File

@@ -96,4 +96,7 @@
// el-dialog
--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-color: #5b62ce; //
--normal-color: rgba(43, 54, 116, 1); //
--theme-textcolor-normal:#5b62ce;;
p, h1, h2, h3, h4, h5, h6, article {
font-family: $font-regular;
}
@@ -56,6 +57,8 @@
//
--quote-bg-color: #e0dfff;
--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-family: "iconfont"; /* Project id 4125778 */
src: url('iconfont.woff2?t=1740279975534') format('woff2'),
url('iconfont.woff?t=1740279975534') format('woff'),
url('iconfont.ttf?t=1740279975534') format('truetype');
src: url('iconfont.woff2?t=1752831319382') format('woff2'),
url('iconfont.woff?t=1752831319382') format('woff'),
url('iconfont.ttf?t=1752831319382') format('truetype');
}
.iconfont {
@@ -13,6 +13,150 @@
-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 {
content: "\eab7";
}
@@ -289,7 +433,7 @@
content: "\e6c4";
}
.icon-mp1:before {
.icon-mp4:before {
content: "\e647";
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,258 @@
"css_prefix_text": "icon-",
"description": "",
"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",
"name": "可灵大模型",
@@ -491,7 +743,7 @@
{
"icon_id": "12600802",
"name": "mp4",
"font_class": "mp1",
"font_class": "mp4",
"unicode": "e647",
"unicode_decimal": 58951
},

Binary file not shown.

View File

@@ -124,7 +124,7 @@ import hl from 'highlight.js'
import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { ref } from 'vue'
import { nextTick, onMounted, reactive, ref, watchEffect } from 'vue'
import Thinking from './Thinking.vue'
// eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({
@@ -155,6 +155,9 @@ const isPlaying = ref(false)
const playIcon = ref('/images/voice.gif')
const store = useSharedStore()
// 添加代码块展开/收起状态管理
const codeBlockStates = reactive({})
const md = new MarkdownIt({
breaks: true,
html: true,
@@ -162,24 +165,29 @@ const md = new MarkdownIt({
typographer: true,
highlight: function (str, lang) {
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>
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
// 显示复制代码按钮和展开/收起按钮
const copyBtn = `<div class="flex">
<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,
'&lt;/textarea>'
)}</textarea>`
let langHtml = ''
let preCode = ''
// 处理代码高亮
if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮
const preCode = hl.highlight(str, { language: lang }).value
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
langHtml = `<span class="lang-name">${lang}</span>`
preCode = hl.highlight(str, { language: lang }).value
} else {
preCode = md.utils.escapeHtml(str)
}
// 处理代码高亮
const preCode = md.utils.escapeHtml(str)
// 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>`
// 将代码包裹在 pre 中,添加收起状态的类
return `<pre class="code-container flex flex-col code-collapsed" data-code-id="${codeIndex}">
<div class="flex justify-between bg-[#50505a] w-full rounded-tl-[10px] rounded-tr-[10px] px-3 py-1">${langHtml}${copyBtn}</div>
<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)
@@ -226,6 +234,81 @@ const stopSynthesis = () => {
const reGenerate = (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>
<style lang="stylus">
@@ -266,8 +349,9 @@ const reGenerate = (messageId) => {
}
.code-container {
background-color #2b2b2b
border-radius 10px
position relative
display flex
.hljs {
border-radius 10px
@@ -275,9 +359,6 @@ const reGenerate = (messageId) => {
}
.copy-code-btn {
position: absolute;
right 10px
top 10px
cursor pointer
font-size 12px
color #c1c1c1
@@ -289,16 +370,50 @@ const reGenerate = (messageId) => {
}
.lang-name {
position absolute;
right 10px
bottom 20px
padding 2px 6px 4px 6px
background-color #444444
border-radius 10px
color #00e0e0
// 添加代码块展开/收起样式
.code-collapsed {
.hljs {
max-height 200px
overflow hidden
position relative
transition max-height 0.3s ease
&::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',

View File

@@ -104,16 +104,16 @@ const routes = [
component: () => import('@/views/Song.vue'),
},
{
name: 'luma',
path: '/luma',
meta: { title: 'Luma视频创作' },
component: () => import('@/views/Luma.vue'),
name: 'video',
path: '/video',
meta: { title: '视频创作中心' },
component: () => import('@/views/Video.vue'),
},
{
name: 'keling',
path: '/keling',
meta: { title: 'KeLing视频创作' },
component: () => import('@/views/KeLing.vue'),
name: 'jimeng',
path: '/jimeng',
meta: { title: '即梦AI' },
component: () => import('@/views/Jimeng.vue'),
},
],
},
@@ -258,6 +258,18 @@ const routes = [
meta: { title: '音视频管理' },
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',
name: 'admin-power-log',
@@ -358,4 +370,4 @@ router.beforeEach((to, from, 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()
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}算力` : '免费' }}
</el-tag>
</div>
<div class="model-description" :title="model.description || '暂无描述'">
{{ model.description || '暂无描述' }}
<div class="model-description" :title="model.desc || '暂无描述'">
{{ model.desc || '暂无描述' }}
</div>
<!-- 暂时屏蔽此信息展示或许用户不想展示此信息 -->
<div class="model-metadata">
@@ -299,7 +299,7 @@
v-model="prompt"
@keydown="onInput"
@input="onInput"
placeholder="按 Enter 键发送消息,使用 Ctrl + Enter 换行"
placeholder="按 Enter 键发送消息,使用 Shift + Enter 换行"
autofocus
>
</textarea>
@@ -488,7 +488,7 @@ const filteredModels = computed(() => {
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
@@ -514,8 +514,8 @@ const toggleFreeModels = () => {
const updateModelCategories = () => {
const categories = new Set()
models.value.forEach((model) => {
if (model.category) {
categories.add(model.category)
if (model.tag) {
categories.add(model.tag)
}
})
modelCategories.value = Array.from(categories)
@@ -539,7 +539,7 @@ const updateGroupedModels = () => {
// 否则按分类分组展示
const groups = {}
filtered.forEach((model) => {
const category = model.category || '未分类'
const category = model.tag || '未分类'
if (!groups[category]) {
groups[category] = []
}
@@ -1092,9 +1092,8 @@ const onInput = (e) => {
// 输入回车自动提交
if (e.keyCode === 13) {
if (e.ctrlKey) {
// Ctrl + Enter 换行
prompt.value += '\n'
// Shift + Enter 换行
if (e.shiftKey) {
return
}
e.preventDefault()

View File

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

View File

@@ -688,87 +688,26 @@
v-if="item.progress === 100"
>
<div class="opt" v-if="item['can_opt']">
<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="upscale(1, 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"
<el-row :gutter="8" class="mb-3">
<el-col :span="6" v-for="i in 4" :key="'u' + i">
<button
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)"
>
<template #reference>
<i class="iconfont icon-prompt text-white text-xl"></i>
</template>
<template #default>
<div class="mj-list-item-prompt">
<span>{{ item.prompt }}</span>
<el-icon
class="copy-prompt-mj"
:data-clipboard-text="item.prompt"
>
<DocumentCopy />
</el-icon>
</div>
</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>
U{{ i }}
</button>
</el-col>
</el-row>
<el-row :gutter="8" class="mb-3">
<el-col :span="6" v-for="i in 4" :key="'v' + i">
<button
class="w-full h-6 rounded bg-gray-500 text-xs text-white shadow-md transition-all duration-300 hover:bg-gray-600"
@click="variation(i, item)"
>
V{{ i }}
</button>
</el-col>
</el-row>
</div>
<div
@@ -797,13 +736,28 @@
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button
type="danger"
:icon="Delete"
@click="removeImage(item)"
circle
/>
<el-button type="danger" @click="removeImage(item)" circle>
<i class="iconfont icon-remove"></i>
</el-button>
</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>
@@ -866,7 +820,7 @@ import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
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 Compressor from 'compressorjs'
import { ElMessage, ElMessageBox, ElNotification } from 'element-plus'

View File

@@ -69,7 +69,7 @@
class="nav-item-box"
@click="router.push(item.url)"
>
<i :class="'iconfont ' + iconMap[item.url]"></i>
<i :class="'iconfont ' + item.icon"></i>
<div>{{ item.name }}</div>
</div>
</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 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 initAnimation = ref('')
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"
>
<div class="inner">
<div class="user-profile">
<user-profile :key="profileKey" />
<el-row class="user-opt" :gutter="20">
<el-col :span="12">
<el-button type="primary" @click="showBindEmailDialog = true">绑定邮箱</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-card class="profile-card">
<el-row class="user-opt" :gutter="16">
<el-col :span="24">
<el-button class="profile-btn email" @click="showBindEmailDialog = true">
<i class="iconfont icon-email"></i> 绑定邮箱
</el-button>
</el-col>
<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-row>
</div>
</el-card>
<div class="profile-bg"></div>
<div class="product-box">
<div class="info" v-if="orderPayInfoText !== ''">
@@ -158,7 +168,6 @@ import PasswordDialog from '@/components/PasswordDialog.vue'
import RedeemVerify from '@/components/RedeemVerify.vue'
import ThirdLogin from '@/components/ThirdLogin.vue'
import UserOrder from '@/components/UserOrder.vue'
import UserProfile from '@/components/UserProfile.vue'
import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
@@ -185,7 +194,6 @@ const orderPayInfoText = ref('')
const payWays = ref([])
const vipInfoText = ref('')
const store = useSharedStore()
const profileKey = ref(0)
const userOrderKey = ref(0)
const showDialog = ref(false)
const qrImg = ref('')
@@ -276,17 +284,13 @@ const pay = (product, payWay) => {
})
}
const redeemCallback = (success) => {
const redeemCallback = () => {
showRedeemVerifyDialog.value = false
if (success) {
profileKey.value += 1
}
}
const payCallback = (success) => {
showDialog.value = false
if (success) {
profileKey.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>
<template #label>
<div class="label-title">
开放注册
菜单图标
<el-tooltip
effect="dark"
content="可以填写 iconfont 图标名称也可以自己上传图片"

View File

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