diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a8cfce..4c9ca1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 的演示文稿生成功能,支持编辑和导出 🔥🔥🔥 diff --git a/README.md b/README.md index fe2036ca..72c0c008 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,7 @@ 1. 安装 docker 和 docker-compose 程序,这个自行解决。 2. 直接在项目根目录运行启动命令: ```shell - cd docker - docker compose up -d + docker-compose up -d ``` ## 功能截图 diff --git a/api/README.md b/api/README.md index 083fe566..4bbb5e2c 100644 --- a/api/README.md +++ b/api/README.md @@ -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 为准,成功后回到会员页并由现有订单轮询刷新状态。 diff --git a/api/core/types/config.go b/api/core/types/config.go index ec0592ea..b08332f0 100644 --- a/api/core/types/config.go +++ b/api/core/types/config.go @@ -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 生成配置 ) diff --git a/api/core/types/geekai.go b/api/core/types/geekai.go index 16f054be..9db2cc6c 100644 --- a/api/core/types/geekai.go +++ b/api/core/types/geekai.go @@ -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"` // 子菜单 +} diff --git a/api/core/types/jimeng.go b/api/core/types/jimeng.go index 5bb2d0ce..bb3f8ce4 100644 --- a/api/core/types/jimeng.go +++ b/api/core/types/jimeng.go @@ -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 +} diff --git a/api/core/types/order.go b/api/core/types/order.go index 39d7c8c4..ebdd6ffe 100644 --- a/api/core/types/order.go +++ b/api/core/types/order.go @@ -27,9 +27,11 @@ var PayChannel = map[string]string{ "alipay": "支付宝商号", "wxpay": "微信商号", "epay": "易支付", + "stripe": "Stripe 商号", } var PayWays = map[string]string{ "alipay": "支付宝", "wxpay": "微信支付", + "stripe": "Stripe", } diff --git a/api/core/types/payment.go b/api/core/types/payment.go index d96fff6c..6a67f066 100644 --- a/api/core/types/payment.go +++ b/api/core/types/payment.go @@ -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 +} diff --git a/api/go.mod b/api/go.mod index 26732332..a401d219 100644 --- a/api/go.mod +++ b/api/go.mod @@ -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 diff --git a/api/go.sum b/api/go.sum index 19460656..09aad14e 100644 --- a/api/go.sum +++ b/api/go.sum @@ -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= diff --git a/api/handler/admin/config_handler.go b/api/handler/admin/config_handler.go index 56b7ebfe..0da0d6b2 100644 --- a/api/handler/admin/config_handler.go +++ b/api/handler/admin/config_handler.go @@ -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) +} diff --git a/api/handler/admin/image_handler.go b/api/handler/admin/image_handler.go index 9bec9668..4b5fe080 100644 --- a/api/handler/admin/image_handler.go +++ b/api/handler/admin/image_handler.go @@ -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, diff --git a/api/handler/admin/product_handler.go b/api/handler/admin/product_handler.go index 2202fa6f..06ec76d4 100644 --- a/api/handler/admin/product_handler.go +++ b/api/handler/admin/product_handler.go @@ -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) diff --git a/api/handler/admin/user_handler.go b/api/handler/admin/user_handler.go index 64e66478..804b1e24 100644 --- a/api/handler/admin/user_handler.go +++ b/api/handler/admin/user_handler.go @@ -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, // 长期有效 diff --git a/api/handler/chat_app_handler.go b/api/handler/chat_app_handler.go index 9c8eb423..c072de24 100644 --- a/api/handler/chat_app_handler.go +++ b/api/handler/chat_app_handler.go @@ -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) diff --git a/api/handler/config_handler.go b/api/handler/config_handler.go index 4198b300..81c09f7c 100644 --- a/api/handler/config_handler.go +++ b/api/handler/config_handler.go @@ -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) } diff --git a/api/handler/image_handler.go b/api/handler/image_handler.go index 24d24980..dd099863 100644 --- a/api/handler/image_handler.go +++ b/api/handler/image_handler.go @@ -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) } diff --git a/api/handler/jimeng_handler.go b/api/handler/jimeng_handler.go index 39abc92c..5935c858 100644 --- a/api/handler/jimeng_handler.go +++ b/api/handler/jimeng_handler.go @@ -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 +} diff --git a/api/handler/mj_handler.go b/api/handler/mj_handler.go index ea185a61..adbbb711 100644 --- a/api/handler/mj_handler.go +++ b/api/handler/mj_handler.go @@ -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) diff --git a/api/handler/payment_handler.go b/api/handler/payment_handler.go index 404f519c..af119c39 100644 --- a/api/handler/payment_handler.go +++ b/api/handler/payment_handler.go @@ -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") +} diff --git a/api/handler/suno_handler.go b/api/handler/suno_handler.go index 7e3e65eb..3c0b80a5 100644 --- a/api/handler/suno_handler.go +++ b/api/handler/suno_handler.go @@ -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 } diff --git a/api/handler/user_handler.go b/api/handler/user_handler.go index 83869f7a..e12f3775 100644 --- a/api/handler/user_handler.go +++ b/api/handler/user_handler.go @@ -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) } diff --git a/api/handler/video_handler.go b/api/handler/video_handler.go index cfddb352..454925a5 100644 --- a/api/handler/video_handler.go +++ b/api/handler/video_handler.go @@ -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) diff --git a/api/handler/wxgzh_handler.go b/api/handler/wxgzh_handler.go index 0cd091f9..63b7ced1 100644 --- a/api/handler/wxgzh_handler.go +++ b/api/handler/wxgzh_handler.go @@ -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, "创建菜单成功") -} diff --git a/api/main.go b/api/main.go index 3346403a..5c62d0aa 100644 --- a/api/main.go +++ b/api/main.go @@ -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), diff --git a/api/service/image/image_service.go b/api/service/image/image_service.go index b1c0bb10..9a1d4985 100644 --- a/api/service/image/image_service.go +++ b/api/service/image/image_service.go @@ -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 } diff --git a/api/service/jimeng/jimemg_service.go b/api/service/jimeng/jimemg_service.go index c1cc6b2a..30d4a4d3 100644 --- a/api/service/jimeng/jimemg_service.go +++ b/api/service/jimeng/jimemg_service.go @@ -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 diff --git a/api/service/jimeng/types.go b/api/service/jimeng/types.go index 66da2a2d..2f42b862 100644 --- a/api/service/jimeng/types.go +++ b/api/service/jimeng/types.go @@ -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" // 同步提交任务 diff --git a/api/service/migration_service.go b/api/service/migration_service.go index db6a3f7c..d24f27c1 100644 --- a/api/service/migration_service.go +++ b/api/service/migration_service.go @@ -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) +} diff --git a/api/service/mj/service.go b/api/service/mj/service.go index ec299031..acd2dd13 100644 --- a/api/service/mj/service.go +++ b/api/service/mj/service.go @@ -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 diff --git a/api/service/payment/pay_service.go b/api/service/payment/pay_service.go index 06c65728..458ed8cc 100644 --- a/api/service/payment/pay_service.go +++ b/api/service/payment/pay_service.go @@ -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 // 回调地址 diff --git a/api/service/payment/stripe_service.go b/api/service/payment/stripe_service.go new file mode 100644 index 00000000..a7521807 --- /dev/null +++ b/api/service/payment/stripe_service.go @@ -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) diff --git a/api/service/smtp_sms_service.go b/api/service/smtp_sms_service.go index 6d430482..17a8ff8d 100644 --- a/api/service/smtp_sms_service.go +++ b/api/service/smtp_sms_service.go @@ -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) diff --git a/api/service/suno/service.go b/api/service/suno/suno_service.go similarity index 92% rename from api/service/suno/service.go rename to api/service/suno/suno_service.go index e53868b1..d380aa8a 100644 --- a/api/service/suno/service.go +++ b/api/service/suno/suno_service.go @@ -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 diff --git a/api/service/types.go b/api/service/types.go index 29c5076e..abccb573 100644 --- a/api/service/types.go +++ b/api/service/types.go @@ -2,7 +2,6 @@ package service import "geekai/log" -const FailTaskProgress = 101 const ( TaskStatusRunning = "RUNNING" TaskStatusFinished = "FINISH" diff --git a/api/service/video/adapters/sora_adapter.go b/api/service/video/adapters/sora_adapter.go index 2df71c99..9d35727c 100644 --- a/api/service/video/adapters/sora_adapter.go +++ b/api/service/video/adapters/sora_adapter.go @@ -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) { diff --git a/api/service/video/video_service.go b/api/service/video/video_service.go index 16a1b213..6a6559db 100644 --- a/api/service/video/video_service.go +++ b/api/service/video/video_service.go @@ -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 diff --git a/api/service/wxgzh_menu.go b/api/service/wxgzh_menu.go new file mode 100644 index 00000000..48be7c08 --- /dev/null +++ b/api/service/wxgzh_menu.go @@ -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 +} diff --git a/api/service/wxgzh_service.go b/api/service/wxgzh_service.go index d8d6cf0e..a63b825f 100644 --- a/api/service/wxgzh_service.go +++ b/api/service/wxgzh_service.go @@ -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 { diff --git a/api/store/model/image_job.go b/api/store/model/image_job.go index 5a785757..ae3cb3be 100644 --- a/api/store/model/image_job.go +++ b/api/store/model/image_job.go @@ -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 { diff --git a/api/store/model/image_status.go b/api/store/model/image_status.go new file mode 100644 index 00000000..83508f2b --- /dev/null +++ b/api/store/model/image_status.go @@ -0,0 +1,10 @@ +package model + +// 图片任务状态常量(普通图片、MidJourney、Suno 共用)。 +const ( + ImageStatusPending = "pending" + ImageStatusInProgress = "in_progress" + ImageStatusDownloading = "downloading" + ImageStatusSuccess = "success" + ImageStatusFailed = "failed" +) diff --git a/api/store/model/mj_job.go b/api/store/model/mj_job.go index 1f6cfaff..927c6617 100644 --- a/api/store/model/mj_job.go +++ b/api/store/model/mj_job.go @@ -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"` diff --git a/api/store/model/product.go b/api/store/model/product.go index d5e22206..7dccdb34 100644 --- a/api/store/model/product.go +++ b/api/store/model/product.go @@ -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 { diff --git a/api/store/model/suno_job.go b/api/store/model/suno_job.go index 6110ced3..ca5611d1 100644 --- a/api/store/model/suno_job.go +++ b/api/store/model/suno_job.go @@ -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"` diff --git a/api/store/model/user.go b/api/store/model/user.go index ecea442a..bf448bc6 100644 --- a/api/store/model/user.go +++ b/api/store/model/user.go @@ -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"` diff --git a/api/store/vo/image_job.go b/api/store/vo/image_job.go index 3959d694..d7358f8b 100644 --- a/api/store/vo/image_job.go +++ b/api/store/vo/image_job.go @@ -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"` } diff --git a/api/store/vo/mj_job.go b/api/store/vo/mj_job.go index 30c5c044..6071fa68 100644 --- a/api/store/vo/mj_job.go +++ b/api/store/vo/mj_job.go @@ -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"` diff --git a/api/store/vo/product.go b/api/store/vo/product.go index 0cc5e198..41005a82 100644 --- a/api/store/vo/product.go +++ b/api/store/vo/product.go @@ -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"` } diff --git a/api/store/vo/suno_job.go b/api/store/vo/suno_job.go index 5258620c..49dc9872 100644 --- a/api/store/vo/suno_job.go +++ b/api/store/vo/suno_job.go @@ -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"` // 是否发布 diff --git a/api/store/vo/user.go b/api/store/vo/user.go index 55db9ab1..f8827557 100644 --- a/api/store/vo/user.go +++ b/api/store/vo/user.go @@ -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"` diff --git a/api/utils/net.go b/api/utils/net.go index 72591b4a..6bcfffc0 100644 --- a/api/utils/net.go +++ b/api/utils/net.go @@ -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 diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index 551940d3..0fb16336 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -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: diff --git a/docs/plans/ppt-resume-generation.md b/docs/plans/ppt-resume-generation.md deleted file mode 100644 index fcb263c5..00000000 --- a/docs/plans/ppt-resume-generation.md +++ /dev/null @@ -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` 返回不可续跑。 diff --git a/web/.env.development b/web/.env.development index 0da53c54..78cfad03 100644 --- a/web/.env.development +++ b/web/.env.development @@ -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 diff --git a/web/.env.production b/web/.env.production index b6073e0f..a5e804c2 100644 --- a/web/.env.production +++ b/web/.env.production @@ -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 diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 00000000..720e90cf --- /dev/null +++ b/web/.eslintrc.cjs @@ -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', + }, +} diff --git a/web/package.json b/web/package.json index c8d4e3f2..80ee72e8 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a562643e..7ff7484a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -129,6 +129,12 @@ importers: autoprefixer: specifier: ^10.4.20 version: 10.4.21(postcss@8.5.6) + eslint: + specifier: 8.57.1 + version: 8.57.1 + eslint-plugin-vue: + specifier: 9.33.0 + version: 9.33.0(eslint@8.57.1) postcss: specifier: ^8.4.49 version: 8.5.6 @@ -346,6 +352,24 @@ packages: cpu: [x64] os: [win32] + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/eslintrc@2.1.4': + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@floating-ui/core@1.7.2': resolution: {integrity: sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==} @@ -358,6 +382,19 @@ packages: '@gera2ld/jsx-dom@2.2.2': resolution: {integrity: sha512-EOqf31IATRE6zS1W1EoWmXZhGfLAoO9FIlwTtHduSrBdud4npYBxYAkv8dZ5hudDPwJeeSjn40kbCL4wAzr8dA==} + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/object-schema@2.0.3': + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -658,6 +695,9 @@ packages: '@types/web-bluetooth@0.0.16': resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vant/popperjs@1.3.0': resolution: {integrity: sha512-hB+czUG+aHtjhaEmCJDuXOep0YTZjdlRR+4MSmIFnkCQIxJaXLQdSsR90XWvAI2yvKUI7TCGqR8pQg2RtvkMHw==} @@ -775,6 +815,11 @@ packages: peerDependencies: acorn: ^8.14.0 + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -793,6 +838,9 @@ packages: peerDependencies: ajv: ^8.8.2 + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} @@ -904,6 +952,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + camelcase-css@2.0.1: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} @@ -915,6 +967,10 @@ packages: caniuse-lite@1.0.30001731: resolution: {integrity: sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + cheerio-select@1.6.0: resolution: {integrity: sha512-eq0GdBvxVFbqWgmCm7M3XGs1I8oLy/nExUnh6oLqmBditPO9AqQJrkslDpMun/hZ0yyTs8L0m85OHp4ho6Qm9g==} @@ -1173,6 +1229,9 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -1192,6 +1251,10 @@ packages: dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dom-serializer@1.4.1: resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} @@ -1297,18 +1360,50 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + escape-string-regexp@5.0.0: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-vue@9.33.0: + resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + eslint-scope@5.1.1: resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} engines: {node: '>=8.0.0'} + eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + esm@3.2.25: resolution: {integrity: sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==} engines: {node: '>=6'} + espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + esrecurse@4.3.0: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} @@ -1327,6 +1422,10 @@ packages: estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -1341,12 +1440,22 @@ packages: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.0.6: resolution: {integrity: sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1355,6 +1464,17 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + follow-redirects@1.15.9: resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} engines: {node: '>=4.0'} @@ -1417,6 +1537,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported + globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + good-listener@1.2.2: resolution: {integrity: sha512-goW1b+d9q/HIwbVYZzZ6SsTr4IgE+WA44A0GmPIQstuOrgsFcT7VEJ48nmr9GaRtNu0XTKacFLGnBPAM6Afouw==} @@ -1430,6 +1554,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1467,9 +1594,21 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + immutable@5.1.3: resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==} + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. @@ -1509,6 +1648,10 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1533,12 +1676,21 @@ packages: json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + juice@8.1.0: resolution: {integrity: sha512-FLzurJrx5Iv1e7CfBSZH68dC04EEvXvvVvPYB7Vx1WAuhCp1ZPIMtqxc+WTWxVkpTIC2Ach/GAv0rQbtGf6YMA==} engines: {node: '>=10.0.0'} @@ -1548,6 +1700,13 @@ packages: resolution: {integrity: sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==} hasBin: true + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1574,6 +1733,10 @@ packages: resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} engines: {node: '>=8'} + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lodash-es@4.17.21: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} @@ -1584,6 +1747,9 @@ packages: lodash: '*' lodash-es: '*' + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -1711,6 +1877,9 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -1758,14 +1927,26 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -1773,6 +1954,10 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + parse5-htmlparser2-tree-adapter@6.0.1: resolution: {integrity: sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==} @@ -1886,10 +2071,18 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prismjs@1.30.0: resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} engines: {node: '>=6'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -1934,6 +2127,10 @@ packages: require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} @@ -1943,6 +2140,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + robust-predicates@3.0.2: resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} @@ -2080,6 +2282,11 @@ packages: select@1.1.2: resolution: {integrity: sha512-OwpTSOfy6xSs1+pwcNrv0RBMOzI39Lp3qQKUTPVVPRjCdNa5JH/oPRiqsesIskK8TVgmRiHwO4KXlV2Li9dANA==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + serialize-javascript@6.0.2: resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} @@ -2162,6 +2369,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-literal@2.1.1: resolution: {integrity: sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==} @@ -2181,6 +2392,10 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + supports-color@8.1.1: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} @@ -2227,6 +2442,9 @@ packages: engines: {node: '>=10'} hasBin: true + text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2268,6 +2486,14 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} @@ -2302,6 +2528,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2359,6 +2588,12 @@ packages: '@vue/composition-api': optional: true + vue-eslint-parser@9.4.3: + resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==} + engines: {node: ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '>=6.0.0' + vue-router@4.5.1: resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==} peerDependencies: @@ -2417,6 +2652,10 @@ packages: wicked-good-xpath@1.3.0: resolution: {integrity: sha512-Gd9+TUn5nXdwj/hFsPVx5cuHHiF5Bwuc30jZ4+ronF1qHK5O7HD0sgmXWSEgwKquT3ClLoKPVbO6qGwVwLzvAw==} + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -2432,6 +2671,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + y18n@4.0.3: resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} @@ -2448,6 +2691,10 @@ packages: resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} engines: {node: '>=8'} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + zrender@5.6.1: resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} @@ -2571,6 +2818,29 @@ snapshots: '@esbuild/win32-x64@0.21.5': optional: true + '@eslint-community/eslint-utils@4.10.1(eslint@8.57.1)': + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/eslintrc@2.1.4': + dependencies: + ajv: 6.15.0 + debug: 4.4.1 + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@8.57.1': {} + '@floating-ui/core@1.7.2': dependencies: '@floating-ui/utils': 0.2.10 @@ -2586,6 +2856,18 @@ snapshots: dependencies: '@babel/runtime': 7.28.2 + '@humanwhocodes/config-array@0.13.0': + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.4.1 + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2862,6 +3144,8 @@ snapshots: '@types/web-bluetooth@0.0.16': {} + '@ungap/structured-clone@1.4.0': {} + '@vant/popperjs@1.3.0': {} '@vant/use@1.6.0(vue@3.5.18)': @@ -3034,6 +3318,10 @@ snapshots: dependencies: acorn: 8.15.0 + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + acorn@8.15.0: {} ajv-formats@2.1.1(ajv@8.17.1): @@ -3045,6 +3333,13 @@ snapshots: ajv: 8.17.1 fast-deep-equal: 3.1.3 + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 @@ -3152,12 +3447,19 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + callsites@3.1.0: {} + camelcase-css@2.0.1: {} camelcase@5.3.1: {} caniuse-lite@1.0.30001731: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + cheerio-select@1.6.0: dependencies: css-select: 4.3.0 @@ -3460,6 +3762,8 @@ snapshots: decode-uri-component@0.2.2: {} + deep-is@0.1.4: {} + delaunator@5.0.1: dependencies: robust-predicates: 3.0.2 @@ -3474,6 +3778,10 @@ snapshots: dlv@1.1.3: {} + doctrine@3.0.0: + dependencies: + esutils: 2.0.3 + dom-serializer@1.4.1: dependencies: domelementtype: 2.3.0 @@ -3614,15 +3922,91 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + escape-string-regexp@5.0.0: {} + eslint-plugin-vue@9.33.0(eslint@8.57.1): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + eslint: 8.57.1 + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.8.5 + vue-eslint-parser: 9.4.3(eslint@8.57.1) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + eslint-scope@5.1.1: dependencies: esrecurse: 4.3.0 estraverse: 4.3.0 + eslint-scope@7.2.2: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint@8.57.1: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@8.57.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.4.0 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.1 + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + esm@3.2.25: {} + espree@9.6.1: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 3.4.3 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + esrecurse@4.3.0: dependencies: estraverse: 5.3.0 @@ -3637,6 +4021,8 @@ snapshots: dependencies: '@types/estree': 1.0.8 + esutils@2.0.3: {} + events@3.3.0: {} exsolve@1.0.7: {} @@ -3651,12 +4037,20 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-uri@3.0.6: {} fastq@1.19.1: dependencies: reusify: 1.1.0 + file-entry-cache@6.0.1: + dependencies: + flat-cache: 3.2.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3666,6 +4060,19 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@3.2.0: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + rimraf: 3.0.2 + + flatted@3.4.4: {} + follow-redirects@1.15.9: {} foreground-child@3.3.1: @@ -3738,6 +4145,10 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + globals@13.24.0: + dependencies: + type-fest: 0.20.2 + good-listener@1.2.2: dependencies: delegate: 3.2.0 @@ -3748,6 +4159,8 @@ snapshots: graceful-fs@4.2.11: {} + graphemer@1.4.0: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3789,8 +4202,17 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore@5.3.2: {} + immutable@5.1.3: {} + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + inflight@1.0.6: dependencies: once: 1.4.0 @@ -3820,6 +4242,8 @@ snapshots: is-number@7.0.0: {} + is-path-inside@3.0.3: {} + isexe@2.0.0: {} jackspeak@3.4.3: @@ -3846,10 +4270,16 @@ snapshots: dependencies: bignumber.js: 9.3.1 + json-buffer@3.0.1: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} + juice@8.1.0: dependencies: cheerio: 1.0.0-rc.10 @@ -3864,6 +4294,15 @@ snapshots: dependencies: commander: 8.3.0 + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -3889,6 +4328,10 @@ snapshots: dependencies: p-locate: 4.1.0 + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lodash-es@4.17.21: {} lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21): @@ -3897,6 +4340,8 @@ snapshots: lodash: 4.17.21 lodash-es: 4.17.21 + lodash.merge@4.6.2: {} + lodash@4.17.21: {} lru-cache@10.4.3: {} @@ -4038,6 +4483,8 @@ snapshots: nanoid@3.3.11: {} + natural-compare@1.4.0: {} + neo-async@2.6.2: {} node-fetch@2.7.0: @@ -4068,18 +4515,39 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + p-limit@2.3.0: dependencies: p-try: 2.2.0 + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + parse5-htmlparser2-tree-adapter@6.0.1: dependencies: parse5: 6.0.1 @@ -4179,8 +4647,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + prelude-ls@1.2.1: {} + prismjs@1.30.0: {} + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -4220,6 +4692,8 @@ snapshots: require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} + resolve@1.22.10: dependencies: is-core-module: 2.16.1 @@ -4228,6 +4702,10 @@ snapshots: reusify@1.1.0: {} + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + robust-predicates@3.0.2: {} rollup@4.46.1: @@ -4359,6 +4837,8 @@ snapshots: select@1.1.2: {} + semver@7.8.5: {} + serialize-javascript@6.0.2: dependencies: randombytes: 2.1.0 @@ -4449,6 +4929,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-json-comments@3.1.1: {} + strip-literal@2.1.1: dependencies: js-tokens: 9.0.1 @@ -4480,6 +4962,10 @@ snapshots: pirates: 4.0.7 ts-interface-checker: 0.1.13 + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + supports-color@8.1.1: dependencies: has-flag: 4.0.0 @@ -4537,6 +5023,8 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 + text-table@0.2.0: {} + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -4569,6 +5057,12 @@ snapshots: tslib@2.8.1: {} + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.20.2: {} + uc.micro@1.0.6: {} ufo@1.6.1: {} @@ -4620,6 +5114,10 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + util-deprecate@1.0.2: {} valid-data-url@3.0.1: {} @@ -4649,6 +5147,19 @@ snapshots: dependencies: vue: 3.5.18 + vue-eslint-parser@9.4.3(eslint@8.57.1): + dependencies: + debug: 4.4.1 + eslint: 8.57.1 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + lodash: 4.17.21 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + vue-router@4.5.1(vue@3.5.18): dependencies: '@vue/devtools-api': 6.6.4 @@ -4731,6 +5242,8 @@ snapshots: wicked-good-xpath@1.3.0: {} + word-wrap@1.2.5: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 @@ -4751,6 +5264,8 @@ snapshots: wrappy@1.0.2: {} + xml-name-validator@4.0.0: {} + y18n@4.0.3: {} yaml@2.8.0: {} @@ -4774,6 +5289,8 @@ snapshots: y18n: 4.0.3 yargs-parser: 18.1.3 + yocto-queue@0.1.0: {} + zrender@5.6.1: dependencies: tslib: 2.3.0 diff --git a/web/public/images/failed.png b/web/public/images/failed.png new file mode 100644 index 00000000..8172687d Binary files /dev/null and b/web/public/images/failed.png differ diff --git a/web/src/assets/css/image-mj.scss b/web/src/assets/css/image-mj.scss index 4050aad7..ba80e229 100644 --- a/web/src/assets/css/image-mj.scss +++ b/web/src/assets/css/image-mj.scss @@ -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; + } +} diff --git a/web/src/assets/css/image.scss b/web/src/assets/css/image.scss index d960ea1b..04ebf041 100644 --- a/web/src/assets/css/image.scss +++ b/web/src/assets/css/image.scss @@ -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; +} diff --git a/web/src/assets/css/video.scss b/web/src/assets/css/video.scss index 76ad24ef..87b47aa0 100644 --- a/web/src/assets/css/video.scss +++ b/web/src/assets/css/video.scss @@ -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; } } } diff --git a/web/src/components/ChatSetting.vue b/web/src/components/ChatSetting.vue index 59d3a3c6..b19ef129 100644 --- a/web/src/components/ChatSetting.vue +++ b/web/src/components/ChatSetting.vue @@ -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) } }) diff --git a/web/src/components/admin/AdminSidebar.vue b/web/src/components/admin/AdminSidebar.vue index faaf8c4b..d1142c94 100644 --- a/web/src/components/admin/AdminSidebar.vue +++ b/web/src/components/admin/AdminSidebar.vue @@ -15,14 +15,14 @@ unique-opened router > - @@ -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; +} diff --git a/web/src/views/admin/Product.vue b/web/src/views/admin/Product.vue index 675a0166..1cbb83fa 100644 --- a/web/src/views/admin/Product.vue +++ b/web/src/views/admin/Product.vue @@ -15,6 +15,7 @@ + @@ -52,6 +53,10 @@ + + + + @@ -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) => { diff --git a/web/src/views/admin/Users.vue b/web/src/views/admin/Users.vue index 5e951aa8..34a3308f 100644 --- a/web/src/views/admin/Users.vue +++ b/web/src/views/admin/Users.vue @@ -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 diff --git a/web/src/views/admin/jimeng/JimengConfig.vue b/web/src/views/admin/jimeng/JimengConfig.vue index 4a04ee06..0c9b1f69 100644 --- a/web/src/views/admin/jimeng/JimengConfig.vue +++ b/web/src/views/admin/jimeng/JimengConfig.vue @@ -75,7 +75,7 @@
- 目前豆包生图 4.0 模型在即梦API中不支持,需要使用火山方舟服务。 + 目前 Seedream 生图模型统一走火山方舟 API Key;Seedance 视频模型共用「视频生成」配置中的 ApiURL 和 ApiKey。
diff --git a/web/src/views/admin/records/ImageList.vue b/web/src/views/admin/records/ImageList.vue index a15dcbf0..1a5eb0c6 100644 --- a/web/src/views/admin/records/ImageList.vue +++ b/web/src/views/admin/records/ImageList.vue @@ -42,11 +42,11 @@
- +