From d4fd38ab7e39587411ed28d961a4d74c0aecb9c4 Mon Sep 17 00:00:00 2001 From: RockYang Date: Sun, 13 Sep 2026 14:37:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=BF=81=E7=A7=BB=20geekai-plus=20v4.3?= =?UTF-8?q?.1=20=E5=88=B0=E5=BC=80=E6=BA=90=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同步 Plus v4.3.1 功能源并移除商业 License 闭环 - 更新开源镜像命名、Docker 部署版本和 geekai 数据库配置 - 补充前端 ESLint 检查配置并修复存量解析与模板问题 - 保留 JWT、管理员权限、API Key 和 OAuth 等正常鉴权机制 --- CHANGELOG.md | 11 + README.md | 3 +- api/README.md | 6 + api/core/types/config.go | 35 +- api/core/types/geekai.go | 16 + api/core/types/jimeng.go | 32 +- api/core/types/order.go | 2 + api/core/types/payment.go | 17 + api/go.mod | 1 + api/go.sum | 4 + api/handler/admin/config_handler.go | 115 + api/handler/admin/image_handler.go | 15 +- api/handler/admin/product_handler.go | 22 +- api/handler/admin/user_handler.go | 1 - api/handler/chat_app_handler.go | 22 - api/handler/config_handler.go | 7 + api/handler/image_handler.go | 16 +- api/handler/jimeng_handler.go | 189 +- api/handler/mj_handler.go | 8 +- api/handler/payment_handler.go | 78 + api/handler/suno_handler.go | 18 +- api/handler/user_handler.go | 14 - api/handler/video_handler.go | 86 +- api/handler/wxgzh_handler.go | 6 - api/main.go | 5 +- api/service/image/image_service.go | 30 +- api/service/jimeng/jimemg_service.go | 483 ++++- api/service/jimeng/types.go | 11 +- api/service/migration_service.go | 149 +- api/service/mj/service.go | 33 +- api/service/payment/pay_service.go | 9 +- api/service/payment/stripe_service.go | 147 ++ api/service/smtp_sms_service.go | 22 +- .../suno/{service.go => suno_service.go} | 33 +- api/service/types.go | 1 - api/service/video/adapters/sora_adapter.go | 107 +- api/service/video/video_service.go | 8 +- api/service/wxgzh_menu.go | 199 ++ api/service/wxgzh_service.go | 11 + api/store/model/image_job.go | 25 +- api/store/model/image_status.go | 10 + api/store/model/mj_job.go | 1 + api/store/model/product.go | 19 +- api/store/model/suno_job.go | 1 + api/store/model/user.go | 1 - api/store/vo/image_job.go | 21 +- api/store/vo/mj_job.go | 2 + api/store/vo/product.go | 17 +- api/store/vo/suno_job.go | 1 + api/store/vo/user.go | 1 - api/utils/net.go | 76 + docker/docker-compose.yaml | 4 +- docs/plans/ppt-resume-generation.md | 54 - web/.env.development | 2 +- web/.env.production | 2 +- web/.eslintrc.cjs | 29 + web/package.json | 4 +- web/pnpm-lock.yaml | 517 +++++ web/public/images/failed.png | Bin 0 -> 68452 bytes web/src/assets/css/image-mj.scss | 614 ++++-- web/src/assets/css/image.scss | 169 +- web/src/assets/css/video.scss | 376 ++-- web/src/components/ChatSetting.vue | 2 +- web/src/components/admin/AdminSidebar.vue | 4 +- web/src/components/mobile/AppCard.vue | 14 +- web/src/store/data/jimeng_params.js | 1845 ++++++----------- web/src/store/data/video_params.js | 10 +- web/src/store/jimeng.js | 206 +- web/src/store/mobile/suno.js | 8 +- web/src/store/mobile/video.js | 2 +- web/src/store/sharedata.js | 3 + web/src/store/suno.js | 77 +- web/src/store/video.js | 200 +- web/src/views/ChatApps.vue | 71 +- web/src/views/Gallery.vue | 520 ++++- web/src/views/Home.vue | 31 +- web/src/views/Image.vue | 261 +-- web/src/views/ImageMj.vue | 996 +++++---- web/src/views/Jimeng.vue | 379 +++- web/src/views/Login.vue | 2 +- web/src/views/Member.vue | 67 +- web/src/views/Suno.vue | 191 +- web/src/views/Video.vue | 757 +++++-- web/src/views/admin/Product.vue | 9 +- web/src/views/admin/Users.vue | 2 +- web/src/views/admin/jimeng/JimengConfig.vue | 2 +- web/src/views/admin/records/ImageList.vue | 49 +- web/src/views/admin/records/Suno.vue | 26 +- .../views/admin/settings/PaymentConfig.vue | 30 +- web/src/views/admin/settings/WechatConfig.vue | 373 +++- web/src/views/mobile/Apps.vue | 46 +- web/src/views/mobile/Index.vue | 40 +- web/src/views/mobile/Member.vue | 38 + web/src/views/mobile/SunoCreate.vue | 20 +- web/src/views/mobile/pages/Image.vue | 6 +- web/src/views/mobile/pages/ImageMj.vue | 6 +- 96 files changed, 7164 insertions(+), 3047 deletions(-) create mode 100644 api/service/payment/stripe_service.go rename api/service/suno/{service.go => suno_service.go} (92%) create mode 100644 api/service/wxgzh_menu.go create mode 100644 api/store/model/image_status.go delete mode 100644 docs/plans/ppt-resume-generation.md create mode 100644 web/.eslintrc.cjs create mode 100644 web/public/images/failed.png 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 0000000000000000000000000000000000000000..8172687d0b0ec12eb451bb31fa2261d361c30e5e GIT binary patch literal 68452 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 A4gdfE literal 0 HcmV?d00001 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 @@
- +