release: v4.2.8

整合开源版 v4.2.8 功能:Sora2 视频、路由重构、手机站开关、DALL-E 参考图,以及启动时自动同步数据表字段等修复与优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
RockYang
2026-08-03 11:12:37 +08:00
parent f8a01cb9a2
commit b18b8ccb02
91 changed files with 3326 additions and 3121 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
name: Bug 报告 🐛
description: geekai 提交错误报告
description: chatgpt-plus 提交错误报告
labels: ['Bug']
body:
- type: checkboxes
+1 -1
View File
@@ -1,5 +1,5 @@
name: 功能优化 🚀
description: geekai 提交优化建议
description: chatgpt-plus 提交优化建议
labels: ['feature']
body:
- type: checkboxes
+1 -1
View File
@@ -14,4 +14,4 @@ logs
*.njsproj
*.sln
*.sw?
deploy/data
miniprogram
+14
View File
@@ -1,5 +1,19 @@
# 更新日志
## v4.2.8
- Bug 修复:修复管理后台邮件配置报“参数错误”问题
- 功能优化:手机端也不显示后端禁用的菜单功能页面
- 功能优化:**聊天页面支持 Sora2 文生视频/图生视频功能** 🔥🔥🔥
- 功能优化:**重构路由代码,优化路由加载逻辑,支持关闭手机站功能** 🔥🔥🔥
- Bug 修复:修复用户算力扣减日志记录逻辑错误问题
- 功能优化:优化 404 页面样式,兼容 PC 端和移动端
- Bug 修复:ipv6 地址登录报错:Data too long for column 'last_login_ip'
- Bug 修复:移动端登录页面输入密码的时候会覆盖在确认按钮上层
- 功能优化:**移动端的 DALL-E 绘图页面支持上传参考图** 🔥🔥🔥
- 功能优化:优化 WebFooter 组件,如果配置了备案号,则显示备案号,否则不显示
- 功能优化:启动时自动同步 model 与数据表字段,缺列自动新建、多余列自动删除
## v4.2.7
- Bug 修复:修复超级管理员无法修改密码的 Bug
+67 -82
View File
@@ -1,92 +1,77 @@
# GeekAI
> 根据[《生成式人工智能服务管理暂行办法》](https://www.cac.gov.cn/2023-07/13/c_1690898327029107.htm)的要求,请勿对中国地区公众提供一切未经备案的生成式人工智能服务。
# 🚀 GeekAI-PLUS:一站式 AI 创意生产力平台
**GeekAI** 基于 AI 大语言模型 API 实现的 AI 助手全套开源解决方案,自带运营管理后台,开箱即用。集成了 OpenAI, Claude, 通义千问,KimiDeepSeekGitee AI 等多个平台的大语言模型。集成了 MidJourney 和 Stable Diffusion AI绘画功能。
**重新定义 AI 创作体验,让每个人都能成为内容创作大师**
主要特性:
基于 GeekAI 项目开发的高级版,增加了很多高级功能,比如思维导图,Dalle 绘画等。**高级版源码不会一次性开放,只提供镜像给大家免费使用**,源码会逐步逐步按照版同步迁移到[社区版(GeekAI](https://github.com/yangjian102621/geekai)。所以如果大家想要二次开发,请移步去社区版。
- 完整的开源系统,前端应用和后台管理系统皆可开箱即用。
- 基于 Websocket 实现,完美的打字机体验。
- 内置了各种预训练好的角色应用,比如小红书写手,英语翻译大师,苏格拉底,孔子,乔布斯,周报助手等。轻松满足你的各种聊天和应用需求。
- 支持 OpenAI, Claude, 通义千问,KimiDeepSeek等多个大语言模型,**支持 Gitee AI Serverless 大模型 API**。
- 支持 Suno 文生音乐
- 支持 MidJourney / Stable Diffusion AI 绘画集成,文生图,图生图,换脸,融图。开箱即用。
- 支持使用个人微信二维码作为充值收费的支付渠道,无需企业支付通道。
- 已集成支付宝支付功能,微信支付,支持多种会员套餐和点卡购买功能。
- 集成插件 API 功能,可结合大语言模型的 function 功能开发各种强大的插件,已内置实现了微博热搜,今日头条,今日早报和 AI
绘画函数插件。
## ✨ 核心特色
### 🚀 更多功能请查看 [GeekAI-PLUS](https://github.com/yangjian102621/geekai-plus)
### 🎨 **全能 AI 创作矩阵**
- [x] 更友好的 UI 界面
- [x] 支持 Dall-E 文生图功能
- [x] 支持文生思维导图
- [x] 支持为模型绑定指定的 API KEY,支持为角色绑定指定的模型等功能
- [x] 支持网站 Logo 版权等信息的修改
- **智能对话**:集成 ChatGPT、Claude 等多款顶级 AI 模型,支持角色扮演和专业对话
- **图像生成**:整合 MidJourney、DALL-E、Stable Diffusion 三大主流 AI 绘画引擎
- **音频创作**:Suno AI 音乐生成,从旋律到歌词一键创作专属音乐
- **视频制作**Luma 和 KeLing,即梦,Veo3 视频 AI,文本到视频,创意无限
- **思维导图**:AI 辅助思维整理,复杂想法可视化呈现
### 🏗️ **企业级技术架构**
- **高性能后端**Go + Gin + MySQL + Redis,支持高并发访问
- **现代化前端**Vue3 + Element Plus + Vant,桌面移动双端适配
- **智能缓存**:多层缓存策略,响应速度提升 80%
- **弹性部署**:Docker 容器化部署,一键启动,轻松扩展
- **私有化部署**:支持私有化部署,私有化部署不支持升级,需要手动升级
- **文档支持**:丰富且详细的部署和 API 开发文档支持,二次开发轻松上手
### 💼 **商业化就绪**
- **完整用户系统**:注册登录、权限管理、积分充值
- **灵活计费模式**:支持按次付费、包月订阅等多种商业模式
- **数据统计分析**:用户行为、消费记录、系统性能全方位监控
- **管理后台**:功能完备的管理员界面,运营数据一目了然
### 🎯 **用户体验优势**
- **响应式设计**:完美适配桌面、平板、手机等全终端设备
- **暗黑模式**:支持明暗主题切换,护眼舒适
- **实时交互**:WebSocket 实时通信,创作过程流畅无卡顿
- **文件管理**:支持多种云存储,作品安全可靠
## 🎪 **应用场景**
- **内容创作者**:博客写作、社交媒体素材、短视频制作
- **企业营销**:品牌宣传材料、产品介绍、创意广告
- **教育培训**:课件制作、知识图谱、互动内容
- **个人娱乐**:AI 聊天、创意绘画、音乐创作
## 🔥 **为什么选择 GeekAI-PLUS**
1. **技术领先**:集成当前最先进的 AI 技术,始终保持创新前沿
2. **开箱即用**:完整的商业化解决方案,无需从零开发
3. **高度定制**:模块化架构设计,支持个性化功能扩展
4. **稳定可靠**:经过大量用户验证,性能稳定,安全可信
5. **持续更新**:紧跟 AI 技术发展,功能持续迭代升级
## 演示站点
[Geek-AI 创作系统](https://www.geekai.me)
## 文档地址
[Geek-AI 文档](https://www.geekai.me/docs/)
## 部署
1. 安装 docker 和 docker-compose 程序,这个自行解决。
2. 直接在项目根目录运行启动命令:
```shell
docker-compose up -d
```
## 功能截图
请参考 [GeekAI 项目介绍](https://docs.geekai.me/info/)。
### 体验地址
> 免费体验地址:[https://chat.geekai.me](https://chat.geekai.me) <br/>
> **注意:请合法使用,禁止输出任何敏感、不友好或违规的内容!!!**
## 快速部署
请参考文档 [**GeekAI 快速部署**](https://docs.geekai.me/install/)。
## 使用须知
1. 本项目基于 Apache2.0 协议,免费开放全部源代码,可以作为个人学习使用或者商用。
2. 如需商用必须保留版权信息,请自觉遵守。确保合法合规使用,在运营过程中产生的一切任何后果自负,与作者无关。
## 项目地址
* Github 地址:https://github.com/yangjian102621/geekai
* 码云地址:https://gitee.com/blackfox/geekai
## 客户端下载
目前已经支持 Win/Linux/Mac/Android 客户端,下载地址为:https://github.com/yangjian102621/geekai/releases/tag/v3.1.2
## TODOLIST
* [ ] 支持基于知识库的 AI 问答
* [ ] 文生视频,文生歌曲功能
* [ ] 微信支付功能
## 项目文档
最新的部署视频教程:[https://www.bilibili.com/video/BV1Cc411t7CX/](https://www.bilibili.com/video/BV1Cc411t7CX/)
详细的部署和开发文档请参考 [**GeekAI 文档**](https://docs.geekai.me)。
加微信进入微信讨论群可获取 **一键部署脚本(添加好友时请注明来自Github!!!)。**
![微信名片](docs/imgs/wx.png)
## 参与贡献
个人的力量始终有限,任何形式的贡献都是欢迎的,包括但不限于贡献代码,优化文档,提交 issue 和 PR 等。
#### 特此声明:由于个人时间有限,不接受在微信或者微信群给开发者提 Bug,有问题或者优化建议请提交 Issue 和 PR。非常感谢您的配合!
### Commit 类型
* feat: 新特性或功能
* fix: 缺陷修复
* docs: 文档更新
* style: 代码风格或者组件样式更新
* refactor: 代码重构,不引入新功能和缺陷修复
* opt: 性能优化
* chore: 一些不涉及到功能变动的小提交,比如修改文字表述,修改注释等
## 打赏
如果你觉得这个项目对你有帮助,并且情况允许的话,可以请作者喝杯咖啡,非常感谢你的支持~
![打赏](docs/imgs/donate.png)
![Star History Chart](https://api.star-history.com/svg?repos=yangjian102621/geekai&type=Date)
---
_让 AI 成为你最强大的创作伙伴,开启无限创意可能!_
+1
View File
@@ -16,6 +16,7 @@ tmp
bin
data
config.toml
config.toml.*.bak
static/upload
static/audio
storage.json
+1 -1
View File
@@ -1,6 +1,6 @@
Listen = "0.0.0.0:5678"
ProxyURL = "" # 如 http://127.0.0.1:7777
MysqlDns = "root:12345678@tcp(172.22.11.200:3307)/chatgpt_plus?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local"
MysqlDns = "root:12345678@tcp(localhost:3306)/geekai?charset=utf8mb4&collation=utf8mb4_unicode_ci&parseTime=True&loc=Local"
StaticDir = "./static" # 静态资源的目录
StaticUrl = "/static" # 静态资源访问 URL
TikaHost = "http://tika:9998"
+1 -1
View File
@@ -44,11 +44,11 @@ func NewServer(appConfig *types.AppConfig, redis *redis.Client, sysConfig *types
}
func (s *AppServer) Init(client *redis.Client) {
s.Engine.Use(middleware.ThumbMiddleware())
s.Engine.Use(middleware.ParameterHandlerMiddleware())
s.Engine.Use(errorHandler)
// 添加静态资源访问
s.Engine.Static("/static", s.Config.StaticDir)
s.Engine.Use(middleware.StaticMiddleware())
}
func (s *AppServer) Run(db *gorm.DB) error {
-10
View File
@@ -85,15 +85,6 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
logger.Error("load system config error: ", err)
}
// 加载许可证配置
var license types.License
sysConfig.Id = 0
db.Where("name", types.ConfigKeyLicense).First(&sysConfig)
err = utils.JsonDecode(sysConfig.Value, &license)
if err != nil {
logger.Error("load license config error: ", err)
}
// 加载验证码配置
var captchaConfig types.CaptchaConfig
sysConfig.Id = 0
@@ -168,7 +159,6 @@ func LoadSystemConfig(db *gorm.DB) *types.SystemConfig {
return &types.SystemConfig{
Base: baseConfig,
License: license,
SMS: smsConfig,
OSS: ossConfig,
SMTP: smtpConfig,
+3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"geekai/core/types"
logger2 "geekai/logger"
"geekai/utils"
"geekai/utils/resp"
"time"
@@ -13,6 +14,8 @@ import (
"github.com/golang-jwt/jwt"
)
var logger = logger2.GetLogger()
// 前端用户授权验证
func UserAuthMiddleware(secretKey string, redis *redis.Client) gin.HandlerFunc {
return func(c *gin.Context) {
@@ -14,8 +14,8 @@ import (
"golang.org/x/image/webp"
)
// 静态资源中间件
func StaticMiddleware() gin.HandlerFunc {
// 缩略图中间件
func ThumbMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
url := c.Request.URL.String()
+2 -18
View File
@@ -37,22 +37,6 @@ type RedisConfig struct {
DB int
}
// LicenseKey 存储许可证书的 KEY
const LicenseKey = "Geek-AI-License"
type License struct {
Key string `json:"key"` // 许可证书密钥
MachineId string `json:"machine_id"` // 机器码
ExpiredAt int64 `json:"expired_at"` // 过期时间
IsActive bool `json:"is_active"` // 是否激活
Configs LicenseConfig `json:"configs"`
}
type LicenseConfig struct {
UserNum int `json:"user_num"` // 用户数量
DeCopy bool `json:"de_copy"` // 去版权
}
func (c RedisConfig) Url() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
@@ -97,6 +81,8 @@ type BaseConfig struct {
EmailWhiteList []string `json:"email_white_list"` // 邮箱白名单列表
AssistantModelId int `json:"assistant_model_id"` // 用来做提示词,翻译的AI模型 id
MaxFileSize int `json:"max_file_size"` // 最大文件大小,单位:MB
EnableMobileSite bool `json:"enable_mobile_site"` // 是否开启手机站点
}
type SystemConfig struct {
@@ -108,7 +94,6 @@ type SystemConfig struct {
Captcha CaptchaConfig
WxLogin WxLoginConfig
Jimeng JimengConfig
License License
Moderation ModerationConfig
}
@@ -121,7 +106,6 @@ const (
ConfigKeyMarkMap = "mark_map"
ConfigKeyCaptcha = "captcha"
ConfigKeyWxLogin = "wx_login"
ConfigKeyLicense = "license"
ConfigKeySms = "sms"
ConfigKeySmtp = "smtp"
ConfigKeyOss = "oss"
+2 -1
View File
@@ -15,7 +15,8 @@ type JimengConfig struct {
type JMTaskStatus string
const (
JMTaskStatusInQueue = JMTaskStatus("in_queue") // 任务已提交
JMTaskStatusSubmited = JMTaskStatus("submited") // 任务已提交
JMTaskStatusInQueue = JMTaskStatus("in_queue") // 任务排队中
JMTaskStatusGenerating = JMTaskStatus("generating") // 任务处理中
JMTaskStatusDone = JMTaskStatus("done") // 处理完成
JMTaskStatusNotFound = JMTaskStatus("not_found") // 任务未找到
+2 -3
View File
@@ -72,14 +72,13 @@ type SdTaskParams struct {
type DallTask struct {
ModelId uint `json:"model_id"`
ModelName string `json:"model_name"`
ModelValue string `json:"model_value"`
Image []string `json:"image,omitempty"`
Id uint `json:"id"`
UserId uint `json:"user_id"`
Prompt string `json:"prompt"`
N int `json:"n"`
Quality string `json:"quality"`
AspectRatio string `json:"aspect_ratio"`
Size string `json:"size"`
Style string `json:"style"`
Power int `json:"power"`
TranslateModelId int `json:"translate_model_id"` // 提示词翻译模型ID
}
-51
View File
@@ -26,7 +26,6 @@ import (
type ConfigHandler struct {
handler.BaseHandler
licenseService *service.LicenseService
sysConfig *types.SystemConfig
alipayService *payment.AlipayService
wxpayService *payment.WxPayService
@@ -41,7 +40,6 @@ type ConfigHandler struct {
func NewConfigHandler(
app *core.AppServer,
db *gorm.DB,
licenseService *service.LicenseService,
sysConfig *types.SystemConfig,
alipayService *payment.AlipayService,
wxpayService *payment.WxPayService,
@@ -54,7 +52,6 @@ func NewConfigHandler(
) *ConfigHandler {
return &ConfigHandler{
BaseHandler: handler.BaseHandler{App: app, DB: db},
licenseService: licenseService,
sysConfig: sysConfig,
alipayService: alipayService,
wxpayService: wxpayService,
@@ -87,8 +84,6 @@ func (h *ConfigHandler) RegisterRoutes() {
rg.POST("update/oss", h.UpdateOss)
rg.POST("update/smtp", h.UpdateStmp)
rg.GET("get", h.Get)
rg.POST("license/active", h.Active)
rg.GET("license/get", h.GetLicense)
}
}
@@ -101,19 +96,6 @@ func (h *ConfigHandler) UpdateBase(c *gin.Context) {
return
}
// 未授权的话不允许修改版权
license := h.licenseService.GetLicense()
if !license.IsActive && data.Copyright != h.sysConfig.Base.Copyright {
resp.ERROR(c, "未授权系统不允许修改版权信息")
return
}
// 未授权的话不允许修改 Logo
if !license.IsActive && data.Logo != h.sysConfig.Base.Logo {
resp.ERROR(c, "未授权系统不允许修改 Logo")
return
}
err := h.Update(types.ConfigKeySystem, data)
if err != nil {
resp.ERROR(c, err.Error())
@@ -407,36 +389,3 @@ func (h *ConfigHandler) Get(c *gin.Context) {
resp.SUCCESS(c, value)
}
// Active 激活系统
func (h *ConfigHandler) Active(c *gin.Context) {
var data struct {
License string `json:"license"`
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
err := h.licenseService.ActiveLicense(data.License)
license := h.licenseService.GetLicense()
if err != nil {
resp.ERROR(c, err.Error())
return
}
if err := h.Update(types.ConfigKeyLicense, license); err != nil {
resp.ERROR(c, err.Error())
return
}
// 更新系统配置
h.sysConfig.License = *license
resp.SUCCESS(c, license.MachineId)
}
// GetLicense 获取 License 信息
func (h *ConfigHandler) GetLicense(c *gin.Context) {
license := h.licenseService.GetLicense()
resp.SUCCESS(c, license)
}
+4 -17
View File
@@ -13,7 +13,6 @@ import (
"geekai/core/middleware"
"geekai/core/types"
"geekai/handler"
"geekai/service"
"geekai/store/model"
"geekai/store/vo"
"geekai/utils"
@@ -28,12 +27,11 @@ import (
type UserHandler struct {
handler.BaseHandler
licenseService *service.LicenseService
redis *redis.Client
redis *redis.Client
}
func NewUserHandler(app *core.AppServer, db *gorm.DB, licenseService *service.LicenseService, redisCli *redis.Client) *UserHandler {
return &UserHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, licenseService: licenseService, redis: redisCli}
func NewUserHandler(app *core.AppServer, db *gorm.DB, redisCli *redis.Client) *UserHandler {
return &UserHandler{BaseHandler: handler.BaseHandler{App: app, DB: db}, redis: redisCli}
}
// RegisterRoutes 注册路由
@@ -114,13 +112,6 @@ func (h *UserHandler) Save(c *gin.Context) {
resp.ERROR(c, types.InvalidArgs)
return
}
// 检测最大注册人数
var totalUser int64
h.DB.Model(&model.User{}).Count(&totalUser)
if h.licenseService.GetLicense().Configs.UserNum > 0 && int(totalUser) >= h.licenseService.GetLicense().Configs.UserNum {
resp.ERROR(c, "当前注册用户数已达上限,请请升级 License")
return
}
var user = model.User{}
var res *gorm.DB
var userVo vo.User
@@ -198,11 +189,7 @@ func (h *UserHandler) Save(c *gin.Context) {
ChatModels: utils.JsonEncode(data.ChatModels),
ExpiredTime: utils.Str2stamp(data.ExpiredTime),
}
if h.licenseService.GetLicense().Configs.DeCopy {
u.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
} else {
u.Nickname = fmt.Sprintf("极客学长@%d", utils.RandomNumber(6))
}
u.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
res = h.DB.Create(&u)
_ = utils.CopyObject(u, &userVo)
userVo.Id = u.Id
+54 -24
View File
@@ -19,6 +19,7 @@ import (
"geekai/service"
"geekai/service/moderation"
"geekai/service/oss"
"geekai/service/sora"
"geekai/store/model"
"geekai/store/vo"
"geekai/utils"
@@ -65,36 +66,39 @@ type ChatHandler struct {
BaseHandler
redis *redis.Client
uploadManager *oss.UploaderManager
licenseService *service.LicenseService
ReqCancelFunc *types.LMap[string, context.CancelFunc] // HttpClient 请求取消 handle function
userService *service.UserService
moderationManager *moderation.ServiceManager
userLocks *types.UserLockManager
soraService *sora.SoraService
}
func NewChatHandler(app *core.AppServer, db *gorm.DB, redis *redis.Client, manager *oss.UploaderManager, licenseService *service.LicenseService, userService *service.UserService, moderationManager *moderation.ServiceManager) *ChatHandler {
func NewChatHandler(app *core.AppServer,
db *gorm.DB,
redis *redis.Client,
manager *oss.UploaderManager,
userService *service.UserService,
soraService *sora.SoraService,
moderationManager *moderation.ServiceManager) *ChatHandler {
return &ChatHandler{
BaseHandler: BaseHandler{App: app, DB: db},
redis: redis,
uploadManager: manager,
licenseService: licenseService,
ReqCancelFunc: types.NewLMap[string, context.CancelFunc](),
userService: userService,
moderationManager: moderationManager,
userLocks: types.NewUserLockManager(),
soraService: soraService,
}
}
// RegisterRoutes 注册路由
func (h *ChatHandler) RegisterRoutes() {
group := h.App.Engine.Group("/api/chat/")
// 聊天接口不需要授权(已在authConfig中配置)
group.Any("message", h.Chat)
// 其他接口需要用户授权
group.Use(middleware.UserAuthMiddleware(h.App.Config.Session.SecretKey, h.App.Redis))
{
group.Any("message", h.Chat)
group.GET("list", h.List)
group.GET("detail", h.Detail)
group.POST("update", h.Update)
@@ -340,14 +344,14 @@ func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.C
for _, file := range input.Files {
logger.Debugf("detected file: %+v", file.URL)
// 处理图片
if isImageURL(file.URL) {
if isImageURL(file.URL) || isVideoURL(file.URL) {
imgList = append(imgList, gin.H{
"type": "image_url",
"image_url": gin.H{
"url": file.URL,
},
})
} else {
} else if isTextURL(file.URL) {
// 处理文件,提取文件内容
content, err := utils.ReadFileContent(file.URL, h.App.Config.TikaHost)
if err != nil {
@@ -408,23 +412,43 @@ func isImageURL(url string) bool {
".svg": true,
".ico": true,
}
return validImageExts[ext]
}
if !validImageExts[ext] {
// 判断是个链接是否是文本链接
func isTextURL(url string) bool {
// 检查是否是有效的URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return false
}
// 发送HEAD请求检查Content-Type
client := &http.Client{
Timeout: 5 * time.Second,
// 检查文件扩展名
ext := strings.ToLower(path.Ext(url))
validTextExts := map[string]bool{
".txt": true,
".doc": true,
".docx": true,
".pdf": true,
}
resp, err := client.Head(url)
if err != nil {
return validTextExts[ext]
}
// 判断是个链接是否是视频
func isVideoURL(url string) bool {
// 检查是否是有效的URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return false
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
return strings.HasPrefix(contentType, "image/")
// 检查文件扩展名
ext := strings.ToLower(path.Ext(url))
validVideoExts := map[string]bool{
".mp4": true,
".avi": true,
".mov": true,
".wmv": true,
}
return validVideoExts[ext]
}
// Tokens 统计 token 数量
@@ -505,11 +529,6 @@ func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, input
return nil, errors.New("no available key, please import key")
}
// ONLY allow apiURL in blank list
err := h.licenseService.IsValidApiURL(apiKey.ApiURL)
if err != nil {
return nil, err
}
logger.Debugf("对话请求消息体:%+v", req)
var apiURL string
p, _ := url.Parse(apiKey.ApiURL)
@@ -644,6 +663,16 @@ func (h *ChatHandler) saveChatHistory(
replyTokens, _ = utils.CalcTokens(message.Content, req.Model)
totalTokens = replyTokens + getTotalTokens(req)
}
// 如果是 Sora 相关模型,则下载视频
files := make([]vo.File, 0)
if strings.HasPrefix(req.Model, "sora") {
video, err := h.soraService.DownloadVideoURL(message.Content)
if err == nil {
files = append(files, *video)
}
}
historyReplyMsg := model.ChatMessage{
UserId: userVo.Id,
ChatId: input.ChatId,
@@ -652,7 +681,7 @@ func (h *ChatHandler) saveChatHistory(
Icon: input.ChatRole.Icon,
Content: utils.JsonEncode(vo.MsgContent{
Text: message.Content,
Files: input.Files,
Files: files,
}),
Tokens: replyTokens,
TotalTokens: totalTokens,
@@ -676,6 +705,7 @@ func (h *ChatHandler) saveChatHistory(
if err != nil {
content.Text = historyReplyMsg.Content
}
content.Files = files
messageVo.Content = content
messageVo.CreatedAt = historyReplyMsg.CreatedAt.Unix()
messageVo.UpdatedAt = historyReplyMsg.UpdatedAt.Unix()
+8
View File
@@ -212,6 +212,14 @@ func (h *ChatHandler) sendOpenAiMessage(
} else {
errMsg = utils.InterfaceToString(apiRes.Data)
contents = append(contents, errMsg)
// 扣减算力
if function.Power > 0 {
h.userService.DecreasePower(userVo.Id, function.Power, model.PowerLog{
Type: types.PowerConsume,
Model: function.Name,
Remark: fmt.Sprintf("函数调用,%s(%s)", function.Label, function.Name),
})
}
}
pushMessage(c, "text", errMsg)
}
+2 -11
View File
@@ -9,7 +9,6 @@ package handler
import (
"geekai/core"
"geekai/service"
"geekai/store/model"
"geekai/utils"
"geekai/utils/resp"
@@ -20,11 +19,10 @@ import (
type ConfigHandler struct {
BaseHandler
licenseService *service.LicenseService
}
func NewConfigHandler(app *core.AppServer, db *gorm.DB, licenseService *service.LicenseService) *ConfigHandler {
return &ConfigHandler{BaseHandler: BaseHandler{App: app, DB: db}, licenseService: licenseService}
func NewConfigHandler(app *core.AppServer, db *gorm.DB) *ConfigHandler {
return &ConfigHandler{BaseHandler: BaseHandler{App: app, DB: db}}
}
// RegisterRoutes 注册路由
@@ -33,7 +31,6 @@ func (h *ConfigHandler) RegisterRoutes() {
// 无需授权的接口
group.GET("get", h.Get)
group.GET("license", h.License)
}
// Get 获取指定的系统配置
@@ -55,9 +52,3 @@ func (h *ConfigHandler) Get(c *gin.Context) {
resp.SUCCESS(c, value)
}
// License 获取 License 配置
func (h *ConfigHandler) License(c *gin.Context) {
license := h.licenseService.GetLicense()
resp.SUCCESS(c, license.Configs)
}
+4 -4
View File
@@ -117,12 +117,12 @@ func (h *DallJobHandler) Image(c *gin.Context) {
task := types.DallTask{
UserId: uint(userId),
ModelId: chatModel.Id,
ModelName: chatModel.Value,
ModelName: chatModel.Name,
ModelValue: chatModel.Value,
Image: data.Image,
Prompt: data.Prompt,
Quality: data.Quality,
AspectRatio: data.AspectRatio,
Size: data.Size,
Style: data.Style,
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
Power: chatModel.Power,
}
@@ -144,7 +144,7 @@ func (h *DallJobHandler) Image(c *gin.Context) {
// 扣减算力
err = h.userService.DecreasePower(user.Id, chatModel.Power, model.PowerLog{
Type: types.PowerConsume,
Model: chatModel.Value,
Model: chatModel.Name,
Remark: fmt.Sprintf("绘画提示词:%s", utils.CutWords(task.Prompt, 10)),
})
if err != nil {
+2 -5
View File
@@ -215,12 +215,9 @@ func (h *FunctionHandler) Dall3(c *gin.Context) {
UserId: user.Id,
Prompt: prompt,
ModelId: chatModel.Id,
ModelName: chatModel.Value,
ModelValue: chatModel.Value,
ModelName: chatModel.Name,
TranslateModelId: h.App.SysConfig.Base.AssistantModelId,
N: 1,
Quality: "standard",
Size: "1024x1024",
Style: "vivid",
Power: chatModel.Power,
}
job := model.DallJob{
+1 -19
View File
@@ -247,30 +247,12 @@ func (h *JimengHandler) Remove(c *gin.Context) {
return
}
tx := h.DB.Begin()
if err := tx.Where("id = ? AND user_id = ?", jobId, user.Id).Delete(&model.JimengJob{}).Error; err != nil {
if err := h.DB.Where("id = ? AND user_id = ?", jobId, user.Id).Delete(&model.JimengJob{}).Error; err != nil {
logger.Errorf("delete jimeng job failed: %v", err)
resp.ERROR(c, "删除任务失败")
return
}
// 失败任务删除后退回算力
if job.Status == types.JMTaskStatusFailed {
logger.Infof("delete jimeng job failed, refund power: %d", job.Power)
err = h.userService.IncreasePower(user.Id, job.Power, model.PowerLog{
Type: types.PowerRefund,
Model: job.ReqKey,
Remark: fmt.Sprintf("删除任务,退回%d算力", job.Power),
})
if err != nil {
resp.ERROR(c, "退回算力失败")
tx.Rollback()
return
}
}
tx.Commit()
resp.SUCCESS(c, gin.H{})
}
+18
View File
@@ -30,6 +30,7 @@ func NewMenuHandler(app *core.AppServer, db *gorm.DB) *MenuHandler {
func (h *MenuHandler) RegisterRoutes() {
group := h.App.Engine.Group("/api/menu/")
group.GET("list", h.List)
group.GET("list/all", h.ListAll)
}
// List 数据列表
@@ -54,3 +55,20 @@ func (h *MenuHandler) List(c *gin.Context) {
}
resp.SUCCESS(c, list)
}
// ListAll 获取所有的菜单列表
func (h *MenuHandler) ListAll(c *gin.Context) {
var items []model.Menu
var list = make([]vo.Menu, 0)
res := h.DB.Order("sort_num ASC").Find(&items)
if res.Error == nil {
for _, item := range items {
var product vo.Menu
err := utils.CopyObject(item, &product)
if err == nil {
list = append(list, product)
}
}
}
resp.SUCCESS(c, list)
}
+1
View File
@@ -138,6 +138,7 @@ func (h *PaymentHandler) SyncOrders() error {
}
case payment.PayChannelAL:
res, err = h.alipayService.Query(order.OrderNo)
logger.Debugf("支付宝支付订单状态:%+v", res)
if err != nil {
logger.Errorf("error with query order info: %v", err)
continue
+9 -17
View File
@@ -35,7 +35,6 @@ type UserHandler struct {
searcher *xdb.Searcher
redis *redis.Client
levelDB *store.LevelDB
licenseService *service.LicenseService
captchaService *service.CaptchaService
userService *service.UserService
wxLoginService *service.WxLoginService
@@ -51,15 +50,13 @@ func NewUserHandler(
captcha *service.CaptchaService,
userService *service.UserService,
wxLoginService *service.WxLoginService,
ipSearcher *xdb.Searcher,
licenseService *service.LicenseService) *UserHandler {
ipSearcher *xdb.Searcher) *UserHandler {
return &UserHandler{
BaseHandler: BaseHandler{DB: db, App: app},
searcher: searcher,
redis: client,
levelDB: levelDB,
captchaService: captcha,
licenseService: licenseService,
userService: userService,
wxLoginService: wxLoginService,
ipSearcher: ipSearcher,
@@ -132,14 +129,6 @@ func (h *UserHandler) Register(c *gin.Context) {
return
}
// 检测最大注册人数
var totalUser int64
h.DB.Model(&model.User{}).Count(&totalUser)
if h.licenseService.GetLicense().Configs.UserNum > 0 && int(totalUser) >= h.licenseService.GetLicense().Configs.UserNum {
resp.ERROR(c, "当前注册用户数已达上限,请请升级 License")
return
}
// 检查验证码
var key string
if data.RegWay == "email" {
@@ -327,7 +316,7 @@ func (h *UserHandler) GetWxLoginState(c *gin.Context) {
}
// createNewUser 创建新用户
func (h *UserHandler) createNewUser(user model.User, inviteCode string) (model.User, error) {
func (h *UserHandler) createNewUser(user model.User, code string) (model.User, error) {
if user.OpenId != "" {
user.Platform = "wechat"
user.Nickname = fmt.Sprintf("微信用户@%d", utils.RandomNumber(6))
@@ -353,19 +342,22 @@ func (h *UserHandler) createNewUser(user model.User, inviteCode string) (model.U
// 创建用户
tx := h.DB.Begin()
if err := tx.Create(&user).Error; err != nil {
tx.Rollback()
return user, err
}
// 记录邀请关系
if inviteCode != "" {
inviteCode := model.InviteCode{}
err := h.DB.Where("code = ?", inviteCode).First(&inviteCode).Error
if code != "" {
var inviteCode model.InviteCode
err := h.DB.Where("code = ?", code).First(&inviteCode).Error
if err != nil {
logger.Error("无效的邀请码:", err.Error())
tx.Rollback()
return user, fmt.Errorf("无效的邀请码")
}
// 增加邀请数量
h.DB.Model(&model.InviteCode{}).Where("code = ?", inviteCode).UpdateColumn("reg_num", gorm.Expr("reg_num + ?", 1))
h.DB.Model(&model.InviteCode{}).Where("code = ?", code).UpdateColumn("reg_num", gorm.Expr("reg_num + ?", 1))
if h.App.SysConfig.Base.InvitePower > 0 {
err := h.userService.IncreasePower(inviteCode.UserId, h.App.SysConfig.Base.InvitePower, model.PowerLog{
Type: types.PowerInvite,
+6 -12
View File
@@ -24,6 +24,7 @@ import (
"geekai/service/payment"
"geekai/service/sd"
"geekai/service/sms"
"geekai/service/sora"
"geekai/service/suno"
"geekai/service/video"
"geekai/store"
@@ -166,12 +167,6 @@ func main() {
// 邮件服务
fx.Provide(service.NewSmtpService),
// License 服务
fx.Provide(service.NewLicenseService),
fx.Invoke(func(licenseService *service.LicenseService) {
licenseService.SyncLicense()
}),
// Dalle 服务
fx.Provide(dalle.NewService),
fx.Invoke(func(s *dalle.Service) {
@@ -189,6 +184,9 @@ func main() {
s.DownloadImages()
}),
// Sora service
fx.Provide(sora.NewSoraService),
// Stable Diffusion 机器人
fx.Provide(sd.NewService),
fx.Invoke(func(s *sd.Service, config *types.AppConfig) {
@@ -222,12 +220,8 @@ func main() {
fx.Provide(sms.NewAliYunSmsService),
fx.Provide(sms.NewBaoSmsService),
fx.Provide(sms.NewSmsManager),
fx.Provide(func(config *types.SystemConfig) *service.CaptchaService {
return service.NewCaptchaService(config.Captcha)
}),
fx.Provide(func(config *types.SystemConfig, client *redis.Client) *service.WxLoginService {
return service.NewWxLoginService(config.WxLogin, client)
}),
fx.Provide(service.NewCaptchaService),
fx.Provide(service.NewWxLoginService),
// 支付服务
fx.Provide(payment.NewAlipayService),
+3 -3
View File
@@ -20,9 +20,9 @@ type CaptchaService struct {
client *req.Client
}
func NewCaptchaService(captchaConfig types.CaptchaConfig) *CaptchaService {
func NewCaptchaService(sysConfig *types.SystemConfig) *CaptchaService {
return &CaptchaService{
config: captchaConfig,
config: sysConfig.Captcha,
client: req.C().SetTimeout(10 * time.Second),
}
}
@@ -35,7 +35,7 @@ func (s *CaptchaService) GetConfig() types.CaptchaConfig {
return s.config
}
func (s *CaptchaService) Get() (interface{}, error) {
func (s *CaptchaService) Get() (any, error) {
url := fmt.Sprintf("%s/api/captcha/get", types.GeekAPIURL)
var res types.BizVo
r, err := s.client.R().
@@ -16,7 +16,6 @@ import (
"geekai/store"
"geekai/store/model"
"geekai/utils"
"strings"
"time"
"github.com/go-redis/redis/v8"
@@ -98,10 +97,8 @@ type imgReq struct {
Model string `json:"model"`
Image []string `json:"image,omitempty"`
Prompt string `json:"prompt"`
N int `json:"n,omitempty"`
AspectRatio string `json:"aspect_ratio,omitempty"`
Size string `json:"size,omitempty"`
Quality string `json:"quality,omitempty"`
Style string `json:"style,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
}
@@ -116,10 +113,10 @@ type imgRes struct {
type ErrRes struct {
Error struct {
Code interface{} `json:"code"`
Message string `json:"message"`
Param interface{} `json:"param"`
Type string `json:"type"`
Code any `json:"code"`
Message string `json:"message"`
Param any `json:"param"`
Type string `json:"type"`
} `json:"error"`
}
@@ -130,7 +127,7 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
if task.ModelId > 0 {
s.db.Where("id", task.ModelId).First(&chatModel)
} else {
s.db.Where("value", task.ModelName).First(&chatModel)
s.db.Where("value", task.ModelValue).First(&chatModel)
}
// get image generation API KEY
@@ -153,16 +150,16 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
}
apiURL := fmt.Sprintf("%s/v1/images/generations", apiKey.ApiURL)
reqBody := imgReq{
Model: chatModel.Value,
Prompt: task.Prompt,
N: 1,
Size: task.Size,
Style: task.Style,
Quality: task.Quality,
Model: chatModel.Value,
Prompt: task.Prompt,
AspectRatio: task.AspectRatio,
Size: task.Size,
ResponseFormat: "url",
}
// 图片编辑
if len(task.Image) > 0 {
reqBody.Prompt = fmt.Sprintf("%s, %s", strings.Join(task.Image, " "), task.Prompt)
reqBody.Image = task.Image
}
logger.Infof("Channel:%s, API KEY:%s, BODY: %+v", apiURL, apiKey.Value, reqBody)
@@ -185,7 +182,7 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
// update the api key last use time
s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
var imgURL string
var data = map[string]interface{}{
var data = map[string]any{
"progress": 100,
"prompt": task.Prompt,
}
+41 -24
View File
@@ -11,6 +11,7 @@ import (
"geekai/core/types"
logger2 "geekai/logger"
"geekai/service"
"geekai/service/oss"
"geekai/store"
"geekai/store/model"
@@ -23,29 +24,31 @@ var logger = logger2.GetLogger()
// Service 即梦服务(合并了消费者功能)
type Service struct {
db *gorm.DB
redis *redis.Client
taskQueue *store.RedisQueue
client *Client
ctx context.Context
cancel context.CancelFunc
running bool
uploader *oss.UploaderManager
db *gorm.DB
redis *redis.Client
taskQueue *store.RedisQueue
client *Client
ctx context.Context
cancel context.CancelFunc
running bool
uploader *oss.UploaderManager
userService *service.UserService
}
// NewService 创建即梦服务
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager, client *Client) *Service {
func NewService(db *gorm.DB, redisCli *redis.Client, uploader *oss.UploaderManager, client *Client, userService *service.UserService) *Service {
taskQueue := store.NewRedisQueue("JimengTaskQueue", redisCli)
ctx, cancel := context.WithCancel(context.Background())
return &Service{
db: db,
redis: redisCli,
taskQueue: taskQueue,
client: client,
ctx: ctx,
cancel: cancel,
running: false,
uploader: uploader,
db: db,
redis: redisCli,
taskQueue: taskQueue,
client: client,
ctx: ctx,
cancel: cancel,
running: false,
uploader: uploader,
userService: userService,
}
}
@@ -115,7 +118,7 @@ func (s *Service) CreateTask(userId uint, req *types.JimengTaskRequest) (*model.
ReqKey: req.ReqKey,
Prompt: req.Prompt,
Params: utils.JsonEncode(req),
Status: types.JMTaskStatusInQueue,
Status: types.JMTaskStatusSubmited,
Power: req.Power,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
@@ -216,6 +219,7 @@ func (s *Service) ProcessTask(jobId uint) error {
if err := s.db.Model(&model.JimengJob{}).Where("id = ?", job.Id).Updates(map[string]any{
"task_id": resp.Data.TaskId,
"raw_data": string(rawData),
"status": types.JMTaskStatusInQueue, // 把任务状态改成排队中,以便开启轮询
"updated_at": time.Now(),
}).Error; err != nil {
logger.Errorf("update jimeng job task_id failed: %v", err)
@@ -277,12 +281,10 @@ func (s *Service) pollTaskStatus() {
for {
var jobs []model.JimengJob
s.db.Where("status IN (?)", []types.JMTaskStatus{types.JMTaskStatusGenerating, types.JMTaskStatusInQueue}).Find(&jobs)
if len(jobs) == 0 {
logger.Debugf("no jimeng task to poll, sleep 10s")
time.Sleep(10 * time.Second)
continue
}
// 找出排队中和处理中的任务进行轮询
s.db.Where("status IN (?)", []types.JMTaskStatus{
types.JMTaskStatusGenerating,
types.JMTaskStatusInQueue}).Find(&jobs)
for _, job := range jobs {
// 任务超时处理
@@ -366,6 +368,21 @@ 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),
})
if err != nil {
continue
}
// 更新任务状态
s.db.Model(&job).UpdateColumn("power", 0)
}
time.Sleep(5 * time.Second)
}
-224
View File
@@ -1,224 +0,0 @@
package service
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * Copyright 2023 The Geek-AI Authors. All rights reserved.
// * Use of this source code is governed by a Apache-2.0 license
// * that can be found in the LICENSE file.
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import (
"errors"
"fmt"
"geekai/core/types"
"geekai/store/model"
"geekai/utils"
"strings"
"time"
"github.com/imroc/req/v3"
"github.com/shirou/gopsutil/host"
"gorm.io/gorm"
)
type LicenseService struct {
license *types.License
urlWhiteList []string
machineId string
db *gorm.DB
}
func NewLicenseService(sysConfig *types.SystemConfig, db *gorm.DB) *LicenseService {
var machineId string
info, err := host.Info()
if err == nil {
machineId = info.HostID
}
logger.Infof("License: %+v", sysConfig.License)
return &LicenseService{
license: &sysConfig.License,
machineId: machineId,
db: db,
}
}
type License struct {
Name string `json:"name"`
License string `json:"license"`
MachineId string `json:"mid"`
ActiveAt int64 `json:"active_at"`
ExpiredAt int64 `json:"expired_at"`
UserNum int `json:"user_num"`
Configs types.LicenseConfig `json:"configs"`
}
// ActiveLicense 激活 License
func (s *LicenseService) ActiveLicense(license string) error {
var res struct {
Code types.BizCode `json:"code"`
Message string `json:"message"`
Data License `json:"data"`
}
apiURL := fmt.Sprintf("%s/%s", types.GeekAPIURL, "api/license/active")
response, err := req.C().R().
SetBody(map[string]string{"license": license, "machine_id": s.machineId}).
SetSuccessResult(&res).Post(apiURL)
if err != nil {
return fmt.Errorf("发送激活请求失败: %v", err)
}
if response.IsErrorState() {
return fmt.Errorf("发送激活请求失败:%v", response.Status)
}
if res.Code != types.Success {
return fmt.Errorf("激活失败:%v", res.Message)
}
if res.Data.ExpiredAt > 0 && res.Data.ExpiredAt < time.Now().Unix() {
return fmt.Errorf("License 已过期")
}
s.license = &types.License{
Key: license,
MachineId: s.machineId,
Configs: res.Data.Configs,
ExpiredAt: res.Data.ExpiredAt,
IsActive: true,
}
// 保存 License 到数据库
err = s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyLicense).UpdateColumn("value", utils.JsonEncode(s.license)).Error
if err != nil {
return fmt.Errorf("保存 License 到数据库失败: %v", err)
}
return nil
}
// SyncLicense 定期同步 License
func (s *LicenseService) SyncLicense() {
go func() {
retryCounter := 0
for {
license, err := s.fetchLicense()
if err != nil {
retryCounter++
if retryCounter < 5 {
logger.Debug(err)
}
s.license.IsActive = false
} else {
s.license = license
// 保存 License 到数据库
err = s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyLicense).UpdateColumn("value", utils.JsonEncode(s.license)).Error
if err != nil {
logger.Errorf("保存 License 到数据库失败: %v", err)
}
}
urls, err := s.fetchUrlWhiteList()
if err == nil {
s.urlWhiteList = urls
}
time.Sleep(time.Second * 10)
}
}()
}
func (s *LicenseService) fetchLicense() (*types.License, error) {
var res struct {
Code types.BizCode `json:"code"`
Message string `json:"message"`
Data License `json:"data"`
}
apiURL := fmt.Sprintf("%s/%s", types.GeekAPIURL, "api/license/check")
response, err := req.C().R().
SetBody(map[string]string{"license": s.license.Key, "machine_id": s.machineId}).
SetSuccessResult(&res).Post(apiURL)
if err != nil {
return nil, fmt.Errorf("License 同步失败: %v", err)
}
if response.IsErrorState() {
return nil, fmt.Errorf("License 同步失败:%v", response.Status)
}
if res.Code != types.Success {
return nil, fmt.Errorf("License 同步失败:%v", res.Message)
}
return &types.License{
Key: res.Data.License,
MachineId: res.Data.MachineId,
Configs: res.Data.Configs,
ExpiredAt: res.Data.ExpiredAt,
IsActive: true,
}, nil
}
func (s *LicenseService) fetchUrlWhiteList() ([]string, error) {
var res struct {
Code types.BizCode `json:"code"`
Message string `json:"message"`
Data []string `json:"data"`
}
apiURL := fmt.Sprintf("%s/%s", types.GeekAPIURL, "api/license/urls")
response, err := req.C().R().SetSuccessResult(&res).Get(apiURL)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %v", err)
}
if response.IsErrorState() {
return nil, fmt.Errorf("发送请求失败:%v", response.Status)
}
if res.Code != types.Success {
return nil, fmt.Errorf("获取白名单失败:%v", res.Message)
}
return res.Data, nil
}
// GetLicense 获取许可信息
func (s *LicenseService) GetLicense() *types.License {
if s.license == nil {
var config model.Config
s.db.Model(&model.Config{}).Where("name = ?", types.ConfigKeyLicense).First(&config)
if config.Value != "" {
utils.JsonDecode(config.Value, &s.license)
}
}
return s.license
}
func (s *LicenseService) SetLicense(licenseKey string) {
s.license.Key = licenseKey
}
// IsValidApiURL 判断是否合法的中转 URL
func (s *LicenseService) IsValidApiURL(uri string) error {
// 获得许可授权的直接放行
if s.license.IsActive {
if s.license.MachineId != s.machineId {
return errors.New("系统使用了盗版的许可证书")
}
if time.Now().Unix() > s.license.ExpiredAt {
return errors.New("系统许可证书已经过期")
}
return nil
}
if len(s.urlWhiteList) == 0 {
urls, err := s.fetchUrlWhiteList()
if err == nil {
s.urlWhiteList = urls
}
}
for _, v := range s.urlWhiteList {
if strings.HasPrefix(uri, v) {
return nil
}
}
return fmt.Errorf("当前 API 地址 %s 不在白名单列表当中。", uri)
}
+5 -12
View File
@@ -13,7 +13,6 @@ import (
"fmt"
"geekai/core/types"
logger2 "geekai/logger"
"geekai/service"
"geekai/store/model"
"geekai/utils"
"github.com/imroc/req/v3"
@@ -26,9 +25,8 @@ import (
// Client MidJourney client
type Client struct {
client *req.Client
licenseService *service.LicenseService
db *gorm.DB
client *req.Client
db *gorm.DB
}
type ImageReq struct {
@@ -77,11 +75,10 @@ type QueryRes struct {
var logger = logger2.GetLogger()
func NewClient(licenseService *service.LicenseService, db *gorm.DB) *Client {
func NewClient(db *gorm.DB) *Client {
return &Client{
client: req.C().SetTimeout(time.Minute).SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
licenseService: licenseService,
db: db,
client: req.C().SetTimeout(time.Minute).SetUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"),
db: db,
}
}
@@ -198,10 +195,6 @@ func (c *Client) doRequest(body interface{}, apiPath string, channel string) (Im
return ImageRes{}, fmt.Errorf("no available MidJourney api key: %v", err)
}
if err = c.licenseService.IsValidApiURL(apiKey.ApiURL); err != nil {
return ImageRes{}, err
}
apiURL := fmt.Sprintf("%s/%s", apiKey.ApiURL, apiPath)
logger.Info("API URL: ", apiURL)
r, err := req.C().R().
+2 -6
View File
@@ -203,8 +203,8 @@ func (s *Service) SyncTaskProgress() {
go func() {
var jobs []model.MidJourneyJob
for {
err := s.db.Where("progress < ?", 100).Find(&jobs).Error
if err != nil {
res := s.db.Where("progress < ?", 100).Where("channel_id <> ?", "").Find(&jobs)
if res.Error != nil {
continue
}
@@ -217,10 +217,6 @@ func (s *Service) SyncTaskProgress() {
continue
}
if job.ChannelId == "" {
continue
}
task, err := s.client.QueryTask(job.TaskId, job.ChannelId)
if err != nil {
logger.Errorf("error with query task: %v", err)
+90
View File
@@ -0,0 +1,90 @@
package sora
import (
"encoding/json"
"errors"
"geekai/service/oss"
"geekai/store/vo"
"geekai/utils"
"io"
"net/http"
"path/filepath"
"regexp"
"time"
logger2 "geekai/logger"
)
var logger = logger2.GetLogger()
type SoraService struct {
uploadManager *oss.UploaderManager
}
func NewSoraService(uploadManager *oss.UploaderManager) *SoraService {
return &SoraService{
uploadManager: uploadManager,
}
}
// 下载视频地址
func (s *SoraService) DownloadVideoURL(text string) (*vo.File, error) {
videoDataURL, err := s.ExtractVideoURL(text)
if err != nil {
return nil, err
}
// 获取 JSON 数据
resp, err := http.Get(videoDataURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// 解析视频下载地址
var videoData map[string]any
err = json.Unmarshal(body, &videoData)
if err != nil {
return nil, err
}
if v, ok := videoData["url"].(string); ok && v != "" {
logger.Infof("try to download video: %s", v)
videoURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v, ".mp4", true)
if err != nil {
return nil, err
}
// 获取文件大小
size, _ := utils.GetFileSize(videoURL)
name := filepath.Base(videoURL)
return &vo.File{
Name: name,
ObjKey: name,
URL: videoURL,
Ext: ".mp4",
Size: size,
CreatedAt: time.Now().Unix(),
}, nil
}
return nil, errors.New("no video URL found: " + string(body))
}
// 从文本中提取视频URL
func (s *SoraService) ExtractVideoURL(text string) (string, error) {
// 提取原始 JSON 数据地址
//[原始数据](https://asyncdata.net/source/task_01k8pye324ef7t6heq6jyaxbfe) 类似这样的链接
re := regexp.MustCompile(`\[原始数据\]\((https?://.*?)\)`)
matches := re.FindAllStringSubmatch(text, 1)
if len(matches) == 0 {
return "", errors.New("no video URL found")
}
return matches[0][1], nil
}
+1
View File
@@ -70,6 +70,7 @@ func (s *UserService) DecreasePower(userId uint, power int, log model.PowerLog)
return fmt.Errorf("扣减算力失败:%v", err)
}
tx.Where("id", userId).First(&user)
err = tx.Create(&model.PowerLog{
UserId: user.Id,
Username: user.Username,
+2 -2
View File
@@ -39,9 +39,9 @@ const (
LoginStatusExpired = "expired" // 登录失效,需要重新登录
)
func NewWxLoginService(config types.WxLoginConfig, redisClient *redis.Client) *WxLoginService {
func NewWxLoginService(sysConfig *types.SystemConfig, redisClient *redis.Client) *WxLoginService {
return &WxLoginService{
config: config,
config: sysConfig.WxLogin,
client: req.C().SetTimeout(10 * time.Second),
redisClient: redisClient,
}
+1 -1
View File
@@ -11,7 +11,7 @@ type AdminUser struct {
Salt string `gorm:"column:salt;type:char(12);not null;comment:密码盐" json:"salt"`
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(16);not null;comment:最后登录 IP" json:"last_login_ip"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(32);not null;comment:最后登录 IP" json:"last_login_ip"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null;comment:更新时间" json:"updated_at"`
}
+1
View File
@@ -9,6 +9,7 @@ type Function struct {
Token string `gorm:"column:token;type:varchar(255);comment:API授权token" json:"token"`
Action string `gorm:"column:action;type:varchar(255);comment:函数处理 API" json:"action"`
Enabled bool `gorm:"column:enabled;type:tinyint(1);not null;default:0;comment:是否启用" json:"enabled"`
Power int `gorm:"column:power;type:int(11);not null;default:0;comment:消费算力点数" json:"power"`
}
func (m *Function) TableName() string {
+1 -1
View File
@@ -21,7 +21,7 @@ type User struct {
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"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(16);not null;comment:最后登录 IP" json:"last_login_ip"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(32);not null;comment:最后登录 IP" json:"last_login_ip"`
OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"`
Platform string `gorm:"column:platform;type:varchar(30);comment:登录平台" json:"platform"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
+1 -1
View File
@@ -8,7 +8,7 @@ type UserLoginLog 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"`
Username string `gorm:"column:username;type:varchar(30);not null;comment:用户名" json:"username"`
LoginIp string `gorm:"column:login_ip;type:char(16);not null;comment:登录IP" json:"login_ip"`
LoginIp string `gorm:"column:login_ip;type:char(32);not null;comment:登录IP" json:"login_ip"`
LoginAddress string `gorm:"column:login_address;type:varchar(30);not null;comment:登录地址" json:"login_address"`
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"`
+1
View File
@@ -20,4 +20,5 @@ type Function struct {
Action string `json:"action"`
Token string `json:"token"`
Enabled bool `json:"enabled"`
Power int `json:"power"`
}
-10
View File
@@ -1,10 +0,0 @@
package test
import (
"fmt"
"testing"
)
func Test(t *testing.T) {
fmt.Println("test")
}
+13
View File
@@ -101,3 +101,16 @@ func GetImgExt(filename string) string {
}
return ext
}
func GetFileSize(url string) (int64, error) {
resp, err := http.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, err
}
return int64(len(body)), nil
}
+2 -2
View File
@@ -15,10 +15,10 @@ cd ../build
# remove docker image if exists
docker rmi -f registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:$version-$arch
# build docker image for geekai-go
# build docker image for Geek-AI API
docker build -t registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:$version-$arch -f dockerfile-api-go ../
# build docker image for geekai-web
# build docker image for Geek-AI-web
docker rmi -f registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:$version-$arch
docker build -t registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:$version-$arch -f dockerfile-web-$arch ../
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-12
View File
@@ -85,17 +85,6 @@ CREATE TABLE `chatgpt_app_types` (
`created_at` datetime NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='应用分类表';
--
-- 转存表中的数据 `chatgpt_app_types`
--
INSERT INTO `chatgpt_app_types` (`id`, `name`, `icon`, `sort_num`, `enabled`, `created_at`) VALUES
(3, '通用工具', 'http://172.22.11.200:5678/static/upload/2024/9/1726307371871693.png', 1, 1, '2024-09-13 11:13:15'),
(4, '角色扮演', 'http://172.22.11.200:5678/static/upload/2024/9/1726307263906218.png', 1, 1, '2024-09-14 09:28:17'),
(5, '学习', 'http://172.22.11.200:5678/static/upload/2024/9/1726307456321179.jpg', 2, 1, '2024-09-14 09:30:18'),
(6, '编程', 'http://172.22.11.200:5678/static/upload/2024/9/1726307462748787.jpg', 3, 1, '2024-09-14 09:34:06'),
(7, '测试分类', '', 4, 1, '2024-09-14 17:54:17');
-- --------------------------------------------------------
--
@@ -168,7 +157,6 @@ CREATE TABLE `chatgpt_chat_models` (
--
INSERT INTO `chatgpt_chat_models` (`id`, `name`, `value`, `sort_num`, `enabled`, `power`, `temperature`, `max_tokens`, `max_context`, `open`, `key_id`, `created_at`, `updated_at`) VALUES
(1, 'gpt-4o-mini', 'gpt-4o-mini', 1, 1, 1, 1.0, 1024, 16384, 1, 66, '2023-08-23 12:06:36', '2024-09-29 19:08:42'),
(15, 'GPT-超级模型', 'gpt-4-all', 4, 1, 30, 1.0, 4096, 32768, 1, 0, '2024-01-15 11:32:52', '2024-09-13 18:01:08'),
(36, 'GPT-4O', 'gpt-4o', 3, 1, 15, 1.0, 4096, 16384, 1, 66, '2024-05-14 09:25:15', '2024-09-29 19:08:53'),
+3 -3
View File
@@ -18,10 +18,10 @@ SET time_zone = "+00:00";
/*!40101 SET NAMES utf8mb4 */;
--
-- 数据库: `geekai_plus`
-- 数据库: `geekai`
--
CREATE DATABASE IF NOT EXISTS `geekai_plus` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE `geekai_plus`;
CREATE DATABASE IF NOT EXISTS `geekai` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE `geekai`;
-- --------------------------------------------------------
+2 -2
View File
@@ -46,7 +46,7 @@ services:
# 后端 API 程序
geekai-api:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.2.4-amd64
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.2.8-amd64
container_name: geekai-api
restart: always
depends_on:
@@ -69,7 +69,7 @@ services:
# 前端应用
geekai-web:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.2.4-amd64
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.2.8-amd64
container_name: geekai-web
restart: always
depends_on:
+1 -1
View File
@@ -6,7 +6,7 @@ VITE_ADMIN_USER=admin
VITE_ADMIN_PASS=admin123
VITE_KEY_PREFIX=GeekAI_DEV_
VITE_TITLE="Geek-AI 创作系统"
VITE_VERSION=v4.2.7
VITE_VERSION=v4.2.8
VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
VITE_GITEE_URL=https://gitee.com/blackfox/geekai
+1 -1
View File
@@ -1,7 +1,7 @@
VITE_API_HOST=
VITE_WS_HOST=
VITE_KEY_PREFIX=GeekAI_
VITE_VERSION=v4.2.7
VITE_VERSION=v4.2.8
VITE_TITLE="Geek-AI 创作系统"
VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
+4 -3
View File
@@ -83,7 +83,7 @@
width: 100%;
}
.el-input__wrapper {
background: var(--card-bg);
//background: var(--card-bg);
}
.el-dialog__title {
font-weight: bold;
@@ -138,7 +138,8 @@
border: none;
}
.el-tag, .el-tag.el-tag--primary {
.el-tag,
.el-tag.el-tag--primary {
--el-tag-bg-color: #f0ebff;
}
.box-card {
@@ -148,4 +149,4 @@
}
.el-table th.el-table__cell {
background-color: var(--chat-bg);
}
}
+3 -2
View File
@@ -189,10 +189,11 @@
}
blockquote {
border-left: 4px solid #42b983;
border-left: 5px solid #c1c1c1;
padding: 10px 15px;
color: #777;
background-color: rgba(66, 185, 131, 0.1);
background-color: rgba(193, 193, 193, 0.2);
border-radius: 10px;
}
table {
+139
View File
@@ -0,0 +1,139 @@
<template>
<div v-if="list.length" class="space-y-3 mb-2">
<div
v-for="(f, idx) in list"
:key="f.url || idx"
class="flex !items-start flex-col justify-center gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg"
>
<!-- Image -->
<div
v-if="isImageFile(f)"
class="flex-shrink-0 bg-gray-100 max-w-[500px] max-h-[500px] dark:bg-gray-700 rounded-lg overflow-hidden"
>
<el-image
:src="f.url"
fit="cover"
:preview-src-list="imageUrls"
:initial-index="imageIndexMap.get(f.url) || 0"
hide-on-click-modal
:z-index="3000"
class="w-full h-full"
/>
</div>
<!-- Video -->
<div
v-else-if="isVideoFile(f)"
class="flex-shrink-0 bg-gray-100 max-w-[500px] dark:bg-gray-700 rounded-lg overflow-hidden"
>
<video :src="f.url" controls preload="metadata" class="w-full h-full object-cover"></video>
</div>
<!-- Audio -->
<div
v-else-if="isAudioFile(f)"
class="flex-shrink-0 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
>
<audio :src="f.url" controls preload="metadata"></audio>
</div>
<!-- Other Files -->
<div
v-else
class="flex-shrink-0 w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded-lg flex items-center justify-center"
>
<img :src="GetFileIcon(extOf(f))" class="w-20 h-20" alt="file" />
</div>
<div class="flex w-full flex-row min-w-0 justify-between items-center">
<!-- File Info -->
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ extOf(f).replace('.', '').toUpperCase() || 'FILE' }} ·
{{ FormatFileSize(f.size || 0) }}
</div>
<!-- Download Button -->
<div class="flex-shrink-0">
<el-tooltip class="box-item" effect="dark" content="下载" placement="top">
<i
class="iconfont icon-download !text-sm cursor-pointer"
v-if="!f.downloading"
@click="downloadFile(f)"
></i>
<el-image src="/images/loading.gif" class="w-4 h-4" fit="cover" v-else />
</el-tooltip>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormatFileSize, GetFileIcon } from '@/store/system'
import { computed } from 'vue'
import { httpDownload } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
const props = defineProps({
files: {
type: Array,
default: () => [],
},
})
const list = computed(() => props.files || [])
const normalizeExt = (ext) => (ext || '').toLowerCase()
const urlExt = (url) => {
if (!url) return ''
try {
const path = url.split('?')[0]
const dot = path.lastIndexOf('.')
return dot >= 0 ? path.substring(dot) : ''
} catch (_) {
return ''
}
}
const extOf = (f) => normalizeExt(f.ext || urlExt(f.url))
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'])
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.ogg', '.mov', '.m4v'])
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.aac', '.m4a'])
const isImageFile = (f) => IMAGE_EXTS.has(extOf(f))
const isVideoFile = (f) => VIDEO_EXTS.has(extOf(f))
const isAudioFile = (f) => AUDIO_EXTS.has(extOf(f))
// image preview urls and index mapping for el-image gallery
const imageUrls = computed(() => list.value.filter((f) => isImageFile(f)).map((f) => f.url))
const imageIndexMap = computed(() => {
const map = new Map()
imageUrls.value.forEach((u, i) => map.set(u, i))
return map
})
const downloadFile = async (item) => {
const url = replaceImg(item.url)
const downloadURL = `/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
try {
const response = await httpDownload(downloadURL)
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
} catch (error) {
showMessageError('下载失败')
item.downloading = false
}
}
</script>
+1 -1
View File
@@ -9,7 +9,7 @@
<div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
<el-image :src="file.url" fit="cover" :preview-src-list="[file.url]" />
</div>
<div class="item" v-else>
<div class="icon">
+25 -4
View File
@@ -6,7 +6,7 @@
<img :src="data.icon" alt="ChatGPT" />
</div>
<div class="chat-item">
<div class="content-wrapper">
<div class="content-wrapper flex-col">
<div
class="content"
v-html="md.render(processContent(data.content.text))"
@@ -15,6 +15,12 @@
<div class="content flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
</div>
<div class="mt-3">
<AttachmentList
v-if="data.content && data.content.files && data.content.files.length"
:files="data.content.files"
/>
</div>
</div>
<div
class="flex text-gray-500 text-sm py-2 items-center space-x-2"
@@ -79,6 +85,7 @@ import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { nextTick, onMounted, reactive, ref, watchEffect } from 'vue'
import Thinking from './Thinking.vue'
import AttachmentList from './AttachmentList.vue'
// eslint-disable-next-line no-undef,no-unused-vars
const props = defineProps({
data: {
@@ -118,7 +125,7 @@ const md = new MarkdownIt({
// /
const copyBtn = `<div class="flex">
<span class="text-[12px] mr-2 text-[#00e0e0] cursor-pointer expand-btn" data-code-id="${codeIndex}" onclick="window.toggleCodeBlock('${codeIndex}')">收起</span>
<span class="copy-code-btn" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
<span class="copy-code-btn text-blue-500 text-sm cursor-pointer" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
</div><textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
'&lt;/textarea>'
@@ -127,7 +134,7 @@ const md = new MarkdownIt({
let preCode = ''
//
if (lang && hl.getLanguage(lang)) {
langHtml = `<span class="lang-name">${lang}</span>`
langHtml = `<span class="lang-name text-white">${lang}</span>`
preCode = hl.highlight(str, { language: lang }).value
} else {
preCode = md.utils.escapeHtml(str)
@@ -137,7 +144,7 @@ const md = new MarkdownIt({
return `<pre class="code-container flex flex-col code-expanded" data-code-id="${codeIndex}">
<div class="flex justify-between bg-[#50505a] w-full rounded-tl-[10px] rounded-tr-[10px] px-3 py-1">${langHtml}${copyBtn}</div>
<code class="language-${lang} hljs">${preCode}</code>
<span class="copy-code-btn absolute right-3 bottom-3" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span></pre>`
<span class="copy-code-btn absolute right-3 bottom-3 text-white text-sm cursor-pointer" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span></pre>`
},
})
md.use(mathjaxPlugin)
@@ -313,6 +320,16 @@ const setupCodeBlockEvents = () => {
border-radius: 0 10px 10px 10px;
width: 100%;
.code-expanded {
position: relative;
}
pre code.hljs {
min-width: 100%;
max-width: 100%;
word-break: break-word;
}
p:first-child {
margin-top: 0;
}
@@ -320,6 +337,10 @@ const setupCodeBlockEvents = () => {
p:last-child {
margin-bottom: 0;
}
think {
border: 1px solid var(--el-border-color);
}
}
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
<el-container class="chat-file-list">
<div v-for="file in fileList" :key="file.url">
<div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
<el-image :src="file.url" fit="cover" :preview-src-list="[file.url]" />
<div class="action">
<el-icon @click="removeFile(file)"><CircleCloseFilled /></el-icon>
</div>
+22 -20
View File
@@ -11,22 +11,26 @@
<span class="mr-2">{{ copyRight }}</span>
</div>
<div class="flex justify-center text-sm">
<a href="https://beian.miit.gov.cn" target="_blank">ICP备案{{ icp }}</a>
<span>|</span>
<img :src="gaBeianImg" class="w-4 h-4 mx-1" alt="beian" />
<a
:href="`http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=${getCodeNum(
gaBeian
)}`"
target="_blank"
>{{ gaBeian }}</a
>
<template v-if="icp">
<a href="https://beian.miit.gov.cn" target="_blank">ICP备案{{ icp }}</a>
</template>
<template v-if="gaBeian">
<span>|</span>
<img :src="gaBeianImg" class="w-4 h-4 mx-1" alt="beian" />
<a
:href="`http://www.beian.gov.cn/portal/registerSystemInfo?recordcode=${getCodeNum(
gaBeian
)}`"
target="_blank"
>{{ gaBeian }}</a
>
</template>
</div>
</div>
</div>
</template>
<script setup>
import { getLicenseInfo, getSystemInfo } from '@/store/cache'
import { getSystemInfo } from '@/store/cache'
import { showMessageError } from '@/utils/dialog'
import { ref } from 'vue'
@@ -36,7 +40,6 @@ const gitURL = ref(import.meta.env.VITE_GITHUB_URL)
const copyRight = ref('')
const icp = ref('')
const gaBeian = ref('')
const license = ref({})
const props = defineProps({
textColor: {
type: String,
@@ -61,14 +64,6 @@ getSystemInfo()
showMessageError('获取系统配置失败:' + e.message)
})
getLicenseInfo()
.then((res) => {
license.value = res.data
})
.catch((e) => {
showMessageError('获取 License 失败:' + e.message)
})
//
const getCodeNum = (code) => {
//
@@ -86,6 +81,7 @@ const getCodeNum = (code) => {
<style scoped lang="scss">
.foot-container {
// PC fixed
position: fixed;
left: 0;
bottom: 0;
@@ -95,6 +91,12 @@ const getCodeNum = (code) => {
// background: var(--theme-bg);
margin-top: -4px;
@media (max-width: 768px) {
margin-top: 2rem !important;
position: static;
margin-top: 0;
}
.footer {
// max-width: 400px;
text-align: center;
+41 -19
View File
@@ -21,15 +21,19 @@
</div>
<div v-else class="upload-item single-image-item">
<el-image :src="imageList[0]" fit="cover" class="upload-image" />
<div class="upload-overlay">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(0)"
class="remove-btn"
/>
<div class="upload-overlay flex items-center justify-center space-x-2">
<el-tooltip content="删除" placement="top">
<i
class="iconfont icon-remove text-base text-red-500 cursor-pointer"
@click="removeImage(index)"
></i>
</el-tooltip>
<el-tooltip content="预览" placement="top">
<i
class="iconfont icon-eye-open text-lg text-white cursor-pointer"
@click="previewImage(index)"
></i>
</el-tooltip>
</div>
</div>
</div>
@@ -40,15 +44,19 @@
<div class="upload-list" v-if="imageList.length > 0">
<div v-for="(image, index) in imageList" :key="index" class="upload-item">
<el-image :src="image" fit="cover" class="upload-image" />
<div class="upload-overlay">
<el-button
type="danger"
:icon="Delete"
size="small"
circle
@click="removeImage(index)"
class="remove-btn"
/>
<div class="upload-overlay flex items-center justify-center space-x-2">
<el-tooltip content="删除" placement="top">
<i
class="iconfont icon-remove text-base text-red-500 cursor-pointer"
@click="removeImage(index)"
></i>
</el-tooltip>
<el-tooltip content="预览" placement="top">
<i
class="iconfont icon-eye-open text-lg text-white cursor-pointer"
@click="previewImage(index)"
></i>
</el-tooltip>
</div>
</div>
<!-- 上传按钮 -->
@@ -100,13 +108,20 @@
:stroke-width="4"
class="upload-progress"
/>
<!-- 图片预览弹窗 -->
<el-image-viewer
v-if="previewVisible"
:url-list="[previewImageSrc]"
@close="previewVisible = false"
/>
</div>
</template>
<script setup>
import { httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { Delete, UploadFilled } from '@element-plus/icons-vue'
import { UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { computed, ref } from 'vue'
@@ -138,6 +153,8 @@ const emit = defineEmits(['update:modelValue', 'upload-success'])
//
const uploading = ref(false)
const uploadProgress = ref(0)
const previewVisible = ref(false)
const previewImageSrc = ref('')
//
const imageList = computed({
@@ -225,6 +242,11 @@ const removeImage = (index) => {
newList.splice(index, 1)
imageList.value = newList
}
const previewImage = (index) => {
previewImageSrc.value = imageList.value[index]
previewVisible.value = true
}
</script>
<style lang="scss">
@@ -224,11 +224,6 @@ const items = [
index: '/admin/config/menu',
title: '菜单配置',
},
{
icon: 'license',
index: '/admin/config/license',
title: '授权激活',
},
{
icon: 'recharge',
index: '/admin/config/payment',
+8 -1
View File
@@ -16,6 +16,10 @@
</div>
</div>
<div class="mt-3">
<AttachmentList v-if="files && files.length" :files="files" />
</div>
<!-- 操作按钮区域 -->
<div class="action-buttons" v-if="showActions && orgContent">
<van-button
@@ -50,7 +54,8 @@ import emoji from 'markdown-it-emoji'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { showImagePreview, showNotify } from 'vant'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import Thinking from '../Thinking.vue'
import AttachmentList from '@/components/AttachmentList.vue'
import Thinking from '@/components/Thinking.vue'
const props = defineProps({
content: {
@@ -90,6 +95,8 @@ const props = defineProps({
},
})
const files = computed(() => props.content.files || [])
const emits = defineEmits(['regenerate'])
const md = new MarkdownIt({
breaks: true,
+11 -2
View File
@@ -8,8 +8,17 @@
isImage(f.ext) ? 'p-0' : 'p-2',
]"
>
<div v-if="isImage(f.ext)" class="relative w-[56px] h-[56px] overflow-hidden rounded-lg">
<img :src="f.url" alt="img" class="w-full h-full object-cover" />
<div
v-if="isImage(f.ext)"
class="relative max-w-[200px] max-h-[200px] overflow-hidden rounded-lg"
>
<el-image
:src="f.url"
alt="img"
class="w-full h-full"
:preview-src-list="[f.url]"
fit="cover"
/>
</div>
<div v-else :class="['flex items-center', direction === 'col' ? 'w-full' : 'max-w-[240px]']">
<img :src="GetFileIcon(f.ext)" class="w-10 h-10 mr-2" />
+52
View File
@@ -0,0 +1,52 @@
<template>
<div class="flex flex-col justify-center items-center py-10 px-4 text-center">
<div class="mb-4 flex items-center justify-center">
<div class="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center shadow">
<svg class="w-7 h-7 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 11c1.657 0 3-1.567 3-3.5S13.657 4 12 4 9 5.567 9 7.5 10.343 11 12 11z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 20v-1a5 5 0 015-5h2a5 5 0 015 5v1"
/>
</svg>
</div>
</div>
<h3 class="text-lg font-semibold text-gray-800 mb-1">{{ title }}</h3>
<p class="text-gray-500 text-sm">{{ statusText }}</p>
<p v-if="description" class="text-xs text-gray-400 mt-2 max-w-sm leading-relaxed">
{{ description }}
</p>
</div>
<!-- 允许使用插槽扩展比如放置返回按钮或客服入口 -->
<slot />
</template>
<script setup>
const props = defineProps({
title: {
type: String,
default: '功能暂未开放',
},
statusText: {
type: String,
default: '该功能暂不可用,请稍后重试或联系管理员',
},
description: {
type: String,
default: '',
},
})
</script>
<style lang="scss">
/* 保留最小样式,去除无关动画与进度条 */
</style>
+222 -210
View File
@@ -6,6 +6,112 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import { createRouter, createWebHistory } from 'vue-router'
import { getSystemInfo, getMenus } from '@/store/cache'
// PC 端主页面路由
const homeRoutes = {
name: 'home',
path: '/home',
redirect: '/chat',
component: () => import('@/views/Home.vue'),
children: [
{
name: 'chat',
path: '/chat',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: 'chat-id',
path: '/chat/:id',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: 'image-mj',
path: '/mj',
meta: { title: 'MidJourney 绘画中心' },
component: () => import('@/views/ImageMj.vue'),
},
{
name: 'image-sd',
path: '/sd',
meta: { title: 'stable diffusion 绘画中心' },
component: () => import('@/views/ImageSd.vue'),
},
{
name: 'member',
path: '/member',
meta: { title: '会员充值中心' },
component: () => import('@/views/Member.vue'),
},
{
name: 'chat-app',
path: '/apps',
meta: { title: '应用中心' },
component: () => import('@/views/ChatApps.vue'),
},
{
name: 'images',
path: '/images-wall',
meta: { title: '作品展示' },
component: () => import('@/views/ImagesWall.vue'),
},
{
name: 'user-invitation',
path: '/invite',
meta: { title: '推广计划' },
component: () => import('@/views/Invitation.vue'),
},
{
name: 'powerLog',
path: '/powerLog',
meta: { title: '消费日志' },
component: () => import('@/views/PowerLog.vue'),
},
{
name: 'xmind',
path: '/xmind',
meta: { title: '思维导图' },
component: () => import('@/views/MarkMap.vue'),
},
{
name: 'dalle',
path: '/dalle',
meta: { title: 'DALLE-3' },
component: () => import('@/views/Dalle.vue'),
},
{
name: 'suno',
path: '/suno',
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: 'ExternalLink',
path: '/external',
component: () => import('@/views/ExternalPage.vue'),
},
{
name: 'song',
path: '/song/:id',
meta: { title: 'Suno音乐播放' },
component: () => import('@/views/Song.vue'),
},
{
name: 'video',
path: '/video',
meta: { title: '视频创作中心' },
component: () => import('@/views/Video.vue'),
},
{
name: 'jimeng',
path: '/jimeng',
meta: { title: '即梦AI' },
component: () => import('@/views/Jimeng.vue'),
},
],
}
const routes = [
{
@@ -14,109 +120,6 @@ const routes = [
meta: { title: '首页' },
component: () => import('@/views/Index.vue'),
},
{
name: 'home',
path: '/home',
redirect: '/chat',
component: () => import('@/views/Home.vue'),
children: [
{
name: 'chat',
path: '/chat',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: 'chat-id',
path: '/chat/:id',
meta: { title: '创作中心' },
component: () => import('@/views/ChatPlus.vue'),
},
{
name: 'image-mj',
path: '/mj',
meta: { title: 'MidJourney 绘画中心' },
component: () => import('@/views/ImageMj.vue'),
},
{
name: 'image-sd',
path: '/sd',
meta: { title: 'stable diffusion 绘画中心' },
component: () => import('@/views/ImageSd.vue'),
},
{
name: 'member',
path: '/member',
meta: { title: '会员充值中心' },
component: () => import('@/views/Member.vue'),
},
{
name: 'chat-app',
path: '/apps',
meta: { title: '应用中心' },
component: () => import('@/views/ChatApps.vue'),
},
{
name: 'images',
path: '/images-wall',
meta: { title: '作品展示' },
component: () => import('@/views/ImagesWall.vue'),
},
{
name: 'user-invitation',
path: '/invite',
meta: { title: '推广计划' },
component: () => import('@/views/Invitation.vue'),
},
{
name: 'powerLog',
path: '/powerLog',
meta: { title: '消费日志' },
component: () => import('@/views/PowerLog.vue'),
},
{
name: 'xmind',
path: '/xmind',
meta: { title: '思维导图' },
component: () => import('@/views/MarkMap.vue'),
},
{
name: 'dalle',
path: '/dalle',
meta: { title: 'DALLE-3' },
component: () => import('@/views/Dalle.vue'),
},
{
name: 'suno',
path: '/suno',
meta: { title: 'Suno音乐创作' },
component: () => import('@/views/Suno.vue'),
},
{
name: 'ExternalLink',
path: '/external',
component: () => import('@/views/ExternalPage.vue'),
},
{
name: 'song',
path: '/song/:id',
meta: { title: 'Suno音乐播放' },
component: () => import('@/views/Song.vue'),
},
{
name: 'video',
path: '/video',
meta: { title: '视频创作中心' },
component: () => import('@/views/Video.vue'),
},
{
name: 'jimeng',
path: '/jimeng',
meta: { title: '即梦AI' },
component: () => import('@/views/Jimeng.vue'),
},
],
},
{
name: 'chat-export',
path: '/chat/export',
@@ -240,12 +243,6 @@ const routes = [
meta: { title: '菜单配置' },
component: () => import('@/views/admin/settings/MenuConfig.vue'),
},
{
path: '/admin/config/license',
name: 'admin-config-license',
meta: { title: '授权激活' },
component: () => import('@/views/admin/settings/LicenseConfig.vue'),
},
{
path: '/admin/user',
name: 'admin-user',
@@ -351,106 +348,6 @@ const routes = [
],
},
{
name: 'mobile',
path: '/mobile',
meta: { title: '首页' },
component: () => import('@/views/mobile/Home.vue'),
redirect: '/mobile/index',
children: [
{
path: '/mobile/index',
name: 'mobile-index',
component: () => import('@/views/mobile/Index.vue'),
},
{
meta: { title: 'AI对话' },
path: '/mobile/chat',
name: 'mobile-chat',
component: () => import('@/views/mobile/ChatList.vue'),
},
{
meta: { title: '创作中心' },
path: '/mobile/create',
name: 'mobile-create',
component: () => import('@/views/mobile/Create.vue'),
},
{
meta: { title: '发现' },
path: '/mobile/discover',
name: 'mobile-discover',
component: () => import('@/views/mobile/Discover.vue'),
},
{
meta: { title: '个人中心' },
path: '/mobile/profile',
name: 'mobile-profile',
component: () => import('@/views/mobile/Profile.vue'),
},
{
meta: { title: '会员充值' },
path: '/mobile/member',
name: 'mobile-member',
component: () => import('@/views/mobile/Member.vue'),
},
{
meta: { title: '作品展示' },
path: '/mobile/imgWall',
name: 'mobile-img-wall',
component: () => import('@/views/mobile/pages/ImgWall.vue'),
},
{
path: '/mobile/chat/session',
name: 'mobile-chat-session',
component: () => import('@/views/mobile/ChatSession.vue'),
},
{
meta: { title: '应用中心' },
path: '/mobile/apps',
name: 'mobile-apps',
component: () => import('@/views/mobile/Apps.vue'),
},
// 新增的功能页面路由
{
meta: { title: '消费日志' },
path: '/mobile/power-log',
name: 'mobile-power-log',
component: () => import('@/views/mobile/PowerLog.vue'),
},
{
meta: { title: '推广计划' },
path: '/mobile/invite',
name: 'mobile-invite',
component: () => import('@/views/mobile/Invite.vue'),
},
{
meta: { title: '设置' },
path: '/mobile/settings',
name: 'mobile-settings',
component: () => import('@/views/mobile/Settings.vue'),
},
{
meta: { title: 'Suno音乐创作' },
path: '/mobile/suno',
name: 'mobile-suno',
component: () => import('@/views/mobile/SunoCreate.vue'),
},
{
meta: { title: '视频生成' },
path: '/mobile/video',
name: 'mobile-video',
component: () => import('@/views/mobile/VideoCreate.vue'),
},
{
meta: { title: '即梦AI' },
path: '/mobile/jimeng',
name: 'mobile-jimeng',
component: () => import('@/views/mobile/JimengCreate.vue'),
},
],
},
{
name: 'test',
path: '/test',
@@ -466,6 +363,106 @@ const routes = [
},
]
const mobileRoutes = {
name: 'mobile',
path: '/mobile',
meta: { title: '首页' },
component: () => import('@/views/mobile/Home.vue'),
redirect: '/mobile/index',
children: [
{
path: '/mobile/index',
name: 'mobile-index',
component: () => import('@/views/mobile/Index.vue'),
},
{
meta: { title: 'AI对话' },
path: '/mobile/chat',
name: 'mobile-chat',
component: () => import('@/views/mobile/ChatList.vue'),
},
{
meta: { title: '创作中心' },
path: '/mobile/create',
name: 'mobile-create',
component: () => import('@/views/mobile/Create.vue'),
},
{
meta: { title: '发现' },
path: '/mobile/discover',
name: 'mobile-discover',
component: () => import('@/views/mobile/Discover.vue'),
},
{
meta: { title: '个人中心' },
path: '/mobile/profile',
name: 'mobile-profile',
component: () => import('@/views/mobile/Profile.vue'),
},
{
meta: { title: '会员充值' },
path: '/mobile/member',
name: 'mobile-member',
component: () => import('@/views/mobile/Member.vue'),
},
{
meta: { title: '作品展示' },
path: '/mobile/imgWall',
name: 'mobile-img-wall',
component: () => import('@/views/mobile/pages/ImgWall.vue'),
},
{
path: '/mobile/chat/session',
name: 'mobile-chat-session',
component: () => import('@/views/mobile/ChatSession.vue'),
},
{
meta: { title: '应用中心' },
path: '/mobile/apps',
name: 'mobile-apps',
component: () => import('@/views/mobile/Apps.vue'),
},
// 新增的功能页面路由
{
meta: { title: '消费日志' },
path: '/mobile/power-log',
name: 'mobile-power-log',
component: () => import('@/views/mobile/PowerLog.vue'),
},
{
meta: { title: '推广计划' },
path: '/mobile/invite',
name: 'mobile-invite',
component: () => import('@/views/mobile/Invite.vue'),
},
{
meta: { title: '设置' },
path: '/mobile/settings',
name: 'mobile-settings',
component: () => import('@/views/mobile/Settings.vue'),
},
{
meta: { title: 'Suno音乐创作' },
path: '/mobile/suno',
name: 'mobile-suno',
component: () => import('@/views/mobile/SunoCreate.vue'),
},
{
meta: { title: '视频生成' },
path: '/mobile/video',
name: 'mobile-video',
component: () => import('@/views/mobile/VideoCreate.vue'),
},
{
meta: { title: '即梦AI' },
path: '/mobile/jimeng',
name: 'mobile-jimeng',
component: () => import('@/views/mobile/JimengCreate.vue'),
},
],
}
// console.log(MY_VARIABLE)
const router = createRouter({
history: createWebHistory(),
@@ -480,4 +477,19 @@ router.beforeEach((to, from, next) => {
next()
})
export { prevRoute, router }
// 检测是否启用了移动端(同步:顶层 await)
const res = await getSystemInfo()
const data = res.data
if (data && data.enable_mobile_site) {
router.addRoute(mobileRoutes)
}
// 获取所有的菜单列表,然后把禁用的菜单从router中移除
const menus = await getMenus()
homeRoutes.children = homeRoutes.children.filter((route) => {
return !menus[route.path] || menus[route.path]?.enabled
})
// 添加主页面路由
router.addRoute(homeRoutes)
export { prevRoute, router, mobileRoutes }
+69 -65
View File
@@ -1,99 +1,103 @@
import { httpGet } from "@/utils/http";
import Storage from "good-storage";
import { randString } from "@/utils/libs";
import { httpGet } from '@/utils/http'
import Storage from 'good-storage'
import { randString } from '@/utils/libs'
const userDataKey = "USER_INFO_CACHE_KEY";
const adminDataKey = "ADMIN_INFO_CACHE_KEY";
const systemInfoKey = "SYSTEM_INFO_CACHE_KEY";
const licenseInfoKey = "LICENSE_INFO_CACHE_KEY";
export function checkSession() {
const item = Storage.get(userDataKey) ?? { expire: 0, data: null };
const userDataKey = 'USER_INFO_CACHE_KEY'
const adminDataKey = 'ADMIN_INFO_CACHE_KEY'
const systemInfoKey = 'SYSTEM_INFO_CACHE_KEY'
const menusKey = 'MENUS_CACHE_KEY'
export function getMenus() {
const item = Storage.get(menusKey) ?? { expire: 0, data: null }
if (item.expire > Date.now()) {
return Promise.resolve(item.data);
return Promise.resolve(item.data)
}
return new Promise((resolve, reject) => {
httpGet("/api/user/session")
httpGet('/api/menu/list/all')
.then((res) => {
item.data = res.data;
item.expire = Date.now() + 1000 * 3;
Storage.set(userDataKey, item);
resolve(item.data);
const menus = {}
for (const menu of res.data) {
menus[menu.url] = menu
}
item.data = menus
item.expire = Date.now() + 1000 * 3
Storage.set(menusKey, item)
resolve(item.data)
})
.catch((err) => {
reject(err)
})
})
}
export function checkSession() {
const item = Storage.get(userDataKey) ?? { expire: 0, data: null }
if (item.expire > Date.now()) {
return Promise.resolve(item.data)
}
return new Promise((resolve, reject) => {
httpGet('/api/user/session')
.then((res) => {
item.data = res.data
item.expire = Date.now() + 1000 * 3
Storage.set(userDataKey, item)
resolve(item.data)
})
.catch((e) => {
Storage.remove(userDataKey);
reject(e);
});
});
Storage.remove(userDataKey)
reject(e)
})
})
}
export function checkAdminSession() {
const item = Storage.get(adminDataKey) ?? { expire: 0, data: null };
const item = Storage.get(adminDataKey) ?? { expire: 0, data: null }
if (item.expire > Date.now()) {
return Promise.resolve(item.data);
return Promise.resolve(item.data)
}
return new Promise((resolve, reject) => {
httpGet("/api/admin/session")
httpGet('/api/admin/session')
.then((res) => {
item.data = res.data;
item.expire = Date.now() + 1000 * 30;
Storage.set(adminDataKey, item);
resolve(item.data);
item.data = res.data
item.expire = Date.now() + 1000 * 3
Storage.set(adminDataKey, item)
resolve(item.data)
})
.catch((e) => {
Storage.remove(adminDataKey);
reject(e);
});
});
Storage.remove(adminDataKey)
reject(e)
})
})
}
export function removeAdminInfo() {
Storage.remove(adminDataKey);
Storage.remove(adminDataKey)
}
export function getSystemInfo() {
const item = Storage.get(systemInfoKey) ?? { expire: 0, data: null };
const item = Storage.get(systemInfoKey) ?? { expire: 0, data: null }
if (item.expire > Date.now()) {
return Promise.resolve(item.data);
return Promise.resolve(item.data)
}
return new Promise((resolve, reject) => {
httpGet("/api/config/get?key=system")
httpGet('/api/config/get?key=system')
.then((res) => {
item.data = res;
item.expire = Date.now() + 1000 * 30;
Storage.set(systemInfoKey, item);
resolve(item.data);
item.data = res
item.expire = Date.now() + 1000 * 3
Storage.set(systemInfoKey, item)
resolve(item.data)
})
.catch((err) => {
reject(err);
});
});
}
export function getLicenseInfo() {
const item = Storage.get(licenseInfoKey) ?? { expire: 0, data: null };
if (item.expire > Date.now()) {
return Promise.resolve(item.data);
}
return new Promise((resolve, reject) => {
httpGet("/api/config/license")
.then((res) => {
item.data = res;
item.expire = Date.now() + 1000 * 30;
Storage.set(licenseInfoKey, item);
resolve(item.data);
reject(err)
})
.catch((err) => {
resolve(err);
});
});
})
}
export function getClientId() {
let clientId = Storage.get("client_id");
let clientId = Storage.get('client_id')
if (clientId) {
return clientId;
return clientId
}
clientId = randString(42);
Storage.set("client_id", clientId);
return clientId;
clientId = randString(42)
Storage.set('client_id', clientId)
return clientId
}
+1 -1
View File
@@ -64,7 +64,7 @@ export const JimengParams = {
type: 'image',
required: false,
placeholder: '请上传图片',
maxSize: 10,
maxSize: 15,
multiple: true,
maxCount: 10,
accept: '.png,.jpg,.jpeg',
+1
View File
@@ -71,6 +71,7 @@ export const useJimengStore = defineStore('jimeng', () => {
// 获取任务状态文本
const getTaskStatusText = (status) => {
const statusMap = {
submited: '任务已提交',
in_queue: '任务排队中',
generating: '任务执行中',
success: '任务成功',
+1 -1
View File
@@ -203,7 +203,7 @@ export function processContent(content) {
if (content.includes('<think>')) {
content = content.replace(/<think>(.*?)<\/think>/gs, (match, content) => {
if (content.length > 10) {
return `<blockquote>${content}</blockquote>`
return `<blockquote>\n\n${content}</blockquote>`
}
return ''
})
+74 -18
View File
@@ -1,39 +1,95 @@
<template>
<div class="page-404" :style="{ height: winHeight + 'px' }">
<div class="page-404">
<div class="inner">
<h1>404</h1>
<h2>饶了地球一圈还是没有找到您要的页面</h2>
<div class="code">404</div>
<p class="title">抱歉页面走丢了</p>
<p class="desc">您访问的页面不存在或已被移动</p>
<div class="actions">
<button class="btn" @click="goHome">返回首页</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const winHeight = ref(window.innerHeight)
const router = useRouter()
function goHome() {
router.replace({ path: '/' })
}
</script>
<style lang="scss" scoped>
.page-404 {
position: relative;
display: flex;
align-items: center;
justify-content: center;
background-color: #282c34;
min-height: 100vh;
padding: calc(env(safe-area-inset-top, 0px) + 24px) 16px
calc(env(safe-area-inset-bottom, 0px) + 32px);
background: radial-gradient(1200px 600px at 50% -10%, #2f3542 0%, #1e222a 60%, #12161c 100%);
color: #ffffff;
text-align: center;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
.inner {
text-align: center;
width: 100%;
max-width: 720px;
margin: 0 auto;
}
h1 {
color: #202020;
font-size: 120px;
font-weight: bold;
letter-spacing: 0.1em;
text-shadow: -1px -1px 1px #111111, 2px 2px 1px #363636;
}
.code {
font-size: clamp(96px, 18vw, 160px);
font-weight: 800;
line-height: 1;
letter-spacing: 0.04em;
background: linear-gradient(180deg, #ffffff 0%, #cbd5e1 60%, #94a3b8 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 12px 30px rgba(0, 0, 0, 0.35);
}
h2 {
color: #ffffff;
font-weight: bold;
}
.title {
margin-top: 16px;
font-size: clamp(18px, 4.6vw, 28px);
font-weight: 700;
}
.desc {
margin-top: 8px;
font-size: clamp(14px, 3.6vw, 16px);
color: #cbd5e1;
}
.actions {
margin-top: 24px;
}
.btn {
appearance: none;
border: 0;
border-radius: 9999px;
padding: 12px 20px;
font-weight: 600;
font-size: 14px;
color: #ffffff;
background: #4f46e5;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.35), inset 0 0 0 1px rgba(255, 255, 255, 0.15);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.btn:hover {
background: #4338ca;
box-shadow: 0 12px 24px rgba(79, 70, 229, 0.45), inset 0 0 0 1px rgba(255, 255, 255, 0.22);
}
.btn:active {
transform: translateY(1px);
}
}
</style>
+7 -16
View File
@@ -140,14 +140,9 @@
plain
>
<div class="selected-model-display">
<span class="model-name-text">{{ getSelectedModelName() }}</span>
<el-tag
v-if="getSelectedModel()"
size="small"
type="info"
style="margin-left: 8px; flex-shrink: 0"
>
{{ getSelectedModel() && getSelectedModel().power }}算力
<span class="model-name-text">{{ selectedModel.name }}</span>
<el-tag size="small" type="info" style="margin-left: 8px; flex-shrink: 0">
{{ selectedModel.power }}算力
</el-tag>
</div>
</el-button>
@@ -546,10 +541,10 @@ watch(
)
//
const getSelectedModelName = () => {
const selectedModel = computed(() => {
const model = getSelectedModel()
return model ? model.name : '选择模型'
}
return model ? model : { name: '选择模型', power: 0 }
})
//
const getSelectedModel = () => {
@@ -585,10 +580,6 @@ watch(
}
)
if (isMobile()) {
router.push('/mobile/chat')
}
// ID
if (router.currentRoute.value.query.role_id) {
roleId.value = parseInt(router.currentRoute.value.query.role_id)
@@ -666,7 +657,7 @@ const initData = async () => {
//
const modelRes = await httpGet('/api/model/list')
models.value = modelRes.data
if (models.value.length > 0) {
if (models.value.length > 0 && !modelID.value) {
modelID.value = models.value[0].id
}
+71 -69
View File
@@ -3,7 +3,7 @@
<div class="page-dall">
<div class="inner custom-scroll">
<div class="sd-box">
<h2>DALL-E 创作中心</h2>
<h2 class="!text-[#252f76] py-3">AI图像生成</h2>
<div class="sd-params">
<el-form :model="params" label-width="80px" label-position="left">
@@ -25,55 +25,25 @@
</div>
<div class="param-line">
<el-form-item label="图片质量">
<el-form-item label="图片比例">
<template #default>
<div class="form-item-inner">
<el-select v-model="params.quality" style="width: 150px">
<el-option
v-for="v in qualities"
:label="v.name"
:value="v.value"
:key="v.value"
/>
</el-select>
</div>
</template>
</el-form-item>
</div>
<div class="param-line">
<el-form-item label="图片尺寸">
<template #default>
<div class="form-item-inner">
<el-select v-model="params.size" style="width: 150px">
<el-option v-for="v in sizes" :label="v" :value="v" :key="v" />
</el-select>
</div>
</template>
</el-form-item>
</div>
<div class="param-line">
<el-form-item label="图片样式">
<template #default>
<div class="form-item-inner">
<el-select v-model="params.style" style="width: 150px">
<el-option
v-for="v in styles"
:label="v.name"
:value="v.value"
:key="v.value"
/>
</el-select>
<el-tooltip
content="生动使模型倾向于生成超真实和戏剧性的图像"
raw-content
placement="right"
<el-select
v-model="params.aspect_ratio"
style="width: 150px"
value-key="value"
@change="changeAspectRatio"
>
<el-icon class="info-icon">
<InfoFilled />
</el-icon>
</el-tooltip>
<el-option
v-for="v in radioAspects"
:label="v.label"
:value="v.value"
:key="v.value"
>
<i class="iconfont mr-1" :class="v.icon"></i>
{{ v.label }}
</el-option>
</el-select>
</div>
</template>
</el-form-item>
@@ -105,7 +75,12 @@
<div class="mt-2 mb-2">
<label class="text-gray-700 font-semibold">参考图(可选)</label>
<div class="py-2">
<ImageUpload v-model="params.image" :max-count="5" :multiple="true" />
<ImageUpload
v-model="params.image"
:max-count="5"
:max-size="20"
:multiple="true"
/>
</div>
</div>
</el-form>
@@ -291,18 +266,18 @@
import nodata from '@/assets/img/no-data.png'
import BackTop from '@/components/BackTop.vue'
import ImageUpload from '@/components/ImageUpload.vue'
import TaskList from '@/components/TaskList.vue'
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { Delete, InfoFilled } from '@element-plus/icons-vue'
import { Delete } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onUnmounted, ref } from 'vue'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css'
import ImageUpload from '@/components/ImageUpload.vue'
const listBoxHeight = ref(0)
// const paramBoxHeight = ref(0)
@@ -321,21 +296,48 @@ resizeElement()
window.onresize = () => {
resizeElement()
}
const qualities = [
{ name: '标准', value: 'standard' },
{ name: '高清', value: 'hd' },
]
const dalleSizes = ['1024x1024', '1792x1024', '1024x1792']
const fluxSizes = ['1024x1024', '1152x896', '896x1152', '1280x960', '1024x576']
const sizes = ref(dalleSizes)
const styles = [
{ name: '生动', value: 'vivid' },
{ name: '自然', value: 'natural' },
]
// size aspect_ratio label aspect_ratio size
const radioAspects = ref({
'1:1': { value: '1:1', size: '1024x1024', label: '1:1(正方形,头像)', icon: 'icon-aspect_1_1' },
'2:3': {
value: '2:3',
size: '512x768',
label: '2:3(社交媒体,自拍)',
icon: 'icon-aspect_2_3',
},
'4:3': {
value: '4:3',
size: '1024x768',
label: '4:3(文章配图,插画)',
icon: 'icon-aspect_4_3',
},
'9:16': {
value: '9:16',
size: '768x1366',
label: '9:16(手机壁纸,人像)',
icon: 'icon-aspect_9_16',
},
'16:9': {
value: '16:9',
size: '1366x768',
label: '16:9(桌面壁纸,风景)',
icon: 'icon-aspect_16_9',
},
'3:2': { value: '3:2', size: '768x512', label: '3:21248x832', icon: 'icon-aspect_3_2' },
'3:4': { value: '3:4', size: '960x1280', label: '3:4864x1152', icon: 'icon-aspect_3_4' },
'4:5': { value: '4:5', size: '960x1280', label: '4:5896x1120', icon: 'icon-aspect_4_5' },
'5:4': {
value: '5:4',
size: '1280x960',
label: '5:41120x896',
icon: 'icon-aspect_5_4',
},
'21:9': { value: '21:9', size: '1344x576', label: '21:91536x658', icon: 'icon-aspect_16_9' },
})
const params = ref({
quality: 'standard',
aspect_ratio: '1:1',
size: '1024x1024',
style: 'vivid',
prompt: '',
})
@@ -385,6 +387,11 @@ onUnmounted(() => {
}
})
//
const changeAspectRatio = (value) => {
params.value.size = radioAspects.value[value].size
}
const initData = () => {
checkSession()
.then((user) => {
@@ -580,11 +587,6 @@ const generatePrompt = () => {
const changeModel = (model) => {
dallPower.value = model.power
if (model.name.startsWith('dall')) {
sizes.value = dalleSizes
} else {
sizes.value = fluxSizes
}
params.value.model_id = selectedModel.value.id
}
</script>
+2 -12
View File
@@ -82,7 +82,7 @@
<span class="username title">账户信息</span>
</div>
</li>
<li v-if="!license.de_copy">
<li>
<a :href="githubURL" target="_blank" class="flex">
<i class="iconfont icon-github"></i>
<span class="title">项目源码</span>
@@ -150,10 +150,9 @@
import LoginDialog from '@/components/LoginDialog.vue'
import ThemeChange from '@/components/ThemeChange.vue'
import ConfigDialog from '@/components/UserInfoDialog.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { showMessageError } from '@/utils/dialog'
import { httpGet } from '@/utils/http'
import { UserFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
@@ -171,7 +170,6 @@ const store = useSharedStore()
const loginUser = ref({})
const routerViewKey = ref(0)
const showConfigDialog = ref(false)
const license = ref({ de_copy: true })
const showLoginDialog = ref(false)
const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
@@ -261,14 +259,6 @@ onMounted(() => {
ElMessage.error('获取系统菜单失败:' + e.message)
})
getLicenseInfo()
.then((res) => {
license.value = res.data
})
.catch((e) => {
license.value = { de_copy: false }
showMessageError('获取 License 配置:' + e.message)
})
curPath.value = '/' + getFirstPathSegment(window.location.href)
init()
})
+6 -18
View File
@@ -8,7 +8,7 @@
<img :src="logo" class="logo" alt="Geek-AI" />
</div>
<div class="menu-item">
<span v-if="!license || !license.de_copy">
<span>
<el-tooltip class="box-item" content="部署文档" placement="bottom">
<a :href="docsURL" class="link-button mr-3" target="_blank">
<i class="iconfont icon-book"></i>
@@ -88,7 +88,7 @@
<script setup>
import FooterBar from '@/components/FooterBar.vue'
import ThemeChange from '@/components/ThemeChange.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
import { httpGet } from '@/utils/http'
import { isMobile } from '@/utils/libs'
@@ -102,7 +102,6 @@ const router = useRouter()
const title = ref('')
const logo = ref('')
const license = ref({ de_copy: true })
const isLogin = ref(false)
const docsURL = ref(import.meta.env.VITE_DOCS_URL)
@@ -123,17 +122,15 @@ const md = new MarkdownIt({
typographer: true,
}).use(emoji)
if (isMobile()) {
router.push('/mobile/index')
}
getSystemInfo()
.then((res) => {
const data = res.data
title.value = data.title
logo.value = data.logo
console.log(data)
if (data.index_page) {
if (isMobile() && data.enable_mobile_site) {
router.push('/mobile/index')
} else if (data.index_page) {
router.push(data.index_page)
}
})
@@ -142,15 +139,6 @@ getSystemInfo()
})
onMounted(() => {
getLicenseInfo()
.then((res) => {
license.value = res.data
})
.catch((e) => {
license.value = { de_copy: false }
ElMessage.error('获取 License 配置失败:' + e.message)
})
httpGet('/api/menu/list?index=1')
.then((res) => {
navs.value = res.data
-1
View File
@@ -229,7 +229,6 @@
>
<template #default="{ item }">
<div class="task-item">
<!-- 保持原有内容 -->
<div class="task-left">
<div class="task-preview">
<el-image
+5 -3
View File
@@ -41,7 +41,8 @@
<el-table-column prop="type" label="模型类型">
<template #default="scope">
<el-tag type="primary" v-if="scope.row.type === 'img'">绘图</el-tag>
<el-tag type="success" v-else>聊天</el-tag>
<el-tag type="warning" v-if="scope.row.type === 'tts'">语音</el-tag>
<el-tag type="success" v-if="scope.row.type === 'chat'">聊天</el-tag>
</template>
</el-table-column>
@@ -230,7 +231,7 @@
<script setup>
import { httpGet, httpPost } from '@/utils/http'
import { dateFormat, removeArrayItem, substr } from '@/utils/libs'
import { DocumentCopy, InfoFilled, Plus, Search, Delete } from '@element-plus/icons-vue'
import { Delete, DocumentCopy, InfoFilled, Plus, Search } from '@element-plus/icons-vue'
import ClipboardJS from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Sortable } from 'sortablejs'
@@ -239,7 +240,7 @@ import { onMounted, onUnmounted, reactive, ref } from 'vue'
//
const items = ref([])
const query = ref({ name: '' })
const item = ref({})
const item = ref({ options: {} })
const showDialog = ref(false)
const title = ref('')
const rules = reactive({
@@ -353,6 +354,7 @@ const add = function () {
max_tokens: 1024,
max_context: 8192,
temperature: 0.9,
options: {},
}
}
+4
View File
@@ -17,6 +17,7 @@
<el-switch v-model="scope.row.enabled" @change="functionSet('enabled', scope.row)" />
</template>
</el-table-column>
<el-table-column label="消耗算力" prop="power" />
<el-table-column label="操作" width="150" align="right">
<template #default="scope">
@@ -128,6 +129,9 @@
<el-form-item label="启用状态">
<el-switch v-model="item.enabled" />
</el-form-item>
<el-form-item label="消耗算力" prop="power">
<el-input-number v-model.number="item.power" autocomplete="off" />
</el-form-item>
</el-form>
<template #footer>
+2 -4
View File
@@ -138,12 +138,12 @@
/>
</el-form-item>
<el-form-item label="聊天角色" prop="chat_roles">
<el-form-item label="应用权限" prop="chat_roles">
<el-select
v-model="user.chat_roles"
multiple
:filterable="true"
placeholder="选择聊天角色,多选"
placeholder="选择 AI 应用"
>
<el-option v-for="item in roles" :key="item.key" :label="item.name" :value="item.key" />
</el-select>
@@ -243,8 +243,6 @@ const rules = reactive({
{ required: true, message: '请输入提问次数' },
{ type: 'number', message: '请输入有效数字' },
],
chat_roles: [{ required: true, message: '请选择聊天角色', trigger: 'change' }],
chat_models: [{ required: true, message: '请选择AI模型', trigger: 'change' }],
})
const loading = ref(true)
+20 -2
View File
@@ -115,7 +115,7 @@
</el-form-item>
<el-form-item label="版权信息" prop="copyright">
<el-input v-model="system['copyright']" placeholder="更改此选项需要获取 License 授权" />
<el-input v-model="system['copyright']" placeholder="请输入版权信息" />
</el-form-item>
<el-form-item label="ICP 备案号" prop="icp">
@@ -251,7 +251,19 @@
</el-form-item>
</div>
<div style="padding: 10px">
<el-form-item>
<template #label>
<div class="label-title">
启用手机站
<span class="text-xs text-gray-500"
>启用手机站点之后用户可以在手机上访问网站</span
>
</div>
</template>
<el-switch v-model="system['enable_mobile_site']" />
</el-form-item>
<div class="py-2">
<el-form-item>
<el-button type="primary" @click="save">保存</el-button>
</el-form-item>
@@ -323,6 +335,12 @@ const save = function () {
if (valid) {
httpPost('/api/admin/config/update/base', system.value)
.then(() => {
//
if (system.value['enable_mobile_site']) {
// addMobileRoutes()
} else {
// removeMobileRoutes()
}
ElMessage.success('操作成功!')
})
.catch((e) => {
@@ -1,159 +0,0 @@
<template>
<div class="license-config form p-5" v-loading="loading">
<div class="container">
<el-descriptions
v-if="license.is_active"
class="margin-top"
title="已授权信息"
:column="1"
border
>
<el-descriptions-item>
<template #label>
<div class="cell-item">License Key</div>
</template>
{{ license.key }}
<el-tooltip content="复制" placement="top">
<i class="iconfont icon-copy ml-2 cursor-pointer" @click="copy(license.key)"></i>
</el-tooltip>
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">机器码</div>
</template>
{{ license.machine_id }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">到期时间</div>
</template>
{{ dateFormat(license.expired_at) }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">用户人数</div>
</template>
{{ license.configs?.user_num }}
</el-descriptions-item>
<el-descriptions-item>
<template #label>
<div class="cell-item">去版权</div>
</template>
<el-icon class="selected" v-if="license.configs?.de_copy"><Select /></el-icon>
<el-icon class="closed" v-else><CloseBold /></el-icon>
<span class="text">去版权之后前端页面将不会显示版权信息和源码地址</span>
</el-descriptions-item>
</el-descriptions>
<div class="mt-5 p-5 border border-gray-200 rounded-md bg-gray-50">
<h3>激活后可获得以下权限</h3>
<div class="py-3 text-gray-500 leading-relaxed">
<p>1使用任意第三方中转 API KEY而不用局限于 GeekAI 推荐的白名单列表</p>
<p>2可以在相关页面去除 GeekAI 的版权信息或者修改为自己的版权信息</p>
</div>
<el-form label-position="top">
<el-form-item label="许可授权码" prop="license">
<el-input v-model="licenseKey" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="active">立即激活</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup>
import { httpGet, httpPost } from '@/utils/http'
import { dateFormat } from '@/utils/libs'
import { CloseBold, Select } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
const loading = ref(true)
const license = ref({ is_active: false })
const licenseKey = ref('')
onMounted(() => {
fetchLicense()
})
const fetchLicense = () => {
httpGet('/api/admin/config/license/get')
.then((res) => {
license.value = res.data
})
.catch((e) => {
ElMessage.error('获取 License 失败:' + e.message)
})
.finally(() => {
loading.value = false
})
}
//
const active = () => {
if (licenseKey.value === '') {
return ElMessage.error('请输入授权码')
}
httpPost('/api/admin/config/license/active', { license: licenseKey.value })
.then((res) => {
ElMessage.success('授权成功,机器编码为:' + res.data)
fetchLicense()
})
.catch((e) => {
ElMessage.error(e.message)
})
}
const copy = (text) => {
navigator.clipboard.writeText(text)
ElMessage.success('复制成功')
}
</script>
<style scoped>
.license-config {
display: flex;
justify-content: center;
}
.container {
width: 100%;
background-color: var(--el-bg-color);
padding: 10px 20px 40px 20px;
}
.margin-top {
margin-top: 20px;
}
.cell-item {
font-weight: bold;
}
.selected {
color: #67c23a;
}
.closed {
color: #f56c6c;
}
.text {
margin-left: 10px;
}
.active-info {
margin: 20px 0;
padding-left: 20px;
}
.active-info li {
margin: 10px 0;
line-height: 1.6;
}
</style>
+1 -1
View File
@@ -37,7 +37,7 @@
<i class="iconfont icon-info"></i>
</el-tooltip>
</label>
<el-input v-model="smtpConfig.port" type="number" placeholder="请输入端口号" />
<el-input v-model.number="smtpConfig.port" type="number" placeholder="请输入端口号" />
</el-form-item>
<el-form-item label="是否使用TLS"
+14 -5
View File
@@ -38,14 +38,14 @@
</el-tab-pane>
<el-tab-pane label="MinIO" name="minio">
<div class="rounded-md bg-blue-100 p-3 text-gray-500 border-blue-500 border-2 text-base">
<Alert type="info">
如果你不知道怎么获取这些配置信息请参考文档
<a
href="https://docs.geekai.me/plus/config/oss.html#%E6%90%AD%E5%BB%BA-minio-%E5%AD%98%E5%82%A8%E6%9C%8D%E5%8A%A1"
target="_blank"
>Minio 配置</a
>
</div>
</Alert>
<el-form :model="minio" class="mt-4" label-position="top">
<el-form-item label="Endpoint"><el-input v-model="minio.endpoint" /></el-form-item>
<el-form-item label="AccessKey"><el-input v-model="minio.access_key" /></el-form-item>
@@ -59,14 +59,14 @@
</el-tab-pane>
<el-tab-pane label="七牛云" name="qiniu">
<div class="rounded-md bg-blue-100 p-3 text-gray-500 border-blue-500 border-2 text-base">
<Alert type="info">
如果你不知道怎么获取这些配置信息请参考文档
<a
href="https://docs.geekai.me/plus/config/oss.html#%E4%B8%83%E7%89%9B%E4%BA%91-oss-%E9%85%8D%E7%BD%AE"
target="_blank"
>七牛云配置</a
>
</div>
</Alert>
<el-form :model="qiniu" class="mt-4" label-position="top">
<el-form-item label="Zone">
<template #label>
@@ -96,6 +96,14 @@
</el-tab-pane>
<el-tab-pane label="阿里云OSS" name="aliyun">
<Alert type="info">
如果你不知道怎么获取这些配置信息请参考文档
<a
href="https://docs.geekai.me/plus/config/oss.html#%E9%98%BF%E9%87%8C%E4%BA%91-oss-%E9%85%8D%E7%BD%AE"
target="_blank"
>阿里云OSS配置</a
>
</Alert>
<el-form :model="aliyun" class="mt-4" label-position="top">
<el-form-item label="Endpoint"><el-input v-model="aliyun.endpoint" /></el-form-item>
<el-form-item label="AccessKey"><el-input v-model="aliyun.access_key" /></el-form-item>
@@ -128,11 +136,12 @@
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import Alert from '@/components/ui/Alert.vue'
const loading = ref(true)
const activeTab = ref('local')
const active = ref('local')
const local = ref({ base_path: '', base_url: '' })
const local = ref({ base_path: './static/upload', base_url: '/static/upload' })
const minio = ref({
endpoint: '',
access_key: '',
+59 -44
View File
@@ -1,5 +1,5 @@
<template>
<div class="app-background">
<div class="app-background" v-if="menus['/chat']?.enabled">
<div class="container mobile-chat-list">
<van-nav-bar
:title="title"
@@ -72,15 +72,19 @@
<van-field v-model="tmpChatTitle" label="" placeholder="请输入对话标题" class="field" />
</van-dialog>
</div>
<div v-else>
<FunDisabled />
</div>
</template>
<script setup>
import { checkSession } from '@/store/cache'
import { checkSession, getMenus } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { removeArrayItem, showLoginDialog } from '@/utils/libs'
import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import FunDisabled from '@/components/ui/FunDisabled.vue'
const title = ref('会话列表')
const router = useRouter()
@@ -99,50 +103,61 @@ const columns = ref([roles.value, models.value])
const showEditChat = ref(false)
const item = ref({})
const tmpChatTitle = ref('')
const menus = ref({})
checkSession()
.then((user) => {
loginUser.value = user
isLogin.value = true
})
.finally(() => {
loading.value = false
finished.value = true
//
httpGet(`/api/app/list`)
.then((res) => {
if (res.data) {
const items = res.data
for (let i = 0; i < items.length; i++) {
// console.log(items[i])
roles.value.push({
text: items[i].name,
value: items[i].id,
icon: items[i].icon,
helloMsg: items[i].hello_msg,
model_id: items[i].model_id,
})
}
}
})
.catch(() => {
showFailToast('加载聊天角色失败')
})
onMounted(() => {
getMenus()
.then((data) => {
menus.value = data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取菜单失败:' + e.message })
})
//
httpGet('/api/model/list?enable=1')
.then((res) => {
if (res.data) {
const items = res.data
for (let i = 0; i < items.length; i++) {
models.value.push({ text: items[i].name, value: items[i].id })
checkSession()
.then((user) => {
loginUser.value = user
isLogin.value = true
})
.finally(() => {
loading.value = false
finished.value = true
//
httpGet(`/api/app/list`)
.then((res) => {
if (res.data) {
const items = res.data
for (let i = 0; i < items.length; i++) {
// console.log(items[i])
roles.value.push({
text: items[i].name,
value: items[i].id,
icon: items[i].icon,
helloMsg: items[i].hello_msg,
model_id: items[i].model_id,
})
}
}
}
})
.catch((e) => {
showFailToast('加载模型失败: ' + e.message)
})
})
})
.catch(() => {
showFailToast('加载聊天角色失败')
})
//
httpGet('/api/model/list?enable=1')
.then((res) => {
if (res.data) {
const items = res.data
for (let i = 0; i < items.length; i++) {
models.value.push({ text: items[i].name, value: items[i].id })
}
}
})
.catch((e) => {
showFailToast('加载模型失败: ' + e.message)
})
})
})
const onLoad = () => {
checkSession()
-6
View File
@@ -50,12 +50,6 @@ import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const activeTab = ref(route.query.tab || 'mj')
const menus = ref([])
const activeMenu = ref({
mj: false,
sd: false,
dall: false,
})
// Tab
const onTabChange = (name) => {
+16
View File
@@ -42,8 +42,10 @@
</template>
<script setup>
import { getMenus } from '@/store/cache'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted } from 'vue'
const router = useRouter()
@@ -60,6 +62,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/create?tab=mj',
path: '/mj',
},
{
key: 'sd',
@@ -71,6 +74,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/create?tab=sd',
path: '/sd',
},
{
key: 'dalle',
@@ -82,6 +86,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/create?tab=dalle',
path: '/dalle',
},
{
key: 'suno',
@@ -94,6 +99,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/suno',
path: '/suno',
},
{
key: 'video',
@@ -105,6 +111,7 @@ const aiTools = ref([
status: 'beta',
statusText: '测试版',
url: '/mobile/video',
path: '/video',
},
{
key: 'jimeng',
@@ -116,6 +123,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/jimeng',
path: '/jimeng',
},
{
key: 'imgWall',
@@ -127,6 +135,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/imgWall',
path: '/images-wall',
},
{
key: 'apps',
@@ -138,6 +147,7 @@ const aiTools = ref([
status: 'active',
statusText: '可用',
url: '/mobile/apps',
path: '/apps',
},
])
@@ -149,6 +159,12 @@ const navigateTo = (url) => {
router.push(url)
}
}
onMounted(() => {
getMenus().then((menus) => {
aiTools.value = aiTools.value.filter((tool) => menus[tool.path]?.enabled)
})
})
</script>
<style lang="scss" scoped>
+1 -1
View File
@@ -84,7 +84,7 @@ onMounted(() => {
</script>
<style lang="scss">
@use '../../assets/iconfont/iconfont.css' as *;
@use '@/assets/iconfont/iconfont.css' as *;
.mobile-home {
.page-content {
+15 -1
View File
@@ -119,7 +119,7 @@
</template>
<script setup>
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, showLoginDialog, substr } from '@/utils/libs'
import { ElMessage } from 'element-plus'
@@ -145,6 +145,7 @@ const features = ref([
icon: 'icon-dalle',
color: '#F59E0B',
url: '/mobile/create?tab=dalle',
path: '/dalle',
},
{
key: 'suno',
@@ -152,6 +153,7 @@ const features = ref([
icon: 'icon-mp3',
color: '#EF4444',
url: '/mobile/suno',
path: '/suno',
},
{
key: 'video',
@@ -159,6 +161,7 @@ const features = ref([
icon: 'icon-video',
color: '#10B981',
url: '/mobile/video',
path: '/video',
},
{
key: 'jimeng',
@@ -166,6 +169,7 @@ const features = ref([
icon: 'icon-jimeng',
color: '#F97316',
url: '/mobile/jimeng',
path: '/jimeng',
},
{
key: '3d',
@@ -173,6 +177,7 @@ const features = ref([
icon: 'icon-3d',
color: '#8B5CF6',
url: '/mobile/3d',
path: '/3d',
},
{ key: 'agent', name: '智能体', icon: 'icon-app', color: '#3B82F6', url: '/mobile/apps' },
{
@@ -181,6 +186,7 @@ const features = ref([
icon: 'icon-image-list',
color: '#EC4899',
url: '/mobile/imgWall',
path: '/images-wall',
},
])
@@ -225,6 +231,14 @@ onMounted(() => {
})
.catch(() => {})
getMenus()
.then((menus) => {
features.value = features.value.filter((feature) => menus[feature.path]?.enabled)
})
.catch((e) => {
ElMessage.error('获取菜单失败:' + e.message)
})
fetchApps()
})
+15 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="member-page">
<div class="member-page" v-if="menus['/member']?.enabled">
<div class="member-content" v-loading="loading" :element-loading-text="loadingText">
<!-- 产品套餐 -->
<div class="products-section">
@@ -103,14 +103,18 @@
</div>
</van-dialog>
</div>
<div v-else>
<FunDisabled />
</div>
</template>
<script setup>
import RedeemVerify from '@/components/RedeemVerify.vue'
import UserOrder from '@/components/UserOrder.vue'
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import FunDisabled from '@/components/ui/FunDisabled.vue'
import QRCode from 'qrcode'
import { showFailToast, showLoadingToast, showSuccessToast } from 'vant'
import { onMounted, onUnmounted, ref } from 'vue'
@@ -123,6 +127,7 @@ const loading = ref(true)
const loadingText = ref('加载中...')
const vipInfoText = ref('')
const userOrderKey = ref(0)
const menus = ref({})
//
const showRedeemVerifyDialog = ref(false)
@@ -171,6 +176,14 @@ onMounted(() => {
.catch((e) => {
console.error('获取系统配置失败:', e.message)
})
getMenus()
.then((data) => {
menus.value = data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取菜单失败:' + e.message })
})
})
//
+225 -230
View File
@@ -1,203 +1,193 @@
<template>
<div class="mobile-sd">
<van-form>
<van-cell-group class="px-3 pt-3 pb-4">
<div>
<van-field
v-model="selectedModel"
is-link
label="生图模型"
placeholder="选择生图模型"
@click="showModelPicker = true"
/>
<van-popup v-model:show="showModelPicker" position="bottom" teleport="#app">
<van-picker
:columns="models"
@cancel="showModelPicker = false"
@confirm="modelConfirm"
<div v-if="menus['/dalle']?.enabled">
<van-form>
<van-cell-group class="px-3 pt-3 pb-4">
<div>
<van-field
v-model="selectedModel"
is-link
label="生图模型"
placeholder="选择生图模型"
@click="showModelPicker = true"
/>
</van-popup>
</div>
<div>
<van-field
v-model="quality"
is-link
label="图片质量"
placeholder="选择图片质量"
@click="showQualityPicker = true"
/>
<van-popup v-model:show="showQualityPicker" position="bottom" teleport="#app">
<van-picker
:columns="qualities"
@cancel="showQualityPicker = false"
@confirm="qualityConfirm"
/>
</van-popup>
</div>
<div>
<van-field
v-model="size"
is-link
label="图片尺寸"
placeholder="选择图片尺寸"
@click="showSizePicker = true"
/>
<van-popup v-model:show="showSizePicker" position="bottom" teleport="#app">
<van-picker :columns="sizes" @cancel="showSizePicker = false" @confirm="sizeConfirm" />
</van-popup>
</div>
<div>
<van-field
v-model="style"
is-link
label="图片样式"
placeholder="选择图片样式"
@click="showStylePicker = true"
/>
<van-popup v-model:show="showStylePicker" position="bottom" teleport="#app">
<van-picker
:columns="styles"
@cancel="showStylePicker = false"
@confirm="styleConfirm"
/>
</van-popup>
</div>
<van-field
v-model="params.prompt"
rows="3"
autosize
maxlength="2000"
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
<div class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm">
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ dallPower }}算力)</span>
</button>
</div>
</van-cell-group>
</van-form>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
<van-popup v-model:show="showModelPicker" position="bottom" teleport="#app">
<van-picker
:columns="models"
@cancel="showModelPicker = false"
@confirm="modelConfirm"
/>
</van-popup>
</div>
<div>
<van-field
v-model="aspectRatio"
is-link
label="图片比例"
placeholder="选择图片比例"
@click="showSizePicker = true"
/>
<van-popup v-model:show="showSizePicker" position="bottom" teleport="#app">
<van-picker
:columns="radioAspects"
@cancel="showSizePicker = false"
@confirm="sizeConfirm"
/>
</van-popup>
</div>
<van-field
v-model="params.prompt"
rows="3"
autosize
maxlength="2000"
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
<div class="mt-3 mb-3 px-3">
<label class="text-gray-700 font-semibold">参考图(可选)</label>
<div class="py-2">
<ImageUpload v-model="params.image" :max-count="5" :multiple="true" />
</div>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
<div
class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm"
>
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ dallPower }}算力)</span>
</button>
</div>
</van-grid-item>
</van-grid>
</div>
</van-cell-group>
</van-form>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image>
<div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage($event, item)" circle />
<el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image>
<div class="remove">
<el-button
type="danger"
:icon="Delete"
@click="removeImage($event, item)"
circle
/>
<el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
</div>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
<button
style="display: none"
class="copy-prompt-dall"
:data-clipboard-text="prompt"
id="copy-btn-dall"
>
复制
</button>
</div>
<div v-else>
<FunDisabled />
</div>
<button
style="display: none"
class="copy-prompt-dall"
:data-clipboard-text="prompt"
id="copy-btn-dall"
>
复制
</button>
</div>
</template>
<script setup>
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { getSessionId } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
@@ -215,52 +205,64 @@ import {
} from 'vant'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import FunDisabled from '@/components/ui/FunDisabled.vue'
import ImageUpload from '@/components/ImageUpload.vue'
const listBoxHeight = ref(window.innerHeight - 40)
const mjBoxHeight = ref(window.innerHeight - 150)
const isLogin = ref(false)
const menus = ref({})
window.onresize = () => {
listBoxHeight.value = window.innerHeight - 40
mjBoxHeight.value = window.innerHeight - 150
}
const qualities = [
{ text: '标准', value: 'standard' },
{ text: '高清', value: 'hd' },
]
const fluxSizes = [
{ text: '1024x1024', value: '1024x1024' },
{ text: '1024x768', value: '1024x768' },
{ text: '768x1024', value: '768x1024' },
{ text: '1280x960', value: '1280x960' },
{ text: '960x1280', value: '960x1280' },
{ text: '1366x768', value: '1366x768' },
{ text: '768x1366', value: '768x1366' },
]
const dalleSizes = [
{ text: '1024x1024', value: '1024x1024' },
{ text: '1792x1024', value: '1792x1024' },
{ text: '1024x1792', value: '1024x1792' },
]
let sizes = dalleSizes
const styles = [
{ text: '生动', value: 'vivid' },
{ text: '自然', value: 'natural' },
]
// size aspect_ratio label aspect_ratio size
const radioAspects = ref([
{ value: '1:1', size: '1024x1024', text: '1:1(正方形,头像)', icon: 'icon-aspect_1_1' },
{
value: '2:3',
size: '512x768',
text: '2:3(社交媒体,自拍)',
icon: 'icon-aspect_2_3',
},
{
value: '4:3',
size: '1024x768',
text: '4:3(文章配图,插画)',
icon: 'icon-aspect_4_3',
},
{
value: '9:16',
size: '768x1366',
text: '9:16(手机壁纸,人像)',
icon: 'icon-aspect_9_16',
},
{
value: '16:9',
size: '1366x768',
text: '16:9(桌面壁纸,风景)',
icon: 'icon-aspect_16_9',
},
{ value: '3:2', size: '768x512', text: '3:21248x832', icon: 'icon-aspect_3_2' },
{ value: '3:4', size: '960x1280', text: '3:4864x1152', icon: 'icon-aspect_3_4' },
{ value: '4:5', size: '960x1280', text: '4:5896x1120', icon: 'icon-aspect_4_5' },
{
value: '5:4',
size: '1280x960',
label: '5:41120x896',
icon: 'icon-aspect_5_4',
},
{ value: '21:9', size: '1344x576', text: '21:91536x658', icon: 'icon-aspect_16_9' },
])
const params = ref({
quality: qualities[0].value,
size: sizes[0].value,
style: styles[0].value,
aspect_ratio: '1:1',
size: '1024x1024',
prompt: '',
})
const quality = ref(qualities[0].text)
const size = ref(sizes[0].text)
const style = ref(styles[0].text)
const aspectRatio = ref(radioAspects.value[0].text)
const showQualityPicker = ref(false)
const showStylePicker = ref(false)
const showSizePicker = ref(false)
const showModelPicker = ref(false)
@@ -313,6 +315,14 @@ onMounted(() => {
.catch((e) => {
showMessageError('获取模型列表失败:' + e.message)
})
getMenus()
.then((data) => {
menus.value = data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取菜单失败:' + e.message })
})
})
onUnmounted(() => {
@@ -408,10 +418,6 @@ const generate = () => {
promptRef.value.focus()
return showToast('请输入绘画提示词!')
}
if (!params.value.seed) {
params.value.seed = -1
}
params.value.session_id = getSessionId()
httpPost('/api/dall/image', params.value)
.then(() => {
@@ -492,21 +498,10 @@ const imageView = (item) => {
showImagePreview([item['img_url']])
}
const qualityConfirm = (item) => {
params.value.quality = item.selectedOptions[0].value
quality.value = item.selectedOptions[0].text
showQualityPicker.value = false
}
const styleConfirm = (item) => {
params.value.style = item.selectedOptions[0].value
style.value = item.selectedOptions[0].text
showStylePicker.value = false
}
const sizeConfirm = (item) => {
params.value.size = item.selectedOptions[0].value
size.value = item.selectedOptions[0].text
params.value.aspect_ratio = item.selectedOptions[0].value
params.value.size = item.selectedOptions[0].size
aspectRatio.value = item.selectedOptions[0].text
showSizePicker.value = false
}
+339 -314
View File
@@ -1,338 +1,353 @@
<template>
<div class="mobile-mj">
<van-form>
<div class="text-line">图片比例</div>
<div class="text-line">
<van-row :gutter="10">
<van-col :span="4" v-for="item in rates" :key="item.value">
<div
:class="item.value === params.rate ? 'rate active' : 'rate'"
@click="changeRate(item)"
>
<div class="icon">
<van-image :src="item.img" fit="cover"></van-image>
</div>
<div class="text">{{ item.text }}</div>
</div>
</van-col>
</van-row>
</div>
<div class="text-line">模型选择</div>
<div class="text-line">
<van-row :gutter="10">
<van-col :span="8" v-for="item in models" :key="item.value">
<div
:class="item.value === params.model ? 'model active' : 'model'"
@click="changeModel(item)"
>
<div class="icon">
<van-image :src="item.img" fit="cover"></van-image>
</div>
<div class="text">
<van-text-ellipsis :content="item.text" />
</div>
</div>
</van-col>
</van-row>
</div>
<div class="text-line">
<van-field label="创意度">
<template #input>
<van-slider
v-model.number="params.chaos"
:max="100"
:step="1"
@update:model-value="showToast('当前值:' + params.chaos)"
/>
</template>
</van-field>
</div>
<div class="text-line">
<van-field label="风格化">
<template #input>
<van-slider
v-model.number="params.stylize"
:max="1000"
:step="1"
@update:model-value="showToast('当前值:' + params.stylize)"
/>
</template>
</van-field>
</div>
<div class="text-line">
<van-field label="原始模式">
<template #input>
<van-switch v-model="params.raw" />
</template>
</van-field>
</div>
<div class="text-line">
<van-tabs v-model:active="activeName" @change="tabChange" animated>
<van-tab title="文生图" name="txt2img">
<div class="text-line">
<van-field
v-model="params.prompt"
maxlength="2000"
rows="3"
autosize
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
</div>
</van-tab>
<van-tab title="图生图" name="img2img">
<div class="text-line">
<van-field
v-model="params.prompt"
rows="3"
autosize
maxlength="2000"
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
</div>
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
<div class="text-line">
<van-field label="垫图权重">
<template #input>
<van-slider
v-model.number="params.iw"
:max="1"
:step="0.01"
@update:model-value="showToast('当前值:' + params.iw)"
/>
</template>
</van-field>
</div>
<div class="tip-text">
提示只有于 niji6 v6 模型支持一致性功能如果选择其他模型此功能将会生成失败
</div>
<van-cell-group>
<van-field
v-model="params.cref"
center
clearable
label="角色一致性"
placeholder="请输入图片URL或者上传图片"
<div v-if="menus['/mj']?.enabled">
<van-form>
<div class="text-line">图片比例</div>
<div class="text-line">
<van-row :gutter="10">
<van-col :span="4" v-for="item in rates" :key="item.value">
<div
:class="item.value === params.rate ? 'rate active' : 'rate'"
@click="changeRate(item)"
>
<template #button>
<van-uploader @click="beforeUpload('cref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" />
</van-uploader>
</template>
</van-field>
</van-cell-group>
<van-cell-group>
<van-field
v-model="params.sref"
center
clearable
label="风格一致性"
placeholder="请输入图片URL或者上传图片"
<div class="icon">
<van-image :src="item.img" fit="cover"></van-image>
</div>
<div class="text">{{ item.text }}</div>
</div>
</van-col>
</van-row>
</div>
<div class="text-line">模型选择</div>
<div class="text-line">
<van-row :gutter="10">
<van-col :span="8" v-for="item in models" :key="item.value">
<div
:class="item.value === params.model ? 'model active' : 'model'"
@click="changeModel(item)"
>
<template #button>
<van-uploader @click="beforeUpload('sref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" />
</van-uploader>
</template>
</van-field>
</van-cell-group>
<div class="text-line">
<van-field label="一致性权重">
<template #input>
<van-slider
v-model.number="params.cw"
:max="100"
:step="1"
@update:model-value="showToast('当前值:' + params.cw)"
/>
</template>
</van-field>
</div>
</van-tab>
<van-tab title="融图" name="blend">
<div class="tip-text">
请上传两张以上的图片最多不超过五张超过五张图片请使用图生图功能
</div>
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
</van-tab>
<van-tab title="换脸" name="swapFace">
<div class="tip-text">请上传两张有脸部的图片用左边图片的脸替换右边图片的脸</div>
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
</van-tab>
</van-tabs>
</div>
<div class="text-line">
<van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt">
<van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item>
</van-collapse>
</div>
<div class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm">
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ mjPower }}算力)</span>
</button>
</div>
</van-form>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
<div class="icon">
<van-image :src="item.img" fit="cover"></van-image>
</div>
<div class="text">
<van-text-ellipsis :content="item.text" />
</div>
</div>
</van-col>
</van-row>
</div>
<div class="text-line">
<van-field label="创意度">
<template #input>
<van-slider
v-model.number="params.chaos"
:max="100"
:step="1"
@update:model-value="showToast('当前值:' + params.chaos)"
/>
</div>
</div>
</template>
</van-field>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
</div>
</van-grid-item>
</van-grid>
</div>
<div class="text-line">
<van-field label="风格化">
<template #input>
<van-slider
v-model.number="params.stylize"
:max="1000"
:step="1"
@update:model-value="showToast('当前值:' + params.stylize)"
/>
</template>
</van-field>
</div>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<div class="text-line">
<van-field label="原始模式">
<template #input>
<van-switch v-model="params.raw" />
</template>
</van-field>
</div>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id" class="min-h-[270px]">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage(item)" size="small">删除</van-button>
<div class="text-line">
<van-tabs v-model:active="activeName" @change="tabChange" animated>
<van-tab title="文生图" name="txt2img">
<div class="text-line">
<van-field
v-model="params.prompt"
maxlength="2000"
rows="3"
autosize
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['thumb_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
<template v-slot:error>
<span style="margin-bottom: 20px">正在下载图片</span>
<van-loading type="circular" color="#1989fa" size="40" />
</template>
</van-image>
<div class="opt" v-if="item['can_opt']">
<van-grid :gutter="3" :column-num="4">
<van-grid-item><a @click="upscale(1, item)" class="opt-btn">U1</a></van-grid-item>
<van-grid-item><a @click="upscale(2, item)" class="opt-btn">U2</a></van-grid-item>
<van-grid-item><a @click="upscale(3, item)" class="opt-btn">U3</a></van-grid-item>
<van-grid-item><a @click="upscale(4, item)" class="opt-btn">U4</a></van-grid-item>
<van-grid-item
><a @click="variation(1, item)" class="opt-btn">V1</a></van-grid-item
>
<van-grid-item
><a @click="variation(2, item)" class="opt-btn">V2</a></van-grid-item
>
<van-grid-item
><a @click="variation(3, item)" class="opt-btn">V3</a></van-grid-item
>
<van-grid-item
><a @click="variation(4, item)" class="opt-btn">V4</a></van-grid-item
>
</van-grid>
</van-tab>
<van-tab title="图生图" name="img2img">
<div class="text-line">
<van-field
v-model="params.prompt"
rows="3"
autosize
maxlength="2000"
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
</div>
<div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage(item)" circle />
<el-button
type="warning"
v-if="item.publish"
@click="publishImage(item, false)"
circle
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
<div class="text-line">
<van-field label="垫图权重">
<template #input>
<van-slider
v-model.number="params.iw"
:max="1"
:step="0.01"
@update:model-value="showToast('当前值:' + params.iw)"
/>
</template>
</van-field>
</div>
<div class="tip-text">
提示只有于 niji6 v6 模型支持一致性功能如果选择其他模型此功能将会生成失败
</div>
<van-cell-group>
<van-field
v-model="params.cref"
center
clearable
label="角色一致性"
placeholder="请输入图片URL或者上传图片"
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage(item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
<template #button>
<van-uploader @click="beforeUpload('cref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" />
</van-uploader>
</template>
</van-field>
</van-cell-group>
<van-cell-group>
<van-field
v-model="params.sref"
center
clearable
label="风格一致性"
placeholder="请输入图片URL或者上传图片"
>
<template #button>
<van-uploader @click="beforeUpload('sref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" />
</van-uploader>
</template>
</van-field>
</van-cell-group>
<div class="text-line">
<van-field label="一致性权重">
<template #input>
<van-slider
v-model.number="params.cw"
:max="100"
:step="1"
@update:model-value="showToast('当前值:' + params.cw)"
/>
</template>
</van-field>
</div>
</van-tab>
<van-tab title="融图" name="blend">
<div class="tip-text">
请上传两张以上的图片最多不超过五张超过五张图片请使用图生图功能
</div>
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
</van-tab>
<van-tab title="换脸" name="swapFace">
<div class="tip-text">请上传两张有脸部的图片用左边图片的脸替换右边图片的脸</div>
<div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" />
</div>
</van-tab>
</van-tabs>
</div>
<div class="text-line">
<van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt">
<van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item>
</van-collapse>
</div>
<div class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm">
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ mjPower }}算力)</span>
</button>
</div>
</van-form>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
</div>
<button style="display: none" class="copy-prompt" :data-clipboard-text="prompt" id="copy-btn">
复制
</button>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id" class="min-h-[270px]">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage(item)" size="small"
>删除</van-button
>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['thumb_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
<template v-slot:error>
<span style="margin-bottom: 20px">正在下载图片</span>
<van-loading type="circular" color="#1989fa" size="40" />
</template>
</van-image>
<div class="opt" v-if="item['can_opt']">
<van-grid :gutter="3" :column-num="4">
<van-grid-item
><a @click="upscale(1, item)" class="opt-btn">U1</a></van-grid-item
>
<van-grid-item
><a @click="upscale(2, item)" class="opt-btn">U2</a></van-grid-item
>
<van-grid-item
><a @click="upscale(3, item)" class="opt-btn">U3</a></van-grid-item
>
<van-grid-item
><a @click="upscale(4, item)" class="opt-btn">U4</a></van-grid-item
>
<van-grid-item
><a @click="variation(1, item)" class="opt-btn">V1</a></van-grid-item
>
<van-grid-item
><a @click="variation(2, item)" class="opt-btn">V2</a></van-grid-item
>
<van-grid-item
><a @click="variation(3, item)" class="opt-btn">V3</a></van-grid-item
>
<van-grid-item
><a @click="variation(4, item)" class="opt-btn">V4</a></van-grid-item
>
</van-grid>
</div>
<div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage(item)" circle />
<el-button
type="warning"
v-if="item.publish"
@click="publishImage(item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage(item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
</div>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
<button style="display: none" class="copy-prompt" :data-clipboard-text="prompt" id="copy-btn">
复制
</button>
</div>
<div v-else>
<FunDisabled />
</div>
</div>
</template>
<script setup>
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { getSessionId } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
@@ -340,6 +355,7 @@ import { showLoginDialog } from '@/utils/libs'
import { Delete } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import Compressor from 'compressorjs'
import FunDisabled from '@/components/ui/FunDisabled.vue'
import {
showConfirmDialog,
showDialog,
@@ -351,6 +367,7 @@ import {
} from 'vant'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const menus = ref({})
const activeColspan = ref([''])
@@ -437,6 +454,14 @@ onMounted(() => {
.catch(() => {
// router.push('/login')
})
getMenus()
.then((data) => {
menus.value = data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取菜单失败:' + e.message })
})
})
onUnmounted(() => {
+241 -219
View File
@@ -1,265 +1,278 @@
<template>
<div class="mobile-sd">
<van-form>
<van-cell-group class="px-3 pt-3 pb-4">
<div>
<van-field
v-model="params.sampler"
is-link
readonly
label="采样方法"
placeholder="选择采样方法"
@click="showSamplerPicker = true"
/>
<van-popup v-model:show="showSamplerPicker" position="bottom" teleport="#app">
<van-picker
:columns="samplers"
@cancel="showSamplerPicker = false"
@confirm="samplerConfirm"
/>
</van-popup>
</div>
<van-field label="图片尺寸">
<template #input>
<van-row gutter="20">
<van-col span="12">
<el-input v-model="params.width" size="small" placeholder="宽" />
</van-col>
<van-col span="12">
<el-input v-model="params.height" size="small" placeholder="高" />
</van-col>
</van-row>
</template>
</van-field>
<van-field v-model.number="params.steps" label="迭代步数" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="showInfo('值越大则代表细节越多,同时也意味着出图速度越慢,一般推荐20-30')"
/>
</template>
</van-field>
<van-field v-model.number="params.cfg_scale" label="引导系数" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="
showInfo('提示词引导系数,图像在多大程度上服从提示词,较低值会产生更有创意的结果')
"
/>
</template>
</van-field>
<van-field v-model.number="params.seed" label="随机因子" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="
showInfo('随机数种子,相同的种子会得到相同的结果,设置为 -1 则每次随机生成种子')
"
/>
</template>
</van-field>
<van-field label="高清修复">
<template #input>
<van-switch v-model="params.hd_fix" />
</template>
</van-field>
<div v-if="params.hd_fix">
<div v-if="menus['/sd']?.enabled">
<van-form>
<van-cell-group class="px-3 pt-3 pb-4">
<div>
<van-field
v-model="params.hd_scale_alg"
v-model="params.sampler"
is-link
readonly
label="放大算法"
placeholder="选择放大算法"
@click="showUpscalePicker = true"
label="采样方法"
placeholder="选择采样方法"
@click="showSamplerPicker = true"
/>
<van-popup v-model:show="showUpscalePicker" position="bottom" teleport="#app">
<van-popup v-model:show="showSamplerPicker" position="bottom" teleport="#app">
<van-picker
:columns="upscaleAlgArr"
@cancel="showUpscalePicker = false"
@confirm="upscaleConfirm"
:columns="samplers"
@cancel="showSamplerPicker = false"
@confirm="samplerConfirm"
/>
</van-popup>
</div>
<van-field v-model.number="params.hd_scale" label="放大倍数" />
<van-field v-model.number="params.hd_steps" label="迭代步数" />
<van-field label="重绘幅度">
<van-field label="图片尺寸">
<template #input>
<van-slider
v-model.number="params.hd_redraw_rate"
:max="1"
:step="0.1"
@update:model-value="showToast('当前值:' + params.hd_redraw_rate)"
/>
<van-row gutter="20">
<van-col span="12">
<el-input v-model="params.width" size="small" placeholder="宽" />
</van-col>
<van-col span="12">
<el-input v-model="params.height" size="small" placeholder="高" />
</van-col>
</van-row>
</template>
</van-field>
<van-field v-model.number="params.steps" label="迭代步数" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="showInfo('决定算法对图像内容的影响程度,较大的值将得到越有创意的图像')"
@click="showInfo('值越大则代表细节越多,同时也意味着出图速度越慢,一般推荐20-30')"
/>
</template>
</van-field>
</div>
<van-field
v-model="params.prompt"
maxlength="2000"
rows="3"
autosize
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
<van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt">
<van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item>
</van-collapse>
<div class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm">
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ sdPower }}算力)</span>
</button>
</div>
</van-cell-group>
</van-form>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
<van-field v-model.number="params.cfg_scale" label="引导系数" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="
showInfo('提示词引导系数,图像在多大程度上服从提示词,较低值会产生更有创意的结果')
"
/>
</template>
</van-field>
<van-field v-model.number="params.seed" label="随机因子" placeholder="">
<template #right-icon>
<van-icon
name="info-o"
@click="
showInfo('随机数种子,相同的种子会得到相同的结果,设置为 -1 则每次随机生成种子')
"
/>
</template>
</van-field>
<van-field label="高清修复">
<template #input>
<van-switch v-model="params.hd_fix" />
</template>
</van-field>
<div v-if="params.hd_fix">
<div>
<van-field
v-model="params.hd_scale_alg"
is-link
readonly
label="放大算法"
placeholder="选择放大算法"
@click="showUpscalePicker = true"
/>
<van-popup v-model:show="showUpscalePicker" position="bottom" teleport="#app">
<van-picker
:columns="upscaleAlgArr"
@cancel="showUpscalePicker = false"
@confirm="upscaleConfirm"
/>
</van-popup>
</div>
<van-field v-model.number="params.hd_scale" label="放大倍数" />
<van-field v-model.number="params.hd_steps" label="迭代步数" />
<van-field label="重绘幅度">
<template #input>
<van-slider
v-model.number="params.hd_redraw_rate"
:max="1"
:step="0.1"
@update:model-value="showToast('当前值:' + params.hd_redraw_rate)"
/>
</template>
<template #right-icon>
<van-icon
name="info-o"
@click="showInfo('决定算法对图像内容的影响程度,较大的值将得到越有创意的图像')"
/>
</template>
</van-field>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
<van-field
v-model="params.prompt"
maxlength="2000"
rows="3"
autosize
type="textarea"
placeholder="请在此输入绘画提示词,系统会自动翻译中文提示词,高手请直接输入英文提示词"
/>
<van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt">
<van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item>
</van-collapse>
<div
class="sticky bottom-4 bg-[var(--van-cell-group-background)] rounded-xl p-4 shadow-sm"
>
<button
@click="generate"
:disabled="loading"
type="button"
class="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 text-white font-semibold rounded-xl disabled:from-gray-400 disabled:to-gray-400 disabled:cursor-not-allowed hover:from-blue-600 hover:to-purple-700 transition-all duration-200 flex items-center justify-center space-x-2"
>
<i v-if="loading" class="iconfont icon-loading animate-spin"></i>
<i v-else class="iconfont icon-chuangzuo"></i>
<span>{{ loading ? '创作中...' : '立即生成' }}({{ sdPower }}算力)</span>
</button>
</div>
</van-grid-item>
</van-grid>
</div>
</van-cell-group>
</van-form>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
<h3 class="m-3">任务列表</h3>
<div class="running-job-list pt-3 pb-3">
<van-empty
v-if="runningJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-grid :gutter="10" :column-num="3" v-else>
<van-grid-item v-for="item in runningJobs" :key="item.id">
<div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image>
<div class="progress">
<van-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image>
<div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage($event, item)" circle />
<el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
</div>
<div v-else class="task-in-queue">
<span class="icon"><i class="iconfont icon-quick-start"></i></span>
<span class="text">排队中</span>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
<h3 class="m-3">创作记录</h3>
<div class="finish-job-list">
<van-empty
v-if="finishedJobs.length === 0"
image="https://fastly.jsdelivr.net/npm/@vant/assets/custom-empty-image.png"
image-size="80"
description="暂无记录"
/>
<van-list
v-else
v-model:error="error"
v-model:loading="loading"
:finished="finished"
error-text="请求失败,点击重新加载"
finished-text="没有更多了"
@load="onLoad"
>
<van-grid :gutter="10" :column-num="2">
<van-grid-item v-for="item in finishedJobs" :key="item.id">
<div class="failed" v-if="item.progress === 101">
<div class="title">任务失败</div>
<div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
</div>
</div>
<div class="job-item" v-else>
<van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading>
<van-loading type="spinner" size="20" />
</template>
</van-image>
<div class="remove">
<el-button
type="danger"
:icon="Delete"
@click="removeImage($event, item)"
circle
/>
<el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i>
</el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle>
<i class="iconfont icon-share-bold"></i>
</el-button>
<el-button type="primary" @click="showPrompt(item)" circle>
<i class="iconfont icon-prompt"></i>
</el-button>
</div>
</div>
</van-grid-item>
</van-grid>
</van-list>
</div>
<button
style="display: none"
class="copy-prompt-sd"
:data-clipboard-text="prompt"
id="copy-btn-sd"
>
复制
</button>
</div>
<div v-else>
<FunDisabled />
</div>
<button
style="display: none"
class="copy-prompt-sd"
:data-clipboard-text="prompt"
id="copy-btn-sd"
>
复制
</button>
</div>
</template>
<script setup>
import { checkSession, getSystemInfo } from '@/store/cache'
import { checkSession, getSystemInfo, getMenus } from '@/store/cache'
import { getSessionId } from '@/store/session'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import { showLoginDialog } from '@/utils/libs'
import { Delete } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import FunDisabled from '@/components/ui/FunDisabled.vue'
import {
showConfirmDialog,
showDialog,
@@ -276,6 +289,7 @@ const listBoxHeight = ref(window.innerHeight - 40)
const mjBoxHeight = ref(window.innerHeight - 150)
const isLogin = ref(false)
const activeColspan = ref([''])
const menus = ref({})
window.onresize = () => {
listBoxHeight.value = window.innerHeight - 40
@@ -352,6 +366,14 @@ onMounted(() => {
.catch((e) => {
showNotify({ type: 'danger', message: '获取系统配置失败:' + e.message })
})
getMenus()
.then((data) => {
menus.value = data
})
.catch((e) => {
showNotify({ type: 'danger', message: '获取菜单失败:' + e.message })
})
})
onUnmounted(() => {
+17
View File
@@ -25,6 +25,23 @@ export default defineConfig(({ mode }) => {
base: env.VITE_BASE_URL,
build: {
outDir: 'dist', // 构建输出目录
target: 'esnext', // 允许使用顶层 await 等最新特性
},
// 开发环境与依赖预构建同样对齐到 esnext,避免 esbuild 报错
esbuild: {
target: 'esnext',
supported: {
'top-level-await': true,
},
},
optimizeDeps: {
esbuildOptions: {
target: 'esnext',
supported: {
'top-level-await': true,
},
},
},
resolve: {