mirror of
https://github.com/yangjian102621/geekai.git
synced 2026-09-16 10:17:13 +00:00
Merge branch 'codex/open-4.3.1'
This commit is contained in:
@@ -1,5 +1,16 @@
|
||||
# 更新日志
|
||||
|
||||
## v4.3.1
|
||||
|
||||
- 功能新增:支持 **Stripe Checkout** 支付,商品可配置美元价,会员页跳转托管收银台并完成回调 🔥🔥🔥
|
||||
- 功能新增:支持 **Seedance2.0** 文生视频,图生视频功能 🔥🔥🔥
|
||||
- 功能新增:公众号**自定义菜单**支持管理后台编辑草稿、发布到微信、以及从微信拉取当前菜单 🔥🔥🔥
|
||||
- 功能优化:统一 **AI 图像 / MidJourney / 视频 / 即梦 / Suno** 等任务的状态展示与列表、详情交互;作品墙改为悬停查看任务详情,瀑布流间距收紧、卡片与配图无圆角
|
||||
- 功能优化:视频生成(含 **Sora** 适配)、即梦相关接口与配置体验调整;管理端部分记录页与 Suno 相关展示同步
|
||||
- 功能重构:**Suno** 后端服务文件拆分为 `suno_service`;补充图片任务状态常量与迁移逻辑
|
||||
- 功能重构:移除用户表的 **`chat_roles_json`** 列及智能体工作台持久化,下线相关接口与前端添加/移除工作台交互
|
||||
- Bug修复:**SMTP** 启动与发信优先读取数据库配置,并校验主机与端口,避免误连 `:0` 等无效地址
|
||||
|
||||
## v4.3.0
|
||||
|
||||
- 功能新增:**PPT 生成功能**,复刻 NotebookLLM 的演示文稿生成功能,支持编辑和导出 🔥🔥🔥
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
1. 安装 docker 和 docker-compose 程序,这个自行解决。
|
||||
2. 直接在项目根目录运行启动命令:
|
||||
```shell
|
||||
cd docker
|
||||
docker compose up -d
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 功能截图
|
||||
|
||||
@@ -2,4 +2,10 @@
|
||||
|
||||
chatgpt-plus 后端 API Go 语言实现。技术选型采用 Gin + Mysql 架构,依赖注入使用的是 fx 框架,ORM 采用的是 GORM 框架。
|
||||
|
||||
## 支付说明
|
||||
|
||||
- 支付配置统一在 `system.payment` 中管理,包含 `alipay`、`wxpay`、`epay` 和 `stripe`。
|
||||
- Stripe 走 Checkout 一次性支付,不支持订阅。
|
||||
- 商品表新增 `stripe_price` 字段,作为 Stripe 专用美元价格;旧数据会在迁移时自动用 `price` 回填。
|
||||
- Stripe 支付回调以 webhook 为准,成功后回到会员页并由现有订单轮询刷新状态。
|
||||
|
||||
|
||||
+18
-17
@@ -102,21 +102,22 @@ type SystemConfig struct {
|
||||
|
||||
// 配置键名常量
|
||||
const (
|
||||
ConfigKeySystem = "system" // 系统配置
|
||||
ConfigKeyNotice = "notice" // 公告配置
|
||||
ConfigKeyAgreement = "agreement" // 用户协议配置
|
||||
ConfigKeyPrivacy = "privacy" // 隐私政策配置
|
||||
ConfigKeyMarkMap = "mark_map" // 水印配置
|
||||
ConfigKeyCaptcha = "captcha" // 验证码配置
|
||||
ConfigKeyWxLogin = "wx_login" // 微信扫码登录配置
|
||||
ConfigKeyWxGzh = "wx_gzh" // 微信公众号配置
|
||||
ConfigKeySms = "sms" // 短信配置
|
||||
ConfigKeySmtp = "smtp" // SMTP 配置
|
||||
ConfigKeyOss = "oss" // OSS 配置
|
||||
ConfigKeyPayment = "payment" // 支付配置
|
||||
ConfigKeyModeration = "moderation" // 文本审查配置
|
||||
ConfigKeyAI3D = "ai3d" // AI3D 配置
|
||||
ConfigKeyJimeng = "jimeng" // 即梦AI配置
|
||||
ConfigKeyVideo = "video" // 视频生成配置
|
||||
ConfigKeyPPT = "ppt" // PPT 生成配置
|
||||
ConfigKeySystem = "system" // 系统配置
|
||||
ConfigKeyNotice = "notice" // 公告配置
|
||||
ConfigKeyAgreement = "agreement" // 用户协议配置
|
||||
ConfigKeyPrivacy = "privacy" // 隐私政策配置
|
||||
ConfigKeyMarkMap = "mark_map" // 水印配置
|
||||
ConfigKeyCaptcha = "captcha" // 验证码配置
|
||||
ConfigKeyWxLogin = "wx_login" // 微信扫码登录配置
|
||||
ConfigKeyWxGzh = "wx_gzh" // 微信公众号配置
|
||||
ConfigKeyWxGzhMenu = "wx_gzh_menu" // 微信公众号自定义菜单草稿
|
||||
ConfigKeySms = "sms" // 短信配置
|
||||
ConfigKeySmtp = "smtp" // SMTP 配置
|
||||
ConfigKeyOss = "oss" // OSS 配置
|
||||
ConfigKeyPayment = "payment" // 支付配置
|
||||
ConfigKeyModeration = "moderation" // 文本审查配置
|
||||
ConfigKeyAI3D = "ai3d" // AI3D 配置
|
||||
ConfigKeyJimeng = "jimeng" // 即梦AI配置
|
||||
ConfigKeyVideo = "video" // 视频生成配置
|
||||
ConfigKeyPPT = "ppt" // PPT 生成配置
|
||||
)
|
||||
|
||||
@@ -40,3 +40,19 @@ type WxGzhConfig struct {
|
||||
EncodingAESKey string `json:"encoding_aes_key"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// WxGzhMenuConfig 公众号自定义菜单草稿(与微信公众平台 menu/create 的 button 结构一致)
|
||||
type WxGzhMenuConfig struct {
|
||||
Button []WxGzhMenuButton `json:"button"`
|
||||
}
|
||||
|
||||
// WxGzhMenuButton 单条菜单;含子菜单时仅填 Name 与 SubButton,不填 Type
|
||||
type WxGzhMenuButton struct {
|
||||
Type string `json:"type,omitempty"` // view | click 等
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key,omitempty"` // click
|
||||
URL string `json:"url,omitempty"` // view
|
||||
AppID string `json:"appid,omitempty"` // miniprogram
|
||||
PagePath string `json:"pagepath,omitempty"` // miniprogram
|
||||
SubButton []WxGzhMenuButton `json:"sub_button,omitempty"` // 子菜单
|
||||
}
|
||||
|
||||
@@ -42,8 +42,9 @@ type JimengTaskRequest struct {
|
||||
Action string `json:"action"` // 请求Action
|
||||
Power int `json:"power"` // 消耗算力
|
||||
// 公共参数
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ImageUrls []string `json:"image_urls,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
ImageUrls []string `json:"image_urls,omitempty"`
|
||||
Content []JMContentItem `json:"content,omitempty"` // Seedance 官方多模态输入,优先级高于兼容字段
|
||||
|
||||
// 图片生成参数
|
||||
Size string `json:"size,omitempty"`
|
||||
@@ -52,10 +53,14 @@ type JimengTaskRequest struct {
|
||||
ForceSingle bool `json:"force_single,omitempty"`
|
||||
|
||||
// 视频生成参数
|
||||
Duration int `json:"duration,omitempty"` // 视频时长,单位:秒
|
||||
TemplateId string `json:"template_id,omitempty"` // 运镜模板ID
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"`
|
||||
CameraStrength string `json:"camera_strength,omitempty"` // 运镜强度
|
||||
Duration int `json:"duration,omitempty"` // 视频时长,单位:秒
|
||||
TemplateId string `json:"template_id,omitempty"` // 运镜模板ID
|
||||
AspectRatio string `json:"aspect_ratio,omitempty"` // 视频比例
|
||||
Resolution string `json:"resolution,omitempty"` // 视频分辨率
|
||||
CameraStrength string `json:"camera_strength,omitempty"` // 运镜强度
|
||||
GenerateAudio *bool `json:"generate_audio,omitempty"` // Seedance 是否生成音频
|
||||
ReturnLastFrame bool `json:"return_last_frame,omitempty"`
|
||||
Watermark *bool `json:"watermark,omitempty"`
|
||||
|
||||
// 数字人视频生成参数
|
||||
AudioURL string `json:"audio_url,omitempty"` // 音频URL
|
||||
@@ -64,3 +69,18 @@ type JimengTaskRequest struct {
|
||||
// 视频动作迁移参数
|
||||
VideoURL string `json:"video_url,omitempty"` // 动作视频URL
|
||||
}
|
||||
|
||||
// JMAssetRef 多模态素材引用。
|
||||
type JMAssetRef struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// JMContentItem Seedance content[] 子项。
|
||||
type JMContentItem struct {
|
||||
Type string `json:"type"` // text/image_url/video_url/audio_url
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *JMAssetRef `json:"image_url,omitempty"`
|
||||
VideoURL *JMAssetRef `json:"video_url,omitempty"`
|
||||
AudioURL *JMAssetRef `json:"audio_url,omitempty"`
|
||||
Role string `json:"role,omitempty"` // first_frame/last_frame/reference_image/reference_video/reference_audio
|
||||
}
|
||||
|
||||
@@ -27,9 +27,11 @@ var PayChannel = map[string]string{
|
||||
"alipay": "支付宝商号",
|
||||
"wxpay": "微信商号",
|
||||
"epay": "易支付",
|
||||
"stripe": "Stripe 商号",
|
||||
}
|
||||
|
||||
var PayWays = map[string]string{
|
||||
"alipay": "支付宝",
|
||||
"wxpay": "微信支付",
|
||||
"stripe": "Stripe",
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ type PaymentConfig struct {
|
||||
Alipay AlipayConfig `json:"alipay,omitempty"` // 支付宝支付渠道配置
|
||||
Epay EpayConfig `json:"epay,omitempty"` // 易支付配置
|
||||
WxPay WxPayConfig `json:"wxpay,omitempty"` // 微信支付渠道配置
|
||||
Stripe StripeConfig `json:"stripe,omitempty"` // Stripe 支付配置
|
||||
}
|
||||
|
||||
// AlipayConfig 支付宝支付配置
|
||||
@@ -58,3 +59,19 @@ func (c *EpayConfig) Equal(other *EpayConfig) bool {
|
||||
c.ApiURL == other.ApiURL &&
|
||||
c.Domain == other.Domain
|
||||
}
|
||||
|
||||
// StripeConfig Stripe 支付配置
|
||||
type StripeConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"` // 是否启用该支付通道
|
||||
SecretKey string `json:"secret_key,omitempty"` // Stripe Secret Key
|
||||
WebhookKey string `json:"webhook_key,omitempty"` // Stripe Webhook Signing Secret
|
||||
Domain string `json:"domain,omitempty"` // 支付回跳域名
|
||||
Currency string `json:"currency,omitempty"` // 货币代码
|
||||
}
|
||||
|
||||
func (c *StripeConfig) Equal(other *StripeConfig) bool {
|
||||
return c.SecretKey == other.SecretKey &&
|
||||
c.WebhookKey == other.WebhookKey &&
|
||||
c.Domain == other.Domain &&
|
||||
c.Currency == other.Currency
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ require (
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 // indirect
|
||||
github.com/tiendc/go-deepcopy v1.7.1 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.13 // indirect
|
||||
github.com/tklauser/numcpus v0.7.0 // indirect
|
||||
|
||||
@@ -268,6 +268,8 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
|
||||
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
|
||||
github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE=
|
||||
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
@@ -350,6 +352,7 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
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-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
@@ -371,6 +374,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -9,6 +9,8 @@ package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"geekai/core"
|
||||
"geekai/core/middleware"
|
||||
"geekai/core/types"
|
||||
@@ -31,6 +33,7 @@ type ConfigHandler struct {
|
||||
alipayService *payment.AlipayService
|
||||
wxpayService *payment.WxPayService
|
||||
epayService *payment.EPayService
|
||||
stripeService *payment.StripeService
|
||||
smsManager *sms.SmsManager
|
||||
uploaderManager *oss.UploaderManager
|
||||
smtpService *service.SmtpService
|
||||
@@ -46,6 +49,7 @@ func NewConfigHandler(
|
||||
alipayService *payment.AlipayService,
|
||||
wxpayService *payment.WxPayService,
|
||||
epayService *payment.EPayService,
|
||||
stripeService *payment.StripeService,
|
||||
smsManager *sms.SmsManager,
|
||||
uploaderManager *oss.UploaderManager,
|
||||
smtpService *service.SmtpService,
|
||||
@@ -59,6 +63,7 @@ func NewConfigHandler(
|
||||
alipayService: alipayService,
|
||||
wxpayService: wxpayService,
|
||||
epayService: epayService,
|
||||
stripeService: stripeService,
|
||||
smsManager: smsManager,
|
||||
uploaderManager: uploaderManager,
|
||||
smtpService: smtpService,
|
||||
@@ -89,6 +94,9 @@ func (h *ConfigHandler) RegisterRoutes() {
|
||||
rg.POST("update/smtp", h.UpdateStmp)
|
||||
rg.GET("get", h.Get)
|
||||
rg.POST("update/wx_gzh", h.UpdateWxGzh)
|
||||
rg.POST("update/wx_gzh_menu", h.UpdateWxGzhMenu)
|
||||
rg.POST("wx_gzh/menu/publish", h.PublishWxGzhMenu)
|
||||
rg.GET("wx_gzh/menu/query", h.QueryWxGzhMenu)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +290,9 @@ func (h *ConfigHandler) UpdatePayment(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if data.Stripe.Enabled {
|
||||
h.stripeService.UpdateConfig(&data.Stripe)
|
||||
}
|
||||
|
||||
h.sysConfig.Payment = data
|
||||
resp.SUCCESS(c, data)
|
||||
@@ -335,6 +346,10 @@ func (h *ConfigHandler) UpdateStmp(c *gin.Context) {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if data.Host == "" || data.Port <= 0 {
|
||||
resp.ERROR(c, "邮件服务器地址和端口不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
err := h.Update(types.ConfigKeySmtp, data)
|
||||
if err != nil {
|
||||
@@ -422,3 +437,103 @@ func (h *ConfigHandler) UpdateWxGzh(c *gin.Context) {
|
||||
h.sysConfig.WxGzh = data
|
||||
resp.SUCCESS(c, data)
|
||||
}
|
||||
|
||||
// UpdateWxGzhMenu 保存公众号自定义菜单草稿(不调用微信接口)。
|
||||
func (h *ConfigHandler) UpdateWxGzhMenu(c *gin.Context) {
|
||||
var data types.WxGzhMenuConfig
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if data.Button == nil {
|
||||
data.Button = []types.WxGzhMenuButton{}
|
||||
}
|
||||
if err := h.Update(types.ConfigKeyWxGzhMenu, data); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, data)
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) loadWxGzhCredentials() (appID, secret string, err error) {
|
||||
var row model.Config
|
||||
if err = h.DB.Where("name", types.ConfigKeyWxGzh).First(&row).Error; err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
var wx types.WxGzhConfig
|
||||
if err = utils.JsonDecode(row.Value, &wx); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return strings.TrimSpace(wx.AppId), strings.TrimSpace(wx.Secret), nil
|
||||
}
|
||||
|
||||
// PublishWxGzhMenu 将草稿菜单同步到微信公众平台(menu/create)。
|
||||
func (h *ConfigHandler) PublishWxGzhMenu(c *gin.Context) {
|
||||
appID, secret, err := h.loadWxGzhCredentials()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.ERROR(c, "请先配置微信公众号 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
if appID == "" || secret == "" {
|
||||
resp.ERROR(c, "请先配置微信公众号 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
|
||||
var menu types.WxGzhMenuConfig
|
||||
var menuRow model.Config
|
||||
if err := h.DB.Where("name", types.ConfigKeyWxGzhMenu).First(&menuRow).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := utils.JsonDecode(menuRow.Value, &menu); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if menu.Button == nil {
|
||||
menu.Button = []types.WxGzhMenuButton{}
|
||||
}
|
||||
|
||||
if err := h.wxGzhService.PublishCustomMenu(c.Request.Context(), appID, secret, menu); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, gin.H{"message": "已同步到微信"})
|
||||
}
|
||||
|
||||
// QueryWxGzhMenu 从微信拉取当前菜单并写入草稿 wx_gzh_menu。
|
||||
func (h *ConfigHandler) QueryWxGzhMenu(c *gin.Context) {
|
||||
appID, secret, err := h.loadWxGzhCredentials()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
resp.ERROR(c, "请先配置微信公众号 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
if appID == "" || secret == "" {
|
||||
resp.ERROR(c, "请先配置微信公众号 AppID 与 AppSecret")
|
||||
return
|
||||
}
|
||||
|
||||
menuCfg, err := h.wxGzhService.GetCustomMenuFromWechat(c.Request.Context(), appID, secret)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
if menuCfg.Button == nil {
|
||||
menuCfg.Button = []types.WxGzhMenuButton{}
|
||||
}
|
||||
if err := h.Update(types.ConfigKeyWxGzhMenu, menuCfg); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, menuCfg)
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (h *ImageHandler) ImageList(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
job.Publish = item.Publish != 0
|
||||
items = append(items, job)
|
||||
}
|
||||
}
|
||||
@@ -148,8 +149,8 @@ func (h *ImageHandler) Remove(c *gin.Context) {
|
||||
tab := c.Query("tab")
|
||||
|
||||
tx := h.DB.Begin()
|
||||
var md, remark, imgURL string
|
||||
var power, userId, progress int
|
||||
var md, remark, imgURL, status string
|
||||
var power, userId int
|
||||
switch tab {
|
||||
case "mj":
|
||||
var job model.MidJourneyJob
|
||||
@@ -161,8 +162,8 @@ func (h *ImageHandler) Remove(c *gin.Context) {
|
||||
md = "mid-journey"
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
remark = fmt.Sprintf("任务失败,退回积分。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
status = job.Status
|
||||
imgURL = job.ImgURL
|
||||
case "image":
|
||||
var job model.ImageJob
|
||||
@@ -176,15 +177,15 @@ func (h *ImageHandler) Remove(c *gin.Context) {
|
||||
md = "image-generation"
|
||||
power = job.Power
|
||||
userId = int(job.UserId)
|
||||
remark = fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
progress = job.Progress
|
||||
remark = fmt.Sprintf("任务失败,退回积分。任务ID:%d,Err: %s", job.Id, job.ErrMsg)
|
||||
status = job.Status
|
||||
imgURL = job.ImgURL
|
||||
default:
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
|
||||
if progress != 100 {
|
||||
if status != model.ImageStatusSuccess {
|
||||
err := h.userService.IncreasePower(uint(userId), power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: md,
|
||||
|
||||
@@ -41,12 +41,13 @@ func (h *ProductHandler) RegisterRoutes() {
|
||||
|
||||
func (h *ProductHandler) Save(c *gin.Context) {
|
||||
var data struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Power int `json:"power"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
StripePrice float64 `json:"stripe_price"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Power int `json:"power"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&data); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
@@ -54,10 +55,11 @@ func (h *ProductHandler) Save(c *gin.Context) {
|
||||
}
|
||||
|
||||
item := model.Product{
|
||||
Name: data.Name,
|
||||
Price: data.Price,
|
||||
Power: data.Power,
|
||||
Enabled: data.Enabled}
|
||||
Name: data.Name,
|
||||
Price: data.Price,
|
||||
StripePrice: data.StripePrice,
|
||||
Power: data.Power,
|
||||
Enabled: data.Enabled}
|
||||
item.Id = data.Id
|
||||
if item.Id > 0 {
|
||||
item.CreatedAt = time.Unix(data.CreatedAt, 0)
|
||||
|
||||
@@ -243,7 +243,6 @@ func (h *UserHandler) ImportUsers(c *gin.Context) {
|
||||
Salt: salt,
|
||||
Power: power,
|
||||
Status: status,
|
||||
ChatRoles: utils.JsonEncode([]string{}),
|
||||
ChatConfig: "{}",
|
||||
ChatModels: utils.JsonEncode([]int{}),
|
||||
ExpiredTime: 0, // 长期有效
|
||||
|
||||
@@ -40,7 +40,6 @@ func (h *ChatAppHandler) RegisterRoutes() {
|
||||
group.POST("create", h.Create)
|
||||
group.POST("copy", h.Copy)
|
||||
group.POST("update", h.UpdateApp)
|
||||
group.POST("workspace", h.UpdateWorkArea)
|
||||
group.POST("remove", h.Remove)
|
||||
}
|
||||
}
|
||||
@@ -217,27 +216,6 @@ func (h *ChatAppHandler) UpdateApp(c *gin.Context) {
|
||||
resp.SUCCESS(c, nil)
|
||||
}
|
||||
|
||||
// UpdateWorkArea 更新用户工作区应用列表(存为应用 id 数组)
|
||||
func (h *ChatAppHandler) UpdateWorkArea(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
if userId == 0 {
|
||||
resp.NotAuth(c)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Ids []uint `json:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if err := h.DB.Model(&model.User{}).Where("id = ?", userId).Update("chat_roles_json", utils.JsonEncode(body.Ids)).Error; err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
resp.SUCCESS(c, nil)
|
||||
}
|
||||
|
||||
// Remove 删除用户智能体(仅允许删除自己创建的)
|
||||
func (h *ChatAppHandler) Remove(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
|
||||
@@ -70,6 +70,13 @@ func (h *ConfigHandler) Get(c *gin.Context) {
|
||||
delete(value, "token")
|
||||
delete(value, "encoding_aes_key")
|
||||
}
|
||||
if key == types.ConfigKeySystem {
|
||||
if value == nil {
|
||||
value = make(map[string]any)
|
||||
}
|
||||
// 支付开关在 payment 配置中,前端会员页仅需是否展示 Stripe
|
||||
value["stripe_pay_enabled"] = h.sysConfig.Payment.Stripe.Enabled
|
||||
}
|
||||
resp.SUCCESS(c, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +127,12 @@ func (h *ImageJobHandler) Image(c *gin.Context) {
|
||||
Power: chatModel.Power,
|
||||
}
|
||||
job := model.ImageJob{
|
||||
UserId: uint(userId),
|
||||
Prompt: data.Prompt,
|
||||
Power: chatModel.Power,
|
||||
Params: utils.JsonEncode(task),
|
||||
UserId: uint(userId),
|
||||
Prompt: data.Prompt,
|
||||
Power: chatModel.Power,
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
Params: utils.JsonEncode(task),
|
||||
}
|
||||
res := h.DB.Create(&job)
|
||||
if res.Error != nil {
|
||||
@@ -189,9 +191,9 @@ func (h *ImageJobHandler) getData(finish bool, userId uint, page int, pageSize i
|
||||
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if finish {
|
||||
session = session.Where("progress >= ?", 100).Order("id DESC")
|
||||
session = session.Where("status IN ?", []string{model.ImageStatusSuccess, model.ImageStatusFailed}).Order("id DESC")
|
||||
} else {
|
||||
session = session.Where("progress < ?", 100).Order("id ASC")
|
||||
session = session.Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress, model.ImageStatusDownloading}).Order("id ASC")
|
||||
}
|
||||
if userId > 0 {
|
||||
session = session.Where("user_id = ?", userId)
|
||||
@@ -221,6 +223,8 @@ func (h *ImageJobHandler) getData(finish bool, userId uint, page int, pageSize i
|
||||
continue
|
||||
}
|
||||
job.CreatedAt = item.CreatedAt.Unix()
|
||||
// CopyObject skips int→bool (model.Publish vs vo.Publish); keep list in sync with DB
|
||||
job.Publish = item.Publish != 0
|
||||
jobs = append(jobs, job)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
|
||||
resp.ERROR(c, types.InvalidArgs)
|
||||
return
|
||||
}
|
||||
if jimeng.IsSeedanceReqKey(req.ReqKey) {
|
||||
if err := h.validateSeedanceRequest(&req); err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 文本审核
|
||||
if h.App.SysConfig.Moderation.Enable && req.Prompt != "" {
|
||||
@@ -83,7 +89,7 @@ func (h *JimengHandler) CreateTask(c *gin.Context) {
|
||||
|
||||
}
|
||||
|
||||
if req.Prompt == "" && len(req.ImageUrls) == 0 {
|
||||
if !jimeng.IsSeedanceReqKey(req.ReqKey) && req.Prompt == "" && len(req.ImageUrls) == 0 {
|
||||
resp.ERROR(c, "提示词和图片不能同时为空")
|
||||
return
|
||||
}
|
||||
@@ -315,6 +321,9 @@ func (h *JimengHandler) getTaskPower(req types.JimengTaskRequest) (int, error) {
|
||||
if req.Duration == 0 {
|
||||
return 0, errors.New("视频时长不能为0")
|
||||
}
|
||||
if req.Duration == -1 {
|
||||
return basePower, nil
|
||||
}
|
||||
return basePower * req.Duration, nil
|
||||
case types.JMTaskTypeVirtualHuman:
|
||||
if req.AudioURL == "" {
|
||||
@@ -354,3 +363,181 @@ func (h *JimengHandler) GetPowerConfig(c *gin.Context) {
|
||||
"powers": config.Powers,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *JimengHandler) validateSeedanceRequest(req *types.JimengTaskRequest) error {
|
||||
if req.Duration != 0 && req.Duration != -1 {
|
||||
if req.Duration < 4 || req.Duration > 15 {
|
||||
return errors.New("Seedance 视频时长必须在 4-15 秒之间,或使用 -1 智能时长")
|
||||
}
|
||||
}
|
||||
|
||||
if req.AspectRatio != "" {
|
||||
allowedRatio := map[string]bool{
|
||||
"16:9": true, "4:3": true, "1:1": true, "3:4": true, "9:16": true, "21:9": true, "adaptive": true,
|
||||
}
|
||||
if !allowedRatio[req.AspectRatio] {
|
||||
return errors.New("不支持的视频比例参数")
|
||||
}
|
||||
}
|
||||
|
||||
if req.Resolution != "" {
|
||||
allowedResolution := map[string]bool{"480p": true, "720p": true, "1080p": true}
|
||||
if !allowedResolution[req.Resolution] {
|
||||
return errors.New("不支持的视频分辨率参数")
|
||||
}
|
||||
}
|
||||
|
||||
if req.ReqKey == "doubao-seedance-2-0-fast-260128" && req.Resolution == "1080p" {
|
||||
return errors.New("Seedance 2.0 Fast 不支持 1080p")
|
||||
}
|
||||
|
||||
if !h.seedanceSupportsVideoAudio(req.ReqKey) {
|
||||
if req.VideoURL != "" || req.AudioURL != "" {
|
||||
return errors.New("当前 Seedance 模型不支持视频或音频输入")
|
||||
}
|
||||
}
|
||||
|
||||
content := req.Content
|
||||
if len(content) == 0 {
|
||||
content = h.buildCompatSeedanceContent(req)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
return errors.New("Seedance 至少需要文本、图片或视频其中之一")
|
||||
}
|
||||
|
||||
imageRoleCount := map[string]int{}
|
||||
hasImageOrVideo := false
|
||||
hasAudio := false
|
||||
hasText := false
|
||||
|
||||
for _, item := range content {
|
||||
switch item.Type {
|
||||
case "text":
|
||||
if item.Text == "" {
|
||||
return errors.New("文本输入不能为空")
|
||||
}
|
||||
hasText = true
|
||||
case "image_url":
|
||||
if item.ImageURL == nil || item.ImageURL.URL == "" {
|
||||
return errors.New("图片输入缺少 url")
|
||||
}
|
||||
role := item.Role
|
||||
if role == "" {
|
||||
role = "first_frame"
|
||||
}
|
||||
if role != "first_frame" && role != "last_frame" && role != "reference_image" {
|
||||
return errors.New("图片 role 仅支持 first_frame、last_frame、reference_image")
|
||||
}
|
||||
imageRoleCount[role]++
|
||||
hasImageOrVideo = true
|
||||
case "video_url":
|
||||
if !h.seedanceSupportsVideoAudio(req.ReqKey) {
|
||||
return errors.New("当前 Seedance 模型不支持视频输入")
|
||||
}
|
||||
if item.VideoURL == nil || item.VideoURL.URL == "" {
|
||||
return errors.New("视频输入缺少 url")
|
||||
}
|
||||
if item.Role != "" && item.Role != "reference_video" {
|
||||
return errors.New("视频 role 仅支持 reference_video")
|
||||
}
|
||||
hasImageOrVideo = true
|
||||
case "audio_url":
|
||||
if !h.seedanceSupportsVideoAudio(req.ReqKey) {
|
||||
return errors.New("当前 Seedance 模型不支持音频输入")
|
||||
}
|
||||
if item.AudioURL == nil || item.AudioURL.URL == "" {
|
||||
return errors.New("音频输入缺少 url")
|
||||
}
|
||||
if item.Role != "" && item.Role != "reference_audio" {
|
||||
return errors.New("音频 role 仅支持 reference_audio")
|
||||
}
|
||||
hasAudio = true
|
||||
default:
|
||||
return fmt.Errorf("不支持的 Seedance content 类型: %s", item.Type)
|
||||
}
|
||||
}
|
||||
|
||||
if hasAudio && !hasImageOrVideo {
|
||||
return errors.New("音频不可单独输入,必须搭配图片或视频")
|
||||
}
|
||||
if imageRoleCount["first_frame"] > 1 {
|
||||
return errors.New("first_frame 最多只能上传 1 张")
|
||||
}
|
||||
if imageRoleCount["last_frame"] > 1 {
|
||||
return errors.New("last_frame 最多只能上传 1 张")
|
||||
}
|
||||
if imageRoleCount["reference_image"] > 9 {
|
||||
return errors.New("reference_image 最多支持 9 张")
|
||||
}
|
||||
if imageRoleCount["reference_image"] > 0 && (imageRoleCount["first_frame"] > 0 || imageRoleCount["last_frame"] > 0) {
|
||||
return errors.New("reference_image 模式不能与 first_frame/last_frame 混用")
|
||||
}
|
||||
if imageRoleCount["last_frame"] > 0 && imageRoleCount["first_frame"] == 0 {
|
||||
return errors.New("使用 last_frame 时必须同时提供 first_frame")
|
||||
}
|
||||
if imageRoleCount["first_frame"] > 0 && imageRoleCount["last_frame"] == 0 && imageRoleCount["reference_image"] == 0 && !hasText {
|
||||
return errors.New("图生视频至少需要提示词或补充参考素材")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *JimengHandler) seedanceSupportsVideoAudio(reqKey string) bool {
|
||||
return reqKey == "doubao-seedance-2-0-260128" || reqKey == "doubao-seedance-2-0-fast-260128"
|
||||
}
|
||||
|
||||
func (h *JimengHandler) buildCompatSeedanceContent(req *types.JimengTaskRequest) []types.JMContentItem {
|
||||
content := make([]types.JMContentItem, 0, 4)
|
||||
if req.Prompt != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "text",
|
||||
Text: req.Prompt,
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.ImageUrls) > 0 {
|
||||
for index, imageURL := range req.ImageUrls {
|
||||
if imageURL == "" {
|
||||
continue
|
||||
}
|
||||
role := "reference_image"
|
||||
if len(req.ImageUrls) == 1 {
|
||||
role = "first_frame"
|
||||
} else if len(req.ImageUrls) == 2 {
|
||||
if index == 0 {
|
||||
role = "first_frame"
|
||||
} else {
|
||||
role = "last_frame"
|
||||
}
|
||||
}
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "image_url",
|
||||
ImageURL: &types.JMAssetRef{
|
||||
URL: imageURL,
|
||||
},
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if req.VideoURL != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "video_url",
|
||||
VideoURL: &types.JMAssetRef{
|
||||
URL: req.VideoURL,
|
||||
},
|
||||
Role: "reference_video",
|
||||
})
|
||||
}
|
||||
|
||||
if req.AudioURL != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "audio_url",
|
||||
AudioURL: &types.JMAssetRef{
|
||||
URL: req.AudioURL,
|
||||
},
|
||||
Role: "reference_audio",
|
||||
})
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ func (h *MidJourneyHandler) Image(c *gin.Context) {
|
||||
UserId: uint(userId),
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
Prompt: fmt.Sprintf("%s %s", data.Prompt, params),
|
||||
Power: power,
|
||||
@@ -318,6 +319,7 @@ func (h *MidJourneyHandler) Upscale(c *gin.Context) {
|
||||
UserId: uint(userId),
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -375,6 +377,7 @@ func (h *MidJourneyHandler) Variation(c *gin.Context) {
|
||||
UserId: uint(userId),
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
Power: power,
|
||||
CreatedAt: time.Now(),
|
||||
@@ -449,6 +452,7 @@ func (h *MidJourneyHandler) Modal(c *gin.Context) {
|
||||
UserId: uint(userId),
|
||||
TaskId: taskId,
|
||||
TaskInfo: utils.JsonEncode(task),
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
@@ -509,9 +513,9 @@ func (h *MidJourneyHandler) JobList(c *gin.Context) {
|
||||
func (h *MidJourneyHandler) getData(finish bool, userId uint, page int, pageSize int, publish bool) (error, vo.Page) {
|
||||
session := h.DB.Session(&gorm.Session{})
|
||||
if finish {
|
||||
session = session.Where("progress >= ?", 100).Order("id DESC")
|
||||
session = session.Where("status IN ?", []string{model.ImageStatusSuccess, model.ImageStatusFailed}).Order("id DESC")
|
||||
} else {
|
||||
session = session.Where("progress < ?", 100).Order("id ASC")
|
||||
session = session.Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress, model.ImageStatusDownloading}).Order("id ASC")
|
||||
}
|
||||
if userId > 0 {
|
||||
session = session.Where("user_id = ?", userId)
|
||||
|
||||
@@ -37,6 +37,7 @@ type PaymentHandler struct {
|
||||
alipayService *payment.AlipayService
|
||||
epayService *payment.EPayService
|
||||
wxpayService *payment.WxPayService
|
||||
stripeService *payment.StripeService
|
||||
snowflake *service.Snowflake
|
||||
userService *service.UserService
|
||||
fs embed.FS
|
||||
@@ -49,6 +50,7 @@ func NewPaymentHandler(
|
||||
alipayService *payment.AlipayService,
|
||||
geekPayService *payment.EPayService,
|
||||
wxpayService *payment.WxPayService,
|
||||
stripeService *payment.StripeService,
|
||||
db *gorm.DB,
|
||||
userService *service.UserService,
|
||||
snowflake *service.Snowflake,
|
||||
@@ -58,6 +60,7 @@ func NewPaymentHandler(
|
||||
alipayService: alipayService,
|
||||
epayService: geekPayService,
|
||||
wxpayService: wxpayService,
|
||||
stripeService: stripeService,
|
||||
snowflake: snowflake,
|
||||
userService: userService,
|
||||
fs: fs,
|
||||
@@ -78,6 +81,7 @@ func (h *PaymentHandler) RegisterRoutes() {
|
||||
rg.POST("notify/alipay", h.AlipayNotify)
|
||||
rg.GET("notify/epay", h.EPayNotify)
|
||||
rg.POST("notify/wxpay", h.WxpayNotify)
|
||||
rg.POST("notify/stripe", h.StripeNotify)
|
||||
|
||||
// 需要用户登录的接口
|
||||
rg.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
|
||||
@@ -143,6 +147,8 @@ func (h *PaymentHandler) SyncOrders() error {
|
||||
logger.Errorf("error with query order info: %v", err)
|
||||
continue
|
||||
}
|
||||
case payment.PayChannelStripe:
|
||||
continue
|
||||
}
|
||||
|
||||
// 订单已关闭
|
||||
@@ -204,6 +210,7 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
}
|
||||
|
||||
amount := product.Price
|
||||
stripeAmount := product.StripePrice
|
||||
var payURL, notifyURL string
|
||||
switch data.PayWay {
|
||||
case "wxpay":
|
||||
@@ -311,6 +318,57 @@ func (h *PaymentHandler) CreateOrder(c *gin.Context) {
|
||||
resp.ERROR(c, "系统没有配置可用的支付渠道!")
|
||||
return
|
||||
}
|
||||
case "stripe":
|
||||
if h.config.Stripe.Enabled {
|
||||
logger.Debugf("Stripe,%+v", data)
|
||||
data.Channel = payment.PayChannelStripe
|
||||
if h.config.Stripe.Domain != "" {
|
||||
data.Domain = h.config.Stripe.Domain
|
||||
}
|
||||
if stripeAmount <= 0 {
|
||||
resp.ERROR(c, "当前产品未配置 Stripe 价格")
|
||||
return
|
||||
}
|
||||
successURL := fmt.Sprintf("%s/member", data.Domain)
|
||||
params := payment.PayRequest{
|
||||
Device: data.Device,
|
||||
OutTradeNo: orderNo,
|
||||
Subject: product.Name,
|
||||
TotalFee: fmt.Sprintf("%.2f", stripeAmount),
|
||||
Currency: h.config.Stripe.Currency,
|
||||
ReturnURL: successURL,
|
||||
}
|
||||
payURL, err = h.stripeService.Pay(params)
|
||||
if err != nil {
|
||||
resp.ERROR(c, "error with generate stripe checkout session: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else if h.config.Epay.Enabled {
|
||||
logger.Debugf("Stripe fall back to epay,%+v", data)
|
||||
data.Channel = payment.PayChannelEpay
|
||||
if h.config.Epay.Domain != "" {
|
||||
data.Domain = h.config.Epay.Domain
|
||||
}
|
||||
notifyURL = fmt.Sprintf("%s/api/payment/notify/epay", data.Domain)
|
||||
params := payment.PayRequest{
|
||||
OutTradeNo: orderNo,
|
||||
Subject: product.Name,
|
||||
TotalFee: fmt.Sprintf("%f", amount),
|
||||
ClientIP: c.ClientIP(),
|
||||
Device: data.Device,
|
||||
PayWay: data.PayWay,
|
||||
NotifyURL: notifyURL,
|
||||
}
|
||||
r, err := h.epayService.Pay(params)
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
payURL = r
|
||||
} else {
|
||||
resp.ERROR(c, "系统没有配置可用的支付渠道!")
|
||||
return
|
||||
}
|
||||
default:
|
||||
resp.ERROR(c, "不支持的支付渠道")
|
||||
return
|
||||
@@ -489,3 +547,23 @@ func (h *PaymentHandler) WxpayNotify(c *gin.Context) {
|
||||
|
||||
c.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
// StripeNotify Stripe 支付异步回调
|
||||
func (h *PaymentHandler) StripeNotify(c *gin.Context) {
|
||||
orderInfo, err := h.stripeService.TradeVerify(c.Request)
|
||||
logger.Infof("收到 Stripe 订单支付回调:%+v", orderInfo)
|
||||
if err != nil {
|
||||
logger.Errorf("订单校验失败:%v", err)
|
||||
c.String(http.StatusBadRequest, "fail")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.paySuccess(orderInfo)
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
c.String(http.StatusOK, "fail")
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "success")
|
||||
}
|
||||
|
||||
@@ -153,15 +153,15 @@ func (h *SunoHandler) Create(c *gin.Context) {
|
||||
|
||||
// 插入数据库
|
||||
job := model.SunoJob{
|
||||
UserId: uint(task.UserId),
|
||||
Prompt: data.Prompt,
|
||||
UserId: uint(task.UserId),
|
||||
Prompt: data.Prompt,
|
||||
Params: vo.SunoParam{
|
||||
Prompt: data.Prompt,
|
||||
Prompt: data.Prompt,
|
||||
Instrumental: data.Instrumental,
|
||||
Tags: data.Tags,
|
||||
ExtendSecs: data.ExtendSecs,
|
||||
Lyrics: data.Lyrics,
|
||||
Model: data.Model,
|
||||
Tags: data.Tags,
|
||||
ExtendSecs: data.ExtendSecs,
|
||||
Lyrics: data.Lyrics,
|
||||
Model: data.Model,
|
||||
},
|
||||
Title: data.Title,
|
||||
Type: data.Type,
|
||||
@@ -169,6 +169,8 @@ func (h *SunoHandler) Create(c *gin.Context) {
|
||||
RefTaskId: data.RefTaskId,
|
||||
Power: h.App.SysConfig.Base.SunoPower,
|
||||
SongId: utils.RandString(32),
|
||||
Status: model.ImageStatusPending,
|
||||
Progress: 0,
|
||||
}
|
||||
if data.Lyrics != "" {
|
||||
job.Prompt = data.Lyrics
|
||||
@@ -266,7 +268,7 @@ func (h *SunoHandler) Remove(c *gin.Context) {
|
||||
}
|
||||
|
||||
// 只有失败或者已完成的任务可以删除
|
||||
if !(job.Progress == service.FailTaskProgress || job.Progress == 100) {
|
||||
if job.Status != model.ImageStatusFailed && job.Status != model.ImageStatusSuccess {
|
||||
resp.ERROR(c, "只有失败和超时(10分钟)的任务才能删除!")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -488,20 +488,6 @@ func (h *UserHandler) Session(c *gin.Context) {
|
||||
h.DB.Model(&user).UpdateColumn("vip", false)
|
||||
}
|
||||
userVo.Id = user.Id
|
||||
// 工作区应用 ID 列表(历史可能为 key 数组,仅解析数字 ID)
|
||||
if user.ChatRoles != "" {
|
||||
var raw []interface{}
|
||||
if utils.JsonDecode(user.ChatRoles, &raw) == nil {
|
||||
for _, v := range raw {
|
||||
if n, ok := v.(float64); ok && n >= 0 {
|
||||
userVo.ChatRoles = append(userVo.ChatRoles, uint(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if userVo.ChatRoles == nil {
|
||||
userVo.ChatRoles = []uint{}
|
||||
}
|
||||
resp.SUCCESS(c, userVo)
|
||||
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ func (h *VideoHandler) RegisterRoutes() {
|
||||
{
|
||||
group.POST("create", h.Create)
|
||||
group.GET("list", h.List)
|
||||
group.GET("tasks", h.TaskList)
|
||||
group.GET("works", h.WorkList)
|
||||
group.GET("remove", h.Remove)
|
||||
group.GET("publish", h.Publish)
|
||||
group.GET("power-config", h.GetPowerConfig) // 获取算力配置
|
||||
@@ -151,11 +153,13 @@ func (h *VideoHandler) Create(c *gin.Context) {
|
||||
|
||||
// 插入数据库
|
||||
job := model.VideoJob{
|
||||
UserId: uint(userId),
|
||||
Type: data.Provider,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
Params: utils.JsonEncode(task),
|
||||
UserId: uint(userId),
|
||||
Type: data.Provider,
|
||||
Prompt: data.Prompt,
|
||||
Power: power,
|
||||
Params: utils.JsonEncode(task),
|
||||
Status: types.VideoStatusPending,
|
||||
Progress: 0,
|
||||
}
|
||||
tx := h.DB.Create(&job)
|
||||
if tx.Error != nil {
|
||||
@@ -305,6 +309,78 @@ func (h *VideoHandler) List(c *gin.Context) {
|
||||
resp.SUCCESS(c, vo.NewPage(total, page, pageSize, items))
|
||||
}
|
||||
|
||||
// TaskList 任务列表:仅返回进行中的任务(pending / in_progress)
|
||||
func (h *VideoHandler) TaskList(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
t := c.Query("type")
|
||||
session := h.DB.Session(&gorm.Session{}).Where("user_id", userId)
|
||||
if t != "" {
|
||||
session = session.Where("type", t)
|
||||
}
|
||||
session = session.Where("status IN ?", []string{types.VideoStatusPending, types.VideoStatusInProgress})
|
||||
|
||||
var list []model.VideoJob
|
||||
err := session.Order("id desc").Find(&list).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]vo.VideoJob, 0, len(list))
|
||||
for _, v := range list {
|
||||
var item vo.VideoJob
|
||||
if err := utils.CopyObject(v, &item); err != nil {
|
||||
continue
|
||||
}
|
||||
item.CreatedAt = v.CreatedAt.Unix()
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, items)
|
||||
}
|
||||
|
||||
// WorkList 作品列表:仅返回 downloading / success / failed
|
||||
func (h *VideoHandler) WorkList(c *gin.Context) {
|
||||
userId := h.GetLoginUserId(c)
|
||||
t := c.Query("type")
|
||||
page := h.GetInt(c, "page", 1)
|
||||
pageSize := h.GetInt(c, "page_size", 20)
|
||||
session := h.DB.Session(&gorm.Session{}).Where("user_id", userId)
|
||||
if t != "" {
|
||||
session = session.Where("type", t)
|
||||
}
|
||||
session = session.Where(
|
||||
"status IN ?",
|
||||
[]string{types.VideoStatusDownloading, types.VideoStatusSuccess, types.VideoStatusFailed},
|
||||
)
|
||||
|
||||
var total int64
|
||||
session.Model(&model.VideoJob{}).Count(&total)
|
||||
|
||||
if page > 0 && pageSize > 0 {
|
||||
offset := (page - 1) * pageSize
|
||||
session = session.Offset(offset).Limit(pageSize)
|
||||
}
|
||||
var list []model.VideoJob
|
||||
err := session.Order("id desc").Find(&list).Error
|
||||
if err != nil {
|
||||
resp.ERROR(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]vo.VideoJob, 0, len(list))
|
||||
for _, v := range list {
|
||||
var item vo.VideoJob
|
||||
if err := utils.CopyObject(v, &item); err != nil {
|
||||
continue
|
||||
}
|
||||
item.CreatedAt = v.CreatedAt.Unix()
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
resp.SUCCESS(c, vo.NewPage(total, page, pageSize, items))
|
||||
}
|
||||
|
||||
func (h *VideoHandler) Remove(c *gin.Context) {
|
||||
id := h.GetInt(c, "id", 0)
|
||||
userId := h.GetLoginUserId(c)
|
||||
|
||||
@@ -87,9 +87,3 @@ func validateSignature(signature, token, timestamp, nonce string) bool {
|
||||
// 4. 与 signature 比对
|
||||
return hashed == signature
|
||||
}
|
||||
|
||||
// 创建微信菜单
|
||||
func (h *WxGzhHandler) CreateMenu(c *gin.Context) {
|
||||
|
||||
resp.SUCCESS(c, "创建菜单成功")
|
||||
}
|
||||
|
||||
+3
-2
@@ -199,8 +199,8 @@ func main() {
|
||||
fx.Provide(suno.NewService),
|
||||
fx.Invoke(func(s *suno.Service) {
|
||||
s.Run()
|
||||
s.SyncTaskProgress()
|
||||
s.DownloadFiles()
|
||||
// s.SyncTaskProgress()
|
||||
// s.DownloadFiles()
|
||||
}),
|
||||
fx.Provide(video.NewService),
|
||||
fx.Invoke(func(s *video.Service) {
|
||||
@@ -230,6 +230,7 @@ func main() {
|
||||
fx.Provide(payment.NewAlipayService),
|
||||
fx.Provide(payment.NewEPayService),
|
||||
fx.Provide(payment.NewWxpayService),
|
||||
fx.Provide(payment.NewStripeService),
|
||||
|
||||
// 文件上传服务
|
||||
fx.Provide(oss.NewLocalStorage),
|
||||
|
||||
@@ -58,7 +58,7 @@ func (s *Service) PushTask(task types.ImageTask) {
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.ImageJob
|
||||
s.db.Where("progress", 0).Find(&jobs)
|
||||
s.db.Where("task_id", "").Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress}).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.ImageTask
|
||||
err := utils.JsonDecode(v.Params, &task)
|
||||
@@ -81,11 +81,17 @@ func (s *Service) Run() {
|
||||
}
|
||||
logger.Infof("handle a new Image generation task: %+v", task)
|
||||
go func() {
|
||||
s.db.Model(&model.ImageJob{Id: task.Id}).UpdateColumns(map[string]any{
|
||||
"status": model.ImageStatusInProgress,
|
||||
"progress": 1,
|
||||
"err_msg": "",
|
||||
})
|
||||
_, err = s.Image(task, false)
|
||||
if err != nil {
|
||||
logger.Errorf("error with image task: %v", err)
|
||||
s.db.Model(&model.ImageJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"status": model.ImageStatusFailed,
|
||||
"progress": 0,
|
||||
"err_msg": err.Error(),
|
||||
})
|
||||
}
|
||||
@@ -189,8 +195,10 @@ func (s *Service) Image(task types.ImageTask, sync bool) (string, error) {
|
||||
s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
|
||||
var imgURL string
|
||||
var data = map[string]any{
|
||||
"status": model.ImageStatusDownloading,
|
||||
"progress": 100,
|
||||
"prompt": task.Prompt,
|
||||
"task_id": utils.RandString(12),
|
||||
}
|
||||
// 如果返回的是base64,则需要上传到oss
|
||||
if res.Data[0].B64Json != "" {
|
||||
@@ -224,18 +232,19 @@ func (s *Service) CheckTaskStatus() {
|
||||
for {
|
||||
// 检查未完成任务进度
|
||||
var jobs []model.ImageJob
|
||||
s.db.Where("progress < ?", 100).Find(&jobs)
|
||||
s.db.Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress, model.ImageStatusDownloading}).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
// 超时的任务标记为失败
|
||||
if time.Since(job.CreatedAt) > time.Minute*10 {
|
||||
job.Progress = service.FailTaskProgress
|
||||
job.Status = model.ImageStatusFailed
|
||||
job.Progress = 0
|
||||
job.ErrMsg = "任务超时"
|
||||
s.db.Updates(&job)
|
||||
}
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
// 找出失败的任务,并恢复其扣减积分
|
||||
s.db.Where("status", model.ImageStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
var task types.ImageTask
|
||||
err := utils.JsonDecode(job.Params, &task)
|
||||
@@ -245,7 +254,7 @@ func (s *Service) CheckTaskStatus() {
|
||||
err = s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: task.ModelName,
|
||||
Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg),
|
||||
Remark: fmt.Sprintf("任务失败,退回积分。任务ID:%d,Err: %s", job.Id, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -262,7 +271,7 @@ func (s *Service) DownloadImages() {
|
||||
go func() {
|
||||
var items []model.ImageJob
|
||||
for {
|
||||
res := s.db.Where("img_url = ? AND progress = ?", "", 100).Find(&items)
|
||||
res := s.db.Where("img_url = ? AND status = ?", "", model.ImageStatusDownloading).Find(&items)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -297,7 +306,10 @@ func (s *Service) downloadImage(jobId uint, orgURL string) (string, error) {
|
||||
}
|
||||
|
||||
// update img_url
|
||||
res := s.db.Model(&model.ImageJob{Id: jobId}).UpdateColumn("img_url", imgURL)
|
||||
res := s.db.Model(&model.ImageJob{Id: jobId}).UpdateColumns(map[string]any{
|
||||
"img_url": imgURL,
|
||||
"status": model.ImageStatusSuccess,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package jimeng
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +26,13 @@ import (
|
||||
|
||||
var logger = log.GetLogger()
|
||||
|
||||
const seedanceOfficialBaseURL = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
|
||||
const (
|
||||
jimengMediaRepairInterval = 60 * time.Second
|
||||
jimengMediaRepairBatch = 50
|
||||
)
|
||||
|
||||
// Service 即梦服务(合并了消费者功能)
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
@@ -61,6 +72,7 @@ func (s *Service) Start() {
|
||||
s.running = true
|
||||
go s.consumeTasks()
|
||||
go s.pollTaskStatus()
|
||||
go s.runJimengSuccessMediaRepairLoop()
|
||||
}
|
||||
|
||||
// Stop 停止服务
|
||||
@@ -145,11 +157,6 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return fmt.Errorf("get jimeng job failed: %w", err)
|
||||
}
|
||||
|
||||
// 更新任务状态为处理中
|
||||
if err := s.UpdateJobStatus(job.Id, types.JMTaskStatusGenerating, ""); err != nil {
|
||||
return fmt.Errorf("update job status failed: %w", err)
|
||||
}
|
||||
|
||||
// 解析任务参数
|
||||
var req types.JimengTaskRequest
|
||||
err := utils.JsonDecode(job.Params, &req)
|
||||
@@ -157,12 +164,6 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return fmt.Errorf("parse task params failed: %w", err)
|
||||
}
|
||||
|
||||
// 构建请求并提交任务
|
||||
params, err := s.buildTaskRequest(&req)
|
||||
if err != nil {
|
||||
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
|
||||
}
|
||||
|
||||
// 数字人任务,先识别主体
|
||||
if req.TaskType == types.JMTaskTypeVirtualHuman {
|
||||
if err := s.client.AvatarRecognition(req.ImageUrls[0], req.RecognizeKey); err != nil {
|
||||
@@ -170,8 +171,8 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 同步任务 ,后台执行
|
||||
if req.ReqKey == DoubaoSeedream40ReqKey {
|
||||
// Seedream 同步生图(Ark)
|
||||
if IsSeedreamReqKey(req.ReqKey) {
|
||||
go func() {
|
||||
resp, err := s.client.SubmitSyncImageTask(req)
|
||||
if err != nil {
|
||||
@@ -191,18 +192,44 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return
|
||||
}
|
||||
|
||||
if len(resp.Data) == 0 || resp.Data[0] == nil || resp.Data[0].Url == nil || strings.TrimSpace(*resp.Data[0].Url) == "" {
|
||||
_ = s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", string(rawData)).Error
|
||||
_ = s.handleTaskError(job.Id, "seedream response has no image url")
|
||||
return
|
||||
}
|
||||
|
||||
remoteURL := strings.TrimSpace(*resp.Data[0].Url)
|
||||
ext := filepath.Ext(strings.Split(remoteURL, "?")[0])
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
updates["status"] = types.JMTaskStatusSuccess
|
||||
// 下载图片
|
||||
imgUrl, err := s.uploader.GetUploadHandler().PutUrlFile(*resp.Data[0].Url, ".png", false)
|
||||
if err == nil {
|
||||
updates["img_url"] = imgUrl
|
||||
// 转存到本地/OSS(失败时回退为官方临时 URL,与即梦异步任务一致)
|
||||
imgURL, err := s.uploader.GetUploadHandler().PutUrlFile(remoteURL, ext, false)
|
||||
if err != nil {
|
||||
logger.Errorf("jimeng seedream upload image failed, job_id=%d: %v", job.Id, err)
|
||||
imgURL = remoteURL
|
||||
}
|
||||
updates["img_url"] = imgURL
|
||||
updates["progress"] = 100
|
||||
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Seedance 视频任务(DoubaoAdapter)
|
||||
if IsSeedanceReqKey(req.ReqKey) {
|
||||
return s.submitSeedanceTask(job.Id, &req)
|
||||
}
|
||||
|
||||
// 其他请求走即梦 Visual 异步任务
|
||||
params, err := s.buildTaskRequest(&req)
|
||||
if err != nil {
|
||||
return s.handleTaskError(job.Id, fmt.Sprintf("build task request failed: %v", err))
|
||||
}
|
||||
|
||||
logger.Debugf("提交即梦任务: %+v", params)
|
||||
// 异步任务 ,前台执行
|
||||
resp, err := s.client.SubmitTask(params)
|
||||
@@ -228,6 +255,123 @@ func (s *Service) ProcessTask(jobId uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) submitSeedanceTask(jobId uint, req *types.JimengTaskRequest) error {
|
||||
jimengConfig, err := s.getJimengConfig()
|
||||
if err != nil {
|
||||
return s.handleTaskError(jobId, fmt.Sprintf("load jimeng config failed: %v", err))
|
||||
}
|
||||
|
||||
content := s.buildSeedanceContent(req)
|
||||
if len(content) == 0 {
|
||||
return s.handleTaskError(jobId, "seedance content 不能为空")
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"model": req.ReqKey,
|
||||
"content": content,
|
||||
"duration": req.Duration,
|
||||
"ratio": req.AspectRatio,
|
||||
"resolution": req.Resolution,
|
||||
}
|
||||
// 兼容旧参数:0 让官方走默认值
|
||||
if req.Duration == 0 {
|
||||
delete(payload, "duration")
|
||||
}
|
||||
if req.AspectRatio == "" {
|
||||
delete(payload, "ratio")
|
||||
}
|
||||
if req.Resolution == "" {
|
||||
delete(payload, "resolution")
|
||||
}
|
||||
if req.ReturnLastFrame {
|
||||
payload["return_last_frame"] = req.ReturnLastFrame
|
||||
}
|
||||
if req.Watermark != nil {
|
||||
payload["watermark"] = *req.Watermark
|
||||
}
|
||||
if req.GenerateAudio != nil {
|
||||
payload["generate_audio"] = *req.GenerateAudio
|
||||
}
|
||||
|
||||
resp, rawData, err := s.callSeedanceCreate(payload, jimengConfig)
|
||||
if err != nil {
|
||||
return s.handleTaskError(jobId, fmt.Sprintf("submit seedance task failed: %v", err))
|
||||
}
|
||||
|
||||
logger.Debugf("seedance create response: %+v", resp)
|
||||
|
||||
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", jobId).Updates(map[string]any{
|
||||
"task_id": resp.TaskID,
|
||||
"raw_data": rawData,
|
||||
"status": types.JMTaskStatusInQueue,
|
||||
"updated_at": time.Now(),
|
||||
}).Error; err != nil {
|
||||
logger.Errorf("update seedance task_id failed: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) buildSeedanceContent(req *types.JimengTaskRequest) []types.JMContentItem {
|
||||
if len(req.Content) > 0 {
|
||||
return req.Content
|
||||
}
|
||||
|
||||
content := make([]types.JMContentItem, 0, 4)
|
||||
if req.Prompt != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "text",
|
||||
Text: req.Prompt,
|
||||
})
|
||||
}
|
||||
|
||||
if len(req.ImageUrls) > 0 {
|
||||
for index, imageURL := range req.ImageUrls {
|
||||
if imageURL == "" {
|
||||
continue
|
||||
}
|
||||
role := "reference_image"
|
||||
if len(req.ImageUrls) == 1 {
|
||||
role = "first_frame"
|
||||
} else if len(req.ImageUrls) == 2 {
|
||||
if index == 0 {
|
||||
role = "first_frame"
|
||||
} else {
|
||||
role = "last_frame"
|
||||
}
|
||||
}
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "image_url",
|
||||
ImageURL: &types.JMAssetRef{
|
||||
URL: imageURL,
|
||||
},
|
||||
Role: role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if req.VideoURL != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "video_url",
|
||||
VideoURL: &types.JMAssetRef{
|
||||
URL: req.VideoURL,
|
||||
},
|
||||
Role: "reference_video",
|
||||
})
|
||||
}
|
||||
|
||||
if req.AudioURL != "" {
|
||||
content = append(content, types.JMContentItem{
|
||||
Type: "audio_url",
|
||||
AudioURL: &types.JMAssetRef{
|
||||
URL: req.AudioURL,
|
||||
},
|
||||
Role: "reference_audio",
|
||||
})
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// buildTaskRequest 构建任务请求(统一的参数解析)
|
||||
func (s *Service) buildTaskRequest(req *types.JimengTaskRequest) (map[string]any, error) {
|
||||
var params map[string]any
|
||||
@@ -295,8 +439,15 @@ func (s *Service) pollTaskStatus() {
|
||||
continue
|
||||
}
|
||||
|
||||
// 豆包生图 4.0 是同步任务,不需要轮询
|
||||
if job.ReqKey == DoubaoSeedream40ReqKey {
|
||||
// Seedream 为同步任务,不需要轮询
|
||||
if IsSeedreamReqKey(job.ReqKey) {
|
||||
continue
|
||||
}
|
||||
|
||||
if IsSeedanceReqKey(job.ReqKey) {
|
||||
if err := s.pollSeedanceTask(&job); err != nil {
|
||||
s.handleTaskError(job.Id, err.Error())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -333,6 +484,7 @@ func (s *Service) pollTaskStatus() {
|
||||
updates := map[string]any{
|
||||
"status": types.JMTaskStatusSuccess,
|
||||
"updated_at": time.Now(),
|
||||
"progress": 100,
|
||||
}
|
||||
|
||||
// 设置结果URL
|
||||
@@ -370,13 +522,13 @@ func (s *Service) pollTaskStatus() {
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
// 找出失败的任务,并恢复其扣减积分
|
||||
s.db.Where("status = ?", types.JMTaskStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.ReqKey,
|
||||
Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%d", job.Id),
|
||||
Remark: fmt.Sprintf("任务失败,退回积分。任务ID:%d", job.Id),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -391,6 +543,159 @@ func (s *Service) pollTaskStatus() {
|
||||
|
||||
}
|
||||
|
||||
func (s *Service) pollSeedanceTask(job *model.JimengJob) error {
|
||||
jimengConfig, err := s.getJimengConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load jimeng config failed: %w", err)
|
||||
}
|
||||
|
||||
resp, rawData, err := s.callSeedanceQuery(job.TaskId, jimengConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query seedance task failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Debugf("seedance query response: %+v", resp)
|
||||
|
||||
s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Update("raw_data", rawData)
|
||||
|
||||
switch resp.Status {
|
||||
case "succeeded":
|
||||
updates := map[string]any{
|
||||
"status": types.JMTaskStatusSuccess,
|
||||
"updated_at": time.Now(),
|
||||
"progress": 100,
|
||||
}
|
||||
if resp.Content.VideoURL != "" {
|
||||
videoURL, upErr := s.uploader.GetUploadHandler().PutUrlFile(resp.Content.VideoURL, ".mp4", false)
|
||||
if upErr != nil {
|
||||
logger.Errorf("upload seedance video failed: %v", upErr)
|
||||
videoURL = resp.Content.VideoURL
|
||||
}
|
||||
updates["video_url"] = videoURL
|
||||
}
|
||||
return s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates).Error
|
||||
case "queued", "running":
|
||||
return s.UpdateJobStatus(job.Id, types.JMTaskStatusGenerating, "")
|
||||
case "failed", "cancelled":
|
||||
errMsg := resp.Error
|
||||
if errMsg == "" {
|
||||
errMsg = "seedance task failed"
|
||||
}
|
||||
return fmt.Errorf("%s", errMsg)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type seedanceCreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platform_id"`
|
||||
}
|
||||
|
||||
type seedanceCreateResult struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
type seedanceQueryResponse struct {
|
||||
ID string `json:"id"`
|
||||
PlatformID string `json:"platform_id"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Content struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
} `json:"content"`
|
||||
}
|
||||
|
||||
func (s *Service) callSeedanceCreate(payload map[string]any, jimengConfig *types.JimengConfig) (*seedanceCreateResult, string, error) {
|
||||
if jimengConfig == nil || strings.TrimSpace(jimengConfig.ApiKey) == "" {
|
||||
return nil, "", fmt.Errorf("jimeng api key 未配置")
|
||||
}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
url := fmt.Sprintf("%s/contents/generations/tasks", seedanceOfficialBaseURL)
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jimengConfig.ApiKey))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, string(raw), fmt.Errorf("status=%d body=%s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var parsed seedanceCreateResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, string(raw), err
|
||||
}
|
||||
taskID := parsed.PlatformID
|
||||
if taskID == "" {
|
||||
taskID = parsed.ID
|
||||
}
|
||||
if taskID == "" {
|
||||
return nil, string(raw), fmt.Errorf("seedance create 响应缺少 task id: %s", string(raw))
|
||||
}
|
||||
return &seedanceCreateResult{TaskID: taskID}, string(raw), nil
|
||||
}
|
||||
|
||||
func (s *Service) callSeedanceQuery(taskID string, jimengConfig *types.JimengConfig) (*seedanceQueryResponse, string, error) {
|
||||
if jimengConfig == nil || strings.TrimSpace(jimengConfig.ApiKey) == "" {
|
||||
return nil, "", fmt.Errorf("jimeng api key 未配置")
|
||||
}
|
||||
url := fmt.Sprintf("%s/contents/generations/tasks/%s", seedanceOfficialBaseURL, taskID)
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(jimengConfig.ApiKey))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, string(raw), fmt.Errorf("status=%d body=%s", resp.StatusCode, string(raw))
|
||||
}
|
||||
var parsed seedanceQueryResponse
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, string(raw), err
|
||||
}
|
||||
return &parsed, string(raw), nil
|
||||
}
|
||||
|
||||
func (s *Service) getJimengConfig() (*types.JimengConfig, error) {
|
||||
var configRow model.Config
|
||||
if err := s.db.Where("name = ?", types.ConfigKeyJimeng).First(&configRow).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jimengConfig types.JimengConfig
|
||||
if err := utils.JsonDecode(configRow.Value, &jimengConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(jimengConfig.ApiKey) == "" {
|
||||
return nil, fmt.Errorf("jimeng api key 未配置")
|
||||
}
|
||||
return &jimengConfig, nil
|
||||
}
|
||||
|
||||
// UpdateJobStatus 更新任务状态
|
||||
func (s *Service) UpdateJobStatus(jobId uint, status types.JMTaskStatus, errMsg string) error {
|
||||
updates := map[string]any{
|
||||
@@ -446,6 +751,142 @@ func (s *Service) GetTaskStats() (map[string]any, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// runJimengSuccessMediaRepairLoop 定时修复:状态已是 success 但进度未满且媒体地址均为空的任务,从 raw_data 重新解析并转存。
|
||||
func (s *Service) runJimengSuccessMediaRepairLoop() {
|
||||
ticker := time.NewTicker(jimengMediaRepairInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
logger.Info("Jimeng success-media repair loop stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.repairJimengSuccessJobsMediaOnce()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) repairJimengSuccessJobsMediaOnce() {
|
||||
var jobs []model.JimengJob
|
||||
err := s.db.Where("status = ?", types.JMTaskStatusSuccess).
|
||||
Where("progress <> ?", 100).
|
||||
Where("(COALESCE(img_url, '') = ? AND COALESCE(video_url, '') = ?)", "", "").
|
||||
Where("raw_data IS NOT NULL AND raw_data <> ?", "").
|
||||
Order("id ASC").
|
||||
Limit(jimengMediaRepairBatch).
|
||||
Find(&jobs).Error
|
||||
if err != nil {
|
||||
logger.Errorf("jimeng media repair query failed: %v", err)
|
||||
return
|
||||
}
|
||||
if len(jobs) == 0 {
|
||||
return
|
||||
}
|
||||
for i := range jobs {
|
||||
job := jobs[i]
|
||||
remoteImg, remoteVid := parseJimengRawMediaURLs(job.RawData)
|
||||
if strings.TrimSpace(remoteImg) == "" && strings.TrimSpace(remoteVid) == "" {
|
||||
logger.Warnf("jimeng media repair: job_id=%d no media url in raw_data", job.Id)
|
||||
continue
|
||||
}
|
||||
updates := map[string]any{
|
||||
"updated_at": time.Now(),
|
||||
"progress": 100,
|
||||
}
|
||||
if strings.TrimSpace(remoteImg) != "" {
|
||||
updates["img_url"] = s.putJimengRemoteMedia(strings.TrimSpace(remoteImg), ".png", false)
|
||||
}
|
||||
if strings.TrimSpace(remoteVid) != "" {
|
||||
updates["video_url"] = s.putJimengRemoteMedia(strings.TrimSpace(remoteVid), ".mp4", true)
|
||||
}
|
||||
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(updates).Error; err != nil {
|
||||
logger.Errorf("jimeng media repair update failed job_id=%d: %v", job.Id, err)
|
||||
} else {
|
||||
logger.Infof("jimeng media repair ok job_id=%d", job.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// parseJimengRawMediaURLs 从 raw_data 解析远程图片/视频地址(即梦异步、Ark Seedream、Seedance)。
|
||||
func parseJimengRawMediaURLs(raw string) (remoteImg, remoteVideo string) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", ""
|
||||
}
|
||||
var root map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &root); err != nil {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if dataRaw, ok := root["data"]; ok {
|
||||
dataBytes := []byte(dataRaw)
|
||||
trimmed := bytes.TrimSpace(dataBytes)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
var items []struct {
|
||||
Url *string `json:"url"`
|
||||
}
|
||||
if json.Unmarshal(dataBytes, &items) == nil {
|
||||
for _, it := range items {
|
||||
if it.Url != nil && strings.TrimSpace(*it.Url) != "" {
|
||||
return strings.TrimSpace(*it.Url), ""
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var qd struct {
|
||||
ImageUrls []string `json:"image_urls"`
|
||||
VideoUrl string `json:"video_url"`
|
||||
}
|
||||
if json.Unmarshal(dataBytes, &qd) == nil {
|
||||
img := ""
|
||||
if len(qd.ImageUrls) > 0 {
|
||||
img = strings.TrimSpace(qd.ImageUrls[0])
|
||||
}
|
||||
vid := strings.TrimSpace(qd.VideoUrl)
|
||||
if img != "" || vid != "" {
|
||||
return img, vid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if contentRaw, ok := root["content"]; ok {
|
||||
var c struct {
|
||||
VideoURL string `json:"video_url"`
|
||||
}
|
||||
if json.Unmarshal(contentRaw, &c) == nil && strings.TrimSpace(c.VideoURL) != "" {
|
||||
return "", strings.TrimSpace(c.VideoURL)
|
||||
}
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (s *Service) putJimengRemoteMedia(remote, fallbackExt string, isVideo bool) string {
|
||||
remote = strings.TrimSpace(remote)
|
||||
if remote == "" {
|
||||
return ""
|
||||
}
|
||||
ext := fallbackExt
|
||||
if !isVideo {
|
||||
u := remote
|
||||
if i := strings.Index(u, "?"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
if e := filepath.Ext(u); e != "" {
|
||||
ext = e
|
||||
}
|
||||
} else {
|
||||
ext = ".mp4"
|
||||
}
|
||||
out, err := s.uploader.GetUploadHandler().PutUrlFile(remote, ext, false)
|
||||
if err != nil {
|
||||
logger.Errorf("jimeng putJimengRemoteMedia failed: %v", err)
|
||||
return remote
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetJob 获取任务
|
||||
func (s *Service) GetJob(jobId uint) (*model.JimengJob, error) {
|
||||
var job model.JimengJob
|
||||
|
||||
@@ -124,10 +124,17 @@ type CreateTaskRequest struct {
|
||||
}
|
||||
|
||||
const (
|
||||
ImageEffectReqKey = "i2i_multi_style_zx2x"
|
||||
DoubaoSeedream40ReqKey = "doubao-seedream-4-0-250828"
|
||||
ImageEffectReqKey = "i2i_multi_style_zx2x"
|
||||
)
|
||||
|
||||
func IsSeedreamReqKey(reqKey string) bool {
|
||||
return len(reqKey) >= len("doubao-seedream-") && reqKey[:len("doubao-seedream-")] == "doubao-seedream-"
|
||||
}
|
||||
|
||||
func IsSeedanceReqKey(reqKey string) bool {
|
||||
return len(reqKey) >= len("doubao-seedance-") && reqKey[:len("doubao-seedance-")] == "doubao-seedance-"
|
||||
}
|
||||
|
||||
const (
|
||||
ASyncActionSubmit = "CVSync2AsyncSubmitTask" // 异步提交任务
|
||||
SyncActionSubmit = "CVSubmitTask" // 同步提交任务
|
||||
|
||||
@@ -388,6 +388,14 @@ func (s *MigrationService) incrementalTableMigration() {
|
||||
if s.db.Migrator().HasColumn(&model.User{}, "chat_config") {
|
||||
s.db.Migrator().DropColumn(&model.User{}, "chat_config")
|
||||
}
|
||||
var hasChatRolesJSON int
|
||||
if s.db.Raw("SELECT COUNT(1) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'geekai_users' AND COLUMN_NAME = 'chat_roles_json'").Scan(&hasChatRolesJSON).Error == nil && hasChatRolesJSON > 0 {
|
||||
if err := s.db.Exec("ALTER TABLE geekai_users DROP COLUMN chat_roles_json").Error; err != nil {
|
||||
logger.Errorf("删除 geekai_users.chat_roles_json 失败: %v", err)
|
||||
} else {
|
||||
logger.Info("geekai_users: 已删除 chat_roles_json 列")
|
||||
}
|
||||
}
|
||||
if s.db.Migrator().HasColumn(&model.ChatModel{}, "category") {
|
||||
s.db.Migrator().DropColumn(&model.ChatModel{}, "category")
|
||||
}
|
||||
@@ -406,6 +414,7 @@ func (s *MigrationService) incrementalTableMigration() {
|
||||
if s.db.Migrator().HasColumn(&model.Product{}, "url") {
|
||||
s.db.Migrator().DropColumn(&model.Product{}, "url")
|
||||
}
|
||||
s.migrateProductStripePrice()
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "water_url") {
|
||||
s.db.Migrator().DropColumn(&model.VideoJob{}, "water_url")
|
||||
}
|
||||
@@ -429,17 +438,7 @@ func (s *MigrationService) incrementalTableMigration() {
|
||||
}
|
||||
|
||||
// ========== 数据迁移:根据业务逻辑更新现有数据 ==========
|
||||
|
||||
// video_job: 根据 progress 填充 status
|
||||
if s.db.Migrator().HasColumn(&model.VideoJob{}, "status") {
|
||||
s.db.Exec(`UPDATE geekai_video_jobs SET status = CASE
|
||||
WHEN progress < 100 THEN 'in_progress'
|
||||
WHEN progress = 100 THEN 'success'
|
||||
WHEN progress = 101 THEN 'failed'
|
||||
WHEN progress = 102 THEN 'downloading'
|
||||
ELSE 'pending'
|
||||
END WHERE status = '' OR status IS NULL`)
|
||||
}
|
||||
s.migrateTaskStatuses()
|
||||
|
||||
// suno_job: 从 output 提取 tags/model_name 填入 params
|
||||
s.migrateSunoJobData()
|
||||
@@ -455,6 +454,96 @@ func (s *MigrationService) TableMigration() {
|
||||
s.migrateChatAppSystemPromptFromJSON()
|
||||
}
|
||||
|
||||
// migrateTaskStatuses 将历史 progress 魔法数与 status 对齐(幂等)。
|
||||
// 顺序:先处理 101/102/100 等离散值,再 1–99 进行中,最后 progress=0 为排队,避免旧逻辑里 progress<100 把 0 标成 in_progress。
|
||||
func (s *MigrationService) migrateTaskStatuses() {
|
||||
migrator := s.db.Migrator()
|
||||
exec := func(label, sql string, args ...interface{}) {
|
||||
if err := s.db.Exec(sql, args...).Error; err != nil {
|
||||
logger.Warnf("回填 %s 失败(可忽略若表/列不存在): %v", label, err)
|
||||
}
|
||||
}
|
||||
|
||||
// image_jobs:与 image_service 约定一致(失败 progress=0;下载中 progress=100)
|
||||
if migrator.HasTable("geekai_image_jobs") && migrator.HasColumn(&model.ImageJob{}, "status") {
|
||||
exec("geekai_image_jobs failed(101)",
|
||||
"UPDATE `geekai_image_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 101",
|
||||
model.ImageStatusFailed)
|
||||
exec("geekai_image_jobs downloading(102)",
|
||||
"UPDATE `geekai_image_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 102",
|
||||
model.ImageStatusDownloading)
|
||||
exec("geekai_image_jobs success(100)",
|
||||
"UPDATE `geekai_image_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 100",
|
||||
model.ImageStatusSuccess)
|
||||
exec("geekai_image_jobs in_progress(1-99)",
|
||||
"UPDATE `geekai_image_jobs` SET `status` = ? WHERE `progress` >= 1 AND `progress` <= 99",
|
||||
model.ImageStatusInProgress)
|
||||
exec("geekai_image_jobs pending(0)",
|
||||
"UPDATE `geekai_image_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 0",
|
||||
model.ImageStatusPending)
|
||||
}
|
||||
|
||||
// mj_jobs:含历史 progress=-1 失败
|
||||
if migrator.HasTable("geekai_mj_jobs") && migrator.HasColumn(&model.MidJourneyJob{}, "status") {
|
||||
exec("geekai_mj_jobs failed(101)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 101",
|
||||
model.ImageStatusFailed)
|
||||
exec("geekai_mj_jobs failed(-1)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = -1",
|
||||
model.ImageStatusFailed)
|
||||
exec("geekai_mj_jobs downloading(102)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 102",
|
||||
model.ImageStatusDownloading)
|
||||
exec("geekai_mj_jobs success(100)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 100",
|
||||
model.ImageStatusSuccess)
|
||||
exec("geekai_mj_jobs in_progress(1-99)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ? WHERE `progress` >= 1 AND `progress` <= 99",
|
||||
model.ImageStatusInProgress)
|
||||
exec("geekai_mj_jobs pending(0)",
|
||||
"UPDATE `geekai_mj_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 0",
|
||||
model.ImageStatusPending)
|
||||
}
|
||||
|
||||
// suno_jobs:与五态常量一致,去掉重复的 101 更新
|
||||
if migrator.HasTable("geekai_suno_jobs") && migrator.HasColumn(&model.SunoJob{}, "status") {
|
||||
exec("geekai_suno_jobs failed(101)",
|
||||
"UPDATE `geekai_suno_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 101",
|
||||
model.ImageStatusFailed)
|
||||
exec("geekai_suno_jobs downloading(102)",
|
||||
"UPDATE `geekai_suno_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 102",
|
||||
model.ImageStatusDownloading)
|
||||
exec("geekai_suno_jobs success(100)",
|
||||
"UPDATE `geekai_suno_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 100",
|
||||
model.ImageStatusSuccess)
|
||||
exec("geekai_suno_jobs in_progress(1-99)",
|
||||
"UPDATE `geekai_suno_jobs` SET `status` = ? WHERE `progress` >= 1 AND `progress` <= 99",
|
||||
model.ImageStatusInProgress)
|
||||
exec("geekai_suno_jobs pending(0)",
|
||||
"UPDATE `geekai_suno_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 0",
|
||||
model.ImageStatusPending)
|
||||
}
|
||||
|
||||
// video_jobs:状态字符串与 Image 五态相同
|
||||
if migrator.HasTable("geekai_video_jobs") && migrator.HasColumn(&model.VideoJob{}, "status") {
|
||||
exec("geekai_video_jobs failed(101)",
|
||||
"UPDATE `geekai_video_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 101",
|
||||
types.VideoStatusFailed)
|
||||
exec("geekai_video_jobs downloading(102)",
|
||||
"UPDATE `geekai_video_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 102",
|
||||
types.VideoStatusDownloading)
|
||||
exec("geekai_video_jobs success(100)",
|
||||
"UPDATE `geekai_video_jobs` SET `status` = ?, `progress` = 100 WHERE `progress` = 100",
|
||||
types.VideoStatusSuccess)
|
||||
exec("geekai_video_jobs in_progress(1-99)",
|
||||
"UPDATE `geekai_video_jobs` SET `status` = ? WHERE `progress` >= 1 AND `progress` <= 99",
|
||||
types.VideoStatusInProgress)
|
||||
exec("geekai_video_jobs pending(0)",
|
||||
"UPDATE `geekai_video_jobs` SET `status` = ?, `progress` = 0 WHERE `progress` = 0",
|
||||
types.VideoStatusPending)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateChatAppSystemPromptFromJSON 将智能体 system_prompt 字段中历史 JSON 数组
|
||||
// 解析后取出 role 为 system 的 content,覆盖回 system_prompt(纯文本)
|
||||
func (s *MigrationService) migrateChatAppSystemPromptFromJSON() {
|
||||
@@ -542,6 +631,11 @@ func (s *MigrationService) migratePaymentConfig(config *types.AppConfig) error {
|
||||
Alipay: config.AlipayConfig,
|
||||
Epay: config.GeekPayConfig,
|
||||
WxPay: config.WechatPayConfig,
|
||||
Stripe: types.StripeConfig{
|
||||
Enabled: false,
|
||||
Currency: "usd",
|
||||
Domain: config.ProxyURL,
|
||||
},
|
||||
}
|
||||
if err := s.saveConfig(types.ConfigKeyPayment, paymentConfig); err != nil {
|
||||
return err
|
||||
@@ -703,3 +797,36 @@ func (s *MigrationService) migrateSunoJobData() {
|
||||
logger.Infof("SunoJob 数据合并完成,共更新 %d 条记录", updatedCount)
|
||||
s.redisClient.Set(context.Background(), key, "1", 0)
|
||||
}
|
||||
|
||||
// migrateProductStripePrice 回填 Stripe 专用价格。
|
||||
// 仅对 stripe_price 为 0 的记录执行回填,避免覆盖已手工配置的 Stripe 价格。
|
||||
func (s *MigrationService) migrateProductStripePrice() {
|
||||
key := "migrate:product_stripe_price"
|
||||
if s.redisClient.Get(context.Background(), key).Val() == "1" {
|
||||
logger.Info("Product stripe_price 已迁移,跳过")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("开始迁移 Product stripe_price...")
|
||||
|
||||
var products []model.Product
|
||||
if err := s.db.Where("stripe_price = 0 OR stripe_price IS NULL").Find(&products).Error; err != nil {
|
||||
logger.Errorf("查询 Product 数据失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
updatedCount := 0
|
||||
for _, product := range products {
|
||||
if product.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.db.Model(&model.Product{}).Where("id = ?", product.Id).Update("stripe_price", product.Price).Error; err != nil {
|
||||
logger.Errorf("回填 Product stripe_price 失败 (ID: %d): %v", product.Id, err)
|
||||
continue
|
||||
}
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
logger.Infof("Product stripe_price 迁移完成,共更新 %d 条记录", updatedCount)
|
||||
s.redisClient.Set(context.Background(), key, "1", 0)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func NewService(redisCli *redis.Client, db *gorm.DB, client *Client, manager *os
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的人物加载到队列
|
||||
var jobs []model.MidJourneyJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
s.db.Where("task_id", "").Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress}).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.MjTask
|
||||
err := utils.JsonDecode(v.TaskInfo, &task)
|
||||
@@ -80,6 +80,11 @@ func (s *Service) Run() {
|
||||
}
|
||||
|
||||
logger.Infof("handle a new MidJourney task: %+v", task)
|
||||
s.db.Model(&model.MidJourneyJob{Id: task.Id}).UpdateColumns(map[string]any{
|
||||
"status": model.ImageStatusInProgress,
|
||||
"progress": 1,
|
||||
"err_msg": "",
|
||||
})
|
||||
var res ImageRes
|
||||
switch task.Type {
|
||||
case types.TaskImage:
|
||||
@@ -111,7 +116,8 @@ func (s *Service) Run() {
|
||||
}
|
||||
|
||||
logger.Error("绘画任务执行失败:", errMsg)
|
||||
job.Progress = service.FailTaskProgress
|
||||
job.Status = model.ImageStatusFailed
|
||||
job.Progress = 0
|
||||
job.ErrMsg = errMsg
|
||||
// update the task progress
|
||||
s.db.Updates(&job)
|
||||
@@ -123,6 +129,7 @@ func (s *Service) Run() {
|
||||
job.TaskId = res.Result
|
||||
job.MessageId = res.Result
|
||||
job.ChannelId = res.Channel
|
||||
job.Status = model.ImageStatusInProgress
|
||||
s.db.Updates(&job)
|
||||
}
|
||||
}()
|
||||
@@ -158,7 +165,7 @@ func (s *Service) DownloadImages() {
|
||||
go func() {
|
||||
var items []model.MidJourneyJob
|
||||
for {
|
||||
res := s.db.Where("img_url = ? AND progress = ?", "", 100).Find(&items)
|
||||
res := s.db.Where("img_url = ? AND status = ?", "", model.ImageStatusDownloading).Find(&items)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -185,6 +192,7 @@ func (s *Service) DownloadImages() {
|
||||
}
|
||||
|
||||
v.ImgURL = imgURL
|
||||
v.Status = model.ImageStatusSuccess
|
||||
s.db.Updates(&v)
|
||||
}
|
||||
|
||||
@@ -206,7 +214,7 @@ func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
var jobs []model.MidJourneyJob
|
||||
for {
|
||||
res := s.db.Where("progress < ?", 100).Where("channel_id <> ?", "").Find(&jobs)
|
||||
res := s.db.Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress, model.ImageStatusDownloading}).Where("channel_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -214,7 +222,8 @@ func (s *Service) SyncTaskProgress() {
|
||||
for _, job := range jobs {
|
||||
// 10 分钟还没完成的任务标记为失败
|
||||
if time.Since(job.CreatedAt) > time.Minute*10 {
|
||||
job.Progress = service.FailTaskProgress
|
||||
job.Status = model.ImageStatusFailed
|
||||
job.Progress = 0
|
||||
job.ErrMsg = "任务超时"
|
||||
s.db.Updates(&job)
|
||||
continue
|
||||
@@ -229,7 +238,8 @@ func (s *Service) SyncTaskProgress() {
|
||||
// 任务执行失败了
|
||||
if task.FailReason != "" {
|
||||
s.db.Model(&model.MidJourneyJob{Id: job.Id}).UpdateColumns(map[string]interface{}{
|
||||
"progress": service.FailTaskProgress,
|
||||
"status": model.ImageStatusFailed,
|
||||
"progress": 0,
|
||||
"err_msg": task.FailReason,
|
||||
})
|
||||
logger.Errorf("task failed: %v", task.FailReason)
|
||||
@@ -243,6 +253,11 @@ func (s *Service) SyncTaskProgress() {
|
||||
if task.ImageUrl != "" {
|
||||
job.OrgURL = task.ImageUrl
|
||||
}
|
||||
if job.Progress >= 100 {
|
||||
job.Status = model.ImageStatusDownloading
|
||||
} else {
|
||||
job.Status = model.ImageStatusInProgress
|
||||
}
|
||||
err = s.db.Updates(&job).Error
|
||||
if err != nil {
|
||||
logger.Errorf("error with update database: %v", err)
|
||||
@@ -250,13 +265,13 @@ func (s *Service) SyncTaskProgress() {
|
||||
}
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
s.db.Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
// 找出失败的任务,并恢复其扣减积分
|
||||
s.db.Where("status", model.ImageStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: "mid-journey",
|
||||
Remark: fmt.Sprintf("任务失败,退回算力。任务ID:%d,Err: %s", job.Id, job.ErrMsg),
|
||||
Remark: fmt.Sprintf("任务失败,退回积分。任务ID:%d,Err: %s", job.Id, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
package payment
|
||||
|
||||
// 支付渠道定义
|
||||
const PayChannelAL = "alipay" // 支付宝
|
||||
const PayChannelWX = "wxpay" // 微信支付
|
||||
const PayChannelEpay = "epay" // 易支付
|
||||
const PayChannelAL = "alipay" // 支付宝
|
||||
const PayChannelWX = "wxpay" // 微信支付
|
||||
const PayChannelEpay = "epay" // 易支付
|
||||
const PayChannelStripe = "stripe" // Stripe
|
||||
|
||||
// 支付方式
|
||||
const PayWayAL = "alipay"
|
||||
const PayWayWX = "wxpay"
|
||||
const PayWayStripe = "stripe"
|
||||
|
||||
const (
|
||||
Success = 0
|
||||
@@ -19,6 +21,7 @@ type PayRequest struct {
|
||||
OutTradeNo string // 商户订单号
|
||||
Subject string // 商品名称
|
||||
TotalFee string // 商品金额
|
||||
Currency string // 币种
|
||||
ReturnURL string // 回调地址
|
||||
NotifyURL string // 回调地址
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package payment
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stripe/stripe-go/v81"
|
||||
"github.com/stripe/stripe-go/v81/checkout/session"
|
||||
"github.com/stripe/stripe-go/v81/webhook"
|
||||
)
|
||||
|
||||
type StripeService struct {
|
||||
config *types.StripeConfig
|
||||
}
|
||||
|
||||
func NewStripeService(sysConfig *types.SystemConfig) *StripeService {
|
||||
return &StripeService{config: &sysConfig.Payment.Stripe}
|
||||
}
|
||||
|
||||
func (s *StripeService) UpdateConfig(config *types.StripeConfig) {
|
||||
s.config = config
|
||||
}
|
||||
|
||||
func (s *StripeService) Pay(params PayRequest) (string, error) {
|
||||
if s.config == nil || !s.config.Enabled {
|
||||
return "", fmt.Errorf("stripe service disabled")
|
||||
}
|
||||
if s.config.SecretKey == "" {
|
||||
return "", fmt.Errorf("stripe secret key is empty")
|
||||
}
|
||||
|
||||
stripe.Key = s.config.SecretKey
|
||||
currency := strings.ToLower(s.config.Currency)
|
||||
if currency == "" {
|
||||
currency = "usd"
|
||||
}
|
||||
|
||||
amount := parseMoneyToMinorUnit(params.TotalFee, currency)
|
||||
if amount <= 0 {
|
||||
return "", fmt.Errorf("invalid stripe amount: %s", params.TotalFee)
|
||||
}
|
||||
|
||||
successURL := strings.TrimRight(params.ReturnURL, "/")
|
||||
if successURL == "" {
|
||||
successURL = strings.TrimRight(s.config.Domain, "/")
|
||||
}
|
||||
cancelURL := successURL
|
||||
sessionParams := &stripe.CheckoutSessionParams{
|
||||
Mode: stripe.String(string(stripe.CheckoutSessionModePayment)),
|
||||
LineItems: []*stripe.CheckoutSessionLineItemParams{
|
||||
{
|
||||
PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
|
||||
Currency: stripe.String(currency),
|
||||
ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
|
||||
Name: stripe.String(params.Subject),
|
||||
},
|
||||
UnitAmount: stripe.Int64(amount),
|
||||
},
|
||||
Quantity: stripe.Int64(1),
|
||||
},
|
||||
},
|
||||
SuccessURL: stripe.String(successURL + "?session_id={CHECKOUT_SESSION_ID}&order_no=" + params.OutTradeNo),
|
||||
CancelURL: stripe.String(cancelURL),
|
||||
Metadata: map[string]string{
|
||||
"order_no": params.OutTradeNo,
|
||||
},
|
||||
PaymentIntentData: &stripe.CheckoutSessionPaymentIntentDataParams{
|
||||
Metadata: map[string]string{
|
||||
"order_no": params.OutTradeNo,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sess, err := session.New(sessionParams)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error with create stripe checkout session: %w", err)
|
||||
}
|
||||
|
||||
return sess.URL, nil
|
||||
}
|
||||
|
||||
func (s *StripeService) Query(outTradeNo string) (OrderInfo, error) {
|
||||
return OrderInfo{}, fmt.Errorf("stripe orders are verified by webhook")
|
||||
}
|
||||
|
||||
func (s *StripeService) TradeVerify(request *http.Request) (OrderInfo, error) {
|
||||
if s.config == nil || !s.config.Enabled {
|
||||
return OrderInfo{}, fmt.Errorf("stripe service disabled")
|
||||
}
|
||||
payload, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
return OrderInfo{}, fmt.Errorf("error with read stripe webhook body: %w", err)
|
||||
}
|
||||
body, err := webhook.ConstructEvent(payload, request.Header.Get("Stripe-Signature"), s.config.WebhookKey)
|
||||
if err != nil {
|
||||
return OrderInfo{}, fmt.Errorf("error with verify stripe webhook: %w", err)
|
||||
}
|
||||
|
||||
switch body.Type {
|
||||
case "checkout.session.completed":
|
||||
var sess stripe.CheckoutSession
|
||||
if err := json.Unmarshal(body.Data.Raw, &sess); err != nil {
|
||||
return OrderInfo{}, fmt.Errorf("error with parse stripe session: %w", err)
|
||||
}
|
||||
if sess.Metadata["order_no"] == "" {
|
||||
return OrderInfo{}, fmt.Errorf("missing order number in stripe metadata")
|
||||
}
|
||||
payTime := time.Unix(body.Created, 0).Format("2006-01-02 15:04:05")
|
||||
tradeID := ""
|
||||
if sess.PaymentIntent != nil {
|
||||
tradeID = sess.PaymentIntent.ID
|
||||
}
|
||||
return OrderInfo{
|
||||
Status: Success,
|
||||
OutTradeNo: sess.Metadata["order_no"],
|
||||
TradeId: strings.TrimSpace(tradeID),
|
||||
Amount: fmt.Sprintf("%.2f", float64(sess.AmountTotal)/100),
|
||||
PayTime: payTime,
|
||||
}, nil
|
||||
default:
|
||||
return OrderInfo{}, fmt.Errorf("unsupported stripe event: %s", body.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func parseMoneyToMinorUnit(amount string, currency string) int64 {
|
||||
if amount == "" {
|
||||
return 0
|
||||
}
|
||||
if strings.EqualFold(currency, "jpy") {
|
||||
return int64(math.Round(parseFloat(amount)))
|
||||
}
|
||||
return int64(math.Round(parseFloat(amount) * 100))
|
||||
}
|
||||
|
||||
func parseFloat(s string) float64 {
|
||||
var v float64
|
||||
_, _ = fmt.Sscanf(s, "%f", &v)
|
||||
return v
|
||||
}
|
||||
|
||||
var _ PayService = (*StripeService)(nil)
|
||||
@@ -21,17 +21,33 @@ type SmtpService struct {
|
||||
config *types.SmtpConfig
|
||||
}
|
||||
|
||||
func NewSmtpService(appConfig *types.AppConfig) *SmtpService {
|
||||
return &SmtpService{
|
||||
config: &appConfig.SmtpConfig,
|
||||
// NewSmtpService 优先使用数据库中的 SMTP 配置(管理后台保存),否则回退到 config.toml。
|
||||
func NewSmtpService(appConfig *types.AppConfig, sysConfig *types.SystemConfig) *SmtpService {
|
||||
s := &SmtpService{}
|
||||
if sysConfig.SMTP.Host != "" && sysConfig.SMTP.Port > 0 {
|
||||
c := sysConfig.SMTP
|
||||
s.config = &c
|
||||
} else {
|
||||
s.config = &appConfig.SmtpConfig
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SmtpService) UpdateConfig(config *types.SmtpConfig) {
|
||||
s.config = config
|
||||
}
|
||||
|
||||
func (s *SmtpService) smtpConfigured() error {
|
||||
if s.config == nil || s.config.Host == "" || s.config.Port <= 0 {
|
||||
return fmt.Errorf("SMTP 未配置或无效:请在管理后台填写邮件服务器地址和端口")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SmtpService) SendVerifyCode(to string, code int) error {
|
||||
if err := s.smtpConfigured(); err != nil {
|
||||
return err
|
||||
}
|
||||
subject := fmt.Sprintf("%s 注册验证码", s.config.AppName)
|
||||
body := fmt.Sprintf("【%s】:您的验证码为 %d,请不要告诉他人。如非本人操作,请忽略此邮件。", s.config.AppName, code)
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *Service) PushTask(task types.SunoTask) {
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的人物加载到队列
|
||||
var jobs []model.SunoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
s.db.Where("task_id", "").Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress}).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
// 从 Params 中提取字段构建 task
|
||||
task := types.SunoTask{
|
||||
@@ -91,6 +91,11 @@ func (s *Service) Run() {
|
||||
logger.Errorf("taking task with error: %v", err)
|
||||
continue
|
||||
}
|
||||
s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]any{
|
||||
"status": model.ImageStatusInProgress,
|
||||
"progress": 1,
|
||||
"err_msg": "",
|
||||
})
|
||||
var r RespVo
|
||||
if task.Type == 3 && task.SongId != "" { // 歌曲拼接
|
||||
r, err = s.Merge(task)
|
||||
@@ -103,7 +108,8 @@ func (s *Service) Run() {
|
||||
logger.Errorf("create task with error: %v", err)
|
||||
s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"err_msg": err.Error(),
|
||||
"progress": service.FailTaskProgress,
|
||||
"status": model.ImageStatusFailed,
|
||||
"progress": 101,
|
||||
})
|
||||
continue
|
||||
}
|
||||
@@ -112,8 +118,10 @@ func (s *Service) Run() {
|
||||
|
||||
// 更新任务信息
|
||||
s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
|
||||
"task_id": r.Data,
|
||||
"channel": r.Channel,
|
||||
"task_id": r.Data,
|
||||
"channel": r.Channel,
|
||||
"status": model.ImageStatusInProgress,
|
||||
"progress": 1,
|
||||
})
|
||||
}
|
||||
}()
|
||||
@@ -281,7 +289,7 @@ func (s *Service) DownloadFiles() {
|
||||
go func() {
|
||||
var items []model.SunoJob
|
||||
for {
|
||||
res := s.db.Where("progress", 102).Find(&items)
|
||||
res := s.db.Where("status", model.ImageStatusDownloading).Find(&items)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -304,6 +312,7 @@ func (s *Service) DownloadFiles() {
|
||||
v.CoverURL = coverURL
|
||||
v.AudioURL = audioURL
|
||||
v.Progress = 100
|
||||
v.Status = model.ImageStatusSuccess
|
||||
s.db.Updates(&v)
|
||||
}
|
||||
|
||||
@@ -317,7 +326,7 @@ func (s *Service) SyncTaskProgress() {
|
||||
go func() {
|
||||
var jobs []model.SunoJob
|
||||
for {
|
||||
res := s.db.Where("progress < ?", 100).Where("task_id <> ?", "").Find(&jobs)
|
||||
res := s.db.Where("status IN ?", []string{model.ImageStatusPending, model.ImageStatusInProgress, model.ImageStatusDownloading}).Where("task_id <> ?", "").Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -342,7 +351,8 @@ func (s *Service) SyncTaskProgress() {
|
||||
tx := s.db.Begin()
|
||||
for _, v := range task.Data.Data {
|
||||
job.Id = 0
|
||||
job.Progress = 102 // 102 表示资源未下载完成
|
||||
job.Progress = 100
|
||||
job.Status = model.ImageStatusDownloading // 资源待下载
|
||||
job.Title = v.Title
|
||||
job.SongId = v.Id
|
||||
job.Duration = int(v.Metadata.Duration)
|
||||
@@ -384,20 +394,21 @@ func (s *Service) SyncTaskProgress() {
|
||||
}
|
||||
tx.Commit()
|
||||
} else if task.Data.FailReason != "" {
|
||||
job.Progress = service.FailTaskProgress
|
||||
job.Status = model.ImageStatusFailed
|
||||
job.Progress = 101
|
||||
job.ErrMsg = task.Data.FailReason
|
||||
s.db.Updates(&job)
|
||||
}
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
// 找出失败的任务,并恢复其扣减积分
|
||||
s.db.Select("id", "user_id", "power", "task_id", "err_msg", "params").
|
||||
Where("progress", service.FailTaskProgress).Where("power > ?", 0).Find(&jobs)
|
||||
Where("status", model.ImageStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Params.Model,
|
||||
Remark: fmt.Sprintf("Suno 任务失败,退回算力。任务ID:%s,Err:%s", job.TaskId, job.ErrMsg),
|
||||
Remark: fmt.Sprintf("Suno 任务失败,退回积分。任务ID:%s,Err:%s", job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -2,7 +2,6 @@ package service
|
||||
|
||||
import "geekai/log"
|
||||
|
||||
const FailTaskProgress = 101
|
||||
const (
|
||||
TaskStatusRunning = "RUNNING"
|
||||
TaskStatusFinished = "FINISH"
|
||||
|
||||
@@ -13,13 +13,22 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"geekai/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
|
||||
"github.com/imroc/req/v3"
|
||||
"github.com/nfnt/resize"
|
||||
_ "golang.org/x/image/webp"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -141,10 +150,10 @@ func (a *SoraAdapter) CreateTask(task types.VideoTask, videoConfig *types.VideoC
|
||||
|
||||
// 其他场景:保持原来的 JSON 调用,input_reference 继续传 URL 字符串
|
||||
reqBody := SoraCreateRequest{
|
||||
Model: model,
|
||||
Prompt: task.Prompt,
|
||||
Size: size,
|
||||
Seconds: seconds,
|
||||
Model: model,
|
||||
Prompt: task.Prompt,
|
||||
Size: size,
|
||||
Seconds: seconds,
|
||||
Watermark: watermark,
|
||||
}
|
||||
|
||||
@@ -201,6 +210,12 @@ func (a *SoraAdapter) createOfficialSoraTask(task types.VideoTask, videoConfig *
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("下载参考图片失败:%v", err)
|
||||
}
|
||||
if size != "" {
|
||||
imgData, err = normalizeImageBytesForSize(imgData, size)
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, fmt.Errorf("处理参考图片尺寸失败:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
writer := multipart.NewWriter(&buf)
|
||||
@@ -224,7 +239,7 @@ func (a *SoraAdapter) createOfficialSoraTask(task types.VideoTask, videoConfig *
|
||||
}
|
||||
|
||||
// 文件字段
|
||||
fileWriter, err := writer.CreateFormFile("input_reference", "image")
|
||||
fileWriter, err := writer.CreateFormFile("input_reference", "image.png")
|
||||
if err != nil {
|
||||
return CreateTaskResponse{}, err
|
||||
}
|
||||
@@ -281,6 +296,86 @@ func downloadImageBytes(imageURL string) ([]byte, error) {
|
||||
return body, err
|
||||
}
|
||||
|
||||
// normalizeImageBytesForSize 将参考图等比缩放并裁切为目标尺寸,确保与 Sora size 参数一致
|
||||
func normalizeImageBytesForSize(imageData []byte, size string) ([]byte, error) {
|
||||
targetWidth, targetHeight, err := parseVideoSize(size)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if targetWidth <= 0 || targetHeight <= 0 {
|
||||
return nil, fmt.Errorf("无效的图片尺寸: %s", size)
|
||||
}
|
||||
|
||||
srcImage, _, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解码参考图片失败:%w", err)
|
||||
}
|
||||
srcBounds := srcImage.Bounds()
|
||||
srcWidth := srcBounds.Dx()
|
||||
srcHeight := srcBounds.Dy()
|
||||
if srcWidth <= 0 || srcHeight <= 0 {
|
||||
return nil, fmt.Errorf("参考图片尺寸无效")
|
||||
}
|
||||
|
||||
if srcWidth == targetWidth && srcHeight == targetHeight {
|
||||
var exactBuf bytes.Buffer
|
||||
if err = png.Encode(&exactBuf, srcImage); err != nil {
|
||||
return nil, fmt.Errorf("编码参考图片失败:%w", err)
|
||||
}
|
||||
return exactBuf.Bytes(), nil
|
||||
}
|
||||
|
||||
scaleByWidth := float64(targetWidth) / float64(srcWidth)
|
||||
scaleByHeight := float64(targetHeight) / float64(srcHeight)
|
||||
scale := scaleByWidth
|
||||
if scaleByHeight > scale {
|
||||
scale = scaleByHeight
|
||||
}
|
||||
|
||||
resizedWidth := int(float64(srcWidth) * scale)
|
||||
resizedHeight := int(float64(srcHeight) * scale)
|
||||
if resizedWidth < targetWidth {
|
||||
resizedWidth = targetWidth
|
||||
}
|
||||
if resizedHeight < targetHeight {
|
||||
resizedHeight = targetHeight
|
||||
}
|
||||
|
||||
resizedImage := resize.Resize(uint(resizedWidth), uint(resizedHeight), srcImage, resize.Lanczos3)
|
||||
offsetX := (resizedWidth - targetWidth) / 2
|
||||
offsetY := (resizedHeight - targetHeight) / 2
|
||||
targetRect := image.Rect(0, 0, targetWidth, targetHeight)
|
||||
croppedImage := image.NewRGBA(targetRect)
|
||||
for y := 0; y < targetHeight; y++ {
|
||||
for x := 0; x < targetWidth; x++ {
|
||||
croppedImage.Set(x, y, resizedImage.At(x+offsetX, y+offsetY))
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err = png.Encode(&out, croppedImage); err != nil {
|
||||
return nil, fmt.Errorf("编码裁切后的参考图片失败:%w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// parseVideoSize 解析 "720x1280" 这样的尺寸字符串
|
||||
func parseVideoSize(size string) (int, int, error) {
|
||||
parts := strings.Split(strings.TrimSpace(size), "x")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("非法尺寸格式: %s", size)
|
||||
}
|
||||
width, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("解析宽度失败: %w", err)
|
||||
}
|
||||
height, err := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("解析高度失败: %w", err)
|
||||
}
|
||||
return width, height, nil
|
||||
}
|
||||
|
||||
// downloadImageAsDataURL 下载远程图片并转为 data URL,避免向官方 Sora 直接传地址
|
||||
// QueryTask 查询任务状态
|
||||
func (a *SoraAdapter) QueryTask(taskId string, channel string, videoConfig *types.VideoConfig) (QueryTaskResponse, error) {
|
||||
|
||||
@@ -143,7 +143,7 @@ func (s *Service) PushTask(task types.VideoTask) {
|
||||
func (s *Service) Run() {
|
||||
// 将数据库中未提交的任务加载到队列
|
||||
var jobs []model.VideoJob
|
||||
s.db.Where("task_id", "").Where("progress", 0).Find(&jobs)
|
||||
s.db.Where("task_id", "").Where("status", types.VideoStatusPending).Find(&jobs)
|
||||
for _, v := range jobs {
|
||||
var task types.VideoTask
|
||||
err := utils.JsonDecode(v.Params, &task)
|
||||
@@ -234,7 +234,7 @@ func (s *Service) SyncTaskProgress() {
|
||||
logger.Info("[video] task status poller started")
|
||||
var jobs []model.VideoJob
|
||||
for {
|
||||
res := s.db.Where("status IN ?", []string{types.VideoStatusInProgress, types.VideoStatusPending}).Where("task_id <> ?", "").Find(&jobs)
|
||||
res := s.db.Where("status IN ?", []string{types.VideoStatusInProgress, types.VideoStatusPending, types.VideoStatusDownloading}).Find(&jobs)
|
||||
if res.Error != nil {
|
||||
continue
|
||||
}
|
||||
@@ -312,14 +312,14 @@ func (s *Service) SyncTaskProgress() {
|
||||
|
||||
}
|
||||
|
||||
// 找出失败的任务,并恢复其扣减算力
|
||||
// 找出失败的任务,并恢复其扣减积分
|
||||
s.db.Select("id", "user_id", "power", "task_id", "err_msg", "type").
|
||||
Where("status", types.VideoStatusFailed).Where("power > ?", 0).Find(&jobs)
|
||||
for _, job := range jobs {
|
||||
err := s.userService.IncreasePower(job.UserId, job.Power, model.PowerLog{
|
||||
Type: types.PowerRefund,
|
||||
Model: job.Type,
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回算力。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
Remark: fmt.Sprintf("%s 任务失败,退回积分。任务ID:%s,Err:%s", job.Type, job.TaskId, job.ErrMsg),
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"geekai/core/types"
|
||||
"geekai/utils"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wxGzhTokenSkew = 300 * time.Second
|
||||
|
||||
// GetClientCredentialToken 获取公众号 client_credential access_token(带进程内缓存)。
|
||||
func (s *WxGzhService) GetClientCredentialToken(ctx context.Context, appID, secret string) (string, error) {
|
||||
appID, secret = strings.TrimSpace(appID), strings.TrimSpace(secret)
|
||||
if appID == "" || secret == "" {
|
||||
return "", fmt.Errorf("微信公众号 AppID 或 AppSecret 未配置")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
s.ccMu.Lock()
|
||||
if s.ccForApp == appID && s.ccToken != "" && now.Before(s.ccExpire) {
|
||||
tok := s.ccToken
|
||||
s.ccMu.Unlock()
|
||||
return tok, nil
|
||||
}
|
||||
s.ccMu.Unlock()
|
||||
|
||||
raw := fmt.Sprintf(
|
||||
"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
||||
url.QueryEscape(appID), url.QueryEscape(secret),
|
||||
)
|
||||
body, status, err := utils.FetchURLBytes(ctx, raw, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("获取 access_token 失败: status=%d: %w", status, err)
|
||||
}
|
||||
|
||||
var parsed struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("解析 token 响应失败: %w", err)
|
||||
}
|
||||
if parsed.ErrCode != 0 {
|
||||
return "", fmt.Errorf("微信 API 错误(%d): %s", parsed.ErrCode, parsed.ErrMsg)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("微信未返回 access_token: %s", string(body))
|
||||
}
|
||||
|
||||
expireAt := now.Add(time.Duration(parsed.ExpiresIn) * time.Second).Add(-wxGzhTokenSkew)
|
||||
if parsed.ExpiresIn <= 0 {
|
||||
expireAt = now.Add(7000 * time.Second)
|
||||
}
|
||||
|
||||
s.ccMu.Lock()
|
||||
s.ccForApp = appID
|
||||
s.ccToken = parsed.AccessToken
|
||||
s.ccExpire = expireAt
|
||||
s.ccMu.Unlock()
|
||||
|
||||
return parsed.AccessToken, nil
|
||||
}
|
||||
|
||||
// PublishCustomMenu 调用 menu/create 全量覆盖公众号菜单。
|
||||
func (s *WxGzhService) PublishCustomMenu(ctx context.Context, appID, secret string, menu types.WxGzhMenuConfig) error {
|
||||
if err := ValidateWxGzhMenu(menu); err != nil {
|
||||
return err
|
||||
}
|
||||
token, err := s.GetClientCredentialToken(ctx, appID, secret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.Marshal(menu)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
api := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s", url.QueryEscape(token))
|
||||
body, status, err := utils.PostURLBytes(ctx, api, "application/json; charset=utf-8", payload, "", 45*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建菜单请求失败: status=%d: %w", status, err)
|
||||
}
|
||||
var res struct {
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return fmt.Errorf("解析创建菜单响应失败: %w", err)
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
return fmt.Errorf("微信创建菜单失败(%d): %s", res.ErrCode, res.ErrMsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCustomMenuFromWechat 调用 menu/get 拉取线上菜单。
|
||||
func (s *WxGzhService) GetCustomMenuFromWechat(ctx context.Context, appID, secret string) (types.WxGzhMenuConfig, error) {
|
||||
var empty types.WxGzhMenuConfig
|
||||
token, err := s.GetClientCredentialToken(ctx, appID, secret)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
api := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/menu/get?access_token=%s", url.QueryEscape(token))
|
||||
body, status, err := utils.FetchURLBytes(ctx, api, "", 30*time.Second, 2, 2<<20)
|
||||
if err != nil {
|
||||
return empty, fmt.Errorf("获取菜单失败: status=%d: %w", status, err)
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Menu *struct {
|
||||
Button []types.WxGzhMenuButton `json:"button"`
|
||||
} `json:"menu"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return empty, fmt.Errorf("解析菜单响应失败: %w", err)
|
||||
}
|
||||
if envelope.ErrCode != 0 {
|
||||
return empty, fmt.Errorf("微信获取菜单失败(%d): %s", envelope.ErrCode, envelope.ErrMsg)
|
||||
}
|
||||
if envelope.Menu == nil || len(envelope.Menu.Button) == 0 {
|
||||
return types.WxGzhMenuConfig{Button: nil}, nil
|
||||
}
|
||||
return types.WxGzhMenuConfig{Button: envelope.Menu.Button}, nil
|
||||
}
|
||||
|
||||
// ValidateWxGzhMenu 校验自定义菜单是否符合微信公众平台规则(view / click / 子菜单)。
|
||||
func ValidateWxGzhMenu(m types.WxGzhMenuConfig) error {
|
||||
if len(m.Button) == 0 {
|
||||
return fmt.Errorf("请至少配置一个一级菜单")
|
||||
}
|
||||
if len(m.Button) > 3 {
|
||||
return fmt.Errorf("一级菜单最多 3 个")
|
||||
}
|
||||
for _, b := range m.Button {
|
||||
if err := validateTopButton(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTopButton(b types.WxGzhMenuButton) error {
|
||||
if strings.TrimSpace(b.Name) == "" {
|
||||
return fmt.Errorf("菜单名称不能为空")
|
||||
}
|
||||
if len(b.SubButton) > 0 {
|
||||
if strings.TrimSpace(b.Type) != "" {
|
||||
return fmt.Errorf("含子菜单的一级项不应设置类型")
|
||||
}
|
||||
if len(b.SubButton) > 5 {
|
||||
return fmt.Errorf("子菜单最多 5 项")
|
||||
}
|
||||
for _, sb := range b.SubButton {
|
||||
if err := validateLeafButton(sb); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return validateLeafButton(b)
|
||||
}
|
||||
|
||||
func validateLeafButton(b types.WxGzhMenuButton) error {
|
||||
if strings.TrimSpace(b.Name) == "" {
|
||||
return fmt.Errorf("菜单名称不能为空")
|
||||
}
|
||||
if len(b.SubButton) > 0 {
|
||||
return fmt.Errorf("仅支持两级菜单,子菜单下不能再嵌套")
|
||||
}
|
||||
t := strings.ToLower(strings.TrimSpace(b.Type))
|
||||
switch t {
|
||||
case "view":
|
||||
if strings.TrimSpace(b.URL) == "" {
|
||||
return fmt.Errorf("跳转链接类型需要填写 URL")
|
||||
}
|
||||
case "click":
|
||||
if strings.TrimSpace(b.Key) == "" {
|
||||
return fmt.Errorf("点击事件类型需要填写 Key")
|
||||
}
|
||||
case "miniprogram":
|
||||
if strings.TrimSpace(b.AppID) == "" || strings.TrimSpace(b.PagePath) == "" || strings.TrimSpace(b.URL) == "" {
|
||||
return fmt.Errorf("小程序类型需要填写 appid、pagepath 与备用 url")
|
||||
}
|
||||
default:
|
||||
if t == "" {
|
||||
return fmt.Errorf("请选择菜单类型")
|
||||
}
|
||||
return fmt.Errorf("暂不支持的菜单类型: %s", b.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"geekai/core/types"
|
||||
"geekai/store/model"
|
||||
"geekai/utils"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -16,10 +17,20 @@ import (
|
||||
type WxGzhService struct {
|
||||
config types.WxGzhConfig
|
||||
DB *gorm.DB
|
||||
|
||||
ccMu sync.Mutex
|
||||
ccToken string
|
||||
ccExpire time.Time
|
||||
ccForApp string
|
||||
}
|
||||
|
||||
func (s *WxGzhService) UpdateConfig(config types.WxGzhConfig) {
|
||||
s.config = config
|
||||
s.ccMu.Lock()
|
||||
s.ccToken = ""
|
||||
s.ccExpire = time.Time{}
|
||||
s.ccForApp = ""
|
||||
s.ccMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *WxGzhService) GetConfig() types.WxGzhConfig {
|
||||
|
||||
@@ -3,18 +3,19 @@ package model
|
||||
import "time"
|
||||
|
||||
type ImageJob struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:提示词" json:"prompt"`
|
||||
Params string `gorm:"column:params;type:text;not null;comment:任务参数" json:"params"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(64);comment:Kapon 异步任务 ID" json:"task_id"`
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(255);not null;comment:图片地址" json:"img_url"`
|
||||
OrgURL string `gorm:"column:org_url;type:varchar(1024);comment:原图地址" json:"org_url"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力" json:"power"`
|
||||
Progress int `gorm:"column:progress;type:smallint;not null;comment:任务进度" json:"progress"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);not null;comment:错误信息" json:"err_msg"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
UserId uint `gorm:"column:user_id;type:int(11);not null;comment:用户ID" json:"user_id"`
|
||||
Prompt string `gorm:"column:prompt;type:text;not null;comment:提示词" json:"prompt"`
|
||||
Params string `gorm:"column:params;type:text;not null;comment:任务参数" json:"params"`
|
||||
TaskId string `gorm:"column:task_id;type:varchar(64);comment:Kapon 异步任务 ID" json:"task_id"`
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(255);not null;comment:图片地址" json:"img_url"`
|
||||
OrgURL string `gorm:"column:org_url;type:varchar(1024);comment:原图地址" json:"org_url"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力" json:"power"`
|
||||
Status string `gorm:"column:status;type:varchar(20);default:pending;comment:任务状态:pending,in_progress,downloading,success,failed" json:"status"`
|
||||
Progress int `gorm:"column:progress;type:smallint;not null;comment:任务进度" json:"progress"`
|
||||
ErrMsg string `gorm:"column:err_msg;type:varchar(1024);not null;comment:错误信息" json:"err_msg"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
}
|
||||
|
||||
func (m *ImageJob) TableName() string {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// 图片任务状态常量(普通图片、MidJourney、Suno 共用)。
|
||||
const (
|
||||
ImageStatusPending = "pending"
|
||||
ImageStatusInProgress = "in_progress"
|
||||
ImageStatusDownloading = "downloading"
|
||||
ImageStatusSuccess = "success"
|
||||
ImageStatusFailed = "failed"
|
||||
)
|
||||
@@ -15,6 +15,7 @@ type MidJourneyJob struct {
|
||||
ImgURL string `gorm:"column:img_url;type:varchar(400);comment:图片URL" json:"img_url"`
|
||||
OrgURL string `gorm:"column:org_url;type:varchar(400);comment:原始图片地址" json:"org_url"`
|
||||
Hash string `gorm:"column:hash;type:varchar(100);comment:message hash" json:"hash"`
|
||||
Status string `gorm:"column:status;type:varchar(20);default:pending;comment:任务状态:pending,in_progress,downloading,success,failed" json:"status"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
UseProxy int `gorm:"column:use_proxy;type:tinyint(1);not null;default:0;comment:是否使用反代" json:"use_proxy"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
|
||||
@@ -6,15 +6,16 @@ import (
|
||||
|
||||
// Product 充值产品
|
||||
type Product struct {
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"column:name;type:varchar(30);not null;comment:名称" json:"name"`
|
||||
Price float64 `gorm:"column:price;type:decimal(10,2);not null;default:0.00;comment:价格" json:"price"`
|
||||
Power int `gorm:"column:power;type:int;not null;default:0;comment:增加算力值" json:"power"`
|
||||
Enabled bool `gorm:"column:enabled;type:tinyint(1);not null;default:0;comment:是否启动" json:"enabled"`
|
||||
Sales int `gorm:"column:sales;type:int;not null;default:0;comment:销量" json:"sales"`
|
||||
SortNum int `gorm:"column:sort_num;type:tinyint;not null;default:0;comment:排序" json:"sort_num"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"column:name;type:varchar(30);not null;comment:名称" json:"name"`
|
||||
Price float64 `gorm:"column:price;type:decimal(10,2);not null;default:0.00;comment:价格" json:"price"`
|
||||
StripePrice float64 `gorm:"column:stripe_price;type:decimal(10,2);not null;default:0.00;comment:Stripe价格" json:"stripe_price"`
|
||||
Power int `gorm:"column:power;type:int;not null;default:0;comment:增加算力值" json:"power"`
|
||||
Enabled bool `gorm:"column:enabled;type:tinyint(1);not null;default:0;comment:是否启动" json:"enabled"`
|
||||
Sales int `gorm:"column:sales;type:int;not null;default:0;comment:销量" json:"sales"`
|
||||
SortNum int `gorm:"column:sort_num;type:tinyint;not null;default:0;comment:排序" json:"sort_num"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (m *Product) TableName() string {
|
||||
|
||||
@@ -19,6 +19,7 @@ type SunoJob struct {
|
||||
Prompt string `gorm:"column:prompt;type:varchar(2000);not null;comment:提示词" json:"prompt"`
|
||||
CoverURL string `gorm:"column:cover_url;type:varchar(512);comment:封面图地址" json:"cover_url"`
|
||||
AudioURL string `gorm:"column:audio_url;type:varchar(512);comment:音频地址" json:"audio_url"`
|
||||
Status string `gorm:"column:status;type:varchar(20);default:pending;comment:任务状态:pending,in_progress,downloading,success,failed" json:"status"`
|
||||
Progress int `gorm:"column:progress;type:smallint;default:0;comment:任务进度" json:"progress"`
|
||||
Duration int `gorm:"column:duration;type:smallint;not null;default:0;comment:歌曲时长" json:"duration"`
|
||||
Publish int `gorm:"column:publish;type:tinyint(1);not null;comment:是否发布" json:"publish"`
|
||||
|
||||
@@ -17,7 +17,6 @@ type User struct {
|
||||
ExpiredTime int64 `gorm:"column:expired_time;type:int;not null;comment:用户过期时间" json:"expired_time"`
|
||||
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
|
||||
ChatConfig string `gorm:"column:chat_config_json;type:text;default:null;comment:聊天配置json" json:"chat_config"`
|
||||
ChatRoles string `gorm:"column:chat_roles_json;type:text;default:null;comment:聊天角色 json" json:"-"`
|
||||
ChatModels string `gorm:"column:chat_models_json;type:text;default:null;comment:AI模型 json" json:"chat_models"`
|
||||
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"`
|
||||
Vip bool `gorm:"column:vip;type:tinyint(1);not null;default:0;comment:是否会员" json:"vip"`
|
||||
|
||||
+11
-10
@@ -1,15 +1,16 @@
|
||||
package vo
|
||||
|
||||
type ImageJob struct {
|
||||
Id uint `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Params string `json:"params"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OrgURL string `json:"org_url"`
|
||||
Publish bool `json:"publish"`
|
||||
Power int `json:"power"`
|
||||
Progress int `json:"progress"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
Id uint `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Params string `json:"params"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OrgURL string `json:"org_url"`
|
||||
Publish bool `json:"publish"`
|
||||
Power int `json:"power"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
ErrMsg string `json:"err_msg"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -6,10 +6,12 @@ type MidJourneyJob struct {
|
||||
UserId uint `json:"user_id"`
|
||||
ChannelId string `json:"channel_id"`
|
||||
TaskId string `json:"task_id"`
|
||||
TaskInfo string `json:"task_info"` // JSON:MjTask(prompt、img_arr、full_prompt、mode 等)
|
||||
MessageId string `json:"message_id"`
|
||||
ImgURL string `json:"img_url"`
|
||||
OrgURL string `json:"org_url"`
|
||||
Hash string `json:"hash"`
|
||||
Status string `json:"status"`
|
||||
Progress int `json:"progress"`
|
||||
Prompt string `json:"prompt"`
|
||||
UseProxy bool `json:"use_proxy"`
|
||||
|
||||
@@ -2,12 +2,13 @@ package vo
|
||||
|
||||
type Product struct {
|
||||
BaseVo
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
Discount float64 `json:"discount"`
|
||||
Days int `json:"days"`
|
||||
Power int `json:"power"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sales int `json:"sales"`
|
||||
SortNum int `json:"sort_num"`
|
||||
Name string `json:"name"`
|
||||
Price float64 `json:"price"`
|
||||
StripePrice float64 `json:"stripe_price"`
|
||||
Discount float64 `json:"discount"`
|
||||
Days int `json:"days"`
|
||||
Power int `json:"power"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sales int `json:"sales"`
|
||||
SortNum int `json:"sort_num"`
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ type SunoJob struct {
|
||||
CoverURL string `json:"cover_url"` // 封面图 URL
|
||||
AudioURL string `json:"audio_url"` // 音频 URL
|
||||
ModelName string `json:"model_name"` // 模型名称
|
||||
Status string `json:"status"` // 任务状态
|
||||
Progress int `json:"progress"` // 任务进度
|
||||
Duration int `json:"duration"` // 银屏时长,秒
|
||||
Publish bool `json:"publish"` // 是否发布
|
||||
|
||||
@@ -10,7 +10,6 @@ type User struct {
|
||||
Salt string `json:"salt"` // 密码盐
|
||||
Power int `json:"power"` // 剩余算力
|
||||
ChatModels []int `json:"chat_models"` // AI模型集合
|
||||
ChatRoles []uint `json:"chat_roles"` // 工作区应用 ID 列表
|
||||
ExpiredTime int64 `json:"expired_time"` // 账户到期时间
|
||||
Status bool `json:"status"` // 当前状态
|
||||
LastLoginAt int64 `json:"last_login_at"`
|
||||
|
||||
@@ -8,6 +8,7 @@ package utils
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -177,6 +178,81 @@ func FetchURLBytes(ctx context.Context, rawURL string, proxy string, timeout tim
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
// PostURLBytes 发起 POST(如 application/json)并返回响应体(只在 2xx 认为成功)。
|
||||
func PostURLBytes(ctx context.Context, rawURL string, contentType string, body []byte, proxy string, timeout time.Duration, retries int, maxBytes int64) ([]byte, int, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = defaultHTTPTimeout
|
||||
}
|
||||
if retries < 0 {
|
||||
retries = 0
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 8 << 20
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= retries; attempt++ {
|
||||
if ctx.Err() != nil {
|
||||
return nil, 0, ctx.Err()
|
||||
}
|
||||
|
||||
client := newHTTPClient(timeout, proxy)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rawURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
if attempt < retries && isRetryableError(err) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
respBody, readErr := readAllLimit(resp.Body, maxBytes)
|
||||
_ = resp.Body.Close()
|
||||
if readErr != nil {
|
||||
lastErr = readErr
|
||||
if attempt < retries && isRetryableError(readErr) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
return nil, resp.StatusCode, readErr
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
status := resp.StatusCode
|
||||
lastErr = fmt.Errorf("request failed: status=%d", status)
|
||||
if attempt < retries && (status == http.StatusTooManyRequests || status >= 500 && status <= 599) {
|
||||
time.Sleep(retryDelay(attempt))
|
||||
continue
|
||||
}
|
||||
preview := strings.TrimSpace(string(respBody))
|
||||
if len(preview) > 256 {
|
||||
preview = preview[:256]
|
||||
}
|
||||
if preview != "" {
|
||||
return respBody, status, fmt.Errorf("request failed: status=%d body=%s", status, preview)
|
||||
}
|
||||
return respBody, status, lastErr
|
||||
}
|
||||
|
||||
return respBody, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
return nil, 0, lastErr
|
||||
}
|
||||
|
||||
// SendMsg 回复客户片段端消息
|
||||
func SendMsg(client *types.WsClient, message types.ReplyMessage) {
|
||||
message.ClientId = client.Id
|
||||
|
||||
@@ -45,7 +45,7 @@ services:
|
||||
|
||||
# 后端 API 程序
|
||||
geekai-api:
|
||||
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.3.0-amd64
|
||||
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.3.1-amd64
|
||||
container_name: geekai-api
|
||||
restart: always
|
||||
depends_on:
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
|
||||
# 前端应用
|
||||
geekai-web:
|
||||
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.3.0-amd64
|
||||
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.3.1-amd64
|
||||
container_name: geekai-web
|
||||
restart: always
|
||||
depends_on:
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# PPT 断点续生成(简化方案 · 修订版)
|
||||
|
||||
## 设计原则(按产品确认)
|
||||
|
||||
- **不新增数据库字段**,只复用现有 `geekai_ppt_jobs.slides`(JSON)。
|
||||
- 分镜结构已在 [`vo.PPTSlideData`](api/store/vo/ppt_job.go) 中体现:`theme/title/points/image_prompt` + `image_url`。
|
||||
- **大模型生成分镜后立刻整表写入 `slides`**:每条记录带完整分镜字段,**`image_url` 为空**,表示「待生图」。
|
||||
- **列表「继续生成」条件**:`status !== 'completed'` 即可展示(实现时需排除无意义的态:如从未写入分镜的异常数据;**`processing` 时按钮应禁用或显示加载**,避免与进行中的任务重复提交)。
|
||||
- **继续生成**:读出 `slides`,**有 `image_url` 则跳过**,无则取当条分镜调生图 API;**每成功一张就更新一次数据库**。
|
||||
- **并发更新 `slides`**:多 goroutine 同时写同一任务的 `slides` 字段时,必须 **加锁**(推荐在 [`PptService`](api/service/ppt/ppt_service.go) 内 **`sync.Mutex` 按 `task_id` 维度**保护「读 JSON → 改一条 slide → 写回」的整段逻辑,避免后写覆盖先写)。
|
||||
|
||||
## 后端改动要点
|
||||
|
||||
### 1. `RunTask` 流程调整([`ppt_service.go`](api/service/ppt/ppt_service.go))
|
||||
|
||||
1. `GenerateSlides` 成功后,将 `[]slidePlan` 转为 `[]SlideData` / `vo.PPTSlides`,**全部 `ImageURL` 置空**,一次性 `Save` / `Updates` 写入 `slides`,并 `SetTotalSlides(total)`。
|
||||
2. **`completed_slides` 语义**:表示「已有配图」的页数,等于 `slides` 中 `image_url` 非空的条数(或单独在更新时维护,与 `len(slides)` 区分)。
|
||||
3. 抽取 **`runSlideImageGeneration`**:对「需要生图」的条目并发生图;每条成功后调用新的 **`applySlideImage(taskID, slideIndex, url)`**(内部持锁、按 index 更新对应元素的 `image_url`,再扣算力)。
|
||||
|
||||
### 2. 替换「仅 append」的 `UpdateProgress`
|
||||
|
||||
- 现有 `UpdateProgress` 是 **append** 一条 slide,与「先写满占位再填图」冲突。
|
||||
- 改为 **按 `slide_index` 原地更新** 指定项的 `image_url`(及必要时 `thumb`),并在持锁下 **整份序列化写回**。
|
||||
|
||||
### 3. `ResumeTask` / `POST .../resume`
|
||||
|
||||
- 校验用户、任务存在;若 `status === processing` 建议直接拒绝或返回「任务进行中」。
|
||||
- 若 `status !== completed`:加载 `slides`,筛出 `image_url` 为空的项,**仅对这些项**走与 `RunTask` 相同的生图 + `applySlideImage`。
|
||||
- 全部非空后:`UpdateStatus(completed)`,清空或保留 `err_msg` 按产品定。
|
||||
- 若某次仍失败:可 `MarkAsFailed` 保留已生成页(与现网一致)。
|
||||
|
||||
### 4. 失败路径
|
||||
|
||||
- 任一页生图失败时,仍可 `MarkAsFailed`,但 **分镜与已生成图均已在 `slides` 中**,续跑只需补空 `image_url`。
|
||||
|
||||
## 前端([`PPTCreate.vue`](web/src/views/PPTCreate.vue))
|
||||
|
||||
- 列表与详情:当 **`status !== 'completed'`**(且建议 **`status !== 'processing'` 才可点**)显示「继续生成」,**`el-tooltip`** 文案:继续生成尚未完成的幻灯片,已有页面保持不变。
|
||||
- 调用 `POST /api/v1/tasks/:id/resume`(路由注册方式同既有 export)。
|
||||
|
||||
## 与旧方案差异
|
||||
|
||||
- **不增加** `slide_plans` 等字段;**不重复存储**分镜,全部以 `slides` 中「无图占位」表达待生成状态。
|
||||
|
||||
## 验证
|
||||
|
||||
- 人为让第 N 页失败:列表出现继续生成,点击后仅补第 N 页及之后空图项。
|
||||
- 并发压测:多页同时完成时 `slides` JSON 无丢失、无覆盖。
|
||||
|
||||
## 实施状态(已实现)
|
||||
|
||||
- 后端:`saveSlidesOutline` + `ApplySlideImage`(`sync.Map` 按 `task_id` 互斥)、`runSlideImageJobs`、`ResumeTask`、`POST /api/v1/tasks/:task_id/resume`。
|
||||
- 前端:`PPTCreate.vue` 列表/详情「继续生成」+ `el-tooltip`,`processing` 时禁用或 loading。
|
||||
- **旧任务**:若 `slides` 条数小于 `total_slides`(无完整占位),`ResumeTask` 返回不可续跑。
|
||||
@@ -6,7 +6,7 @@ VITE_ADMIN_USER=admin
|
||||
VITE_ADMIN_PASS=admin123
|
||||
VITE_KEY_PREFIX=GeekAI_DEV_
|
||||
VITE_TITLE="Geek-AI 创作系统"
|
||||
VITE_VERSION=v4.3.0
|
||||
VITE_VERSION=v4.3.1
|
||||
VITE_DOCS_URL=https://docs.geekai.me
|
||||
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
|
||||
VITE_GITEE_URL=https://gitee.com/blackfox/geekai
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
VITE_API_HOST=
|
||||
VITE_WS_HOST=
|
||||
VITE_KEY_PREFIX=GeekAI_
|
||||
VITE_VERSION=v4.3.0
|
||||
VITE_VERSION=v4.3.1
|
||||
VITE_TITLE="Geek-AI 创作系统"
|
||||
VITE_DOCS_URL=https://docs.geekai.me
|
||||
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2022: true,
|
||||
node: true,
|
||||
},
|
||||
extends: [
|
||||
'plugin:vue/vue3-essential',
|
||||
],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
defineProps: 'readonly',
|
||||
defineEmits: 'readonly',
|
||||
defineExpose: 'readonly',
|
||||
withDefaults: 'readonly',
|
||||
},
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-mutating-props': 'off',
|
||||
'vue/require-valid-default-prop': 'off',
|
||||
'vue/no-dupe-keys': 'off',
|
||||
'vue/no-side-effects-in-computed-properties': 'off',
|
||||
'vue/no-textarea-mustache': 'off',
|
||||
},
|
||||
}
|
||||
+3
-1
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-scroll/core": "^2.5.1",
|
||||
@@ -51,6 +51,8 @@
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "8.57.1",
|
||||
"eslint-plugin-vue": "9.33.0",
|
||||
"postcss": "^8.4.49",
|
||||
"sass-embedded": "^1.89.2",
|
||||
"stylus": "^0.58.1",
|
||||
|
||||
Generated
+517
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
+480
-134
@@ -10,23 +10,39 @@
|
||||
|
||||
.mj-box {
|
||||
margin: 10px;
|
||||
// background-color: #262626;
|
||||
// border: 1px solid #454545;
|
||||
// height: calc(100vh - 50px);
|
||||
// overflow: scroll;
|
||||
min-width: 300px;
|
||||
max-width: 300px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
flex: 0 0 360px;
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
padding: 12px 12px 14px;
|
||||
border-radius: 12px;
|
||||
color: var(--text-theme-color);
|
||||
font-size: 14px;
|
||||
overflow: auto;
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.04));
|
||||
border: 1px solid var(--chat-bg, #e8e8ef);
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
&__head {
|
||||
text-align: center;
|
||||
color: var(--theme-textcolor-normal);
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 4px;
|
||||
border-bottom: 1px solid var(--chat-bg, #e5e7eb);
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--theme-textcolor-normal, var(--text-theme-color));
|
||||
}
|
||||
|
||||
&__sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary, #909399);
|
||||
}
|
||||
|
||||
// 隐藏滚动条
|
||||
@@ -37,135 +53,223 @@
|
||||
}
|
||||
|
||||
.mj-params {
|
||||
margin-top: 10px;
|
||||
margin-top: 4px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.param-line {
|
||||
padding: 0 10px;
|
||||
.mj-params-form {
|
||||
.mj-form-item--compact {
|
||||
margin-bottom: 6px;
|
||||
|
||||
.el-icon {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-left: 5px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-content.active {
|
||||
// color: #47fff1;
|
||||
// background-color: #585858;
|
||||
border: 1px solid var(--theme-border-hover);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
:deep(.el-form-item__label) {
|
||||
padding-bottom: 2px;
|
||||
line-height: 1.35;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.param-line.pt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
.el-input,
|
||||
.el-slider {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.el-select.mj-select-full {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-sec {
|
||||
padding: 10px 0 12px;
|
||||
border-bottom: 1px solid var(--chat-bg, rgba(15, 23, 42, 0.08));
|
||||
|
||||
&--last {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-sec__title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--el-text-color-secondary, #64748b);
|
||||
}
|
||||
|
||||
.mj-sec__hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: var(--el-text-color-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.mj-code {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--chat-bg, #f1f5f9);
|
||||
color: var(--el-color-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.mj-aspect-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mj-chip {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
color: var(--text-theme-color);
|
||||
background: var(--chat-bg, #f8fafc);
|
||||
border: 1px solid #e5e7f5;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #a5b4fc;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-switch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mj-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e5e7f5;
|
||||
background: var(--chat-bg, #f8fafc);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
&__text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-theme-color);
|
||||
line-height: 1.3;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-slider-field {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.mj-model-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mj-model-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid #e5e7f5;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #a5b4fc;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #6366f1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&--active {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-model-card__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mj-model-card__name {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 1.35;
|
||||
color: var(--theme-textcolor-normal, #252f76);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mj-model-card__flag {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
color: #6366f1;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
border: 1px solid #e5e7f5;
|
||||
}
|
||||
|
||||
.mj-model-card--active .mj-model-card__flag {
|
||||
background: #fff;
|
||||
border-color: #c7d2fe;
|
||||
}
|
||||
|
||||
.mj-model-card__badge {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
.mj-model-card__summary {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-regular, #6b7280);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,3 +591,245 @@
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
/* 创作记录:成功态 U/V、局部重绘固定在图下方;悬停仅图片区域时底栏显示分享/详情/删除(与 Image.vue 底栏样式一致) */
|
||||
.page-mj .finish-job-list {
|
||||
:deep(.waterfall-item:has(.image-task-preview--failed) .waterfall-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-task-item {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
&:has(.image-task-preview--failed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 仅图片区域为定位上下文,悬停条贴在图底部(下方 U/V 行不参与悬停显隐) */
|
||||
.image-task-media {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.image-task-item:has(.image-task-preview--failed) .image-task-media {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.image-task-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--card-bg);
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.image-task-preview--failed {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.image-task-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-task-image--failed {
|
||||
position: static;
|
||||
width: 60%;
|
||||
max-width: 220px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
object-position: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-task-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
backdrop-filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.image-task-item:hover .image-task-overlay {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.image-task-media:hover .image-task-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-task-overlay-time {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.image-task-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-task-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 7px;
|
||||
background: rgba(8, 10, 16, 0.62);
|
||||
color: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.image-task-tool .iconfont {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.image-task-tool:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(129, 140, 248, 0.8);
|
||||
background: rgba(79, 70, 229, 0.72);
|
||||
}
|
||||
|
||||
.image-task-tool--danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.image-task-tool--danger:hover {
|
||||
color: #fff;
|
||||
background: var(--el-color-danger);
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
/* 成功态:底栏单行,与 Image.vue 一致(时间 + 分享/详情/删除) */
|
||||
.mj-task-overlay--meta {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mj-task-opt {
|
||||
padding: 6px 4px 4px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-top: 0;
|
||||
background: var(--card-bg, rgba(255, 255, 255, 0.04));
|
||||
}
|
||||
|
||||
.mj-task-opt__uv {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.mj-task-opt__modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.mj-task-opt-btn {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
color: #fff;
|
||||
background-color: #4e5058;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #6d6f78;
|
||||
}
|
||||
}
|
||||
|
||||
.mj-task-opt-btn--uv {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mj-task-opt-btn--modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.mj-task-overlay--failed {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
max-height: 88%;
|
||||
overflow-y: auto;
|
||||
min-height: auto;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.mj-task-overlay--failed .image-task-overlay-time {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mj-task-overlay--failed .image-task-tools {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
margin: 10px;
|
||||
// background-color: #262626;
|
||||
// border: 1px solid #454545;
|
||||
min-width: 300px;
|
||||
// 侧栏过窄时固定宽 btn 会提前换行;260px 仅能摆 4×56+gap,Chrome/Blink 常因 flex 分配更宽而表现为 5 列。锁定 360px 保证与 max-width 一致的可视空间。
|
||||
flex: 0 0 360px;
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
max-width: 360px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
@@ -222,3 +225,167 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reference-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
.image-task-item {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
|
||||
&:has(.image-task-preview--failed) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 失败项:瀑布流格子有高度,让卡片铺满以便占位图 cover */
|
||||
.finish-job-list :deep(.waterfall-item:has(.image-task-preview--failed) .waterfall-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.image-task-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
background: var(--card-bg);
|
||||
flex: 0 0 auto;
|
||||
|
||||
&.image-task-preview--failed {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 180px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.image-task-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-task-image--failed {
|
||||
position: static;
|
||||
width: 60%;
|
||||
max-width: 220px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
object-position: center center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-detail-failed-actions {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.image-task-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
backdrop-filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.image-task-item:hover .image-task-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.image-task-overlay-time {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.image-task-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.image-task-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 7px;
|
||||
background: rgba(8, 10, 16, 0.62);
|
||||
color: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.image-task-tool .iconfont {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.image-task-tool:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(129, 140, 248, 0.8);
|
||||
background: rgba(79, 70, 229, 0.72);
|
||||
}
|
||||
|
||||
.image-task-tool--danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.image-task-tool--danger:hover {
|
||||
color: #fff;
|
||||
background: var(--el-color-danger);
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.image-task-image--failed .lazy__img {
|
||||
width: 90% !important;
|
||||
}
|
||||
|
||||
+212
-164
@@ -289,7 +289,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 算力信息
|
||||
// 积分信息
|
||||
.text-info {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
@@ -341,179 +341,221 @@
|
||||
// }
|
||||
}
|
||||
|
||||
// 任务列表
|
||||
// 任务列表(竖排卡片:大预览 + 底部标签与操作,提示词见详情)
|
||||
.video-list {
|
||||
.list-box {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
|
||||
.item {
|
||||
.video-task-item {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 20px;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
transition: none;
|
||||
|
||||
&:has(.video-task-preview--failed) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 失败卡片:瀑布流格子有固定高度,让内容区铺满以便占位图 cover */
|
||||
:deep(.waterfall-item:has(.video-task-preview--failed) .waterfall-card) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video-task-preview {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 180px;
|
||||
max-height: none;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
background: var(--card-bg);
|
||||
flex: 0 0 auto;
|
||||
|
||||
.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;
|
||||
// 允许中间区域在 flex 布局中收缩,避免挤压右侧操作区
|
||||
&.video-task-preview--failed {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
|
||||
.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;
|
||||
// 错误信息中经常包含很长的无空格字符串(如 JSON、错误栈),
|
||||
// 强制单词内换行,防止撑破布局把右侧删除按钮挤出视口
|
||||
word-break: break-all;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 8px;
|
||||
|
||||
.el-tag {
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
min-height: 180px;
|
||||
background: var(--card-bg);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
min-width: 200px;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
&.video-task-preview--busy {
|
||||
min-height: 180px;
|
||||
}
|
||||
}
|
||||
|
||||
.tools {
|
||||
display: flex;
|
||||
justify-content: left;
|
||||
align-items: center;
|
||||
flex-flow: row;
|
||||
height: 90px;
|
||||
.video-task-preview-inner {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-publish {
|
||||
padding: 2px 10px;
|
||||
.video-task-preview-inner--success {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
.video-task-preview-inner--busy {
|
||||
padding: 8px;
|
||||
min-height: 180px;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
padding: 6px;
|
||||
transition: background 0.6s ease 0s;
|
||||
color: #919191;
|
||||
// 失败态:占位图铺满格子(与 LazyImg 成功态 cover 一致)
|
||||
.video-task-preview-inner--failed {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.video-task-fail-img {
|
||||
width: 60%;
|
||||
|
||||
.downloading {
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
display: block;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.video-task-video {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
object-fit: contain;
|
||||
object-position: center center;
|
||||
display: block;
|
||||
background: #111;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.video-task-preview .video-task-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
backdrop-filter: blur(6px);
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.video-task-overlay-time {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.video-task-preview .video-task-overlay .video-task-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.video-task-item:hover .video-task-preview .video-task-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.video-task-tool {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 7px;
|
||||
background: rgba(8, 10, 16, 0.62);
|
||||
color: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
|
||||
.iconfont {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.right-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
// 保证错误状态下删除按钮始终占据固定宽度,不被中间内容挤压
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
&:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
border-color: rgba(129, 140, 248, 0.8);
|
||||
background: rgba(79, 70, 229, 0.72);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.video-task-tool--danger {
|
||||
color: var(--el-color-danger);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: #fff;
|
||||
background: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.video-task-tool-loading {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
.waterfall-load-more {
|
||||
padding: 10px 0 14px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-disabled-color, #909399);
|
||||
|
||||
.waterfall-loading-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.waterfall-no-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-disabled-color, #909399);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -555,22 +597,28 @@
|
||||
.main-content {
|
||||
padding: 1rem;
|
||||
|
||||
.video-list .list-box .item {
|
||||
.left .container {
|
||||
width: 120px;
|
||||
|
||||
.video,
|
||||
.el-image {
|
||||
width: 120px;
|
||||
}
|
||||
.video-list .list-box {
|
||||
.video-task-preview {
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.center {
|
||||
padding: 0 10px;
|
||||
.video-task-item {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.right {
|
||||
min-width: 120px;
|
||||
.video-task-preview .video-task-overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.video-task-overlay-time {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.video-task-preview-inner--busy {
|
||||
min-height: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ onMounted(() => {
|
||||
// 获取模型列表
|
||||
httpGet('/api/model/list?type=tts').then((res) => {
|
||||
models.value = res.data
|
||||
if (!data.ttsModel && models.value.length > 0) {
|
||||
if (!data.value.ttsModel && models.value.length > 0) {
|
||||
store.setTtsModel(models.value[0].id)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
unique-opened
|
||||
router
|
||||
>
|
||||
<template v-for="item in items">
|
||||
<template v-for="item in items" :key="item.path">
|
||||
<template v-if="item.subs">
|
||||
<el-sub-menu :index="item.index" :key="item.index">
|
||||
<template #title>
|
||||
<i :class="'iconfont icon-' + item.icon"></i>
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<template v-for="subItem in item.subs">
|
||||
<template v-for="subItem in item.subs" :key="subItem.index">
|
||||
<el-sub-menu v-if="subItem.subs" :index="subItem.index" :key="subItem.index">
|
||||
<template #title>{{ subItem.title }}</template>
|
||||
<el-menu-item
|
||||
|
||||
@@ -19,14 +19,6 @@
|
||||
@click="$emit('use-role', app.id)"
|
||||
>开始对话</van-button
|
||||
>
|
||||
<van-button
|
||||
size="small"
|
||||
:type="hasRole ? 'danger' : 'success'"
|
||||
class="action-btn"
|
||||
@click="$emit('update-role', app, hasRole ? 'remove' : 'add')"
|
||||
>
|
||||
{{ hasRole ? '移出工作台' : '添加到工作台' }}
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-cell>
|
||||
@@ -38,13 +30,9 @@ defineProps({
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
hasRole: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['use-role', 'update-role'])
|
||||
defineEmits(['use-role'])
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
+636
-1209
File diff suppressed because it is too large
Load Diff
@@ -65,13 +65,11 @@ export const VideoParams = {
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '10秒(逆向)', value: '10' },
|
||||
{ label: '15秒(逆向)', value: '15' },
|
||||
{ label: '4秒(官转)', value: '4' },
|
||||
{ label: '8秒(官转)', value: '8' },
|
||||
{ label: '12秒(官转)', value: '12' },
|
||||
{ label: '4秒', value: '4' },
|
||||
{ label: '8秒', value: '8' },
|
||||
{ label: '12秒', value: '12' },
|
||||
],
|
||||
value: '10',
|
||||
value: '4',
|
||||
},
|
||||
],
|
||||
priceParams: ['fixed'], // 固定价格
|
||||
|
||||
+171
-35
@@ -7,13 +7,44 @@
|
||||
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { JimengFunctions, JimengParams } from '@/store/data/jimeng_params'
|
||||
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 failedIcon from '@/assets/img/failed.png'
|
||||
import loadingGif from '@/assets/img/loading.gif'
|
||||
import { getThumbURL, replaceImg, substr } from '@/utils/libs'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { defineStore } from 'pinia'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { nextTick, reactive, ref } from 'vue'
|
||||
|
||||
/** 与瀑布流 imgSelector: img_thumb 对齐(参考 Image.vue) */
|
||||
function normalizeJimengWaterfallThumb(item) {
|
||||
const img = item.img_url ? replaceImg(item.img_url) : ''
|
||||
if (img) {
|
||||
item.img_thumb = getThumbURL(img, 300, 0)
|
||||
return
|
||||
}
|
||||
if (item.video_url) {
|
||||
item.img_thumb = loadingGif
|
||||
return
|
||||
}
|
||||
if (item.status === 'failed') {
|
||||
item.img_thumb = failedIcon
|
||||
return
|
||||
}
|
||||
item.img_thumb = loadingGif
|
||||
}
|
||||
|
||||
function refreshJimengListThumbs(list) {
|
||||
if (!list || !list.length) {
|
||||
return
|
||||
}
|
||||
for (const row of list) {
|
||||
normalizeJimengWaterfallThumb(row)
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询需跟进的非终态(含 submited:队列尚未改 in_queue 前) */
|
||||
const POLLING_ACTIVE_STATUSES = new Set(['submited', 'in_queue', 'generating', 'done'])
|
||||
|
||||
export const useJimengStore = defineStore('jimeng', () => {
|
||||
// 共同状态
|
||||
@@ -26,15 +57,10 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
const currentList = ref([])
|
||||
const isOver = ref(false)
|
||||
|
||||
// 用户信息
|
||||
const isLogin = ref(false)
|
||||
// 视频预览
|
||||
const showDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
|
||||
// 登录弹窗
|
||||
const shareStore = useSharedStore()
|
||||
|
||||
// 积分消耗配置
|
||||
const powerConfig = reactive({ powers: {} })
|
||||
const currentPowerCost = ref('0积分')
|
||||
@@ -74,6 +100,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
submited: '任务已提交',
|
||||
in_queue: '任务排队中',
|
||||
generating: '任务执行中',
|
||||
done: '处理完成',
|
||||
success: '任务成功',
|
||||
failed: '任务失败',
|
||||
canceled: '任务已取消',
|
||||
@@ -131,6 +158,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
} else {
|
||||
currentList.value = currentList.value.concat(data.items)
|
||||
}
|
||||
refreshJimengListThumbs(currentList.value)
|
||||
} catch (error) {
|
||||
showMessageError('获取任务列表失败:' + error.message)
|
||||
} finally {
|
||||
@@ -138,34 +166,63 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询合并:新行对象 + 新数组引用,避免瀑布流对同 id 行就地 mutate 不刷新 */
|
||||
const mergeJobsIntoCurrentList = (rows) => {
|
||||
if (!rows || !rows.length) {
|
||||
return
|
||||
}
|
||||
const next = currentList.value.map((item) => {
|
||||
const hit = rows.find((i) => i.id === item.id)
|
||||
if (hit) {
|
||||
const row = { ...item, ...hit }
|
||||
normalizeJimengWaterfallThumb(row)
|
||||
return row
|
||||
}
|
||||
return item
|
||||
})
|
||||
currentList.value = next
|
||||
}
|
||||
|
||||
// 简单轮询逻辑
|
||||
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 || data.items.length === 0) {
|
||||
stopPolling()
|
||||
return
|
||||
}
|
||||
try {
|
||||
const activeIds = currentList.value
|
||||
.filter((item) => POLLING_ACTIVE_STATUSES.has(item.status))
|
||||
.map((item) => item.id)
|
||||
|
||||
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])
|
||||
const body = {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
filter: taskFilter.value,
|
||||
}
|
||||
})
|
||||
if (todoList.length === 0) {
|
||||
stopPolling()
|
||||
if (activeIds.length > 0) {
|
||||
body.ids = activeIds
|
||||
}
|
||||
|
||||
const response = await httpPost('/api/jimeng/jobs', body)
|
||||
const data = response.data || {}
|
||||
if (!data.items || data.items.length === 0) {
|
||||
if (activeIds.length === 0) {
|
||||
stopPolling()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
mergeJobsIntoCurrentList(data.items)
|
||||
|
||||
const stillActive = currentList.value.some((item) =>
|
||||
POLLING_ACTIVE_STATUSES.has(item.status)
|
||||
)
|
||||
|
||||
if (!stillActive) {
|
||||
stopPolling()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('jimeng poll error', e)
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
@@ -179,10 +236,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
|
||||
// 提交任务
|
||||
const submitTask = async () => {
|
||||
if (!isLogin.value) {
|
||||
shareStore.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
for (const key in requiredKeys.value) {
|
||||
if (!formData.value[key]) {
|
||||
showMessageError('缺少参数:' + requiredKeys.value[key].label)
|
||||
@@ -203,6 +256,24 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
if (data.image_urls && !Array.isArray(data.image_urls)) {
|
||||
data.image_urls = [data.image_urls]
|
||||
}
|
||||
if (data.video_url && !Array.isArray(data.video_url)) {
|
||||
data.video_url = [data.video_url]
|
||||
}
|
||||
if (data.audio_url && !Array.isArray(data.audio_url)) {
|
||||
data.audio_url = [data.audio_url]
|
||||
}
|
||||
|
||||
if (typeof data.req_key === 'string' && data.req_key.startsWith('doubao-seedance-')) {
|
||||
data.content = buildSeedanceContent(data)
|
||||
if (data.seedance_mode === 'multimodal' && data.content.length === 0) {
|
||||
throw new Error('多模态模式下,请至少上传图片、视频或音频中的一种素材')
|
||||
}
|
||||
// Seedance 统一使用 content[] 传多模态,避免旧字段类型与后端绑定冲突。
|
||||
delete data.video_url
|
||||
delete data.audio_url
|
||||
delete data.image_role
|
||||
delete data.seedance_mode
|
||||
}
|
||||
|
||||
const response = await httpPost('/api/jimeng/task', data)
|
||||
showMessageOK('任务提交成功')
|
||||
@@ -294,6 +365,73 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeMediaList = (value) => {
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(Boolean)
|
||||
}
|
||||
return [value]
|
||||
}
|
||||
|
||||
const buildSeedanceContent = (data) => {
|
||||
const content = []
|
||||
if (data.prompt) {
|
||||
content.push({
|
||||
type: 'text',
|
||||
text: data.prompt,
|
||||
})
|
||||
}
|
||||
|
||||
const imageList = normalizeMediaList(data.image_urls)
|
||||
const videoList = normalizeMediaList(data.video_url)
|
||||
const audioList = normalizeMediaList(data.audio_url)
|
||||
const imageRole = data.image_role || 'reference_image'
|
||||
|
||||
if (data.seedance_mode === 'image_first') {
|
||||
imageList.slice(0, 1).forEach((url) => {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: 'first_frame',
|
||||
})
|
||||
})
|
||||
} else if (data.seedance_mode === 'image_first_last') {
|
||||
imageList.slice(0, 2).forEach((url, index) => {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: index === 0 ? 'first_frame' : 'last_frame',
|
||||
})
|
||||
})
|
||||
} else {
|
||||
imageList.forEach((url) => {
|
||||
content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url },
|
||||
role: imageRole,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
videoList.forEach((url) => {
|
||||
content.push({
|
||||
type: 'video_url',
|
||||
video_url: { url },
|
||||
role: 'reference_video',
|
||||
})
|
||||
})
|
||||
audioList.forEach((url) => {
|
||||
content.push({
|
||||
type: 'audio_url',
|
||||
audio_url: { url },
|
||||
role: 'reference_audio',
|
||||
})
|
||||
})
|
||||
return content
|
||||
}
|
||||
|
||||
watch(
|
||||
() => formData.value,
|
||||
() => {
|
||||
@@ -310,8 +448,7 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
powerConfig.powers = powerRes.data.powers || {}
|
||||
setFunctionPowers()
|
||||
}
|
||||
const user = await checkSession()
|
||||
isLogin.value = true
|
||||
await checkSession()
|
||||
// 获取任务列表
|
||||
await fetchData(1)
|
||||
// 开始轮询
|
||||
@@ -345,7 +482,6 @@ export const useJimengStore = defineStore('jimeng', () => {
|
||||
taskFilter,
|
||||
currentList,
|
||||
isOver,
|
||||
isLogin,
|
||||
showDialog,
|
||||
currentVideoUrl,
|
||||
// 配置
|
||||
|
||||
@@ -198,10 +198,10 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
if (v.status === 'success') {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
@@ -236,10 +236,10 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
let needPull = false
|
||||
const firstPageItems = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 100) {
|
||||
if (v.status === 'success') {
|
||||
v.major_model_version = v['raw_data']['major_model_version']
|
||||
}
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
|
||||
needPull = true
|
||||
}
|
||||
firstPageItems.push(v)
|
||||
|
||||
@@ -251,7 +251,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
let needPull = false
|
||||
const items = []
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
|
||||
@@ -3,6 +3,8 @@ import loadingIcon from '@/assets/img/loading.gif'
|
||||
import Storage from 'good-storage'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const taskFailedImage = '/images/failed.png'
|
||||
|
||||
let waterfallOptions = {
|
||||
// 唯一key值
|
||||
rowKey: 'id',
|
||||
@@ -87,6 +89,7 @@ export const useSharedStore = defineStore('shared', {
|
||||
chatListExtend: Storage.get('chat_list_extend', true),
|
||||
ttsModel: Storage.get('tts_model', ''),
|
||||
waterfallOptions,
|
||||
taskFailedImage,
|
||||
}),
|
||||
getters: {},
|
||||
actions: {
|
||||
|
||||
+31
-46
@@ -1,22 +1,42 @@
|
||||
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { downloadFile, httpDownload, httpGet, httpPost } from '@/utils/http'
|
||||
import { replaceImg } from '@/utils/libs'
|
||||
import Compressor from 'compressorjs'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { compact } from 'lodash'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { checkSession, getSystemInfo } from './cache'
|
||||
import { useSharedStore } from './sharedata'
|
||||
import { getSystemInfo } from './cache'
|
||||
|
||||
export const useSunoStore = defineStore('suno', () => {
|
||||
// 响应式数据
|
||||
const custom = ref(false)
|
||||
/**
|
||||
* Suno mv 与各版本说明(Geek 服务文档对齐)
|
||||
* badge:下拉左侧角标内短文案
|
||||
*/
|
||||
const models = ref([
|
||||
{ label: 'v3.0', value: 'chirp-v3-0' },
|
||||
{ label: 'v3.5', value: 'chirp-v3-5' },
|
||||
{ label: 'v4.0', value: 'chirp-v4' },
|
||||
{ label: 'v4.5', value: 'chirp-auk' },
|
||||
{
|
||||
label: 'v5.5',
|
||||
value: 'chirp-fenix',
|
||||
badge: '5.5',
|
||||
hint: '当前最强,人声细腻情绪足,最长8分钟',
|
||||
},
|
||||
{ label: 'v5', value: 'chirp-crow', badge: '5.0', hint: '接近真人、提示词理解准,通用首选' },
|
||||
{
|
||||
label: 'v4.5+',
|
||||
value: 'chirp-bluejay',
|
||||
badge: '4.5+',
|
||||
hint: '配器音色丰富可编辑,最长8分钟',
|
||||
},
|
||||
{
|
||||
label: 'v4.5-all',
|
||||
value: 'chirp-auk-turbo',
|
||||
badge: '4.5t',
|
||||
hint: '极速出歌结构好,最长4分钟',
|
||||
},
|
||||
{ label: 'v4.5', value: 'chirp-auk', badge: '4.5', hint: '人声稳定配器均衡,最长4分钟' },
|
||||
{ label: 'v4', value: 'chirp-v4', badge: '4.0', hint: '48k细节多中文友好,最长150秒' },
|
||||
])
|
||||
|
||||
const tags = ref([
|
||||
@@ -39,7 +59,7 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
])
|
||||
|
||||
const data = ref({
|
||||
model: 'chirp-auk',
|
||||
model: 'chirp-fenix',
|
||||
tags: '',
|
||||
lyrics: '',
|
||||
prompt: '',
|
||||
@@ -63,8 +83,6 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
const promptPlaceholder = ref('请在这里输入你自己写的歌词...')
|
||||
const isGenerating = ref(false)
|
||||
const sunoPower = ref(0)
|
||||
const isLogin = ref(false)
|
||||
const shareStore = useSharedStore()
|
||||
|
||||
// 分页相关
|
||||
const page = ref(1)
|
||||
@@ -81,9 +99,6 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
getSystemInfo().then((res) => {
|
||||
sunoPower.value = res.data.suno_power
|
||||
})
|
||||
checkSession().then((res) => {
|
||||
isLogin.value = true
|
||||
})
|
||||
})
|
||||
|
||||
// 方法
|
||||
@@ -104,7 +119,7 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
const items = []
|
||||
|
||||
for (let v of res.data.items) {
|
||||
if (v.progress === 0 || v.progress === 102) {
|
||||
if (['pending', 'in_progress', 'downloading'].includes(v.status)) {
|
||||
needPull = true
|
||||
}
|
||||
items.push(v)
|
||||
@@ -126,10 +141,6 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
}
|
||||
|
||||
const create = async () => {
|
||||
if (!isLogin.value) {
|
||||
return shareStore.setShowLoginDialog(true)
|
||||
}
|
||||
|
||||
data.value.type = custom.value ? 2 : 1
|
||||
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ''
|
||||
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ''
|
||||
@@ -175,36 +186,10 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
}
|
||||
|
||||
const download = async (item) => {
|
||||
const url = replaceImg(item.audio_url)
|
||||
const downloadURL = `/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
|
||||
}
|
||||
await downloadFile(item, 'audio_url')
|
||||
}
|
||||
|
||||
const uploadAudio = async (file) => {
|
||||
// 判断是否登录
|
||||
if (!isLogin.value) {
|
||||
return shareStore.setShowLoginDialog(true)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file, file.name)
|
||||
showLoading('正在上传文件...')
|
||||
@@ -375,7 +360,7 @@ export const useSunoStore = defineStore('suno', () => {
|
||||
|
||||
const resetData = () => {
|
||||
data.value = {
|
||||
model: 'chirp-auk',
|
||||
model: 'chirp-fenix',
|
||||
tags: '',
|
||||
lyrics: '',
|
||||
prompt: '',
|
||||
|
||||
+160
-40
@@ -6,28 +6,61 @@
|
||||
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
import nodata from '@/assets/img/no-data.png'
|
||||
import failedIcon from '@/assets/img/failed.png'
|
||||
import loadingGif from '@/assets/img/loading.gif'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { getVideoModelByKey, getVideoModels, getVideoProviders } from '@/store/data/video_params'
|
||||
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 { getThumbURL, replaceImg, substr } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export const useVideoStore = defineStore('video', () => {
|
||||
const normalizeWorkItemThumb = (item) => {
|
||||
if (!item) {
|
||||
return item
|
||||
}
|
||||
if (item.status === 'success' && item.video_url) {
|
||||
item.img_thumb = getThumbURL(replaceImg(item.video_url), 300, 0)
|
||||
return item
|
||||
}
|
||||
if (item.status === 'failed') {
|
||||
item.img_thumb = failedIcon
|
||||
return item
|
||||
}
|
||||
if (item.status === 'downloading') {
|
||||
item.img_thumb = loadingGif
|
||||
return item
|
||||
}
|
||||
item.img_thumb = loadingGif
|
||||
return item
|
||||
}
|
||||
|
||||
const normalizeWorkListThumbs = (rows) => {
|
||||
if (!rows || !rows.length) {
|
||||
return rows
|
||||
}
|
||||
for (const row of rows) {
|
||||
normalizeWorkItemThumb(row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const providers = getVideoProviders()
|
||||
const activeProvider = ref(providers.includes('sora') ? 'sora' : providers[0] || '')
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const taskList = ref([])
|
||||
const list = ref([])
|
||||
const noData = ref(true)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const isOver = ref(false)
|
||||
const taskPulling = ref(true)
|
||||
const pullHandler = ref(null)
|
||||
const clipboard = ref(null)
|
||||
@@ -35,9 +68,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
const showDialog = ref(false)
|
||||
const currentVideoUrl = ref('')
|
||||
|
||||
const isLogin = ref(false)
|
||||
const availablePower = ref(0)
|
||||
const shareStore = useSharedStore()
|
||||
|
||||
const taskFilter = ref('all') // 'all' 或 provider
|
||||
|
||||
@@ -48,10 +79,11 @@ export const useVideoStore = defineStore('video', () => {
|
||||
const currentPowerCost = ref(0)
|
||||
|
||||
const currentList = computed(() => {
|
||||
return list.value.filter((item) => {
|
||||
const filtered = list.value.filter((item) => {
|
||||
if (taskFilter.value === 'all') return true
|
||||
return item.type === taskFilter.value
|
||||
})
|
||||
return filtered
|
||||
})
|
||||
|
||||
const providerModels = computed(() => {
|
||||
@@ -72,11 +104,10 @@ export const useVideoStore = defineStore('video', () => {
|
||||
if (pullHandler.value) {
|
||||
clearInterval(pullHandler.value)
|
||||
}
|
||||
pollLatest()
|
||||
pullHandler.value = setInterval(() => {
|
||||
if (taskPulling.value) {
|
||||
fetchData(page.value)
|
||||
}
|
||||
}, 5000)
|
||||
pollLatest()
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
@@ -89,11 +120,11 @@ export const useVideoStore = defineStore('video', () => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const user = await checkSession()
|
||||
isLogin.value = true
|
||||
availablePower.value = user.power
|
||||
|
||||
initClipboard()
|
||||
await loadPowerConfig()
|
||||
await fetchTaskList()
|
||||
await fetchData(1)
|
||||
startPolling()
|
||||
} catch (error) {
|
||||
@@ -108,43 +139,129 @@ export const useVideoStore = defineStore('video', () => {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
const fetchData = async (_page) => {
|
||||
if (_page) {
|
||||
page.value = _page
|
||||
}
|
||||
|
||||
const fetchData = async (pageNum = 1) => {
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await httpGet('/api/video/list', {
|
||||
page: page.value,
|
||||
page.value = pageNum
|
||||
if (pageNum === 1) {
|
||||
isOver.value = false
|
||||
}
|
||||
|
||||
const res = await httpGet('/api/video/works', {
|
||||
page: pageNum,
|
||||
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) {
|
||||
// 检查是否需要继续轮询:progress 为 0 或 102,或者状态为 pending/in_progress/downloading
|
||||
if (v.status === 'pending' || v.status === 'in_progress' || v.status === 'downloading') {
|
||||
needPull = true
|
||||
const items = (res.data.items || []).map((v) => ({
|
||||
...v,
|
||||
downloading: false,
|
||||
}))
|
||||
normalizeWorkListThumbs(items)
|
||||
|
||||
if (items.length === 0) {
|
||||
isOver.value = true
|
||||
if (pageNum === 1) {
|
||||
list.value = []
|
||||
noData.value = true
|
||||
}
|
||||
items.push({
|
||||
...v,
|
||||
downloading: false,
|
||||
})
|
||||
taskPulling.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (items.length < pageSize.value || pageNum * pageSize.value >= total.value) {
|
||||
isOver.value = true
|
||||
}
|
||||
|
||||
const needPull = items.some((item) => item.status === 'downloading')
|
||||
taskPulling.value = needPull
|
||||
list.value = items
|
||||
|
||||
if (pageNum === 1) {
|
||||
list.value = items
|
||||
} else {
|
||||
const merged = [...list.value]
|
||||
const exists = new Set(merged.map((it) => it.id))
|
||||
for (const item of items) {
|
||||
if (!exists.has(item.id)) {
|
||||
merged.push(item)
|
||||
}
|
||||
}
|
||||
list.value = merged
|
||||
}
|
||||
noData.value = list.value.length === 0
|
||||
} catch (error) {
|
||||
noData.value = true
|
||||
if (pageNum === 1) {
|
||||
noData.value = true
|
||||
}
|
||||
console.error('获取任务列表失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollLatest = async () => {
|
||||
try {
|
||||
const [taskRes, workRes] = await Promise.all([
|
||||
httpGet('/api/video/tasks', {
|
||||
type: taskFilter.value === 'all' ? '' : taskFilter.value,
|
||||
}),
|
||||
httpGet('/api/video/works', {
|
||||
page: 1,
|
||||
page_size: pageSize.value,
|
||||
type: taskFilter.value === 'all' ? '' : taskFilter.value,
|
||||
}),
|
||||
])
|
||||
const nextTasks = (taskRes.data || []).map((v) => ({ ...v, downloading: false }))
|
||||
taskList.value = nextTasks
|
||||
const res = workRes
|
||||
const items = (res.data.items || []).map((v) => ({ ...v, downloading: false }))
|
||||
normalizeWorkListThumbs(items)
|
||||
let needPull = false
|
||||
const latestMap = new Map(items.map((it) => [it.id, it]))
|
||||
const matchedIds = []
|
||||
const missedIds = []
|
||||
for (const fetchedItem of items) {
|
||||
if (list.value.some((localItem) => localItem.id === fetchedItem.id)) {
|
||||
matchedIds.push(fetchedItem.id)
|
||||
} else {
|
||||
missedIds.push(fetchedItem.id)
|
||||
}
|
||||
}
|
||||
list.value = list.value.map((row) => {
|
||||
const latest = latestMap.get(row.id)
|
||||
if (!latest) {
|
||||
return row
|
||||
}
|
||||
if (latest.status === 'downloading') {
|
||||
needPull = true
|
||||
}
|
||||
return normalizeWorkItemThumb({ ...row, ...latest })
|
||||
})
|
||||
for (const row of items) {
|
||||
if (!list.value.some((item) => item.id === row.id)) {
|
||||
list.value.unshift(row)
|
||||
}
|
||||
}
|
||||
normalizeWorkListThumbs(list.value)
|
||||
taskPulling.value = needPull || nextTasks.length > 0
|
||||
} catch (error) {
|
||||
console.error('轮询视频任务失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTaskList = async () => {
|
||||
try {
|
||||
const res = await httpGet('/api/video/tasks', {
|
||||
type: taskFilter.value === 'all' ? '' : taskFilter.value,
|
||||
})
|
||||
const items = (res.data || []).map((v) => ({ ...v, downloading: false }))
|
||||
taskList.value = items
|
||||
} catch (error) {
|
||||
console.error('获取任务列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const switchProvider = (provider) => {
|
||||
activeProvider.value = provider
|
||||
}
|
||||
@@ -152,6 +269,8 @@ export const useVideoStore = defineStore('video', () => {
|
||||
const switchTaskFilter = (filter) => {
|
||||
taskFilter.value = filter
|
||||
page.value = 1
|
||||
isOver.value = false
|
||||
fetchTaskList()
|
||||
fetchData(1)
|
||||
}
|
||||
|
||||
@@ -160,7 +279,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
const res = await httpGet('/api/video/power-config')
|
||||
powerConfig.value = res.data || {}
|
||||
} catch (error) {
|
||||
console.error('加载算力配置失败:', error)
|
||||
console.error('加载积分配置失败:', error)
|
||||
powerConfig.value = {}
|
||||
}
|
||||
}
|
||||
@@ -218,7 +337,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
// 防抖定时器
|
||||
let powerDebounceTimer = null
|
||||
|
||||
// 根据 priceKey 获取算力值
|
||||
// 根据 priceKey 获取积分值
|
||||
const getPowerByPriceKey = async (modelKey, priceKey) => {
|
||||
try {
|
||||
const res = await httpGet('/api/video/power-by-key', {
|
||||
@@ -227,7 +346,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
})
|
||||
return res.data?.power || 0
|
||||
} catch (error) {
|
||||
console.error('获取算力失败:', error)
|
||||
console.error('获取积分失败:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -258,7 +377,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 调用 API 获取算力
|
||||
// 调用 API 获取积分
|
||||
const power = await getPowerByPriceKey(modelKey, priceKey)
|
||||
currentPowerCost.value = power
|
||||
}, 300) // 300ms 防抖
|
||||
@@ -290,11 +409,6 @@ export const useVideoStore = defineStore('video', () => {
|
||||
}
|
||||
|
||||
const createVideoTask = async () => {
|
||||
if (!isLogin.value) {
|
||||
shareStore.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
const modelKey = formData.value?.req_key
|
||||
if (!modelKey) {
|
||||
return ElMessage.error('请选择模型')
|
||||
@@ -349,6 +463,8 @@ export const useVideoStore = defineStore('video', () => {
|
||||
await httpPost('/api/video/create', requestData)
|
||||
showMessageOK('任务创建成功')
|
||||
closeLoading()
|
||||
isOver.value = false
|
||||
await fetchTaskList()
|
||||
await fetchData(1)
|
||||
taskPulling.value = true
|
||||
} catch (error) {
|
||||
@@ -399,6 +515,8 @@ export const useVideoStore = defineStore('video', () => {
|
||||
|
||||
await httpGet('/api/video/remove', { id: item.id })
|
||||
ElMessage.success('任务删除成功')
|
||||
isOver.value = false
|
||||
await fetchTaskList()
|
||||
await fetchData(1)
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
@@ -415,14 +533,15 @@ export const useVideoStore = defineStore('video', () => {
|
||||
loading,
|
||||
submitting,
|
||||
list,
|
||||
taskList,
|
||||
currentList,
|
||||
noData,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
isOver,
|
||||
showDialog,
|
||||
currentVideoUrl,
|
||||
isLogin,
|
||||
availablePower,
|
||||
nodata,
|
||||
taskFilter,
|
||||
@@ -435,6 +554,7 @@ export const useVideoStore = defineStore('video', () => {
|
||||
init,
|
||||
cleanup,
|
||||
fetchData,
|
||||
fetchTaskList,
|
||||
switchProvider,
|
||||
switchTaskFilter,
|
||||
|
||||
|
||||
@@ -35,19 +35,6 @@
|
||||
<el-button size="small" class="sm-btn-theme" @click="useRole(scope.item)"
|
||||
>使用</el-button
|
||||
>
|
||||
<el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.id)">
|
||||
<el-button size="small" type="danger" @click="updateRole(scope.item, 'remove')"
|
||||
>移除</el-button
|
||||
>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="添加到工作区" placement="top" v-else>
|
||||
<el-button
|
||||
size="small"
|
||||
style="--el-color-primary: #009999"
|
||||
@click="updateRole(scope.item, 'add')"
|
||||
>添加</el-button
|
||||
>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,10 +51,8 @@
|
||||
<script setup>
|
||||
import nodata from '@/assets/img/no-data.png'
|
||||
import ItemList from '@/components/ItemList.vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { arrayContains, removeArrayItem, substr } from '@/utils/libs'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { substr } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -77,25 +62,12 @@ const listBoxHeight = window.innerHeight - 133
|
||||
const typeId = ref('')
|
||||
const appTypes = ref([])
|
||||
const list = ref([])
|
||||
const roles = ref([])
|
||||
const store = useSharedStore()
|
||||
|
||||
onMounted(() => {
|
||||
getAppType()
|
||||
getAppList()
|
||||
getRoles()
|
||||
})
|
||||
|
||||
const getRoles = () => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const getAppType = () => {
|
||||
httpGet('/api/app/type/list')
|
||||
.then((res) => {
|
||||
@@ -122,45 +94,6 @@ const getAppList = (tid = '') => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateRole = (row, opt) => {
|
||||
checkSession()
|
||||
.then(() => {
|
||||
const title = ref('')
|
||||
if (opt === 'add') {
|
||||
title.value = '添加应用'
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(row.id)
|
||||
} else {
|
||||
title.value = '移除应用'
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, row.id)
|
||||
}
|
||||
httpPost('/api/app/workspace', { ids: roles.value })
|
||||
.then(() => {
|
||||
ElMessage.success({
|
||||
message: title.value + '成功!',
|
||||
duration: 1000,
|
||||
})
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error(title.value + '失败:' + e.message)
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
store.setShowLoginDialog(true)
|
||||
})
|
||||
}
|
||||
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const useRole = (role) => {
|
||||
router.push(`/chat?role_id=${role.id}`)
|
||||
|
||||
+408
-112
@@ -18,59 +18,33 @@
|
||||
<Waterfall
|
||||
v-if="imgType === 'mj'"
|
||||
id="waterfall-mj"
|
||||
v-bind="galleryWaterfallBind"
|
||||
:list="data['mj']"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
:width="waterfallOptions.width"
|
||||
:breakpoints="waterfallOptions.breakpoints"
|
||||
:img-selector="waterfallOptions.imgSelector"
|
||||
:background-color="waterfallOptions.backgroundColor"
|
||||
:animation-effect="waterfallOptions.animationEffect"
|
||||
:animation-duration="waterfallOptions.animationDuration"
|
||||
:animation-delay="waterfallOptions.animationDelay"
|
||||
:animation-cancel="waterfallOptions.animationCancel"
|
||||
:lazyload="waterfallOptions.lazyload"
|
||||
:load-props="waterfallOptions.loadProps"
|
||||
:cross-origin="waterfallOptions.crossOrigin"
|
||||
:align="waterfallOptions.align"
|
||||
:is-loading="loading"
|
||||
:is-over="isOver"
|
||||
@afterRender="loading = false"
|
||||
>
|
||||
<template #default="{ item, url }">
|
||||
<div
|
||||
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
|
||||
class="gallery-wall-card image-task-item bg-gray-900 shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<div class="image-task-preview overflow-hidden">
|
||||
<LazyImg
|
||||
:url="url"
|
||||
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
|
||||
class="image-task-image cursor-pointer transition-transform duration-300 ease-linear"
|
||||
@click="previewImg(item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
|
||||
<div
|
||||
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
|
||||
>
|
||||
<div class="opt">
|
||||
<el-tooltip class="box-item" content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt-wall"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip class="box-item" content="画同款" placement="top">
|
||||
<el-button type="primary" circle @click="drawSameMj(item)">
|
||||
<i class="iconfont icon-palette"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="image-task-overlay">
|
||||
<div class="image-task-overlay-time">
|
||||
{{ dateFormat(item.created_at) }}
|
||||
</div>
|
||||
<div class="image-task-tools">
|
||||
<el-tooltip content="任务详情" placement="top">
|
||||
<button type="button" class="image-task-tool" @click.stop="openDetail(item)">
|
||||
<i class="iconfont icon-info text-[#6366f1]"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,53 +54,33 @@
|
||||
<Waterfall
|
||||
v-if="imgType === 'image'"
|
||||
id="waterfall-image"
|
||||
v-bind="galleryWaterfallBind"
|
||||
:list="data['image']"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
:width="waterfallOptions.width"
|
||||
:breakpoints="waterfallOptions.breakpoints"
|
||||
:img-selector="waterfallOptions.imgSelector"
|
||||
:background-color="waterfallOptions.backgroundColor"
|
||||
:animation-effect="waterfallOptions.animationEffect"
|
||||
:animation-duration="waterfallOptions.animationDuration"
|
||||
:animation-delay="waterfallOptions.animationDelay"
|
||||
:animation-cancel="waterfallOptions.animationCancel"
|
||||
:lazyload="waterfallOptions.lazyload"
|
||||
:load-props="waterfallOptions.loadProps"
|
||||
:cross-origin="waterfallOptions.crossOrigin"
|
||||
:align="waterfallOptions.align"
|
||||
:is-loading="loading"
|
||||
:is-over="isOver"
|
||||
@afterRender="loading = false"
|
||||
>
|
||||
<template #default="{ item, url }">
|
||||
<div
|
||||
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
|
||||
class="gallery-wall-card image-task-item bg-gray-900 shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<div class="image-task-preview overflow-hidden">
|
||||
<LazyImg
|
||||
:url="url"
|
||||
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
|
||||
class="image-task-image cursor-pointer transition-transform duration-300 ease-linear"
|
||||
@click="previewImg(item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="px-4 pt-2 pb-4 border-t border-t-gray-800">
|
||||
<div
|
||||
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
|
||||
>
|
||||
<div class="opt">
|
||||
<el-tooltip class="box-item" content="复制提示词" placement="top">
|
||||
<el-button
|
||||
type="info"
|
||||
circle
|
||||
class="copy-prompt-wall"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-file"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="image-task-overlay">
|
||||
<div class="image-task-overlay-time">
|
||||
{{ dateFormat(item.created_at) }}
|
||||
</div>
|
||||
<div class="image-task-tools">
|
||||
<el-tooltip content="任务详情" placement="top">
|
||||
<button type="button" class="image-task-tool" @click.stop="openDetail(item)">
|
||||
<i class="iconfont icon-info text-[#6366f1]"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -156,18 +110,196 @@
|
||||
|
||||
<back-top :right="30" :bottom="30" />
|
||||
</div>
|
||||
<!-- end of waterfall -->
|
||||
</div>
|
||||
<!-- 图片预览 -->
|
||||
<el-image-viewer
|
||||
@close="
|
||||
() => {
|
||||
previewURL = ''
|
||||
}
|
||||
"
|
||||
v-if="previewURL !== ''"
|
||||
:url-list="[previewURL]"
|
||||
/>
|
||||
|
||||
<el-image-viewer @close="closePreview" v-if="previewURL !== ''" :url-list="[previewURL]" />
|
||||
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="任务详情"
|
||||
:width="detailKind === 'mj' ? '680px' : '600px'"
|
||||
:class="['gallery-detail-dialog', { 'mj-detail-dialog': detailKind === 'mj' }]"
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<div
|
||||
v-if="detailKind === 'mj' && currentDetail"
|
||||
class="gallery-mj-detail-body detail-content"
|
||||
>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="任务 ID">
|
||||
<div class="mj-detail-copy-row">
|
||||
<span class="break-all font-mono text-[13px]">{{
|
||||
currentDetail.task_id || '-'
|
||||
}}</span>
|
||||
<el-tooltip v-if="currentDetail.task_id" content="复制" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy mj-detail-copy-ico"
|
||||
@click="copyText(currentDetail.task_id)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务类型">
|
||||
{{ mjDetailTypeLabel(currentDetail.type) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
{{ currentDetail.status || '-' }}
|
||||
<span
|
||||
v-if="currentDetail.progress != null && currentDetail.status !== 'success'"
|
||||
class="text-gray-500 ml-1"
|
||||
>
|
||||
({{ currentDetail.progress }}%)
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="原始提示词" v-if="detailTaskPayload?.prompt">
|
||||
<div class="mj-detail-copy-row">
|
||||
<span class="break-all mj-detail-text">{{ detailTaskPayload.prompt }}</span>
|
||||
<el-tooltip content="复制" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy mj-detail-copy-ico"
|
||||
@click="copyText(detailTaskPayload.prompt)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="负面提示词" v-if="detailTaskPayload?.neg_prompt">
|
||||
<span class="break-all mj-detail-text">{{ detailTaskPayload.neg_prompt }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="完整提示词">
|
||||
<div class="mj-detail-copy-row align-start">
|
||||
<span class="break-all mj-detail-text">{{ currentDetail.prompt || '—' }}</span>
|
||||
<el-tooltip v-if="currentDetail.prompt" content="复制" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy mj-detail-copy-ico"
|
||||
@click="copyText(currentDetail.prompt)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="引用图片" v-if="detailTaskImages.length > 0">
|
||||
<div class="mj-detail-refimgs">
|
||||
<el-image
|
||||
v-for="(u, idx) in detailTaskImages"
|
||||
:key="'ref-' + idx"
|
||||
:src="u"
|
||||
:preview-src-list="detailTaskImages"
|
||||
fit="cover"
|
||||
class="mj-detail-refimg"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="局部重绘" v-if="detailHasMask">
|
||||
<el-text type="info">已提交蒙版(内容略)</el-text>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="生成结果"
|
||||
v-if="currentDetail.status === 'success' && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 240, 240)"
|
||||
:preview-src-list="[currentDetail.img_url]"
|
||||
fit="cover"
|
||||
class="mj-detail-result"
|
||||
preview-teleported
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power ?? 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
label="原始载荷"
|
||||
v-if="currentDetail.task_info && !detailTaskPayload"
|
||||
>
|
||||
<span class="text-gray-500 text-xs">无法解析 JSON,以下为原始文本:</span>
|
||||
<pre class="mj-detail-raw">{{ currentDetail.task_info }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detailKind === 'image' && currentDetail" class="detail-content">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div>
|
||||
<span>{{ currentDetail.prompt }}</span>
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy ml-2 cursor-pointer"
|
||||
@click="copyText(currentDetail.prompt)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.status === 'success' && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
:preview-src-list="[currentDetail.img_url]"
|
||||
fit="cover"
|
||||
style="width: 200px; height: 200px"
|
||||
preview-teleported
|
||||
/>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="参考图"
|
||||
v-if="currentDetail.params?.image && currentDetail.params.image.length > 0"
|
||||
>
|
||||
<div class="reference-images">
|
||||
<el-image
|
||||
v-for="(img, idx) in currentDetail.params.image"
|
||||
:key="idx"
|
||||
:src="getThumbURL(img, 100, 100)"
|
||||
:preview-src-list="currentDetail.params.image"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
style="width: 100px; height: 100px; margin-right: 10px"
|
||||
preview-teleported
|
||||
/>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="生图模型">
|
||||
{{ currentDetail.params?.model_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片比例">
|
||||
{{ currentDetail.params?.aspect_ratio || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="图片尺寸">
|
||||
{{ currentDetail.params?.size || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,24 +307,29 @@
|
||||
import BackTop from '@/components/BackTop.vue'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { getThumbURL } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { dateFormat, getThumbURL } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const store = useSharedStore()
|
||||
const waterfallOptions = store.waterfallOptions
|
||||
|
||||
/** 作品墙:间距约 2px、无外围 gutter,视觉接近小红书信息流 */
|
||||
const galleryWaterfallBind = computed(() => ({
|
||||
...store.waterfallOptions,
|
||||
gutter: 3,
|
||||
hasAroundGutter: false,
|
||||
}))
|
||||
|
||||
const data = ref({
|
||||
mj: [],
|
||||
image: [],
|
||||
})
|
||||
const loading = ref(true)
|
||||
const isOver = ref(false)
|
||||
const imgType = ref('mj') // 图片类别
|
||||
const imgType = ref('mj')
|
||||
const listBoxHeight = window.innerHeight - 124
|
||||
const previewURL = ref('')
|
||||
|
||||
@@ -200,9 +337,13 @@ const previewImg = (item) => {
|
||||
previewURL.value = item.img_url
|
||||
}
|
||||
|
||||
const closePreview = () => {
|
||||
previewURL.value = ''
|
||||
}
|
||||
|
||||
const page = ref(0)
|
||||
const pageSize = ref(15)
|
||||
// 获取下一页数据
|
||||
|
||||
const getNext = () => {
|
||||
if (isOver.value) {
|
||||
return
|
||||
@@ -227,7 +368,6 @@ const getNext = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成缩略图
|
||||
const imageList = res.data.items
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
|
||||
@@ -250,24 +390,7 @@ const getNext = () => {
|
||||
|
||||
getNext()
|
||||
|
||||
const clipboard = ref(null)
|
||||
onMounted(() => {
|
||||
clipboard.value = new Clipboard('.copy-prompt-wall')
|
||||
clipboard.value.on('success', () => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
|
||||
clipboard.value.on('error', () => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clipboard.value.destroy()
|
||||
})
|
||||
|
||||
const changeImgType = () => {
|
||||
console.log(imgType.value)
|
||||
document.getElementById('waterfall-box').scrollTo(0, 0)
|
||||
page.value = 0
|
||||
data.value = {
|
||||
@@ -279,13 +402,186 @@ const changeImgType = () => {
|
||||
nextTick(() => getNext())
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const drawSameMj = (row) => {
|
||||
router.push({ name: 'image-mj', params: { prompt: row.prompt } })
|
||||
/** ---------- 任务详情(与 Image / ImageMj 对齐) ---------- */
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailKind = ref('mj')
|
||||
const currentDetail = ref(null)
|
||||
|
||||
function parseMjTaskInfo(raw) {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'object') return raw
|
||||
if (typeof raw !== 'string') return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const detailTaskPayload = computed(() => {
|
||||
if (detailKind.value !== 'mj' || !currentDetail.value) return null
|
||||
return parseMjTaskInfo(currentDetail.value.task_info)
|
||||
})
|
||||
|
||||
const detailTaskImages = computed(() => {
|
||||
const arr = detailTaskPayload.value?.img_arr
|
||||
return Array.isArray(arr) ? arr.filter((u) => u && String(u).trim()) : []
|
||||
})
|
||||
|
||||
const detailHasMask = computed(() => {
|
||||
const m = detailTaskPayload.value?.mask_base64
|
||||
return typeof m === 'string' && m.length > 0
|
||||
})
|
||||
|
||||
function mjDetailTypeLabel(type) {
|
||||
const map = {
|
||||
image: '绘图',
|
||||
upscale: '放大',
|
||||
variation: '变换',
|
||||
blend: '融图',
|
||||
swapFace: '换脸',
|
||||
modal: '局部重绘',
|
||||
}
|
||||
return map[type] || type || '-'
|
||||
}
|
||||
|
||||
const openDetail = (item) => {
|
||||
detailKind.value = imgType.value
|
||||
if (imgType.value === 'image') {
|
||||
let params = {}
|
||||
try {
|
||||
if (item.params) {
|
||||
params = typeof item.params === 'string' ? JSON.parse(item.params) : item.params
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 params 失败:', e)
|
||||
}
|
||||
currentDetail.value = { ...item, params }
|
||||
} else {
|
||||
currentDetail.value = { ...item }
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const copyText = (text) => {
|
||||
if (!text) return
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
ElMessage.success('复制成功!')
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('复制失败!')
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use '../assets/css/images-wall.scss' as *;
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
@use '../assets/css/image.scss' as *;
|
||||
</style>
|
||||
|
||||
<!-- Dialog teleport 到 body,与 ImageMj 一致使用非 scoped 样式 -->
|
||||
<style lang="scss">
|
||||
.gallery-detail-dialog.mj-detail-dialog {
|
||||
:deep(.el-dialog__body) {
|
||||
padding-top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.gallery-mj-detail-body {
|
||||
max-height: min(72vh, 640px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mj-detail-copy-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&.align-start {
|
||||
align-items: flex-start;
|
||||
|
||||
.mj-detail-copy-ico {
|
||||
margin-top: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mj-detail-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mj-detail-copy-ico {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
opacity: 0.85;
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mj-detail-refimgs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mj-detail-refimg {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.mj-detail-result {
|
||||
width: 240px;
|
||||
max-width: 100%;
|
||||
height: 240px;
|
||||
border-radius: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.mj-detail-raw {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
border-radius: 0;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.page-images-wall .gallery-wall-card,
|
||||
.page-images-wall .gallery-wall-card .image-task-preview {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.page-images-wall .gallery-wall-card :deep(img),
|
||||
.page-images-wall .gallery-wall-card :deep(.lazy__img) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.gallery-detail-dialog :deep(.el-image__inner),
|
||||
.gallery-detail-dialog :deep(.el-image__wrapper) {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.gallery-wall-card:hover .image-task-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
+5
-26
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div @click="showMemberDialog = true" class="flex">
|
||||
<div @click="goMember" class="flex">
|
||||
<i class="iconfont icon-config"></i>
|
||||
<span class="title">用户设置</span>
|
||||
</div>
|
||||
@@ -148,21 +148,6 @@
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 用户设置弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showMemberDialog"
|
||||
title="用户设置"
|
||||
width="90%"
|
||||
:close-on-click-modal="true"
|
||||
:close-on-press-escape="true"
|
||||
style="max-width: 1400px"
|
||||
@close="showMemberDialog = false"
|
||||
>
|
||||
<div class="member-dialog-content">
|
||||
<Member />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 推广计划弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showInvitationDialog"
|
||||
@@ -195,7 +180,6 @@
|
||||
import LoginDialog from '@/components/LoginDialog.vue'
|
||||
import ThemeChange from '@/components/ThemeChange.vue'
|
||||
import PowerLog from '@/views/PowerLog.vue'
|
||||
import Member from '@/views/Member.vue'
|
||||
import Invitation from '@/views/Invitation.vue'
|
||||
import { checkSession, getSystemInfo } from '@/store/cache'
|
||||
import { removeUserToken } from '@/store/session'
|
||||
@@ -216,7 +200,6 @@ const store = useSharedStore()
|
||||
const loginUser = ref({})
|
||||
const routerViewKey = ref(0)
|
||||
const showPowerLogDialog = ref(false)
|
||||
const showMemberDialog = ref(false)
|
||||
const showInvitationDialog = ref(false)
|
||||
const showLoginDialog = ref(false)
|
||||
const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
|
||||
@@ -319,6 +302,10 @@ const init = () => {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const goMember = () => {
|
||||
router.push('/member')
|
||||
}
|
||||
|
||||
const logout = function () {
|
||||
httpGet('/api/user/logout')
|
||||
.then(() => {
|
||||
@@ -345,7 +332,6 @@ const loginSuccess = () => {
|
||||
|
||||
<style lang="scss">
|
||||
.powerlog-dialog-content,
|
||||
.member-dialog-content,
|
||||
.invitation-dialog-content {
|
||||
max-height: calc(100vh - 150px);
|
||||
overflow-y: auto;
|
||||
@@ -360,13 +346,6 @@ const loginSuccess = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.member-dialog-content {
|
||||
.member-page {
|
||||
min-height: auto;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.invitation-dialog-content {
|
||||
.page-invitation {
|
||||
.inner {
|
||||
|
||||
+113
-148
@@ -147,7 +147,7 @@
|
||||
<i v-if="isGenerating" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span v-if="isGenerating">创作中...</span>
|
||||
<span v-else>立即生成({{ imagePower }}算力)</span>
|
||||
<span v-else>立即生成({{ imagePower }}积分)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,118 +157,93 @@
|
||||
<h2 class="text-xl">任务列表</h2>
|
||||
<task-list :list="runningJobs" />
|
||||
<template v-if="finishedJobs.length > 0">
|
||||
<h2 class="text-xl">创作记录</h2>
|
||||
<h2 class="text-xl">你的作品</h2>
|
||||
<div class="finish-job-list mt-3">
|
||||
<div v-if="finishedJobs.length > 0">
|
||||
<Waterfall
|
||||
:list="finishedJobs"
|
||||
:row-key="waterfallOptions.rowKey"
|
||||
:gutter="waterfallOptions.gutter"
|
||||
:has-around-gutter="waterfallOptions.hasAroundGutter"
|
||||
:width="waterfallOptions.width"
|
||||
:breakpoints="waterfallOptions.breakpoints"
|
||||
:img-selector="waterfallOptions.imgSelector"
|
||||
:background-color="waterfallOptions.backgroundColor"
|
||||
:animation-effect="waterfallOptions.animationEffect"
|
||||
:animation-duration="waterfallOptions.animationDuration"
|
||||
:animation-delay="waterfallOptions.animationDelay"
|
||||
:animation-cancel="waterfallOptions.animationCancel"
|
||||
:lazyload="waterfallOptions.lazyload"
|
||||
:load-props="waterfallOptions.loadProps"
|
||||
:cross-origin="waterfallOptions.crossOrigin"
|
||||
:align="waterfallOptions.align"
|
||||
v-bind="imageWaterfallOptions"
|
||||
:is-loading="loading"
|
||||
:is-over="isOver"
|
||||
@afterRender="loading = false"
|
||||
:lazyload="true"
|
||||
@afterRender="onWaterfallAfterRender"
|
||||
>
|
||||
<template #default="{ item, url }">
|
||||
<div
|
||||
class="bg-gray-900 rounded-lg shadow-md overflow-hidden transition-all duration-300 ease-linear hover:shadow-md hover:shadow-purple-800 group"
|
||||
>
|
||||
<div class="overflow-hidden rounded-lg">
|
||||
<div class="image-task-item">
|
||||
<div
|
||||
class="image-task-preview"
|
||||
:class="{ 'image-task-preview--failed': item.status === 'failed' }"
|
||||
>
|
||||
<LazyImg
|
||||
:url="url"
|
||||
v-if="item.progress === 100"
|
||||
class="cursor-pointer transition-all duration-300 ease-linear group-hover:scale-105"
|
||||
v-if="item.status === 'success'"
|
||||
class="image-task-image"
|
||||
@click="previewImg(item)"
|
||||
/>
|
||||
<el-image v-else-if="item.progress === 101">
|
||||
<template #error>
|
||||
<div class="image-slot">
|
||||
<div class="err-msg-container">
|
||||
<div class="title">任务失败</div>
|
||||
<div class="opt">
|
||||
<el-popover
|
||||
title="错误详情"
|
||||
trigger="click"
|
||||
:width="250"
|
||||
:content="item['err_msg']"
|
||||
placement="top"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="info">详情</el-button>
|
||||
</template>
|
||||
</el-popover>
|
||||
<el-button type="danger" @click="removeImage(item)"
|
||||
>删除</el-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<img
|
||||
v-else-if="item.status === 'failed'"
|
||||
class="image-task-image image-task-image--failed"
|
||||
:src="taskFailedImage"
|
||||
title="点击查看详情"
|
||||
@click="showDetail(item)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="px-4 pt-2 pb-4 border-t border-t-gray-800"
|
||||
v-if="item.progress === 100"
|
||||
>
|
||||
<div
|
||||
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
|
||||
>
|
||||
<div class="flex">
|
||||
<el-tooltip content="取消分享" placement="top" v-if="item.publish">
|
||||
<el-button
|
||||
type="warning"
|
||||
@click="publishImage(item, false)"
|
||||
circle
|
||||
>
|
||||
<i class="iconfont icon-cancel-share"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分享" placement="top" v-else>
|
||||
<el-button
|
||||
type="success"
|
||||
@click="publishImage(item, true)"
|
||||
circle
|
||||
>
|
||||
<i class="iconfont icon-share-bold"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<div class="image-task-overlay">
|
||||
<div class="image-task-overlay-time">
|
||||
{{ dateFormat(item.created_at) }}
|
||||
</div>
|
||||
<div class="image-task-tools">
|
||||
<el-tooltip
|
||||
content="取消分享"
|
||||
placement="top"
|
||||
v-if="item.status === 'success' && item.publish"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-tool"
|
||||
@click="publishImage(item, false)"
|
||||
>
|
||||
<i class="iconfont icon-cancel-share"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="分享" placement="top" v-else-if="item.status === 'success'">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-tool"
|
||||
@click="publishImage(item, true)"
|
||||
>
|
||||
<i class="iconfont icon-share-bold"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="详情" placement="top">
|
||||
<el-button type="info" circle @click="showDetail(item)">
|
||||
<i class="iconfont icon-info"></i>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button
|
||||
type="danger"
|
||||
:icon="Delete"
|
||||
@click="removeImage(item)"
|
||||
circle
|
||||
/>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="详情" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-tool"
|
||||
@click="showDetail(item)"
|
||||
>
|
||||
<i class="iconfont icon-info text-[#6366f1]"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-tool image-task-tool--danger"
|
||||
@click="removeImage(item)"
|
||||
>
|
||||
<i class="iconfont icon-remove"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-tooltip content="下载" placement="top">
|
||||
<el-button
|
||||
type="primary"
|
||||
circle
|
||||
:icon="Download"
|
||||
@click="downloadImage(item)"
|
||||
:loading="item.downloading"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<el-tooltip content="下载" placement="top" v-if="item.status === 'success'">
|
||||
<button
|
||||
type="button"
|
||||
class="image-task-tool"
|
||||
@click="downloadImage(item)"
|
||||
>
|
||||
<i class="iconfont icon-download"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -277,19 +252,12 @@
|
||||
|
||||
<div class="flex justify-center py-10">
|
||||
<img
|
||||
:src="waterfallOptions.loadProps.loading"
|
||||
:src="imageWaterfallOptions.loadProps.loading"
|
||||
class="max-w-[50px] max-h-[50px]"
|
||||
v-if="loading"
|
||||
v-if="!waterfallRendered"
|
||||
/>
|
||||
<div v-else>
|
||||
<button
|
||||
class="px-5 py-2 rounded-full bg-purple-700 text-md text-white cursor-pointer hover:bg-purple-800 transition-all duration-300"
|
||||
@click="fetchFinishJobs"
|
||||
v-if="!isOver"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
<div class="no-more-data" v-else>
|
||||
<div class="no-more-data" v-if="isOver">
|
||||
<span class="text-gray-500 mr-2">没有更多数据了</span>
|
||||
<i class="iconfont icon-face"></i>
|
||||
</div>
|
||||
@@ -341,7 +309,7 @@
|
||||
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
v-if="currentDetail.status === 'success' && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
@@ -372,7 +340,7 @@
|
||||
{{ currentDetail.params?.model_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="消耗算力">
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -390,11 +358,14 @@
|
||||
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div v-if="currentDetail.status === 'failed'" class="image-detail-failed-actions">
|
||||
<el-button type="danger" @click="removeImageFromDetail">删除此任务</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
@@ -413,23 +384,28 @@ import { showMessageError } from '@/utils/dialog'
|
||||
import { downloadFile, httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, getThumbURL } from '@/utils/libs'
|
||||
import Clipboard from 'clipboard'
|
||||
import { Delete, Download, List } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const listBoxHeight = ref(0)
|
||||
const isLogin = ref(false)
|
||||
const loading = ref(true)
|
||||
const isOver = ref(false)
|
||||
const previewURL = ref('')
|
||||
const store = useSharedStore()
|
||||
const models = ref([])
|
||||
const waterfallOptions = store.waterfallOptions
|
||||
const imageWaterfallOptions = computed(() => ({
|
||||
...waterfallOptions,
|
||||
gutter: 2,
|
||||
hasAroundGutter: false,
|
||||
}))
|
||||
const waterfallRendered = ref(false)
|
||||
const resizeElement = function () {
|
||||
listBoxHeight.value = window.innerHeight - 58
|
||||
}
|
||||
const taskFailedImage = store.taskFailedImage
|
||||
|
||||
resizeElement()
|
||||
window.onresize = () => {
|
||||
@@ -537,7 +513,6 @@ const initData = () => {
|
||||
.then((user) => {
|
||||
userPower.value = user['power']
|
||||
userId.value = user.id
|
||||
isLogin.value = true
|
||||
page.value = 0
|
||||
fetchRunningJobs()
|
||||
fetchFinishJobs()
|
||||
@@ -561,9 +536,6 @@ const initData = () => {
|
||||
}
|
||||
|
||||
const fetchRunningJobs = () => {
|
||||
if (!isLogin.value) {
|
||||
return
|
||||
}
|
||||
// 获取运行中的任务
|
||||
httpGet(`/api/image/jobs?finish=false`)
|
||||
.then((res) => {
|
||||
@@ -588,11 +560,8 @@ const page = ref(1)
|
||||
const pageSize = ref(15)
|
||||
// 获取已完成的任务
|
||||
const fetchFinishJobs = () => {
|
||||
if (!isLogin.value) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
waterfallRendered.value = false
|
||||
page.value = page.value + 1
|
||||
|
||||
httpGet(`/api/image/jobs?finish=true&page=${page.value}&page_size=${pageSize.value}`)
|
||||
@@ -606,7 +575,7 @@ const fetchFinishJobs = () => {
|
||||
for (let i = 0; i < imageList.length; i++) {
|
||||
if (imageList[i]['img_url']) {
|
||||
imageList[i]['img_thumb'] = getThumbURL(imageList[i]['img_url'], 300, 0)
|
||||
} else if (imageList[i].progress === 100) {
|
||||
} else if (imageList[i].status === 'downloading') {
|
||||
needPulling = true
|
||||
imageList[i]['img_thumb'] = waterfallOptions.loadProps.loading
|
||||
}
|
||||
@@ -621,6 +590,7 @@ const fetchFinishJobs = () => {
|
||||
} else {
|
||||
finishedJobs.value = finishedJobs.value.concat(imageList)
|
||||
}
|
||||
loading.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取任务失败:' + e.message)
|
||||
@@ -628,6 +598,14 @@ const fetchFinishJobs = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const onWaterfallAfterRender = () => {
|
||||
waterfallRendered.value = true
|
||||
loading.value = false
|
||||
if (!isOver.value) {
|
||||
fetchFinishJobs()
|
||||
}
|
||||
}
|
||||
|
||||
const isGenerating = ref(false)
|
||||
const generate = () => {
|
||||
if (isGenerating.value) {
|
||||
@@ -637,11 +615,6 @@ const generate = () => {
|
||||
return ElMessage.error('请输入绘画提示词!')
|
||||
}
|
||||
|
||||
if (!isLogin.value) {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (!params.value.size) {
|
||||
return ElMessage.error('请选择或填写图片尺寸!')
|
||||
}
|
||||
@@ -667,6 +640,7 @@ const generate = () => {
|
||||
// 追加任务列表
|
||||
runningJobs.value.push({
|
||||
prompt: params.value.prompt,
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
})
|
||||
allowPulling.value = true
|
||||
@@ -680,7 +654,8 @@ const generate = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const removeImage = (item) => {
|
||||
const removeImage = (item, options = {}) => {
|
||||
const { closeDetailAfterRemove = false } = options
|
||||
ElMessageBox.confirm('此操作将会删除任务和图片,继续操作码?', '删除提示', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
@@ -690,6 +665,9 @@ const removeImage = (item) => {
|
||||
httpGet('/api/image/remove', { id: item.id })
|
||||
.then(() => {
|
||||
ElMessage.success('任务删除成功')
|
||||
if (closeDetailAfterRemove) {
|
||||
detailDialogVisible.value = false
|
||||
}
|
||||
page.value = 0
|
||||
isOver.value = false
|
||||
fetchFinishJobs()
|
||||
@@ -701,6 +679,13 @@ const removeImage = (item) => {
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const removeImageFromDetail = () => {
|
||||
if (!currentDetail.value) {
|
||||
return
|
||||
}
|
||||
removeImage(currentDetail.value, { closeDetailAfterRemove: true })
|
||||
}
|
||||
|
||||
const previewImg = (item) => {
|
||||
previewURL.value = item.img_url
|
||||
}
|
||||
@@ -772,29 +757,9 @@ const showDetail = (item) => {
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '../assets/css/image.scss' as *;
|
||||
@use '../assets/css/custom-scroll.scss' as *;
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.prompt-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.reference-images {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss"></style>
|
||||
|
||||
+572
-424
File diff suppressed because it is too large
Load Diff
+376
-3
@@ -233,6 +233,7 @@
|
||||
<div class="task-preview">
|
||||
<el-image
|
||||
v-if="item.img_url"
|
||||
:key="`${item.id}-${item.status}-${item.img_url}`"
|
||||
:src="item.img_url"
|
||||
:preview-src-list="[item.img_url]"
|
||||
:preview-teleported="true"
|
||||
@@ -247,6 +248,7 @@
|
||||
</el-image>
|
||||
<div v-else-if="item.video_url" class="w-full h-full preview-video-wrapper">
|
||||
<video
|
||||
:key="`${item.id}-${item.status}-${item.video_url}`"
|
||||
:src="item.video_url"
|
||||
preload="auto"
|
||||
loop="loop"
|
||||
@@ -274,7 +276,11 @@
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'generating'"
|
||||
v-else-if="
|
||||
item.status === 'generating' ||
|
||||
item.status === 'submited' ||
|
||||
item.status === 'done'
|
||||
"
|
||||
class="flex flex-col items-center gap-1"
|
||||
>
|
||||
<span>
|
||||
@@ -321,6 +327,15 @@
|
||||
</el-tooltip>
|
||||
</span>
|
||||
|
||||
<span class="ml-1">
|
||||
<el-tooltip content="任务详情" placement="top">
|
||||
<i
|
||||
class="iconfont icon-info cursor-pointer text-[#6366f1]"
|
||||
@click="showTaskDetail(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">
|
||||
@@ -400,6 +415,132 @@
|
||||
</video>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务详情(参考 Image.vue) -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="任务详情"
|
||||
width="680px"
|
||||
class="jimeng-detail-dialog"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="任务类型">
|
||||
<el-tag size="small" :type="store.getTaskType(currentDetail.type)">
|
||||
{{ store.getFunctionName(currentDetail.type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="模型 / ReqKey">
|
||||
{{ currentDetail.req_key || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
{{ store.getTaskStatusText(currentDetail.status) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务 ID">
|
||||
{{ currentDetail.task_id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power ?? 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div class="prompt-with-copy">
|
||||
<span class="break-all">{{ currentDetail.prompt || '(无)' }}</span>
|
||||
<el-tooltip v-if="currentDetail.prompt" content="复制提示词" placement="top">
|
||||
<i
|
||||
class="iconfont icon-copy ml-2 cursor-pointer shrink-0"
|
||||
@click="copyPrompt(currentDetail.prompt)"
|
||||
/>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="currentDetail.err_msg && String(currentDetail.err_msg).trim()"
|
||||
label="错误信息"
|
||||
>
|
||||
<el-text type="danger" class="break-all">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detailUploadImages.length">
|
||||
<h4 class="detail-section-title">上传的图片</h4>
|
||||
<div class="detail-media-grid detail-media-grid--image">
|
||||
<el-image
|
||||
v-for="(url, idx) in detailUploadImages"
|
||||
:key="'uimg-' + idx"
|
||||
:src="getThumbURL(url, 160, 160)"
|
||||
:preview-src-list="detailUploadImages"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
class="detail-thumb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailUploadVideos.length">
|
||||
<h4 class="detail-section-title">上传的视频</h4>
|
||||
<div class="detail-media-stack">
|
||||
<video
|
||||
v-for="(url, idx) in detailUploadVideos"
|
||||
:key="'uvid-' + idx"
|
||||
:src="url"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="detail-video-preview"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailUploadAudios.length">
|
||||
<h4 class="detail-section-title">上传的音频</h4>
|
||||
<div class="detail-media-stack">
|
||||
<audio
|
||||
v-for="(url, idx) in detailUploadAudios"
|
||||
:key="'uaud-' + idx"
|
||||
:src="url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailResultImage || detailResultVideo">
|
||||
<h4 class="detail-section-title">生成结果</h4>
|
||||
<div v-if="detailResultImage" class="detail-media-grid detail-media-grid--image mb-3">
|
||||
<el-image
|
||||
:src="getThumbURL(detailResultImage, 220, 220)"
|
||||
:preview-src-list="[detailResultImage]"
|
||||
fit="cover"
|
||||
class="detail-thumb detail-thumb--large"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="detailResultVideo" class="detail-media-stack">
|
||||
<video
|
||||
:src="detailResultVideo"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="detail-video-preview detail-video-preview--large"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailOtherParamsText">
|
||||
<el-collapse class="detail-params-collapse">
|
||||
<el-collapse-item title="其他请求参数(JSON)" name="params">
|
||||
<pre class="detail-json">{{ detailOtherParamsText }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -410,9 +551,9 @@ import ParamBuilder from '@/components/ParamBuilder.vue'
|
||||
import Generating from '@/components/ui/Generating.vue'
|
||||
import { useJimengStore } from '@/store/jimeng'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { dateFormat } from '@/utils/libs'
|
||||
import { dateFormat, getThumbURL, replaceImg } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
@@ -430,6 +571,167 @@ const templatePreview = ref('')
|
||||
const guideActive = ref([])
|
||||
|
||||
const videoPreviewRef = ref(null)
|
||||
|
||||
function toUrlList(v) {
|
||||
if (v == null) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (typeof x === 'string' ? x.trim() : x)).filter(Boolean)
|
||||
}
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
return [v.trim()]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Seedance content 项里的 { url: "..." } 引用
|
||||
function pickRefUrl(ref) {
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
return ''
|
||||
}
|
||||
const url = ref.url
|
||||
return typeof url === 'string' && url.trim() ? url.trim() : ''
|
||||
}
|
||||
|
||||
function collectContentUrls(params, type) {
|
||||
if (!params || !Array.isArray(params.content)) {
|
||||
return []
|
||||
}
|
||||
const out = []
|
||||
for (const row of params.content) {
|
||||
if (!row || typeof row !== 'object' || row.type !== type) {
|
||||
continue
|
||||
}
|
||||
if (type === 'image_url') {
|
||||
const u = pickRefUrl(row.image_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
} else if (type === 'video_url') {
|
||||
const u = pickRefUrl(row.video_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
} else if (type === 'audio_url') {
|
||||
const u = pickRefUrl(row.audio_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function uniqUrls(urls) {
|
||||
return [...new Set(urls.filter(Boolean))]
|
||||
}
|
||||
|
||||
// 解析任务 params(与 Image.vue 一致:支持字符串或对象)
|
||||
function parseJobParams(item) {
|
||||
let params = {}
|
||||
try {
|
||||
if (item.params) {
|
||||
if (typeof item.params === 'string') {
|
||||
params = JSON.parse(item.params)
|
||||
} else if (typeof item.params === 'object') {
|
||||
params = { ...item.params }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析即梦任务 params 失败:', e)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const detailParsedParams = computed(() => {
|
||||
const row = currentDetail.value
|
||||
if (!row) {
|
||||
return {}
|
||||
}
|
||||
return parseJobParams(row)
|
||||
})
|
||||
|
||||
const detailUploadImages = computed(() => {
|
||||
const p = detailParsedParams.value
|
||||
const fromFields = toUrlList(p.image_urls)
|
||||
const fromContent = collectContentUrls(p, 'image_url')
|
||||
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailUploadVideos = computed(() => {
|
||||
const p = detailParsedParams.value
|
||||
const fromFields = toUrlList(p.video_url)
|
||||
const fromContent = collectContentUrls(p, 'video_url')
|
||||
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailUploadAudios = computed(() => {
|
||||
const p = detailParsedParams.value
|
||||
const fromFields = toUrlList(p.audio_url)
|
||||
const fromContent = collectContentUrls(p, 'audio_url')
|
||||
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailResultImage = computed(() => {
|
||||
const row = currentDetail.value
|
||||
if (!row || row.status !== 'success' || !row.img_url) {
|
||||
return ''
|
||||
}
|
||||
return replaceImg(row.img_url)
|
||||
})
|
||||
|
||||
const detailResultVideo = computed(() => {
|
||||
const row = currentDetail.value
|
||||
if (!row || row.status !== 'success' || !row.video_url) {
|
||||
return ''
|
||||
}
|
||||
return replaceImg(row.video_url)
|
||||
})
|
||||
|
||||
const PARAM_SKIP_KEYS = new Set([
|
||||
'prompt',
|
||||
'image_urls',
|
||||
'video_url',
|
||||
'audio_url',
|
||||
'content',
|
||||
'type',
|
||||
'req_key',
|
||||
'action',
|
||||
'power',
|
||||
])
|
||||
|
||||
const detailOtherParamsText = computed(() => {
|
||||
const p = detailParsedParams.value
|
||||
const rest = {}
|
||||
for (const k of Object.keys(p)) {
|
||||
if (PARAM_SKIP_KEYS.has(k)) {
|
||||
continue
|
||||
}
|
||||
rest[k] = p[k]
|
||||
}
|
||||
if (Object.keys(rest).length === 0) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(rest, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function showTaskDetail(item) {
|
||||
const params = parseJobParams(item)
|
||||
currentDetail.value = {
|
||||
...item,
|
||||
params,
|
||||
}
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 播放视频
|
||||
const playVideo = (item) => {
|
||||
store.currentVideoUrl = item.video_url
|
||||
@@ -505,4 +807,75 @@ function copyErrorMsg(msg) {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/jimeng.scss' as *;
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 112px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-theme-color, #252f76);
|
||||
}
|
||||
|
||||
.prompt-with-copy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.detail-media-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-media-grid--image .detail-thumb {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-thumb--large {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.detail-media-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-video-preview {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.detail-video-preview--large {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.detail-params-collapse {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -81,7 +81,7 @@ const handleChangeActive = (newValue) => {
|
||||
onMounted(() => {
|
||||
// 确保默认显示注册状态
|
||||
if (loginDialogRef.value) {
|
||||
loginDialogRef.value.login = !isRegister
|
||||
loginDialogRef.value.login = !isRegister.value
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
|
||||
+50
-17
@@ -167,23 +167,38 @@
|
||||
<span>{{ feature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto grid grid-cols-2 gap-2">
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
type="success"
|
||||
@click="wxPay(item)"
|
||||
>
|
||||
<i class="iconfont icon-wechat-pay mr-1"></i>
|
||||
<span>微信支付</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
color="#1677FF"
|
||||
@click="alipay(item)"
|
||||
>
|
||||
<i class="iconfont icon-alipay mr-1"></i>
|
||||
<span>支付宝</span>
|
||||
</el-button>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<span>
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
type="success"
|
||||
@click="wxPay(item)"
|
||||
>
|
||||
<i class="iconfont icon-wechat-pay mr-1"></i>
|
||||
<span>微信支付</span>
|
||||
</el-button>
|
||||
</span>
|
||||
<span>
|
||||
<el-button
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
color="#1677FF"
|
||||
@click="alipay(item)"
|
||||
>
|
||||
<i class="iconfont icon-alipay mr-1"></i>
|
||||
<span>支付宝</span>
|
||||
</el-button>
|
||||
</span>
|
||||
<span class="col-span-2">
|
||||
<el-button
|
||||
v-if="stripePayEnabled"
|
||||
class="h-10 w-full text-[0.8125rem] font-medium"
|
||||
color="#f59e0b"
|
||||
@click="stripePay(item)"
|
||||
>
|
||||
<i class="iconfont icon-reward mr-1"></i>
|
||||
<span>Stripe</span>
|
||||
</el-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,6 +337,7 @@ const usernameInput = ref('')
|
||||
const user = ref(null)
|
||||
const isLogin = ref(false)
|
||||
const orderTimeout = ref(1800)
|
||||
const stripePayEnabled = ref(false)
|
||||
const loading = ref(true)
|
||||
const loadingText = ref('加载中...')
|
||||
|
||||
@@ -387,6 +403,7 @@ onMounted(() => {
|
||||
if (res.data['order_pay_timeout'] > 0) {
|
||||
orderTimeout.value = res.data['order_pay_timeout']
|
||||
}
|
||||
stripePayEnabled.value = !!res.data['stripe_pay_enabled']
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取系统配置失败:' + e.message)
|
||||
@@ -485,6 +502,17 @@ const alipay = (product) => {
|
||||
generateOrder('alipay')
|
||||
}
|
||||
|
||||
const stripePay = (product) => {
|
||||
if (!isLogin.value) {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
selectedPid.value = product.id
|
||||
title.value = 'Stripe Checkout'
|
||||
generateOrder('stripe')
|
||||
}
|
||||
|
||||
const generateOrder = (payWay) => {
|
||||
showLoading('正在生成支付订单...')
|
||||
// 生成支付订单
|
||||
@@ -497,6 +525,11 @@ const generateOrder = (payWay) => {
|
||||
.then((res) => {
|
||||
closeLoading()
|
||||
|
||||
if (payWay === 'stripe') {
|
||||
window.location.href = res.data.pay_url
|
||||
return
|
||||
}
|
||||
|
||||
if (isMobile()) {
|
||||
window.location.href = res.data.pay_url
|
||||
} else {
|
||||
|
||||
+162
-29
@@ -30,13 +30,31 @@
|
||||
</template>
|
||||
</el-popover>
|
||||
</div>
|
||||
<el-select v-model="store.data.model" placeholder="请选择模型" class="w-full">
|
||||
<el-select
|
||||
v-model="store.data.model"
|
||||
placeholder="请选择模型"
|
||||
class="suno-model-select w-full"
|
||||
fit-input-width
|
||||
popper-class="suno-model-select__popper"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in store.models"
|
||||
:key="model.value"
|
||||
:label="model.label"
|
||||
:value="model.value"
|
||||
/>
|
||||
>
|
||||
<div class="suno-model-option">
|
||||
<div aria-hidden="true">
|
||||
<i class="iconfont icon-suno !text-2xl"></i>
|
||||
</div>
|
||||
<div class="suno-model-option__body">
|
||||
<div class="suno-model-option__title">
|
||||
Suno {{ model.label }} - {{ model.value }}
|
||||
</div>
|
||||
<div class="suno-model-option__hint" :title="model.hint">{{ model.hint }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
@@ -210,7 +228,7 @@
|
||||
<i v-if="store.loading" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span
|
||||
>{{ store.loading ? '创作中...' : store.btnText }} ({{ store.sunoPower }}算力)</span
|
||||
>{{ store.loading ? '创作中...' : store.btnText }} ({{ store.sunoPower }}积分)</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
@@ -289,15 +307,15 @@
|
||||
</template>
|
||||
</el-image>
|
||||
<!-- 音乐播放按钮 -->
|
||||
<button v-if="item.progress === 100" @click="play(item)" class="play-overlay">
|
||||
<button v-if="item.status === 'success'" @click="play(item)" class="play-overlay">
|
||||
<i class="iconfont icon-play text-white text-xl"></i>
|
||||
</button>
|
||||
<!-- 进度动画 -->
|
||||
<div v-if="item.progress < 100 && item.progress !== 101" class="progress-overlay">
|
||||
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="progress-overlay">
|
||||
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
<!-- 失败状态 -->
|
||||
<div v-if="item.progress === 101" class="error-overlay">
|
||||
<div v-if="item.status === 'failed'" class="error-overlay">
|
||||
<i class="iconfont icon-warning text-red-500 text-xl"></i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,8 +333,8 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- 任务状态 -->
|
||||
<div v-if="item.progress < 100" class="task-status">
|
||||
<div v-if="item.progress === 101" class="status-error">
|
||||
<div v-if="item.status !== 'success'" class="task-status">
|
||||
<div v-if="item.status === 'failed'" class="status-error">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -351,15 +369,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="song-actions">
|
||||
<!-- 操作按钮:仅生成成功时展示;失败时仅保留删除 -->
|
||||
<div v-if="item.status === 'success'" class="song-actions">
|
||||
<div class="action-buttons">
|
||||
<button v-if="item.progress === 100" @click="play(item)" class="action-btn play-btn">
|
||||
<button @click="play(item)" class="action-btn play-btn">
|
||||
<i class="iconfont icon-play text-xs"></i>
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
@click="store.download(item)"
|
||||
:disabled="item.downloading"
|
||||
class="action-btn download-btn"
|
||||
@@ -387,29 +404,17 @@
|
||||
<i v-else class="iconfont icon-download text-xs"></i>
|
||||
<span>{{ item.downloading ? '下载中...' : '下载' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100 && item.ref_song"
|
||||
@click="store.merge(item)"
|
||||
class="action-btn merge-btn"
|
||||
>
|
||||
<button v-if="item.ref_song" @click="store.merge(item)" class="action-btn merge-btn">
|
||||
<i class="iconfont icon-concat text-xs"></i>
|
||||
<span>合并</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress !== 101"
|
||||
@click="store.extend(item)"
|
||||
class="action-btn extend-btn"
|
||||
>
|
||||
<button @click="store.extend(item)" class="action-btn extend-btn">
|
||||
<i class="iconfont icon-edit text-xs"></i>
|
||||
<span>续写</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
v-if="item.progress !== 101"
|
||||
@click="store.update(item)"
|
||||
class="action-btn edit-btn"
|
||||
>
|
||||
<button @click="store.update(item)" class="action-btn edit-btn">
|
||||
<i class="iconfont icon-edit text-xs"></i>
|
||||
<span>编辑</span>
|
||||
</button>
|
||||
@@ -419,9 +424,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="item.status === 'failed'" class="song-actions">
|
||||
<div class="action-buttons ml-auto">
|
||||
<button @click="store.removeJob(item)" class="action-btn delete-btn">
|
||||
<i class="iconfont icon-remove text-xs"></i>
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="item.progress < 100 && item.progress !== 101" class="progress-bar">
|
||||
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="progress-bar">
|
||||
<div class="progress-info">
|
||||
<span>生成进度</span>
|
||||
<span>{{ item.progress }}%</span>
|
||||
@@ -432,7 +445,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="item.progress === 101" class="error-message">
|
||||
<div v-if="item.status === 'failed'" class="error-message">
|
||||
<div class="flex items-start space-x-2">
|
||||
<div>
|
||||
<p class="error-text">{{ item.err_msg || '未知错误' }}</p>
|
||||
@@ -570,3 +583,123 @@ onUnmounted(() => {
|
||||
<style lang="scss" scoped>
|
||||
@use '@/assets/css/suno.scss';
|
||||
</style>
|
||||
|
||||
<!-- 下拉 teleport 到 body,与 Element 内部结构用纯 CSS 覆盖 -->
|
||||
<style lang="scss">
|
||||
.suno-model-select__popper {
|
||||
margin-top: 6px !important;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1);
|
||||
|
||||
/* 插槽外层 span 默认单行高,强制放开避免行与行叠字 */
|
||||
.el-select-dropdown__item > span {
|
||||
display: block !important;
|
||||
height: auto !important;
|
||||
width: 100%;
|
||||
min-height: 0 !important;
|
||||
line-height: normal !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.el-select-dropdown__item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
height: auto !important;
|
||||
min-height: 56px;
|
||||
margin: 2px 6px;
|
||||
padding: 8px 10px !important;
|
||||
line-height: normal !important;
|
||||
white-space: normal !important;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(37, 99, 235, 0.07) !important;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
background-color: rgba(37, 99, 235, 0.1) !important;
|
||||
|
||||
.suno-model-option__title {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suno-model-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suno-model-option__badge {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(155deg, #8b5cf6 0%, #6366f1 42%, #4f46e5 100%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.38),
|
||||
0 2px 8px rgba(79, 70, 229, 0.45);
|
||||
}
|
||||
|
||||
.suno-model-option__badge::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 52%;
|
||||
border-radius: 10px 10px 0 0;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.suno-model-option__badge-text {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 0 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.suno-model-option__body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.suno-model-option__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.suno-model-option__hint {
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.suno-model-option__title,
|
||||
.suno-model-option__hint {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+640
-117
@@ -34,10 +34,10 @@
|
||||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<el-icon color="#f59e42" size="20"><i class="iconfont icon-lightning"></i></el-icon>
|
||||
<span class="font-medium text-gray-700">当前可用算力:</span>
|
||||
<span class="font-medium text-gray-700">当前可用积分:</span>
|
||||
<span class="font-bold text-lg text-yellow-500">{{ store.availablePower }}</span>
|
||||
</div>
|
||||
<el-tooltip content="算力用于生成视频,每次生成会消耗对应算力" placement="left">
|
||||
<el-tooltip content="积分用于生成视频,每次生成会消耗对应积分" placement="left">
|
||||
<el-icon color="#a78bfa" size="18"><InfoFilled /></el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i v-if="store.submitting" class="iconfont icon-loading animate-spin"></i>
|
||||
<i v-else class="iconfont icon-chuangzuo"></i>
|
||||
<span>立即生成 ({{ store.currentPowerCost }}算力)</span>
|
||||
<span>立即生成 ({{ store.currentPowerCost }}积分)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,6 +63,11 @@
|
||||
v-loading="store.loading"
|
||||
element-loading-background="rgba(100,100,100,0.3)"
|
||||
>
|
||||
<div class="job-list-box px-2 pt-2 pb-2">
|
||||
<h2 class="text-xl mb-1">任务列表</h2>
|
||||
<task-list :list="videoRunningJobsForList" />
|
||||
</div>
|
||||
|
||||
<div class="works-header">
|
||||
<h2 class="h-title text-2xl">你的作品</h2>
|
||||
<div class="filter-buttons">
|
||||
@@ -89,139 +94,137 @@
|
||||
|
||||
<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.status === 'success'">
|
||||
<Waterfall
|
||||
:list="worksListForWaterfall"
|
||||
v-bind="videoWaterfallOptions"
|
||||
:is-loading="store.loading"
|
||||
:is-over="store.isOver"
|
||||
:lazyload="true"
|
||||
@afterRender="onWaterfallAfterRender"
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<div class="video-task-item">
|
||||
<div
|
||||
class="video-task-preview"
|
||||
:class="{
|
||||
'video-task-preview--failed': item.status === 'failed',
|
||||
'video-task-preview--busy':
|
||||
item.status === 'downloading' ||
|
||||
item.status === 'pending' ||
|
||||
item.status === 'in_progress',
|
||||
}"
|
||||
@mouseenter="handleTaskPreviewEnter"
|
||||
@mouseleave="handleTaskPreviewLeave"
|
||||
>
|
||||
<div
|
||||
v-if="item.status === 'success'"
|
||||
class="video-task-preview-inner video-task-preview-inner--success"
|
||||
>
|
||||
<video
|
||||
class="video"
|
||||
class="video-task-video"
|
||||
:src="store.replaceImg(item.video_url)"
|
||||
preload="auto"
|
||||
loop="loop"
|
||||
muted="muted"
|
||||
preload="metadata"
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
@loadedmetadata="handleTaskMediaReady"
|
||||
@loadeddata="handleTaskMediaReady"
|
||||
@error="handleTaskMediaReady"
|
||||
@click.stop="store.playVideo(item)"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
<button
|
||||
class="play flex justify-center items-center"
|
||||
@click="store.playVideo(item)"
|
||||
>
|
||||
<img src="/images/play.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="item.status === 'downloading'"
|
||||
class="flex items-center justify-center"
|
||||
style="height: 200px"
|
||||
class="video-task-preview-inner video-task-preview-inner--busy"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-center px-2">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-600 mx-auto"
|
||||
class="animate-spin rounded-full h-12 w-12 border-2 border-indigo-500 border-t-transparent mx-auto"
|
||||
></div>
|
||||
<span class="text-sm text-purple-600 mt-2 block">视频下载中...</span>
|
||||
<span class="text-sm text-indigo-600 dark:text-indigo-300 mt-3 block">
|
||||
视频下载中…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-image
|
||||
src="/images/failed.jpg"
|
||||
class="border rounded-lg"
|
||||
fit="cover"
|
||||
<div
|
||||
v-else-if="item.status === 'failed'"
|
||||
/>
|
||||
class="video-task-preview-inner video-task-preview-inner--failed"
|
||||
>
|
||||
<img class="video-task-fail-img" :src="taskFailedImage" alt="" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="(item.progress || 0) > 0 && (item.progress || 0) < 100"
|
||||
class="flex h-[120px] items-center justify-center"
|
||||
class="video-task-preview-inner video-task-preview-inner--busy"
|
||||
>
|
||||
<el-progress
|
||||
type="circle"
|
||||
:percentage="item.progress || 0"
|
||||
:width="80"
|
||||
:width="96"
|
||||
:stroke-width="6"
|
||||
class="rounded-full bg-white/95 p-1 shadow-sm flex items-center justify-center"
|
||||
>
|
||||
<template #default="{ percentage }">
|
||||
<span class="flex w-full justify-center text-base font-medium text-gray-700"
|
||||
>{{ percentage }}%</span
|
||||
>
|
||||
<span class="text-base font-medium text-gray-700">{{ percentage }}%</span>
|
||||
</template>
|
||||
</el-progress>
|
||||
</div>
|
||||
<div class="flex !items-end justify-center h-[120px]" v-else>
|
||||
<div v-else class="video-task-preview-inner video-task-preview-inner--busy">
|
||||
<Generating message="正在生成视频" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-0 right-0"
|
||||
v-if="item.status === 'pending' && !((item.progress || 0) > 0)"
|
||||
>
|
||||
<!-- 非 in_progress 状态才显示 status 标签 -->
|
||||
<el-tag type="info" class="mr-1"> 排队中 </el-tag>
|
||||
<div class="video-task-overlay">
|
||||
<div class="video-task-overlay-time">{{ dateFormat(item.created_at) }}</div>
|
||||
<div class="video-task-tools">
|
||||
<el-tooltip content="复制提示词" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="video-task-tool copy-prompt"
|
||||
:data-clipboard-text="item.prompt"
|
||||
>
|
||||
<i class="iconfont icon-copy"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="任务详情(含完整提示词)" placement="top">
|
||||
<button type="button" class="video-task-tool" @click="showTaskDetail(item)">
|
||||
<i class="iconfont icon-info text-[#6366f1]"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="item.status === 'success' && item.video_url"
|
||||
content="下载视频"
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="video-task-tool"
|
||||
:disabled="item.downloading"
|
||||
@click="store.downloadVideo(item)"
|
||||
>
|
||||
<i v-if="!item.downloading" class="iconfont icon-download"></i>
|
||||
<img
|
||||
v-else
|
||||
src="/images/loading.gif"
|
||||
class="video-task-tool-loading"
|
||||
alt=""
|
||||
/>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除任务" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="video-task-tool video-task-tool--danger"
|
||||
@click="store.removeJob(item)"
|
||||
>
|
||||
<i class="iconfont icon-remove"></i>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="center">
|
||||
<div class="pb-2">
|
||||
<el-tag class="mr-1">{{ item.type }}</el-tag>
|
||||
<template v-if="item.params">
|
||||
<el-tag class="mr-1" v-if="item.params.task_type">{{
|
||||
item.params.task_type
|
||||
}}</el-tag>
|
||||
<el-tag class="mr-1" v-if="item.params.model">{{ item.params.model }}</el-tag>
|
||||
<el-tag class="mr-1" v-if="item.params.duration"
|
||||
>{{ item.params.duration }}秒</el-tag
|
||||
>
|
||||
<el-tag class="mr-1" v-if="item.params.mode">{{ item.params.mode }}</el-tag>
|
||||
<el-tag class="mr-1" v-if="item.params.size">
|
||||
分辨率:{{ item.params.size }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<el-tag class="mr-1" type="warning" v-if="item.power">
|
||||
消耗算力:{{ item.power }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="failed" v-if="item.status === 'failed'">
|
||||
任务执行失败:{{ 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>
|
||||
</template>
|
||||
</Waterfall>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
@@ -231,18 +234,20 @@
|
||||
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 class="waterfall-load-more" v-if="!store.noData">
|
||||
<img
|
||||
:src="videoWaterfallOptions.loadProps.loading"
|
||||
class="waterfall-loading-icon"
|
||||
v-if="!waterfallRendered"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
v-if="waterfallRendered && store.isOver"
|
||||
class="waterfall-no-more bg-[#f5f5f5] text-gray-500 rounded-md px-3 py-2 mt-3"
|
||||
>
|
||||
<span class="mr-2 text-base">没有更多数据了</span>
|
||||
<i class="iconfont icon-face"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -266,19 +271,388 @@
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 任务详情 -->
|
||||
<el-dialog
|
||||
v-model="detailDialogVisible"
|
||||
title="任务详情"
|
||||
width="680px"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="detail-content" v-if="currentDetail">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="提供商">
|
||||
{{ getProviderName(currentDetail.type) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentDetail.channel" label="渠道">
|
||||
{{ currentDetail.channel }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
{{ statusConfig[currentDetail.status]?.label || currentDetail.status }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="进度">
|
||||
{{ currentDetail.progress ?? 0 }}%
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="任务 ID">
|
||||
{{ currentDetail.task_id || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailFlatParams.model" label="模型">
|
||||
{{ detailFlatParams.model }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailFlatParams.task_type" label="生成模式">
|
||||
{{ detailFlatParams.task_type }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="detailFlatParams.duration != null && detailFlatParams.duration !== ''"
|
||||
label="时长"
|
||||
>
|
||||
{{ detailFlatParams.duration }} 秒
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="detailFlatParams.size || detailFlatParams.resolution"
|
||||
label="分辨率"
|
||||
>
|
||||
{{ detailFlatParams.size || detailFlatParams.resolution }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detailFlatParams.mode" label="模式">
|
||||
{{ detailFlatParams.mode }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="detailFlatParams.sound != null && detailFlatParams.sound !== ''"
|
||||
label="声音"
|
||||
>
|
||||
{{ detailFlatParams.sound }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power ?? 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提示词">
|
||||
<div class="prompt-with-copy">
|
||||
<span class="break-all">{{ currentDetail.prompt || '(无)' }}</span>
|
||||
<el-tooltip v-if="currentDetail.prompt" content="复制提示词" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-btn"
|
||||
@click="copyPromptText(currentDetail.prompt)"
|
||||
>
|
||||
<i class="iconfont icon-copy ml-2 cursor-pointer shrink-0" />
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item
|
||||
v-if="currentDetail.err_msg && String(currentDetail.err_msg).trim()"
|
||||
label="错误信息"
|
||||
>
|
||||
<el-text type="danger" class="break-all">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detailUploadImages.length">
|
||||
<h4 class="detail-section-title">参考 / 上传的图片</h4>
|
||||
<div class="detail-media-grid detail-media-grid--image">
|
||||
<el-image
|
||||
v-for="(url, idx) in detailUploadImages"
|
||||
:key="'uimg-' + idx"
|
||||
:src="getThumbURL(url, 160, 160)"
|
||||
:preview-src-list="detailUploadImages"
|
||||
:initial-index="idx"
|
||||
fit="cover"
|
||||
class="detail-thumb"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailUploadVideos.length">
|
||||
<h4 class="detail-section-title">参考 / 上传的视频</h4>
|
||||
<div class="detail-media-stack">
|
||||
<video
|
||||
v-for="(url, idx) in detailUploadVideos"
|
||||
:key="'uvid-' + idx"
|
||||
:src="url"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="detail-video-preview"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailUploadAudios.length">
|
||||
<h4 class="detail-section-title">参考 / 上传的音频</h4>
|
||||
<div class="detail-media-stack">
|
||||
<audio
|
||||
v-for="(url, idx) in detailUploadAudios"
|
||||
:key="'uaud-' + idx"
|
||||
:src="url"
|
||||
controls
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailResultVideo">
|
||||
<h4 class="detail-section-title">生成结果</h4>
|
||||
<div class="detail-media-stack">
|
||||
<video
|
||||
:src="detailResultVideo"
|
||||
controls
|
||||
preload="metadata"
|
||||
class="detail-video-preview detail-video-preview--large"
|
||||
>
|
||||
您的浏览器不支持视频播放
|
||||
</video>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailOtherParamsText">
|
||||
<el-collapse class="detail-params-collapse">
|
||||
<el-collapse-item title="其他请求参数(JSON)" name="params">
|
||||
<pre class="detail-json">{{ detailOtherParamsText }}</pre>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ParamBuilder from '@/components/ParamBuilder.vue'
|
||||
import TaskList from '@/components/TaskList.vue'
|
||||
import Generating from '@/components/ui/Generating.vue'
|
||||
import { useSharedStore } from '@/store/sharedata'
|
||||
import { useVideoStore } from '@/store/video'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import { getProviderName } from '@/store/data/video_params'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { dateFormat, getThumbURL, replaceImg } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Waterfall } from 'vue-waterfall-plugin-next'
|
||||
import 'vue-waterfall-plugin-next/dist/style.css'
|
||||
|
||||
const store = useVideoStore()
|
||||
const sharedStore = useSharedStore()
|
||||
const videoPlayerRef = ref(null)
|
||||
const waterfallRendered = ref(false)
|
||||
const waterfallOptions = sharedStore.waterfallOptions
|
||||
const taskFailedImage = sharedStore.taskFailedImage
|
||||
const videoWaterfallOptions = computed(() => ({
|
||||
...waterfallOptions,
|
||||
gutter: 2,
|
||||
hasAroundGutter: false,
|
||||
}))
|
||||
const worksListForWaterfall = computed(() => {
|
||||
return store.currentList
|
||||
})
|
||||
|
||||
function isVideoTaskRunning(item) {
|
||||
return item.status === 'pending' || item.status === 'in_progress'
|
||||
}
|
||||
|
||||
/** TaskList:队首为执行中,其余为排队;按创建时间升序 */
|
||||
const videoRunningJobsForList = computed(() => {
|
||||
const rows = store.taskList.filter(isVideoTaskRunning)
|
||||
return [...rows]
|
||||
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
progress: row.progress ?? 0,
|
||||
}))
|
||||
})
|
||||
|
||||
const VIDEO_IMAGE_PARAM_KEYS = [
|
||||
'image_urls',
|
||||
'images',
|
||||
'input_reference',
|
||||
'image',
|
||||
'image_tail',
|
||||
'first_frame_image',
|
||||
]
|
||||
|
||||
function toUrlList(v) {
|
||||
if (v == null) {
|
||||
return []
|
||||
}
|
||||
if (Array.isArray(v)) {
|
||||
return v.map((x) => (typeof x === 'string' ? x.trim() : x)).filter(Boolean)
|
||||
}
|
||||
if (typeof v === 'string' && v.trim()) {
|
||||
return [v.trim()]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function pickRefUrl(ref) {
|
||||
if (!ref || typeof ref !== 'object') {
|
||||
return ''
|
||||
}
|
||||
const url = ref.url
|
||||
return typeof url === 'string' && url.trim() ? url.trim() : ''
|
||||
}
|
||||
|
||||
function collectContentUrls(params, type) {
|
||||
if (!params || !Array.isArray(params.content)) {
|
||||
return []
|
||||
}
|
||||
const out = []
|
||||
for (const row of params.content) {
|
||||
if (!row || typeof row !== 'object' || row.type !== type) {
|
||||
continue
|
||||
}
|
||||
if (type === 'image_url') {
|
||||
const u = pickRefUrl(row.image_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
} else if (type === 'video_url') {
|
||||
const u = pickRefUrl(row.video_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
} else if (type === 'audio_url') {
|
||||
const u = pickRefUrl(row.audio_url)
|
||||
if (u) {
|
||||
out.push(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function uniqUrls(urls) {
|
||||
return [...new Set(urls.filter(Boolean))]
|
||||
}
|
||||
|
||||
function parseJobParams(item) {
|
||||
let params = {}
|
||||
try {
|
||||
if (item.params) {
|
||||
if (typeof item.params === 'string') {
|
||||
params = JSON.parse(item.params)
|
||||
} else if (typeof item.params === 'object') {
|
||||
params = { ...item.params }
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析视频任务 params 失败:', e)
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// 数据库存的是 VideoTask JSON,模型表单在嵌套字段 params 里
|
||||
function mergeVideoTaskParams(parsed) {
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {}
|
||||
}
|
||||
const inner = parsed.params
|
||||
if (inner && typeof inner === 'object' && !Array.isArray(inner)) {
|
||||
const { params: _nested, ...rest } = parsed
|
||||
return { ...rest, ...inner }
|
||||
}
|
||||
return { ...parsed }
|
||||
}
|
||||
|
||||
const detailDialogVisible = ref(false)
|
||||
const currentDetail = ref(null)
|
||||
|
||||
const detailFlatParams = computed(() => {
|
||||
const row = currentDetail.value
|
||||
if (!row) {
|
||||
return {}
|
||||
}
|
||||
return mergeVideoTaskParams(parseJobParams(row))
|
||||
})
|
||||
|
||||
const detailUploadImages = computed(() => {
|
||||
const p = detailFlatParams.value
|
||||
const acc = []
|
||||
for (const k of VIDEO_IMAGE_PARAM_KEYS) {
|
||||
if (p[k] != null) {
|
||||
acc.push(...toUrlList(p[k]))
|
||||
}
|
||||
}
|
||||
acc.push(...collectContentUrls(p, 'image_url'))
|
||||
return uniqUrls(acc.map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailUploadVideos = computed(() => {
|
||||
const p = detailFlatParams.value
|
||||
const fromFields = toUrlList(p.video_url)
|
||||
const fromContent = collectContentUrls(p, 'video_url')
|
||||
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailUploadAudios = computed(() => {
|
||||
const p = detailFlatParams.value
|
||||
const fromFields = toUrlList(p.audio_url)
|
||||
const fromContent = collectContentUrls(p, 'audio_url')
|
||||
return uniqUrls([...fromFields, ...fromContent].map((u) => replaceImg(u)))
|
||||
})
|
||||
|
||||
const detailResultVideo = computed(() => {
|
||||
const row = currentDetail.value
|
||||
if (!row || row.status !== 'success' || !row.video_url) {
|
||||
return ''
|
||||
}
|
||||
return replaceImg(row.video_url)
|
||||
})
|
||||
|
||||
const PARAM_SHOWN_IN_DESCRIPTIONS = new Set([
|
||||
'model',
|
||||
'task_type',
|
||||
'duration',
|
||||
'size',
|
||||
'mode',
|
||||
'sound',
|
||||
'resolution',
|
||||
...VIDEO_IMAGE_PARAM_KEYS,
|
||||
'prompt',
|
||||
'video_url',
|
||||
'audio_url',
|
||||
'content',
|
||||
'image_urls',
|
||||
])
|
||||
|
||||
const detailOtherParamsText = computed(() => {
|
||||
const p = detailFlatParams.value
|
||||
const rest = {}
|
||||
for (const k of Object.keys(p)) {
|
||||
if (PARAM_SHOWN_IN_DESCRIPTIONS.has(k)) {
|
||||
continue
|
||||
}
|
||||
rest[k] = p[k]
|
||||
}
|
||||
if (Object.keys(rest).length === 0) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(rest, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
function showTaskDetail(item) {
|
||||
currentDetail.value = { ...item }
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
function copyPromptText(text) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
ElMessage.success('提示词已复制')
|
||||
})
|
||||
.catch(() => {
|
||||
ElMessage.error('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
const providerOrder = computed(() =>
|
||||
['sora', 'veo', 'doubao', 'keling', 'minimax', 'wan'].filter((p) => store.providers.includes(p))
|
||||
@@ -305,10 +679,14 @@ const statusConfig = {
|
||||
failed: { label: '失败', type: 'danger' },
|
||||
}
|
||||
|
||||
function videoStatusTagType(status) {
|
||||
return statusConfig[status]?.type || 'info'
|
||||
}
|
||||
|
||||
// 处理价格参数变化事件
|
||||
const handlePriceParamsChange = () => {
|
||||
// 价格参数变化时,store 中的 watch 会自动触发 setCurrentPowerCost
|
||||
// setCurrentPowerCost 是异步的,会调用 API 获取最新算力值
|
||||
// setCurrentPowerCost 是异步的,会调用 API 获取最新积分值
|
||||
// 无需额外处理,watch 会自动更新 currentPowerCost
|
||||
}
|
||||
|
||||
@@ -321,11 +699,80 @@ const handleVideoDialogClose = () => {
|
||||
store.showDialog = false
|
||||
}
|
||||
|
||||
function handleTaskPreviewEnter(event) {
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const videoElement = target.querySelector('.video-task-video')
|
||||
if (!(videoElement instanceof HTMLVideoElement)) {
|
||||
return
|
||||
}
|
||||
videoElement.muted = true
|
||||
const playPromise = videoElement.play()
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskPreviewLeave(event) {
|
||||
const target = event.currentTarget
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return
|
||||
}
|
||||
const videoElement = target.querySelector('.video-task-video')
|
||||
if (!(videoElement instanceof HTMLVideoElement)) {
|
||||
return
|
||||
}
|
||||
videoElement.pause()
|
||||
}
|
||||
|
||||
let taskMediaReadyRafId = 0
|
||||
|
||||
function handleTaskMediaReady() {
|
||||
if (taskMediaReadyRafId) {
|
||||
cancelAnimationFrame(taskMediaReadyRafId)
|
||||
}
|
||||
taskMediaReadyRafId = requestAnimationFrame(() => {
|
||||
window.dispatchEvent(new Event('resize'))
|
||||
taskMediaReadyRafId = 0
|
||||
})
|
||||
}
|
||||
|
||||
function onWaterfallAfterRender() {
|
||||
waterfallRendered.value = true
|
||||
if (!store.loading && !store.isOver) {
|
||||
store.fetchData(store.page + 1)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => store.loading,
|
||||
(value) => {
|
||||
if (value) {
|
||||
waterfallRendered.value = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => store.isOver,
|
||||
(value) => {
|
||||
if (value) {
|
||||
waterfallRendered.value = true
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
store.init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (taskMediaReadyRafId) {
|
||||
cancelAnimationFrame(taskMediaReadyRafId)
|
||||
taskMediaReadyRafId = 0
|
||||
}
|
||||
store.cleanup()
|
||||
})
|
||||
</script>
|
||||
@@ -361,4 +808,80 @@ onUnmounted(() => {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
:deep(.el-descriptions__label) {
|
||||
min-width: 112px;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-theme-color, #252f76);
|
||||
}
|
||||
|
||||
.prompt-with-copy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.inline-btn {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
.detail-media-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-media-grid--image .detail-thumb {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-media-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-video-preview {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.detail-video-preview--large {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.detail-params-collapse {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.detail-json {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light, #f5f7fa);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price" label="商品价格" />
|
||||
<el-table-column prop="stripe_price" label="Stripe 价格(USD)" />
|
||||
<el-table-column prop="power" label="算力" />
|
||||
<el-table-column prop="sales" label="销量" />
|
||||
<el-table-column prop="enabled" label="启用状态">
|
||||
@@ -52,6 +53,10 @@
|
||||
<el-input v-model="item.price" autocomplete="off" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Stripe 价格(USD):" prop="stripe_price">
|
||||
<el-input v-model="item.stripe_price" autocomplete="off" placeholder="Stripe 专用美元价格" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="算力:" prop="power">
|
||||
<el-input v-model.number="item.power" autocomplete="off" placeholder="增加算力值" />
|
||||
</el-form-item>
|
||||
@@ -87,6 +92,7 @@ const title = ref('')
|
||||
const rules = reactive({
|
||||
name: [{ required: true, message: '请输入产品名称', trigger: 'change' }],
|
||||
price: [{ required: true, message: '请输产品价格', trigger: 'change' }],
|
||||
stripe_price: [{ required: true, message: '请输 Stripe 价格', trigger: 'change' }],
|
||||
discount: [{ required: true, message: '请输优惠金额', trigger: 'change' }],
|
||||
days: [{ required: true, message: '请输入有效期', trigger: 'change' }],
|
||||
})
|
||||
@@ -143,7 +149,7 @@ onMounted(() => {
|
||||
const add = function () {
|
||||
title.value = '新增产品'
|
||||
showDialog.value = true
|
||||
item.value = {}
|
||||
item.value = { stripe_price: 0 }
|
||||
}
|
||||
|
||||
const edit = function (row) {
|
||||
@@ -157,6 +163,7 @@ const save = function () {
|
||||
if (valid) {
|
||||
showDialog.value = false
|
||||
item.value['price'] = parseFloat(item.value['price'])
|
||||
item.value['stripe_price'] = parseFloat(item.value['stripe_price'])
|
||||
item.value['discount'] = parseFloat(item.value['discount'])
|
||||
httpPost('/api/admin/product/save', item.value)
|
||||
.then((res) => {
|
||||
|
||||
@@ -352,7 +352,7 @@ const userEdit = function (row) {
|
||||
}
|
||||
|
||||
const addUser = () => {
|
||||
user.value = { chat_id: 0, chat_roles: [], chat_models: [] }
|
||||
user.value = { chat_id: 0, chat_models: [] }
|
||||
title.value = '添加用户'
|
||||
showUserEditDialog.value = true
|
||||
add.value = true
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
</template>
|
||||
<el-input v-model="jimengConfig.api_key" placeholder="请输入火山方舟服务API Key" />
|
||||
<div class="text-sm mt-2 text-gray-500">
|
||||
目前豆包生图 4.0 模型在即梦API中不支持,需要使用火山方舟服务。
|
||||
目前 Seedream 生图模型统一走火山方舟 API Key;Seedance 视频模型共用「视频生成」配置中的 ApiURL 和 ApiKey。
|
||||
</div>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
@@ -42,11 +42,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="任务进度">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<span v-if="scope.row.status !== 'failed'">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" />
|
||||
<el-table-column prop="power" label="消耗积分" />
|
||||
<el-table-column label="结果图片">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
@@ -87,7 +87,7 @@
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.err_msg"
|
||||
v-if="scope.row.progress === 101"
|
||||
v-if="scope.row.status === 'failed'"
|
||||
>
|
||||
<template #reference>
|
||||
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
|
||||
@@ -157,11 +157,11 @@
|
||||
<el-table-column prop="user_id" label="用户ID" />
|
||||
<el-table-column prop="progress" label="任务进度">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<span v-if="scope.row.status !== 'failed'">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" />
|
||||
<el-table-column prop="power" label="消耗积分" />
|
||||
<el-table-column label="结果图片">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
@@ -202,7 +202,7 @@
|
||||
:width="300"
|
||||
trigger="hover"
|
||||
:content="scope.row.err_msg"
|
||||
v-if="scope.row.progress === 101"
|
||||
v-if="scope.row.status === 'failed'"
|
||||
>
|
||||
<template #reference>
|
||||
<el-text type="danger">{{ substr(scope.row.err_msg, 20) }}</el-text>
|
||||
@@ -283,7 +283,7 @@
|
||||
|
||||
<el-descriptions-item
|
||||
label="生成的图片"
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
v-if="currentDetail.status === 'success' && currentDetail.img_url"
|
||||
>
|
||||
<el-image
|
||||
:src="getThumbURL(currentDetail.img_url, 200, 200)"
|
||||
@@ -314,7 +314,7 @@
|
||||
{{ currentDetail.params?.model_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="消耗算力">
|
||||
<el-descriptions-item label="消耗积分">
|
||||
{{ currentDetail.power || 0 }}
|
||||
</el-descriptions-item>
|
||||
|
||||
@@ -327,12 +327,12 @@
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatTime(currentDetail.created_at) }}
|
||||
{{ dateFormat(currentDetail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item
|
||||
label="错误信息"
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
|
||||
>
|
||||
<el-text type="danger">{{ currentDetail.err_msg }}</el-text>
|
||||
</el-descriptions-item>
|
||||
@@ -343,10 +343,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, getThumbURL, substr } from '@/utils/libs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
// 变量定义
|
||||
@@ -415,7 +415,7 @@ const fetchMjData = () => {
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
showMessageError('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -434,18 +434,18 @@ const fetchImageData = () => {
|
||||
d.loading = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
showMessageError('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = function (row, tab) {
|
||||
httpGet(`/api/admin/image/remove?id=${row.id}&tab=${tab}`)
|
||||
.then(() => {
|
||||
ElMessage.success('删除成功!')
|
||||
showMessageOK('删除成功!')
|
||||
handleChange(tab)
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
showMessageError('删除失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -482,26 +482,13 @@ const showDetail = (row) => {
|
||||
showDetailDialog.value = true
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp * 1000)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(date.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
// 复制提示词
|
||||
const copyPrompt = async (text) => {
|
||||
if (!text) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('复制成功!')
|
||||
showMessageOK('复制成功!')
|
||||
return
|
||||
}
|
||||
const textarea = document.createElement('textarea')
|
||||
@@ -510,9 +497,9 @@ const copyPrompt = async (text) => {
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
ElMessage.success('复制成功!')
|
||||
showMessageOK('复制成功!')
|
||||
} catch {
|
||||
ElMessage.error('复制失败!')
|
||||
showMessageError('复制失败!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
<img src="/images/play.svg" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<el-image v-else-if="scope.row.progress === 101" src="/images/failed.jpg" style="height: 90px" fit="cover" />
|
||||
<el-image v-else-if="scope.row.status === 'failed'" src="/images/failed.jpg" style="height: 90px" fit="cover" />
|
||||
<div class="flex flex-col items-center justify-center h-[100px]" v-else>
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600 mx-auto"></div>
|
||||
<span class="text-xs text-purple-600 mt-2 block">生成中...</span>
|
||||
@@ -56,11 +56,11 @@
|
||||
</el-table-column>
|
||||
<el-table-column prop="progress" label="任务进度" width="100">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.progress <= 100">{{ scope.row.progress }}%</span>
|
||||
<span v-if="scope.row.status !== 'failed'">{{ scope.row.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="power" label="消耗算力" width="100" />
|
||||
<el-table-column prop="power" label="消耗积分" width="100" />
|
||||
<el-table-column prop="play_times" label="播放次数" width="100" />
|
||||
<el-table-column label="歌词" width="110">
|
||||
<template #default="scope">
|
||||
@@ -77,7 +77,7 @@
|
||||
<el-table-column label="失败原因" width="180">
|
||||
<template #default="scope">
|
||||
<el-popover
|
||||
v-if="scope.row.progress === 101"
|
||||
v-if="scope.row.status === 'failed'"
|
||||
placement="top-start"
|
||||
title="失败原因"
|
||||
:width="300"
|
||||
@@ -151,12 +151,12 @@
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">任务进度</div>
|
||||
<div class="detail-value">
|
||||
<span v-if="currentDetail.progress <= 100">{{ currentDetail.progress }}%</span>
|
||||
<span v-if="currentDetail.status !== 'failed'">{{ currentDetail.progress }}%</span>
|
||||
<el-tag v-else type="danger">已失败</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<div class="detail-label">算力消耗</div>
|
||||
<div class="detail-label">积分消耗</div>
|
||||
<div class="detail-value">{{ currentDetail.power }}</div>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
@@ -178,13 +178,13 @@
|
||||
<div class="detail-value" v-html="md.render(currentDetail.prompt)" />
|
||||
</div>
|
||||
|
||||
<div v-if="currentDetail.progress === 101 && currentDetail.err_msg" class="detail-full error">
|
||||
<div v-if="currentDetail.status === 'failed' && currentDetail.err_msg" class="detail-full error">
|
||||
<div class="detail-label">错误信息</div>
|
||||
<div class="detail-value">{{ currentDetail.err_msg }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 音乐预览区域 - 仅成功任务显示 -->
|
||||
<div v-if="currentDetail.progress === 100 && currentDetail.cover_url" class="detail-preview">
|
||||
<div v-if="currentDetail.status === 'success' && currentDetail.cover_url" class="detail-preview">
|
||||
<div class="detail-label mb-2">音乐预览</div>
|
||||
<div class="preview-container">
|
||||
<el-image :src="currentDetail.cover_url" fit="cover" class="preview-image" />
|
||||
@@ -201,10 +201,10 @@
|
||||
|
||||
<script setup>
|
||||
import MusicPlayer from '@/components/MusicPlayer.vue'
|
||||
import { showMessageError, showMessageOK } from '@/utils/dialog'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { dateFormat, formatTime, substr } from '@/utils/libs'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
@@ -249,18 +249,18 @@ const fetchData = () => {
|
||||
loading.value = false
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('获取数据失败:' + e.message)
|
||||
showMessageError('获取数据失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
const remove = function (row) {
|
||||
httpGet(`/api/admin/suno/remove?id=${row.id}`)
|
||||
.then(() => {
|
||||
ElMessage.success('删除成功!')
|
||||
showMessageOK('删除成功!')
|
||||
fetchData()
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('删除失败:' + e.message)
|
||||
showMessageError('删除失败:' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ const playMusic = (item) => {
|
||||
|
||||
const md = MarkdownIt({
|
||||
breaks: true,
|
||||
html: true,
|
||||
html: false,
|
||||
linkify: true,
|
||||
})
|
||||
|
||||
|
||||
@@ -106,6 +106,26 @@
|
||||
<el-form-item label="启用该支付通道"><el-switch v-model="epay.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="Stripe" name="stripe">
|
||||
<template #label>
|
||||
<div class="d-flex align-items-center text-orange-600">
|
||||
<i class="iconfont icon-reward"></i>
|
||||
<span class="ms-2">Stripe</span>
|
||||
</div>
|
||||
</template>
|
||||
<Alert type="info">
|
||||
Stripe Checkout 采用托管收银台模式,支付完成后会回跳到会员页,最终支付状态以 webhook 为准。
|
||||
</Alert>
|
||||
|
||||
<el-form :model="stripe" class="mt-4" label-position="top">
|
||||
<el-form-item label="Secret Key"><el-input v-model="stripe.secret_key" /></el-form-item>
|
||||
<el-form-item label="Webhook Signing Secret"><el-input v-model="stripe.webhook_key" /></el-form-item>
|
||||
<el-form-item label="回跳域名"><el-input v-model="stripe.domain" /></el-form-item>
|
||||
<el-form-item label="货币代码"><el-input v-model="stripe.currency" placeholder="usd" /></el-form-item>
|
||||
<el-form-item label="启用该支付通道"><el-switch v-model="stripe.enabled" /></el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="flex justify-center mt-6">
|
||||
<el-button type="primary" @click="save" :loading="loading">提交保存</el-button>
|
||||
@@ -143,6 +163,7 @@ const wxpay = ref({
|
||||
domain: '',
|
||||
})
|
||||
const epay = ref({ enabled: false, app_id: '', private_key: '', api_url: '', domain: '' })
|
||||
const stripe = ref({ enabled: false, secret_key: '', webhook_key: '', domain: '', currency: 'usd' })
|
||||
|
||||
onMounted(() => {
|
||||
httpGet('/api/admin/config/get?key=payment')
|
||||
@@ -151,6 +172,7 @@ onMounted(() => {
|
||||
alipay.value = { ...alipay.value, ...(data.alipay || {}) }
|
||||
wxpay.value = { ...wxpay.value, ...(data.wxpay || data.wechat || {}) }
|
||||
epay.value = { ...epay.value, ...(data.epay || {}) }
|
||||
stripe.value = { ...stripe.value, ...(data.stripe || {}) }
|
||||
|
||||
// 如果 domain 为空,则设置为当前域名
|
||||
if (!alipay.value.domain) {
|
||||
@@ -162,6 +184,12 @@ onMounted(() => {
|
||||
if (!epay.value.domain) {
|
||||
epay.value.domain = domain.value
|
||||
}
|
||||
if (!stripe.value.domain) {
|
||||
stripe.value.domain = domain.value
|
||||
}
|
||||
if (!stripe.value.currency) {
|
||||
stripe.value.currency = 'usd'
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => (loading.value = false))
|
||||
@@ -169,7 +197,7 @@ onMounted(() => {
|
||||
|
||||
const save = () => {
|
||||
loading.value = true
|
||||
const payload = { alipay: alipay.value, wxpay: wxpay.value, epay: epay.value }
|
||||
const payload = { alipay: alipay.value, wxpay: wxpay.value, epay: epay.value, stripe: stripe.value }
|
||||
httpPost('/api/admin/config/update/payment', payload)
|
||||
.then(() => ElMessage.success('保存成功'))
|
||||
.catch((e) => ElMessage.error(e.message))
|
||||
|
||||
@@ -26,12 +26,128 @@
|
||||
<el-switch v-model="gzhConfig['enabled']" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="tab-actions">
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="自定义菜单" name="menu">
|
||||
<el-alert type="info" show-icon :closable="false" class="menu-hint">
|
||||
<template #title>
|
||||
菜单将同步到微信公众平台。跳转链接(view)的域名需在公众号后台配置为「业务域名」或通过授权页打开;小程序类型需填写 appid、pagepath
|
||||
与备用网页 url。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<div v-for="(item, i) in menuTops" :key="i" class="menu-top-card">
|
||||
<div class="menu-top-head">
|
||||
<span class="menu-top-label">一级菜单 {{ i + 1 }}</span>
|
||||
<el-button type="danger" link @click="removeTop(i)">删除</el-button>
|
||||
</div>
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="菜单名称">
|
||||
<el-input v-model="item.name" placeholder="显示在公众号底部的名称" maxlength="16" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="item.isSubmenu">
|
||||
<el-radio :label="false">直接响应(链接 / 点击)</el-radio>
|
||||
<el-radio :label="true">展开子菜单(最多 5 个)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="!item.isSubmenu">
|
||||
<el-form-item label="动作类型">
|
||||
<el-select v-model="item.type" style="width: 220px">
|
||||
<el-option label="跳转网页 view" value="view" />
|
||||
<el-option label="点击推事件 click" value="click" />
|
||||
<el-option label="打开小程序 miniprogram" value="miniprogram" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="item.type === 'view'" label="网页链接 URL">
|
||||
<el-input v-model="item.url" placeholder="https:// 开头" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="item.type === 'click'" label="事件 Key">
|
||||
<el-input v-model="item.key" placeholder="与服务器事件匹配,勿与现有 key 冲突" />
|
||||
</el-form-item>
|
||||
<template v-if="item.type === 'miniprogram'">
|
||||
<el-form-item label="小程序 AppID">
|
||||
<el-input v-model="item.appid" />
|
||||
</el-form-item>
|
||||
<el-form-item label="小程序页面路径 pagepath">
|
||||
<el-input v-model="item.pagepath" placeholder="如 pages/index/index" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备用网页 url(必填)">
|
||||
<el-input v-model="item.url" placeholder="无法打开小程序时跳转" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="sub-menu-toolbar">
|
||||
<el-button
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
:disabled="(item.sub_button || []).length >= 5"
|
||||
@click="addSub(i)"
|
||||
>
|
||||
添加子菜单
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-for="(sub, j) in item.sub_button"
|
||||
:key="j"
|
||||
class="sub-menu-row"
|
||||
>
|
||||
<el-divider content-position="left">子项 {{ j + 1 }}</el-divider>
|
||||
<div class="sub-menu-head">
|
||||
<el-button type="danger" link size="small" @click="removeSub(i, j)">删除</el-button>
|
||||
</div>
|
||||
<el-form-item label="子菜单名称">
|
||||
<el-input v-model="sub.name" maxlength="60" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="动作类型">
|
||||
<el-select v-model="sub.type" style="width: 220px">
|
||||
<el-option label="跳转网页 view" value="view" />
|
||||
<el-option label="点击推事件 click" value="click" />
|
||||
<el-option label="打开小程序 miniprogram" value="miniprogram" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="sub.type === 'view'" label="网页链接 URL">
|
||||
<el-input v-model="sub.url" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="sub.type === 'click'" label="事件 Key">
|
||||
<el-input v-model="sub.key" />
|
||||
</el-form-item>
|
||||
<template v-if="sub.type === 'miniprogram'">
|
||||
<el-form-item label="小程序 AppID">
|
||||
<el-input v-model="sub.appid" />
|
||||
</el-form-item>
|
||||
<el-form-item label="小程序页面路径">
|
||||
<el-input v-model="sub.pagepath" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备用网页 url">
|
||||
<el-input v-model="sub.url" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<el-button class="add-top-btn" :disabled="menuTops.length >= 3" @click="addTop">
|
||||
添加一级菜单(最多 3 个)
|
||||
</el-button>
|
||||
|
||||
<div class="tab-actions menu-actions">
|
||||
<el-button :loading="menuSaving" @click="saveMenuDraft">保存草稿</el-button>
|
||||
<el-button type="primary" :loading="menuPublishing" @click="syncMenuToWechat">
|
||||
同步到微信
|
||||
</el-button>
|
||||
<el-button :loading="menuPulling" @click="pullMenuFromWechat">从微信拉取</el-button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<div style="padding: 10px">
|
||||
<el-button type="primary" @click="save">保存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -45,14 +161,198 @@ const activeName = ref('gzh')
|
||||
const gzhConfig = ref({ app_id: '', secret: '' })
|
||||
const wechatFormRef = ref(null)
|
||||
|
||||
const menuTops = ref([])
|
||||
const menuSaving = ref(false)
|
||||
const menuPublishing = ref(false)
|
||||
const menuPulling = ref(false)
|
||||
|
||||
function emptySub() {
|
||||
return {
|
||||
name: '',
|
||||
type: 'view',
|
||||
url: '',
|
||||
key: '',
|
||||
appid: '',
|
||||
pagepath: '',
|
||||
}
|
||||
}
|
||||
|
||||
function emptyTop() {
|
||||
return {
|
||||
name: '',
|
||||
isSubmenu: false,
|
||||
type: 'view',
|
||||
url: '',
|
||||
key: '',
|
||||
appid: '',
|
||||
pagepath: '',
|
||||
sub_button: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLeaf(s) {
|
||||
const t = s.type
|
||||
let type = 'view'
|
||||
if (t === 'click' || t === 'miniprogram') {
|
||||
type = t
|
||||
} else if (t && t !== 'view') {
|
||||
type = s.url ? 'view' : 'click'
|
||||
}
|
||||
return {
|
||||
name: s.name || '',
|
||||
type,
|
||||
url: s.url || '',
|
||||
key: s.key || '',
|
||||
appid: s.appid || '',
|
||||
pagepath: s.pagepath || '',
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTop(b) {
|
||||
if (b.sub_button && b.sub_button.length) {
|
||||
return {
|
||||
name: b.name || '',
|
||||
isSubmenu: true,
|
||||
type: 'view',
|
||||
url: '',
|
||||
key: '',
|
||||
appid: '',
|
||||
pagepath: '',
|
||||
sub_button: b.sub_button.map(normalizeLeaf),
|
||||
}
|
||||
}
|
||||
const l = normalizeLeaf(b)
|
||||
return {
|
||||
name: b.name || '',
|
||||
isSubmenu: false,
|
||||
type: l.type,
|
||||
url: l.url,
|
||||
key: l.key,
|
||||
appid: l.appid,
|
||||
pagepath: l.pagepath,
|
||||
sub_button: [],
|
||||
}
|
||||
}
|
||||
|
||||
function applyMenuFromApi(payload) {
|
||||
const buttons = payload?.button
|
||||
if (!buttons || !buttons.length) {
|
||||
menuTops.value = []
|
||||
return
|
||||
}
|
||||
menuTops.value = buttons.map(normalizeTop)
|
||||
}
|
||||
|
||||
function leafToApi(s) {
|
||||
const o = { name: s.name, type: s.type }
|
||||
if (s.type === 'view') {
|
||||
o.url = s.url
|
||||
} else if (s.type === 'click') {
|
||||
o.key = s.key
|
||||
} else if (s.type === 'miniprogram') {
|
||||
o.appid = s.appid
|
||||
o.pagepath = s.pagepath
|
||||
o.url = s.url
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
function menuTopsToButton() {
|
||||
return menuTops.value.map((b) => {
|
||||
if (b.isSubmenu) {
|
||||
return {
|
||||
name: b.name,
|
||||
sub_button: (b.sub_button || []).map(leafToApi),
|
||||
}
|
||||
}
|
||||
return leafToApi({
|
||||
name: b.name,
|
||||
type: b.type,
|
||||
url: b.url,
|
||||
key: b.key,
|
||||
appid: b.appid,
|
||||
pagepath: b.pagepath,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function addTop() {
|
||||
if (menuTops.value.length >= 3) {
|
||||
return
|
||||
}
|
||||
menuTops.value.push(emptyTop())
|
||||
}
|
||||
|
||||
function removeTop(i) {
|
||||
menuTops.value.splice(i, 1)
|
||||
}
|
||||
|
||||
function addSub(topIndex) {
|
||||
const item = menuTops.value[topIndex]
|
||||
if (!item.sub_button) {
|
||||
item.sub_button = []
|
||||
}
|
||||
if (item.sub_button.length >= 5) {
|
||||
return
|
||||
}
|
||||
item.sub_button.push(emptySub())
|
||||
}
|
||||
|
||||
function removeSub(topIndex, subIndex) {
|
||||
menuTops.value[topIndex].sub_button.splice(subIndex, 1)
|
||||
}
|
||||
|
||||
async function saveMenuDraft() {
|
||||
menuSaving.value = true
|
||||
try {
|
||||
const body = { button: menuTopsToButton() }
|
||||
await httpPost('/api/admin/config/update/wx_gzh_menu', body)
|
||||
ElMessage.success('菜单草稿已保存')
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
menuSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncMenuToWechat() {
|
||||
menuPublishing.value = true
|
||||
try {
|
||||
const body = { button: menuTopsToButton() }
|
||||
await httpPost('/api/admin/config/update/wx_gzh_menu', body)
|
||||
await httpPost('/api/admin/config/wx_gzh/menu/publish', {})
|
||||
ElMessage.success('已保存并同步到微信')
|
||||
} catch (e) {
|
||||
ElMessage.error('同步失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
menuPublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pullMenuFromWechat() {
|
||||
menuPulling.value = true
|
||||
try {
|
||||
const res = await httpGet('/api/admin/config/wx_gzh/menu/query')
|
||||
applyMenuFromApi(res.data)
|
||||
ElMessage.success('已从微信拉取并写入草稿')
|
||||
} catch (e) {
|
||||
ElMessage.error('拉取失败:' + (e.message || String(e)))
|
||||
} finally {
|
||||
menuPulling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 加载系统配置
|
||||
httpGet('/api/admin/config/get?key=wx_gzh')
|
||||
.then((res) => {
|
||||
gzhConfig.value = res.data || {}
|
||||
Promise.all([
|
||||
httpGet('/api/admin/config/get?key=wx_gzh'),
|
||||
httpGet('/api/admin/config/get?key=wx_gzh_menu'),
|
||||
])
|
||||
.then(([r1, r2]) => {
|
||||
gzhConfig.value = r1.data || {}
|
||||
applyMenuFromApi(r2.data || {})
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('加载系统配置失败: ' + e.message)
|
||||
ElMessage.error('加载系统配置失败: ' + (e.message || String(e)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,7 +374,7 @@ const save = function () {
|
||||
ElMessage.success('操作成功!')
|
||||
})
|
||||
.catch((e) => {
|
||||
ElMessage.error('操作失败:' + e.message)
|
||||
ElMessage.error('操作失败:' + (e.message || String(e)))
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -92,7 +392,58 @@ const save = function () {
|
||||
width: 100%;
|
||||
background-color: var(--el-bg-color);
|
||||
padding: 10px 20px 40px 20px;
|
||||
/* border: 1px solid var(--el-border-color); */
|
||||
}
|
||||
}
|
||||
|
||||
.menu-hint {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.menu-top-card {
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.menu-top-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.menu-top-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-menu-toolbar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sub-menu-row {
|
||||
padding-left: 8px;
|
||||
border-left: 3px solid var(--el-color-primary-light-5);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sub-menu-head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-top-btn {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab-actions {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
.menu-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
v-for="item in currentApps"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.id)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!loading && currentApps.length === 0">
|
||||
@@ -45,9 +43,7 @@
|
||||
v-for="item in getAppsByType(type.id)"
|
||||
:key="item.id"
|
||||
:app="item"
|
||||
:has-role="hasRole(item.id)"
|
||||
@use-role="useRole"
|
||||
@update-role="updateRole"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="!loading && getAppsByType(type.id).length === 0">
|
||||
@@ -78,8 +74,8 @@ import EmptyState from '@/components/mobile/EmptyState.vue'
|
||||
import CustomTabPane from '@/components/ui/CustomTabPane.vue'
|
||||
import CustomTabs from '@/components/ui/CustomTabs.vue'
|
||||
import { checkSession } from '@/store/cache'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { showLoginDialog, substr } from '@/utils/libs'
|
||||
import { showNotify } from 'vant'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -89,7 +85,6 @@ const isLogin = ref(false)
|
||||
const allApps = ref([]) // 存储所有应用数据
|
||||
const appTypes = ref([])
|
||||
const loading = ref(false)
|
||||
const roles = ref([])
|
||||
const activeTab = ref('all')
|
||||
const initialized = ref(false)
|
||||
|
||||
@@ -116,9 +111,8 @@ const currentApps = computed(() => {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const user = await checkSession()
|
||||
await checkSession()
|
||||
isLogin.value = true
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
} catch (error) {
|
||||
// 用户未登录,继续执行
|
||||
}
|
||||
@@ -173,40 +167,6 @@ const handleTabChange = async (tabName) => {
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
const updateRole = async (app, opt) => {
|
||||
if (!isLogin.value) {
|
||||
return showLoginDialog(router)
|
||||
}
|
||||
|
||||
let actionTitle = ''
|
||||
if (opt === 'add') {
|
||||
actionTitle = '添加应用'
|
||||
const exists = arrayContains(roles.value, app.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(app.id)
|
||||
} else {
|
||||
actionTitle = '移除应用'
|
||||
const exists = arrayContains(roles.value, app.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, app.id)
|
||||
}
|
||||
|
||||
try {
|
||||
await httpPost('/api/app/workspace', { ids: roles.value })
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!' })
|
||||
} catch (e) {
|
||||
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
|
||||
}
|
||||
}
|
||||
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const useRole = (roleId) => {
|
||||
if (!isLogin.value) {
|
||||
return showLoginDialog(router)
|
||||
|
||||
@@ -120,8 +120,8 @@
|
||||
|
||||
<script setup>
|
||||
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
|
||||
import { httpGet, httpPost } from '@/utils/http'
|
||||
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
|
||||
import { httpGet } from '@/utils/http'
|
||||
import { showLoginDialog, substr } from '@/utils/libs'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { showNotify } from 'vant'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
@@ -132,7 +132,6 @@ const router = useRouter()
|
||||
const isLogin = ref(false)
|
||||
const apps = ref([])
|
||||
const loading = ref(false)
|
||||
const roles = ref([])
|
||||
const userAvatar = ref('/images/avatar/default.jpg')
|
||||
|
||||
// 功能配置
|
||||
@@ -225,7 +224,6 @@ onMounted(() => {
|
||||
checkSession()
|
||||
.then((user) => {
|
||||
isLogin.value = true
|
||||
roles.value = Array.isArray(user.chat_roles) ? user.chat_roles : []
|
||||
userAvatar.value = user.avatar || '/images/avatar/default.jpg'
|
||||
})
|
||||
.catch(() => {})
|
||||
@@ -256,40 +254,6 @@ const fetchApps = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const updateRole = (row, opt) => {
|
||||
if (!isLogin.value) {
|
||||
return showLoginDialog(router)
|
||||
}
|
||||
|
||||
let actionTitle = ''
|
||||
if (opt === 'add') {
|
||||
actionTitle = '添加应用'
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (exists) {
|
||||
return
|
||||
}
|
||||
roles.value.push(row.id)
|
||||
} else {
|
||||
actionTitle = '移除应用'
|
||||
const exists = arrayContains(roles.value, row.id)
|
||||
if (!exists) {
|
||||
return
|
||||
}
|
||||
roles.value = removeArrayItem(roles.value, row.id)
|
||||
}
|
||||
httpPost('/api/app/workspace', { ids: roles.value })
|
||||
.then(() => {
|
||||
showNotify({ type: 'success', message: actionTitle + '成功!', duration: 1000 })
|
||||
})
|
||||
.catch((e) => {
|
||||
showNotify({ type: 'danger', message: actionTitle + '失败:' + e.message })
|
||||
})
|
||||
}
|
||||
|
||||
const hasRole = (roleId) => {
|
||||
return arrayContains(roles.value, roleId, (v1, v2) => v1 === v2)
|
||||
}
|
||||
|
||||
const useRole = (roleId) => {
|
||||
if (!isLogin.value) {
|
||||
return showLoginDialog(router)
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
<i class="iconfont icon-alipay"></i>
|
||||
<span>支付宝</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="stripePayEnabled"
|
||||
class="payment-btn stripe-btn"
|
||||
@click="stripePay(item)"
|
||||
>
|
||||
<i class="iconfont icon-reward"></i>
|
||||
<span>Stripe</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,6 +148,7 @@ const currentPrice = ref(0)
|
||||
const currentProduct = ref(null)
|
||||
const selectedPid = ref(0)
|
||||
const orderTimeout = ref(1800)
|
||||
const stripePayEnabled = ref(false)
|
||||
const handler = ref(null)
|
||||
const title = ref('')
|
||||
|
||||
@@ -174,6 +183,7 @@ onMounted(() => {
|
||||
if (res.data['order_pay_timeout'] > 0) {
|
||||
orderTimeout.value = res.data['order_pay_timeout']
|
||||
}
|
||||
stripePayEnabled.value = !!res.data['stripe_pay_enabled']
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error('获取系统配置失败:', e.message)
|
||||
@@ -242,6 +252,25 @@ const alipay = (product) => {
|
||||
GenerateOrder('alipay')
|
||||
}
|
||||
|
||||
const stripePay = (product) => {
|
||||
if (!isLogin.value) {
|
||||
store.setShowLoginDialog(true)
|
||||
return
|
||||
}
|
||||
|
||||
selectedPid.value = product.id
|
||||
currentProduct.value = product
|
||||
currentPrice.value = Number(product.price)
|
||||
title.value = 'Stripe Checkout'
|
||||
|
||||
showLoadingToast({
|
||||
message: '正在生成 Stripe 支付订单...',
|
||||
forbidClick: true,
|
||||
})
|
||||
|
||||
GenerateOrder('stripe')
|
||||
}
|
||||
|
||||
function GenerateOrder(payWay, device = 'pc') {
|
||||
// 生成支付订单
|
||||
httpPost('/api/payment/create', {
|
||||
@@ -251,6 +280,10 @@ function GenerateOrder(payWay, device = 'pc') {
|
||||
device: device,
|
||||
})
|
||||
.then((res) => {
|
||||
if (payWay === 'stripe') {
|
||||
window.location.href = res.data.pay_url
|
||||
return
|
||||
}
|
||||
if (res.data.pay_url) {
|
||||
// 如果是微信浏览器,并且微信公众号配置启用,则使用微信JSAPI支付
|
||||
if (isWechat() && wxGzhConfig.value.enabled) {
|
||||
@@ -551,6 +584,11 @@ const onBridgeReady = function onBridgeReady(payParams) {
|
||||
background: #1395d1;
|
||||
}
|
||||
}
|
||||
|
||||
&.stripe-btn {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,7 +254,7 @@
|
||||
</el-image>
|
||||
<!-- 音乐播放按钮 -->
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
v-if="item.status === 'success'"
|
||||
@click="suno.play(item)"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50 opacity-0 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
@@ -262,14 +262,14 @@
|
||||
</button>
|
||||
<!-- 进度动画 -->
|
||||
<div
|
||||
v-if="item.progress < 100 && item.progress !== 101"
|
||||
v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-blue-500 bg-opacity-20"
|
||||
>
|
||||
<i class="iconfont icon-loading animate-spin text-blue-500 text-xl"></i>
|
||||
</div>
|
||||
<!-- 失败状态 -->
|
||||
<div
|
||||
v-if="item.progress === 101"
|
||||
v-if="item.status === 'failed'"
|
||||
class="absolute inset-0 flex items-center justify-center bg-red-500 bg-opacity-20"
|
||||
>
|
||||
<i class="iconfont icon-warning text-red-500 text-xl"></i>
|
||||
@@ -287,9 +287,9 @@
|
||||
</p>
|
||||
</div>
|
||||
<!-- 任务状态 -->
|
||||
<div v-if="item.progress < 100" class="flex items-center space-x-2 text-sm">
|
||||
<div v-if="item.status !== 'success'" class="flex items-center space-x-2 text-sm">
|
||||
<div
|
||||
v-if="item.progress === 101"
|
||||
v-if="item.status === 'failed'"
|
||||
class="text-red-600 flex items-center space-x-1"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -344,7 +344,7 @@
|
||||
<div class="flex items-center justify-between mt-4">
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
v-if="item.status === 'success'"
|
||||
@click="suno.play(item)"
|
||||
class="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-1"
|
||||
>
|
||||
@@ -352,7 +352,7 @@
|
||||
<span>播放</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
v-if="item.status === 'success'"
|
||||
@click="suno.download(item)"
|
||||
:disabled="item.downloading"
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors disabled:bg-gray-400 flex items-center space-x-1"
|
||||
@@ -381,7 +381,7 @@
|
||||
<span>{{ item.downloading ? '下载中...' : '下载' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="item.progress === 100"
|
||||
v-if="item.status === 'success'"
|
||||
@click="suno.extend(item)"
|
||||
class="px-3 py-1.5 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center min-w-[60px]"
|
||||
>
|
||||
@@ -399,7 +399,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div v-if="item.progress < 100 && item.progress !== 101" class="mt-4">
|
||||
<div v-if="item.status === 'pending' || item.status === 'in_progress' || item.status === 'downloading'" class="mt-4">
|
||||
<div class="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>生成进度</span>
|
||||
<span>{{ item.progress }}%</span>
|
||||
@@ -414,7 +414,7 @@
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div
|
||||
v-if="item.progress === 101"
|
||||
v-if="item.status === 'failed'"
|
||||
class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg"
|
||||
>
|
||||
<div class="flex items-start space-x-2">
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
>
|
||||
<van-grid :gutter="10" :column-num="2">
|
||||
<van-grid-item v-for="item in finishedJobs" :key="item.id">
|
||||
<div class="failed" v-if="item.progress === 101">
|
||||
<div class="failed" v-if="item.status === 'failed'">
|
||||
<div class="title">任务失败</div>
|
||||
<div class="opt">
|
||||
<van-button size="small" @click="showDetail(item)">详情</van-button>
|
||||
@@ -192,7 +192,7 @@
|
||||
<van-cell title="创建时间" :value="formatTime(currentDetail.created_at)" />
|
||||
|
||||
<van-cell
|
||||
v-if="currentDetail.progress === 100 && currentDetail.img_url"
|
||||
v-if="currentDetail.status === 'success' && currentDetail.img_url"
|
||||
title="生成的图片"
|
||||
>
|
||||
<template #value>
|
||||
@@ -226,7 +226,7 @@
|
||||
</van-cell>
|
||||
|
||||
<van-cell
|
||||
v-if="currentDetail.progress === 101 && currentDetail.err_msg"
|
||||
v-if="currentDetail.status === 'failed' && currentDetail.err_msg"
|
||||
title="错误信息"
|
||||
>
|
||||
<template #value>
|
||||
|
||||
@@ -258,7 +258,7 @@
|
||||
>
|
||||
<van-grid :gutter="10" :column-num="2">
|
||||
<van-grid-item v-for="item in finishedJobs" :key="item.id" class="min-h-[270px]">
|
||||
<div class="failed" v-if="item.progress === 101">
|
||||
<div class="failed" v-if="item.status === 'failed'">
|
||||
<div class="title">任务失败</div>
|
||||
<div class="opt">
|
||||
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
|
||||
@@ -548,11 +548,11 @@ const fetchFinishJobs = (page) => {
|
||||
jobs[i]['thumb_url'] = getThumbURL(jobs[i]['img_url'], 480, 480)
|
||||
}
|
||||
|
||||
if (jobs[i]['img_url'] === '' && jobs[i].progress === 100) {
|
||||
if (jobs[i]['img_url'] === '' && jobs[i].status === 'downloading') {
|
||||
hasDownload = true
|
||||
}
|
||||
|
||||
if (jobs[i].type !== 'upscale' && jobs[i].progress === 100) {
|
||||
if (jobs[i].type !== 'upscale' && jobs[i].status === 'success') {
|
||||
jobs[i]['can_opt'] = true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user