merge v4.2.4

This commit is contained in:
RockYang
2026-02-14 17:20:53 +08:00
98 changed files with 6457 additions and 18325 deletions

View File

@@ -1,5 +1,17 @@
# 更新日志 # 更新日志
## v4.2.4
- 功能优化:更改前端构建技术选型,使用 Vite 构建,提升构建速度和兼容性
- 功能优化:使用 SSE 发送消息,替换原来的 Websocket 消息方案
- 功能新增:管理后台支持设置默认昵称
- 功能优化:支持 Suno v4.5 模型支持
- 功能新增:用户注册和用户登录增加用户协议和隐私政策功能,需要用户同意协议才可注册和登录。
- 功能优化:修改重新回答功能,撤回千面的问答内容为可编辑内容,撤回的内容不会增加额外的上下文
- 功能优化:优化聊天记录的存储结构,增加模型名称字段,支持存储更长的模型名称
- Bug 修复:聊天应用绑定模型后无效,还是会轮询 API KEY导致一会成功一会请求失败。
- 功能优化:如果管理后台没有启用会员充值菜单,移动端也不显示充值套餐功能
## v4.2.3 ## v4.2.3
- 功能优化:增加模型分组与模型描述,采用卡片展示模式改进模型选择功能体验 - 功能优化:增加模型分组与模型描述,采用卡片展示模式改进模型选择功能体验

View File

@@ -100,6 +100,26 @@ func (s *AppServer) Run(db *gorm.DB) error {
&model.UserLoginLog{}, &model.UserLoginLog{},
&model.DallJob{}, &model.DallJob{},
) )
// 手动删除字段
if db.Migrator().HasColumn(&model.Order{}, "deleted_at") {
db.Migrator().DropColumn(&model.Order{}, "deleted_at")
}
if db.Migrator().HasColumn(&model.ChatItem{}, "deleted_at") {
db.Migrator().DropColumn(&model.ChatItem{}, "deleted_at")
}
if db.Migrator().HasColumn(&model.ChatMessage{}, "deleted_at") {
db.Migrator().DropColumn(&model.ChatMessage{}, "deleted_at")
}
if db.Migrator().HasColumn(&model.User{}, "chat_config") {
db.Migrator().DropColumn(&model.User{}, "chat_config")
}
if db.Migrator().HasColumn(&model.ChatModel{}, "category") {
db.Migrator().DropColumn(&model.ChatModel{}, "category")
}
if db.Migrator().HasColumn(&model.ChatModel{}, "description") {
db.Migrator().DropColumn(&model.ChatModel{}, "description")
}
logger.Info("Database tables migrated successfully") logger.Info("Database tables migrated successfully")
// 统计安装信息 // 统计安装信息

View File

@@ -52,17 +52,6 @@ type Delta struct {
} `json:"function_call,omitempty"` } `json:"function_call,omitempty"`
} }
// ChatSession 聊天会话对象
type ChatSession struct {
UserId uint `json:"user_id"`
ClientIP string `json:"client_ip"` // 客户端 IP
ChatId string `json:"chat_id"` // 客户端聊天会话 ID, 多会话模式专用字段
Model ChatModel `json:"model"` // GPT 模型
Start int64 `json:"start"` // 开始请求时间戳
Tools []int `json:"tools"` // 工具函数列表
Stream bool `json:"stream"` // 是否采用流式输出
}
type ChatModel struct { type ChatModel struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`

View File

@@ -162,10 +162,11 @@ type SystemConfig struct {
SdNegPrompt string `json:"sd_neg_prompt"` // SD 默认反向提示词 SdNegPrompt string `json:"sd_neg_prompt"` // SD 默认反向提示词
MjMode string `json:"mj_mode"` // midjourney 默认的API模式relax, fast, turbo MjMode string `json:"mj_mode"` // midjourney 默认的API模式relax, fast, turbo
IndexNavs []int `json:"index_navs"` // 首页显示的导航菜单 IndexNavs []int `json:"index_navs"` // 首页显示的导航菜单
Copyright string `json:"copyright"` // 版权信息 Copyright string `json:"copyright"` // 版权信息
ICP string `json:"icp"` // ICP 备案号 DefaultNickname string `json:"default_nickname"` // 默认昵称
MarkMapText string `json:"mark_map_text"` // 思维导入的默认文本 ICP string `json:"icp"` // ICP 备案号
MarkMapText string `json:"mark_map_text"` // 思维导入的默认文本
EnabledVerify bool `json:"enabled_verify"` // 是否启用验证码 EnabledVerify bool `json:"enabled_verify"` // 是否启用验证码
EmailWhiteList []string `json:"email_white_list"` // 邮箱白名单列表 EmailWhiteList []string `json:"email_white_list"` // 邮箱白名单列表

View File

@@ -16,7 +16,7 @@ type MKey interface {
string | int | uint string | int | uint
} }
type MValue interface { type MValue interface {
*WsClient | *ChatSession | context.CancelFunc | []any *WsClient | context.CancelFunc | []any
} }
type LMap[K MKey, T MValue] struct { type LMap[K MKey, T MValue] struct {
lock sync.RWMutex lock sync.RWMutex

View File

@@ -209,20 +209,28 @@ func (h *ChatHandler) Messages(c *gin.Context) {
func (h *ChatHandler) History(c *gin.Context) { func (h *ChatHandler) History(c *gin.Context) {
chatId := c.Query("chat_id") // 会话 ID chatId := c.Query("chat_id") // 会话 ID
var items []model.ChatMessage var items []model.ChatMessage
var messages = make([]vo.HistoryMessage, 0) var messages = make([]vo.ChatMessage, 0)
res := h.DB.Where("chat_id = ?", chatId).Find(&items) res := h.DB.Where("chat_id = ?", chatId).Find(&items)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, "No history message") resp.ERROR(c, "No history message")
return return
} else { } else {
for _, item := range items { for _, item := range items {
var v vo.HistoryMessage var v vo.ChatMessage
err := utils.CopyObject(item, &v) err := utils.CopyObject(item, &v)
if err != nil {
continue
}
// 解析内容
var content vo.MsgContent
err = utils.JsonDecode(item.Content, &content)
if err != nil {
content.Text = item.Content
}
v.Content = content
v.CreatedAt = item.CreatedAt.Unix() v.CreatedAt = item.CreatedAt.Unix()
v.UpdatedAt = item.UpdatedAt.Unix() v.UpdatedAt = item.UpdatedAt.Unix()
if err == nil { messages = append(messages, v)
messages = append(messages, v)
}
} }
} }

View File

@@ -40,8 +40,8 @@ func (h *ChatModelHandler) Save(c *gin.Context) {
Power int `json:"power"` Power int `json:"power"`
MaxTokens int `json:"max_tokens"` // 最大响应长度 MaxTokens int `json:"max_tokens"` // 最大响应长度
MaxContext int `json:"max_context"` // 最大上下文长度 MaxContext int `json:"max_context"` // 最大上下文长度
Description string `json:"description"` //模型描述 Desc string `json:"desc"` //模型描述
Category string `json:"category"` //模型类别 Tag string `json:"tag"` //模型标签
Temperature float32 `json:"temperature"` // 模型温度 Temperature float32 `json:"temperature"` // 模型温度
KeyId int `json:"key_id,omitempty"` KeyId int `json:"key_id,omitempty"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
@@ -66,8 +66,8 @@ func (h *ChatModelHandler) Save(c *gin.Context) {
item.Power = data.Power item.Power = data.Power
item.MaxTokens = data.MaxTokens item.MaxTokens = data.MaxTokens
item.MaxContext = data.MaxContext item.MaxContext = data.MaxContext
item.Description = data.Description item.Desc = data.Desc
item.Category = data.Category item.Tag = data.Tag
item.Temperature = data.Temperature item.Temperature = data.Temperature
item.KeyId = uint(data.KeyId) item.KeyId = uint(data.KeyId)
item.Type = data.Type item.Type = data.Type
@@ -100,12 +100,16 @@ func (h *ChatModelHandler) List(c *gin.Context) {
session := h.DB.Session(&gorm.Session{}) session := h.DB.Session(&gorm.Session{})
enable := h.GetBool(c, "enable") enable := h.GetBool(c, "enable")
name := h.GetTrim(c, "name") name := h.GetTrim(c, "name")
modelType := h.GetTrim(c, "type")
if enable { if enable {
session = session.Where("enabled", enable) session = session.Where("enabled", enable)
} }
if name != "" { if name != "" {
session = session.Where("name LIKE ?", name+"%") session = session.Where("name LIKE ?", name+"%")
} }
if modelType != "" {
session = session.Where("type", modelType)
}
var items []model.ChatModel var items []model.ChatModel
var cms = make([]vo.ChatModel, 0) var cms = make([]vo.ChatModel, 0)
res := session.Order("sort_num ASC").Find(&items) res := session.Order("sort_num ASC").Find(&items)

View File

@@ -106,8 +106,8 @@ func (h *RedeemHandler) Export(c *gin.Context) {
} }
// 设置响应头,告诉浏览器这是一个附件,需要下载 // 设置响应头,告诉浏览器这是一个附件,需要下载
c.Header("Content-Disposition", "attachment; filename=output.csv") c.Header("Prompt-Disposition", "attachment; filename=output.csv")
c.Header("Content-Type", "text/csv") c.Header("Prompt-Type", "text/csv")
// 创建一个 CSV writer // 创建一个 CSV writer
writer := csv.NewWriter(c.Writer) writer := csv.NewWriter(c.Writer)

View File

@@ -178,6 +178,7 @@ func (h *UserHandler) Save(c *gin.Context) {
Power: data.Power, Power: data.Power,
Status: true, Status: true,
ChatRoles: utils.JsonEncode(data.ChatRoles), ChatRoles: utils.JsonEncode(data.ChatRoles),
ChatConfig: "{}",
ChatModels: utils.JsonEncode(data.ChatModels), ChatModels: utils.JsonEncode(data.ChatModels),
ExpiredTime: utils.Str2stamp(data.ExpiredTime), ExpiredTime: utils.Str2stamp(data.ExpiredTime),
} }
@@ -353,4 +354,4 @@ func (h *UserHandler) GenLoginLink(c *gin.Context) {
} }
resp.SUCCESS(c, tokenString) resp.SUCCESS(c, tokenString)
} }

View File

@@ -21,11 +21,11 @@ import (
"geekai/store/vo" "geekai/store/vo"
"geekai/utils" "geekai/utils"
"geekai/utils/resp" "geekai/utils/resp"
"html/template"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -36,13 +36,34 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
const (
ChatEventStart = "start"
ChatEventEnd = "end"
ChatEventError = "error"
ChatEventMessageDelta = "message_delta"
ChatEventTitle = "title"
)
type ChatInput struct {
UserId uint `json:"user_id"`
RoleId uint `json:"role_id"`
ModelId uint `json:"model_id"`
ChatId string `json:"chat_id"`
Prompt string `json:"prompt"`
Tools []uint `json:"tools"`
Stream bool `json:"stream"`
Files []vo.File `json:"files"`
ChatModel model.ChatModel `json:"chat_model,omitempty"`
ChatRole model.ChatRole `json:"chat_role,omitempty"`
LastMsgId uint `json:"last_msg_id,omitempty"` // 最后的消息ID用于重新生成答案的时候过滤上下文
}
type ChatHandler struct { type ChatHandler struct {
BaseHandler BaseHandler
redis *redis.Client redis *redis.Client
uploadManager *oss.UploaderManager uploadManager *oss.UploaderManager
licenseService *service.LicenseService licenseService *service.LicenseService
ReqCancelFunc *types.LMap[string, context.CancelFunc] // HttpClient 请求取消 handle function ReqCancelFunc *types.LMap[string, context.CancelFunc] // HttpClient 请求取消 handle function
ChatContexts *types.LMap[string, []any] // 聊天上下文 Map [chatId] => []Message
userService *service.UserService userService *service.UserService
} }
@@ -53,14 +74,74 @@ func NewChatHandler(app *core.AppServer, db *gorm.DB, redis *redis.Client, manag
uploadManager: manager, uploadManager: manager,
licenseService: licenseService, licenseService: licenseService,
ReqCancelFunc: types.NewLMap[string, context.CancelFunc](), ReqCancelFunc: types.NewLMap[string, context.CancelFunc](),
ChatContexts: types.NewLMap[string, []any](),
userService: userService, userService: userService,
} }
} }
func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSession, role model.ChatRole, prompt string, ws *types.WsClient) error { // Chat 处理聊天请求
func (h *ChatHandler) Chat(c *gin.Context) {
var input ChatInput
if err := c.ShouldBindJSON(&input); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
// 设置SSE响应头
c.Header("Prompt-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
ctx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
// 使用旧的聊天数据覆盖模型和角色ID
var chat model.ChatItem
h.DB.Where("chat_id", input.ChatId).First(&chat)
if chat.Id > 0 {
input.ModelId = chat.ModelId
input.RoleId = chat.RoleId
}
// 验证聊天角色
var chatRole model.ChatRole
err := h.DB.First(&chatRole, input.RoleId).Error
if err != nil || !chatRole.Enable {
pushMessage(c, ChatEventError, "当前聊天角色不存在或者未启用,请更换角色之后再发起对话!")
return
}
input.ChatRole = chatRole
// 获取模型信息
var chatModel model.ChatModel
err = h.DB.Where("id", input.ModelId).First(&chatModel).Error
if err != nil || !chatModel.Enabled {
pushMessage(c, ChatEventError, "当前AI模型暂未启用请更换模型后再发起对话")
return
}
input.ChatModel = chatModel
// 发送消息
err = h.sendMessage(ctx, input, c)
if err != nil {
pushMessage(c, ChatEventError, err.Error())
return
}
pushMessage(c, ChatEventEnd, "对话完成")
}
func pushMessage(c *gin.Context, msgType string, content interface{}) {
c.SSEvent("message", map[string]interface{}{
"type": msgType,
"body": content,
})
c.Writer.Flush()
}
func (h *ChatHandler) sendMessage(ctx context.Context, input ChatInput, c *gin.Context) error {
var user model.User var user model.User
res := h.DB.Model(&model.User{}).First(&user, session.UserId) res := h.DB.Model(&model.User{}).First(&user, input.UserId)
if res.Error != nil { if res.Error != nil {
return errors.New("未授权用户,您正在进行非法操作!") return errors.New("未授权用户,您正在进行非法操作!")
} }
@@ -71,12 +152,12 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
return errors.New("User 对象转换失败," + err.Error()) return errors.New("User 对象转换失败," + err.Error())
} }
if userVo.Status == false { if !userVo.Status {
return errors.New("您的账号已经被禁用,如果疑问,请联系管理员!") return errors.New("您的账号已经被禁用,如果疑问,请联系管理员!")
} }
if userVo.Power < session.Model.Power { if userVo.Power < input.ChatModel.Power {
return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, session.Model.Power) return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, input.ChatModel.Power)
} }
if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() { if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() {
@@ -84,30 +165,29 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
} }
// 检查 prompt 长度是否超过了当前模型允许的最大上下文长度 // 检查 prompt 长度是否超过了当前模型允许的最大上下文长度
promptTokens, _ := utils.CalcTokens(prompt, session.Model.Value) promptTokens, _ := utils.CalcTokens(input.Prompt, input.ChatModel.Value)
if promptTokens > session.Model.MaxContext { if promptTokens > input.ChatModel.MaxContext {
return errors.New("对话内容超出了当前模型允许的最大上下文长度!") return errors.New("对话内容超出了当前模型允许的最大上下文长度!")
} }
var req = types.ApiRequest{ var req = types.ApiRequest{
Model: session.Model.Value, Model: input.ChatModel.Value,
Stream: session.Stream, Stream: input.Stream,
Temperature: session.Model.Temperature, Temperature: input.ChatModel.Temperature,
} }
// 兼容 OpenAI 模型 // 兼容 OpenAI 模型
if strings.HasPrefix(session.Model.Value, "o1-") || if strings.HasPrefix(input.ChatModel.Value, "o1-") ||
strings.HasPrefix(session.Model.Value, "o3-") || strings.HasPrefix(input.ChatModel.Value, "o3-") ||
strings.HasPrefix(session.Model.Value, "gpt") { strings.HasPrefix(input.ChatModel.Value, "gpt") {
req.MaxCompletionTokens = session.Model.MaxTokens req.MaxCompletionTokens = input.ChatModel.MaxTokens
session.Start = time.Now().Unix()
} else { } else {
req.MaxTokens = session.Model.MaxTokens req.MaxTokens = input.ChatModel.MaxTokens
} }
if len(session.Tools) > 0 && !strings.HasPrefix(session.Model.Value, "o1-") { if len(input.Tools) > 0 && !strings.HasPrefix(input.ChatModel.Value, "o1-") {
var items []model.Function var items []model.Function
res = h.DB.Where("enabled", true).Where("id IN ?", session.Tools).Find(&items) res = h.DB.Where("enabled", true).Where("id IN ?", input.Tools).Find(&items)
if res.Error == nil { if res.Error == nil {
var tools = make([]types.Tool, 0) var tools = make([]types.Tool, 0)
for _, v := range items { for _, v := range items {
@@ -138,25 +218,27 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
} }
// 加载聊天上下文 // 加载聊天上下文
chatCtx := make([]interface{}, 0) chatCtx := make([]any, 0)
messages := make([]interface{}, 0) messages := make([]any, 0)
if h.App.SysConfig.EnableContext { if h.App.SysConfig.EnableContext {
if h.ChatContexts.Has(session.ChatId) { _ = utils.JsonDecode(input.ChatRole.Context, &messages)
messages = h.ChatContexts.Get(session.ChatId) if h.App.SysConfig.ContextDeep > 0 {
} else { var historyMessages []model.ChatMessage
_ = utils.JsonDecode(role.Context, &messages) dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
if h.App.SysConfig.ContextDeep > 0 { if input.LastMsgId > 0 { // 重新生成逻辑
var historyMessages []model.ChatMessage dbSession = dbSession.Where("id < ?", input.LastMsgId)
res := h.DB.Where("chat_id = ? and use_context = 1", session.ChatId).Limit(h.App.SysConfig.ContextDeep).Order("id DESC").Find(&historyMessages) // 删除对应的聊天记录
if res.Error == nil { h.DB.Debug().Where("chat_id", input.ChatId).Where("id >= ?", input.LastMsgId).Delete(&model.ChatMessage{})
for i := len(historyMessages) - 1; i >= 0; i-- { }
msg := historyMessages[i] err = dbSession.Limit(h.App.SysConfig.ContextDeep).Order("id DESC").Find(&historyMessages).Error
ms := types.Message{Role: "user", Content: msg.Content} if err == nil {
if msg.Type == types.ReplyMsg { for i := len(historyMessages) - 1; i >= 0; i-- {
ms.Role = "assistant" msg := historyMessages[i]
} ms := types.Message{Role: "user", Content: msg.Content}
chatCtx = append(chatCtx, ms) if msg.Type == types.ReplyMsg {
ms.Role = "assistant"
} }
chatCtx = append(chatCtx, ms)
} }
} }
} }
@@ -171,7 +253,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
v := messages[i] v := messages[i]
tks, _ = utils.CalcTokens(utils.JsonEncode(v), req.Model) tks, _ = utils.CalcTokens(utils.JsonEncode(v), req.Model)
// 上下文 token 超出了模型的最大上下文长度 // 上下文 token 超出了模型的最大上下文长度
if tokens+tks >= session.Model.MaxContext { if tokens+tks >= input.ChatModel.MaxContext {
break break
} }
@@ -183,78 +265,106 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
tokens += tks tokens += tks
chatCtx = append(chatCtx, v) chatCtx = append(chatCtx, v)
} }
logger.Debugf("聊天上下文:%+v", chatCtx)
} }
reqMgs := make([]interface{}, 0) reqMgs := make([]any, 0)
for i := len(chatCtx) - 1; i >= 0; i-- { for i := len(chatCtx) - 1; i >= 0; i-- {
reqMgs = append(reqMgs, chatCtx[i]) reqMgs = append(reqMgs, chatCtx[i])
} }
fullPrompt := prompt fileContents := make([]string, 0) // 文件内容
text := prompt var finalPrompt = input.Prompt
// extract files in prompt imgList := make([]any, 0)
files := utils.ExtractFileURLs(prompt) for _, file := range input.Files {
logger.Debugf("detected FILES: %+v", files) logger.Debugf("detected file: %+v", file.URL)
// 如果不是逆向模型,则提取文件内容 // 处理图片
if len(files) > 0 && !(session.Model.Value == "gpt-4-all" || if isImageURL(file.URL) {
strings.HasPrefix(session.Model.Value, "gpt-4-gizmo") || imgList = append(imgList, gin.H{
strings.HasSuffix(session.Model.Value, "claude-3")) {
contents := make([]string, 0)
var file model.File
for _, v := range files {
h.DB.Where("url = ?", v).First(&file)
content, err := utils.ReadFileContent(v, h.App.Config.TikaHost)
if err != nil {
logger.Error("error with read file: ", err)
} else {
contents = append(contents, fmt.Sprintf("%s 文件内容:%s", file.Name, content))
}
text = strings.Replace(text, v, "", 1)
}
if len(contents) > 0 {
fullPrompt = fmt.Sprintf("请根据提供的文件内容信息回答问题(其中Excel 已转成 HTML)\n\n %s\n\n 问题:%s", strings.Join(contents, "\n"), text)
}
tokens, _ := utils.CalcTokens(fullPrompt, req.Model)
if tokens > session.Model.MaxContext {
return fmt.Errorf("文件的长度超出模型允许的最大上下文长度,请减少文件内容数量或文件大小。")
}
}
logger.Debug("最终Prompt", fullPrompt)
// extract images from prompt
imgURLs := utils.ExtractImgURLs(prompt)
logger.Debugf("detected IMG: %+v", imgURLs)
var content interface{}
if len(imgURLs) > 0 {
data := make([]interface{}, 0)
for _, v := range imgURLs {
text = strings.Replace(text, v, "", 1)
data = append(data, gin.H{
"type": "image_url", "type": "image_url",
"image_url": gin.H{ "image_url": gin.H{
"url": v, "url": file.URL,
}, },
}) })
} else {
// 如果不是逆向模型,则提取文件内容
modelValue := input.ChatModel.Value
if !(strings.Contains(modelValue, "-all") || strings.HasPrefix(modelValue, "gpt-4-gizmo") || strings.HasPrefix(modelValue, "claude")) {
content, err := utils.ReadFileContent(file.URL, h.App.Config.TikaHost)
if err != nil {
logger.Error("error with read file: ", err)
continue
} else {
fileContents = append(fileContents, fmt.Sprintf("%s 文件内容:%s", file.Name, content))
}
}
} }
data = append(data, gin.H{
"type": "text",
"text": strings.TrimSpace(text),
})
content = data
} else {
content = fullPrompt
} }
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": content,
})
logger.Debugf("%+v", req.Messages) if len(fileContents) > 0 {
finalPrompt = fmt.Sprintf("请根据提供的文件内容信息回答问题(其中Excel 已转成 HTML)\n\n %s\n\n 问题:%s", strings.Join(fileContents, "\n"), input.Prompt)
tokens, _ := utils.CalcTokens(finalPrompt, req.Model)
if tokens > input.ChatModel.MaxContext {
return fmt.Errorf("文件的长度超出模型允许的最大上下文长度,请减少文件内容数量或文件大小。")
}
} else {
finalPrompt = input.Prompt
}
return h.sendOpenAiMessage(req, userVo, ctx, session, role, prompt, ws) if len(imgList) > 0 {
imgList = append(imgList, map[string]interface{}{
"type": "text",
"text": input.Prompt,
})
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": imgList,
})
} else {
req.Messages = append(reqMgs, map[string]interface{}{
"role": "user",
"content": finalPrompt,
})
}
return h.sendOpenAiMessage(req, userVo, ctx, input, c)
}
// 判断一个 URL 是否图片链接
func isImageURL(url string) bool {
// 检查是否是有效的URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return false
}
// 检查文件扩展名
ext := strings.ToLower(path.Ext(url))
validImageExts := map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".bmp": true,
".webp": true,
".svg": true,
".ico": true,
}
if !validImageExts[ext] {
return false
}
// 发送HEAD请求检查Content-Type
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Head(url)
if err != nil {
return false
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
return strings.HasPrefix(contentType, "image/")
} }
// Tokens 统计 token 数量 // Tokens 统计 token 数量
@@ -323,15 +433,14 @@ func (h *ChatHandler) StopGenerate(c *gin.Context) {
// 发送请求到 OpenAI 服务器 // 发送请求到 OpenAI 服务器
// useOwnApiKey: 是否使用了用户自己的 API KEY // useOwnApiKey: 是否使用了用户自己的 API KEY
func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, session *types.ChatSession, apiKey *model.ApiKey) (*http.Response, error) { func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, input ChatInput, apiKey *model.ApiKey) (*http.Response, error) {
// if the chat model bind a KEY, use it directly // if the chat model bind a KEY, use it directly
if session.Model.KeyId > 0 { if input.ChatModel.KeyId > 0 {
h.DB.Where("id", session.Model.KeyId).Find(apiKey) h.DB.Where("id", input.ChatModel.KeyId).Find(apiKey)
} } else { // use the last unused key
// use the last unused key
if apiKey.Id == 0 {
h.DB.Where("type", "chat").Where("enabled", true).Order("last_used_at ASC").First(apiKey) h.DB.Where("type", "chat").Where("enabled", true).Order("last_used_at ASC").First(apiKey)
} }
if apiKey.Id == 0 { if apiKey.Id == 0 {
return nil, errors.New("no available key, please import key") return nil, errors.New("no available key, please import key")
} }
@@ -381,16 +490,16 @@ func (h *ChatHandler) doRequest(ctx context.Context, req types.ApiRequest, sessi
} }
// 扣减用户算力 // 扣减用户算力
func (h *ChatHandler) subUserPower(userVo vo.User, session *types.ChatSession, promptTokens int, replyTokens int) { func (h *ChatHandler) subUserPower(userVo vo.User, input ChatInput, promptTokens int, replyTokens int) {
power := 1 power := 1
if session.Model.Power > 0 { if input.ChatModel.Power > 0 {
power = session.Model.Power power = input.ChatModel.Power
} }
err := h.userService.DecreasePower(userVo.Id, power, model.PowerLog{ err := h.userService.DecreasePower(userVo.Id, power, model.PowerLog{
Type: types.PowerConsume, Type: types.PowerConsume,
Model: session.Model.Value, Model: input.ChatModel.Value,
Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", session.Model.Name, promptTokens, replyTokens), Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", input.ChatModel.Name, promptTokens, replyTokens),
}) })
if err != nil { if err != nil {
logger.Error(err) logger.Error(err)
@@ -401,19 +510,11 @@ func (h *ChatHandler) saveChatHistory(
req types.ApiRequest, req types.ApiRequest,
usage Usage, usage Usage,
message types.Message, message types.Message,
session *types.ChatSession, input ChatInput,
role model.ChatRole,
userVo vo.User, userVo vo.User,
promptCreatedAt time.Time, promptCreatedAt time.Time,
replyCreatedAt time.Time) { replyCreatedAt time.Time) {
// 更新上下文消息
if h.App.SysConfig.EnableContext {
chatCtx := req.Messages // 提问消息
chatCtx = append(chatCtx, message) // 回复消息
h.ChatContexts.Put(session.ChatId, chatCtx)
}
// 追加聊天记录 // 追加聊天记录
// for prompt // for prompt
var promptTokens, replyTokens, totalTokens int var promptTokens, replyTokens, totalTokens int
@@ -424,12 +525,15 @@ func (h *ChatHandler) saveChatHistory(
} }
historyUserMsg := model.ChatMessage{ historyUserMsg := model.ChatMessage{
UserId: userVo.Id, UserId: userVo.Id,
ChatId: session.ChatId, ChatId: input.ChatId,
RoleId: role.Id, RoleId: input.RoleId,
Type: types.PromptMsg, Type: types.PromptMsg,
Icon: userVo.Avatar, Icon: userVo.Avatar,
Content: template.HTMLEscapeString(usage.Prompt), Content: utils.JsonEncode(vo.MsgContent{
Text: usage.Prompt,
Files: input.Files,
}),
Tokens: promptTokens, Tokens: promptTokens,
TotalTokens: promptTokens, TotalTokens: promptTokens,
UseContext: true, UseContext: true,
@@ -452,12 +556,15 @@ func (h *ChatHandler) saveChatHistory(
totalTokens = replyTokens + getTotalTokens(req) totalTokens = replyTokens + getTotalTokens(req)
} }
historyReplyMsg := model.ChatMessage{ historyReplyMsg := model.ChatMessage{
UserId: userVo.Id, UserId: userVo.Id,
ChatId: session.ChatId, ChatId: input.ChatId,
RoleId: role.Id, RoleId: input.RoleId,
Type: types.ReplyMsg, Type: types.ReplyMsg,
Icon: role.Icon, Icon: input.ChatRole.Icon,
Content: usage.Content, Content: utils.JsonEncode(vo.MsgContent{
Text: message.Content,
Files: input.Files,
}),
Tokens: replyTokens, Tokens: replyTokens,
TotalTokens: totalTokens, TotalTokens: totalTokens,
UseContext: true, UseContext: true,
@@ -471,17 +578,17 @@ func (h *ChatHandler) saveChatHistory(
} }
// 更新用户算力 // 更新用户算力
if session.Model.Power > 0 { if input.ChatModel.Power > 0 {
h.subUserPower(userVo, session, promptTokens, replyTokens) h.subUserPower(userVo, input, promptTokens, replyTokens)
} }
// 保存当前会话 // 保存当前会话
var chatItem model.ChatItem var chatItem model.ChatItem
err = h.DB.Where("chat_id = ?", session.ChatId).First(&chatItem).Error err = h.DB.Where("chat_id = ?", input.ChatId).First(&chatItem).Error
if err != nil { if err != nil {
chatItem.ChatId = session.ChatId chatItem.ChatId = input.ChatId
chatItem.UserId = userVo.Id chatItem.UserId = userVo.Id
chatItem.RoleId = role.Id chatItem.RoleId = input.RoleId
chatItem.ModelId = session.Model.Id chatItem.ModelId = input.ModelId
if utf8.RuneCountInString(usage.Prompt) > 30 { if utf8.RuneCountInString(usage.Prompt) > 30 {
chatItem.Title = string([]rune(usage.Prompt)[:30]) + "..." chatItem.Title = string([]rune(usage.Prompt)[:30]) + "..."
} else { } else {
@@ -495,7 +602,7 @@ func (h *ChatHandler) saveChatHistory(
} }
} }
// 文本生成语音 // TextToSpeech 文本生成语音
func (h *ChatHandler) TextToSpeech(c *gin.Context) { func (h *ChatHandler) TextToSpeech(c *gin.Context) {
var data struct { var data struct {
ModelId int `json:"model_id"` ModelId int `json:"model_id"`
@@ -509,13 +616,19 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
textHash := utils.Sha256(fmt.Sprintf("%d/%s", data.ModelId, data.Text)) textHash := utils.Sha256(fmt.Sprintf("%d/%s", data.ModelId, data.Text))
audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir) audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir)
if _, err := os.Stat(audioFile); err != nil { if _, err := os.Stat(audioFile); err != nil {
os.MkdirAll(audioFile, 0755) resp.ERROR(c, err.Error())
return
}
if err := os.MkdirAll(audioFile, 0755); err != nil {
resp.ERROR(c, err.Error())
return
} }
audioFile = fmt.Sprintf("%s/%s.mp3", audioFile, textHash) audioFile = fmt.Sprintf("%s/%s.mp3", audioFile, textHash)
if _, err := os.Stat(audioFile); err == nil { if _, err := os.Stat(audioFile); err == nil {
// 设置响应头 // 设置响应头
c.Header("Content-Type", "audio/mpeg") c.Header("Prompt-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3") c.Header("Prompt-Disposition", "attachment; filename=speech.mp3")
c.File(audioFile) c.File(audioFile)
return return
} }
@@ -579,9 +692,230 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
} }
// 设置响应头 // 设置响应头
c.Header("Content-Type", "audio/mpeg") c.Header("Prompt-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3") c.Header("Prompt-Disposition", "attachment; filename=speech.mp3")
// 直接写入完整的音频数据到响应 // 直接写入完整的音频数据到响应
c.Writer.Write(audioBytes) _, err = c.Writer.Write(audioBytes)
if err != nil {
logger.Error("写入音频数据到响应失败:", err)
}
} }
// // OPenAI 消息发送实现
// func (h *ChatHandler) sendOpenAiMessage(
// req types.ApiRequest,
// userVo vo.User,
// ctx context.Context,
// session *types.ChatSession,
// role model.ChatRole,
// prompt string,
// c *gin.Context) error {
// promptCreatedAt := time.Now() // 记录提问时间
// start := time.Now()
// var apiKey = model.ApiKey{}
// response, err := h.doRequest(ctx, req, session, &apiKey)
// logger.Info("HTTP请求完成耗时", time.Since(start))
// if err != nil {
// if strings.Contains(err.Error(), "context canceled") {
// return fmt.Errorf("用户取消了请求:%s", prompt)
// } else if strings.Contains(err.Error(), "no available key") {
// return errors.New("抱歉😔😔😔,系统已经没有可用的 API KEY请联系管理员")
// }
// return err
// } else {
// defer response.Body.Close()
// }
// if response.StatusCode != 200 {
// body, _ := io.ReadAll(response.Body)
// return fmt.Errorf("请求 OpenAI API 失败:%d, %v", response.StatusCode, string(body))
// }
// contentType := response.Header.Get("Prompt-Type")
// if strings.Contains(contentType, "text/event-stream") {
// replyCreatedAt := time.Now() // 记录回复时间
// // 循环读取 Chunk 消息
// var message = types.Message{Role: "assistant"}
// var contents = make([]string, 0)
// var function model.Function
// var toolCall = false
// var arguments = make([]string, 0)
// var reasoning = false
// pushMessage(c, ChatEventStart, "开始响应")
// scanner := bufio.NewScanner(response.Body)
// for scanner.Scan() {
// line := scanner.Text()
// if !strings.Contains(line, "data:") || len(line) < 30 {
// continue
// }
// var responseBody = types.ApiResponse{}
// err = json.Unmarshal([]byte(line[6:]), &responseBody)
// if err != nil { // 数据解析出错
// return errors.New(line)
// }
// if len(responseBody.Choices) == 0 { // Fixed: 兼容 Azure API 第一个输出空行
// continue
// }
// if responseBody.Choices[0].Delta.Prompt == nil &&
// responseBody.Choices[0].Delta.ToolCalls == nil &&
// responseBody.Choices[0].Delta.ReasoningContent == "" {
// continue
// }
// if responseBody.Choices[0].FinishReason == "stop" && len(contents) == 0 {
// pushMessage(c, ChatEventError, "抱歉😔😔😔AI助手由于未知原因已经停止输出内容。")
// break
// }
// var tool types.ToolCall
// if len(responseBody.Choices[0].Delta.ToolCalls) > 0 {
// tool = responseBody.Choices[0].Delta.ToolCalls[0]
// if toolCall && tool.Function.Name == "" {
// arguments = append(arguments, tool.Function.Arguments)
// continue
// }
// }
// // 兼容 Function Call
// fun := responseBody.Choices[0].Delta.FunctionCall
// if fun.Name != "" {
// tool = *new(types.ToolCall)
// tool.Function.Name = fun.Name
// } else if toolCall {
// arguments = append(arguments, fun.Arguments)
// continue
// }
// if !utils.IsEmptyValue(tool) {
// res := h.DB.Where("name = ?", tool.Function.Name).First(&function)
// if res.Error == nil {
// toolCall = true
// callMsg := fmt.Sprintf("正在调用工具 `%s` 作答 ...\n\n", function.Label)
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text",
// "content": callMsg,
// })
// contents = append(contents, callMsg)
// }
// continue
// }
// if responseBody.Choices[0].FinishReason == "tool_calls" ||
// responseBody.Choices[0].FinishReason == "function_call" { // 函数调用完毕
// break
// }
// // output stopped
// if responseBody.Choices[0].FinishReason != "" {
// break // 输出完成或者输出中断了
// } else { // 正常输出结果
// // 兼容思考过程
// if responseBody.Choices[0].Delta.ReasoningContent != "" {
// reasoningContent := responseBody.Choices[0].Delta.ReasoningContent
// if !reasoning {
// reasoningContent = fmt.Sprintf("<think>%s", reasoningContent)
// reasoning = true
// }
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text",
// "content": reasoningContent,
// })
// contents = append(contents, reasoningContent)
// } else if responseBody.Choices[0].Delta.Prompt != "" {
// finalContent := responseBody.Choices[0].Delta.Prompt
// if reasoning {
// finalContent = fmt.Sprintf("</think>%s", responseBody.Choices[0].Delta.Prompt)
// reasoning = false
// }
// contents = append(contents, utils.InterfaceToString(finalContent))
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text",
// "content": finalContent,
// })
// }
// }
// } // end for
// if err := scanner.Err(); err != nil {
// if strings.Contains(err.Error(), "context canceled") {
// logger.Info("用户取消了请求:", prompt)
// } else {
// logger.Error("信息读取出错:", err)
// }
// }
// if toolCall { // 调用函数完成任务
// params := make(map[string]any)
// _ = utils.JsonDecode(strings.Join(arguments, ""), &params)
// logger.Debugf("函数名称: %s, 函数参数:%s", function.Name, params)
// params["user_id"] = userVo.Id
// var apiRes types.BizVo
// r, err := req2.C().R().SetHeader("Body-Type", "application/json").
// SetHeader("Authorization", function.Token).
// SetBody(params).Post(function.Action)
// errMsg := ""
// if err != nil {
// errMsg = err.Error()
// } else {
// all, _ := io.ReadAll(r.Body)
// err = json.Unmarshal(all, &apiRes)
// if err != nil {
// errMsg = err.Error()
// } else if apiRes.Code != types.Success {
// errMsg = apiRes.Message
// }
// }
// if errMsg != "" {
// errMsg = "调用函数工具出错:" + errMsg
// contents = append(contents, errMsg)
// } else {
// errMsg = utils.InterfaceToString(apiRes.Data)
// contents = append(contents, errMsg)
// }
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text",
// "content": errMsg,
// })
// }
// // 消息发送成功
// if len(contents) > 0 {
// usage := Usage{
// Prompt: prompt,
// Prompt: strings.Join(contents, ""),
// PromptTokens: 0,
// CompletionTokens: 0,
// TotalTokens: 0,
// }
// message.Prompt = usage.Prompt
// h.saveChatHistory(req, usage, message, session, role, userVo, promptCreatedAt, replyCreatedAt)
// }
// } else {
// var respVo OpenAIResVo
// body, err := io.ReadAll(response.Body)
// if err != nil {
// return fmt.Errorf("读取响应失败:%v", body)
// }
// err = json.Unmarshal(body, &respVo)
// if err != nil {
// return fmt.Errorf("解析响应失败:%v", body)
// }
// content := respVo.Choices[0].Message.Prompt
// if strings.HasPrefix(req.Model, "o1-") {
// content = fmt.Sprintf("AI思考结束耗时%d 秒。\n%s", time.Now().Unix()-session.Start, respVo.Choices[0].Message.Prompt)
// }
// pushMessage(c, ChatEventMessageDelta, map[string]interface{}{
// "type": "text",
// "content": content,
// })
// respVo.Usage.Prompt = prompt
// respVo.Usage.Prompt = content
// h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, session, role, userVo, promptCreatedAt, time.Now())
// }
// return nil
// }

View File

@@ -104,8 +104,6 @@ func (h *ChatHandler) Clear(c *gin.Context) {
var chatIds = make([]string, 0) var chatIds = make([]string, 0)
for _, chat := range chats { for _, chat := range chats {
chatIds = append(chatIds, chat.ChatId) chatIds = append(chatIds, chat.ChatId)
// 清空会话上下文
h.ChatContexts.Delete(chat.ChatId)
} }
err = h.DB.Transaction(func(tx *gorm.DB) error { err = h.DB.Transaction(func(tx *gorm.DB) error {
res := h.DB.Where("user_id =?", user.Id).Delete(&model.ChatItem{}) res := h.DB.Where("user_id =?", user.Id).Delete(&model.ChatItem{})
@@ -133,20 +131,28 @@ func (h *ChatHandler) Clear(c *gin.Context) {
func (h *ChatHandler) History(c *gin.Context) { func (h *ChatHandler) History(c *gin.Context) {
chatId := c.Query("chat_id") // 会话 ID chatId := c.Query("chat_id") // 会话 ID
var items []model.ChatMessage var items []model.ChatMessage
var messages = make([]vo.HistoryMessage, 0) var messages = make([]vo.ChatMessage, 0)
res := h.DB.Where("chat_id = ?", chatId).Find(&items) res := h.DB.Where("chat_id = ?", chatId).Find(&items)
if res.Error != nil { if res.Error != nil {
resp.ERROR(c, "No history message") resp.ERROR(c, "No history message")
return return
} else { } else {
for _, item := range items { for _, item := range items {
var v vo.HistoryMessage var v vo.ChatMessage
err := utils.CopyObject(item, &v) err := utils.CopyObject(item, &v)
if err != nil {
continue
}
// 解析内容
var content vo.MsgContent
err = utils.JsonDecode(item.Content, &content)
if err != nil {
content.Text = item.Content
}
v.Content = content
v.CreatedAt = item.CreatedAt.Unix() v.CreatedAt = item.CreatedAt.Unix()
v.UpdatedAt = item.UpdatedAt.Unix() v.UpdatedAt = item.UpdatedAt.Unix()
if err == nil { messages = append(messages, v)
messages = append(messages, v)
}
} }
} }
@@ -179,10 +185,6 @@ func (h *ChatHandler) Remove(c *gin.Context) {
return return
} }
// TODO: 是否要删除 MidJourney 绘画记录和图片文件?
// 清空会话上下文
h.ChatContexts.Delete(chatId)
resp.SUCCESS(c, types.OkMsg) resp.SUCCESS(c, types.OkMsg)
} }

View File

@@ -21,6 +21,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/gin-gonic/gin"
req2 "github.com/imroc/req/v3" req2 "github.com/imroc/req/v3"
) )
@@ -55,18 +56,16 @@ func (h *ChatHandler) sendOpenAiMessage(
req types.ApiRequest, req types.ApiRequest,
userVo vo.User, userVo vo.User,
ctx context.Context, ctx context.Context,
session *types.ChatSession, input ChatInput,
role model.ChatRole, c *gin.Context) error {
prompt string,
ws *types.WsClient) error {
promptCreatedAt := time.Now() // 记录提问时间 promptCreatedAt := time.Now() // 记录提问时间
start := time.Now() start := time.Now()
var apiKey = model.ApiKey{} var apiKey = model.ApiKey{}
response, err := h.doRequest(ctx, req, session, &apiKey) response, err := h.doRequest(ctx, req, input, &apiKey)
logger.Info("HTTP请求完成耗时", time.Since(start)) logger.Info("HTTP请求完成耗时", time.Since(start))
if err != nil { if err != nil {
if strings.Contains(err.Error(), "context canceled") { if strings.Contains(err.Error(), "context canceled") {
return fmt.Errorf("用户取消了请求:%s", prompt) return fmt.Errorf("用户取消了请求:%s", input.Prompt)
} else if strings.Contains(err.Error(), "no available key") { } else if strings.Contains(err.Error(), "no available key") {
return errors.New("抱歉😔😔😔,系统已经没有可用的 API KEY请联系管理员") return errors.New("抱歉😔😔😔,系统已经没有可用的 API KEY请联系管理员")
} }
@@ -112,7 +111,7 @@ func (h *ChatHandler) sendOpenAiMessage(
} }
if responseBody.Choices[0].FinishReason == "stop" && len(contents) == 0 { if responseBody.Choices[0].FinishReason == "stop" && len(contents) == 0 {
utils.SendChunkMsg(ws, "抱歉😔😔😔AI助手由于未知原因已经停止输出内容。") pushMessage(c, "text", "抱歉😔😔😔AI助手由于未知原因已经停止输出内容。")
break break
} }
@@ -140,7 +139,7 @@ func (h *ChatHandler) sendOpenAiMessage(
if res.Error == nil { if res.Error == nil {
toolCall = true toolCall = true
callMsg := fmt.Sprintf("正在调用工具 `%s` 作答 ...\n\n", function.Label) callMsg := fmt.Sprintf("正在调用工具 `%s` 作答 ...\n\n", function.Label)
utils.SendChunkMsg(ws, callMsg) pushMessage(c, "text", callMsg)
contents = append(contents, callMsg) contents = append(contents, callMsg)
} }
continue continue
@@ -163,7 +162,7 @@ func (h *ChatHandler) sendOpenAiMessage(
reasoning = true reasoning = true
} }
utils.SendChunkMsg(ws, reasoningContent) pushMessage(c, "text", reasoningContent)
contents = append(contents, reasoningContent) contents = append(contents, reasoningContent)
} else if responseBody.Choices[0].Delta.Content != "" { } else if responseBody.Choices[0].Delta.Content != "" {
finalContent := responseBody.Choices[0].Delta.Content finalContent := responseBody.Choices[0].Delta.Content
@@ -172,14 +171,14 @@ func (h *ChatHandler) sendOpenAiMessage(
reasoning = false reasoning = false
} }
contents = append(contents, utils.InterfaceToString(finalContent)) contents = append(contents, utils.InterfaceToString(finalContent))
utils.SendChunkMsg(ws, finalContent) pushMessage(c, "text", finalContent)
} }
} }
} // end for } // end for
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
if strings.Contains(err.Error(), "context canceled") { if strings.Contains(err.Error(), "context canceled") {
logger.Info("用户取消了请求:", prompt) logger.Info("用户取消了请求:", input.Prompt)
} else { } else {
logger.Error("信息读取出错:", err) logger.Error("信息读取出错:", err)
} }
@@ -214,20 +213,20 @@ func (h *ChatHandler) sendOpenAiMessage(
errMsg = utils.InterfaceToString(apiRes.Data) errMsg = utils.InterfaceToString(apiRes.Data)
contents = append(contents, errMsg) contents = append(contents, errMsg)
} }
utils.SendChunkMsg(ws, errMsg) pushMessage(c, "text", errMsg)
} }
// 消息发送成功 // 消息发送成功
if len(contents) > 0 { if len(contents) > 0 {
usage := Usage{ usage := Usage{
Prompt: prompt, Prompt: input.Prompt,
Content: strings.Join(contents, ""), Content: strings.Join(contents, ""),
PromptTokens: 0, PromptTokens: 0,
CompletionTokens: 0, CompletionTokens: 0,
TotalTokens: 0, TotalTokens: 0,
} }
message.Content = usage.Content message.Content = usage.Content
h.saveChatHistory(req, usage, message, session, role, userVo, promptCreatedAt, replyCreatedAt) h.saveChatHistory(req, usage, message, input, userVo, promptCreatedAt, replyCreatedAt)
} }
} else { // 非流式输出 } else { // 非流式输出
var respVo OpenAIResVo var respVo OpenAIResVo
@@ -240,13 +239,10 @@ func (h *ChatHandler) sendOpenAiMessage(
return fmt.Errorf("解析响应失败:%v", body) return fmt.Errorf("解析响应失败:%v", body)
} }
content := respVo.Choices[0].Message.Content content := respVo.Choices[0].Message.Content
if strings.HasPrefix(req.Model, "o1-") { pushMessage(c, "text", content)
content = fmt.Sprintf("AI思考结束耗时%d 秒。\n%s", time.Now().Unix()-session.Start, respVo.Choices[0].Message.Content) respVo.Usage.Prompt = input.Prompt
}
utils.SendChunkMsg(ws, content)
respVo.Usage.Prompt = prompt
respVo.Usage.Content = content respVo.Usage.Content = content
h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, session, role, userVo, promptCreatedAt, time.Now()) h.saveChatHistory(req, respVo.Usage, respVo.Choices[0].Message, input, userVo, promptCreatedAt, time.Now())
} }
return nil return nil

View File

@@ -137,13 +137,15 @@ func (h *UserHandler) Register(c *gin.Context) {
salt := utils.RandString(8) salt := utils.RandString(8)
user := model.User{ user := model.User{
Username: data.Username, Username: data.Username,
Password: utils.GenPassword(data.Password, salt), Password: utils.GenPassword(data.Password, salt),
Avatar: "/images/avatar/user.png", Avatar: "/images/avatar/user.png",
Salt: salt, Salt: salt,
Status: true, Status: true,
ChatRoles: utils.JsonEncode([]string{"gpt"}), // 默认只订阅通用助手角色 ChatRoles: utils.JsonEncode([]string{"gpt"}), // 默认只订阅通用助手角色
Power: h.App.SysConfig.InitPower, ChatConfig: "{}",
ChatModels: "{}",
Power: h.App.SysConfig.InitPower,
} }
// check if the username is existing // check if the username is existing
@@ -170,10 +172,15 @@ func (h *UserHandler) Register(c *gin.Context) {
if data.InviteCode != "" { if data.InviteCode != "" {
user.Power += h.App.SysConfig.InvitePower user.Power += h.App.SysConfig.InvitePower
} }
if h.licenseService.GetLicense().Configs.DeCopy { if h.licenseService.GetLicense().Configs.DeCopy {
user.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6)) user.Nickname = fmt.Sprintf("用户@%d", utils.RandomNumber(6))
} else { } else {
user.Nickname = fmt.Sprintf("极客学长@%d", utils.RandomNumber(6)) defaultNickname := h.App.SysConfig.DefaultNickname
if defaultNickname == "" {
defaultNickname = "极客学长"
}
user.Nickname = fmt.Sprintf("%s@%d", defaultNickname, utils.RandomNumber(6))
} }
tx := h.DB.Begin() tx := h.DB.Begin()

View File

@@ -1,152 +0,0 @@
package handler
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
// * 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 (
"context"
"geekai/core"
"geekai/core/types"
"geekai/service"
"geekai/store/model"
"geekai/utils"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"gorm.io/gorm"
)
// Websocket 连接处理 handler
type WebsocketHandler struct {
BaseHandler
wsService *service.WebsocketService
chatHandler *ChatHandler
}
func NewWebsocketHandler(app *core.AppServer, s *service.WebsocketService, db *gorm.DB, chatHandler *ChatHandler) *WebsocketHandler {
return &WebsocketHandler{
BaseHandler: BaseHandler{App: app, DB: db},
chatHandler: chatHandler,
wsService: s,
}
}
func (h *WebsocketHandler) Client(c *gin.Context) {
clientProtocols := c.GetHeader("Sec-WebSocket-Protocol")
ws, err := (&websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
Subprotocols: strings.Split(clientProtocols, ","),
}).Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Error(err)
c.Abort()
return
}
clientId := c.Query("client_id")
client := types.NewWsClient(ws, clientId)
userId := h.GetLoginUserId(c)
if userId == 0 {
_ = client.Send([]byte("Invalid user_id"))
c.Abort()
return
}
var user model.User
if err := h.DB.Where("id", userId).First(&user).Error; err != nil {
_ = client.Send([]byte("Invalid user_id"))
c.Abort()
return
}
h.wsService.Clients.Put(clientId, client)
logger.Infof("New websocket connected, IP: %s", c.RemoteIP())
go func() {
for {
_, msg, err := client.Receive()
if err != nil {
logger.Debugf("close connection: %s", client.Conn.RemoteAddr())
client.Close()
h.wsService.Clients.Delete(clientId)
break
}
var message types.InputMessage
err = utils.JsonDecode(string(msg), &message)
if err != nil {
continue
}
logger.Debugf("Receive a message:%+v", message)
if message.Type == types.MsgTypePing {
utils.SendChannelMsg(client, types.ChPing, "pong")
continue
}
// 当前只处理聊天消息,其他消息全部丢弃
var chatMessage types.ChatMessage
err = utils.JsonDecode(utils.JsonEncode(message.Body), &chatMessage)
if err != nil || message.Channel != types.ChChat {
logger.Warnf("invalid message body:%+v", message.Body)
continue
}
var chatRole model.ChatRole
err = h.DB.First(&chatRole, chatMessage.RoleId).Error
if err != nil || !chatRole.Enable {
utils.SendAndFlush(client, "当前聊天角色不存在或者未启用,请更换角色之后再发起对话!!!")
continue
}
// if the role bind a model_id, use role's bind model_id
if chatRole.ModelId > 0 {
chatMessage.RoleId = int(chatRole.ModelId)
}
// get model info
var chatModel model.ChatModel
err = h.DB.Where("id", chatMessage.ModelId).First(&chatModel).Error
if err != nil || chatModel.Enabled == false {
utils.SendAndFlush(client, "当前AI模型暂未启用请更换模型后再发起对话")
continue
}
session := &types.ChatSession{
ClientIP: c.ClientIP(),
UserId: userId,
}
// use old chat data override the chat model and role ID
var chat model.ChatItem
h.DB.Where("chat_id", chatMessage.ChatId).First(&chat)
if chat.Id > 0 {
chatModel.Id = chat.ModelId
chatMessage.RoleId = int(chat.RoleId)
}
session.ChatId = chatMessage.ChatId
session.Tools = chatMessage.Tools
session.Stream = chatMessage.Stream
// 复制模型数据
err = utils.CopyObject(chatModel, &session.Model)
if err != nil {
logger.Error(err, chatModel)
}
session.Model.Id = chatModel.Id
ctx, cancel := context.WithCancel(context.Background())
h.chatHandler.ReqCancelFunc.Put(clientId, cancel)
err = h.chatHandler.sendMessage(ctx, session, chatRole, chatMessage.Content, client)
if err != nil {
logger.Error(err)
utils.SendAndFlush(client, err.Error())
} else {
utils.SendMsg(client, types.ReplyMessage{Channel: types.ChChat, Type: types.MsgTypeEnd})
logger.Infof("回答完毕: %v", message.Body)
}
}
}()
}

View File

@@ -248,6 +248,7 @@ func main() {
}), }),
fx.Invoke(func(s *core.AppServer, h *handler.ChatHandler) { fx.Invoke(func(s *core.AppServer, h *handler.ChatHandler) {
group := s.Engine.Group("/api/chat/") group := s.Engine.Group("/api/chat/")
group.Any("message", h.Chat)
group.GET("list", h.List) group.GET("list", h.List)
group.GET("detail", h.Detail) group.GET("detail", h.Detail)
group.POST("update", h.Update) group.POST("update", h.Update)
@@ -519,11 +520,6 @@ func main() {
group := s.Engine.Group("/api/test") group := s.Engine.Group("/api/test")
group.Any("sse", h.PostTest, h.SseTest) group.Any("sse", h.PostTest, h.SseTest)
}), }),
fx.Provide(service.NewWebsocketService),
fx.Provide(handler.NewWebsocketHandler),
fx.Invoke(func(s *core.AppServer, h *handler.WebsocketHandler) {
s.Engine.Any("/api/ws", h.Client)
}),
fx.Provide(handler.NewPromptHandler), fx.Provide(handler.NewPromptHandler),
fx.Invoke(func(s *core.AppServer, h *handler.PromptHandler) { fx.Invoke(func(s *core.AppServer, h *handler.PromptHandler) {
group := s.Engine.Group("/api/prompt") group := s.Engine.Group("/api/prompt")

View File

@@ -24,15 +24,15 @@ func NewService() (*Service, error) {
// 启动浏览器 // 启动浏览器
path, _ := launcher.LookPath() path, _ := launcher.LookPath()
u := launcher.New().Bin(path). u := launcher.New().Bin(path).
Headless(true). // 无头模式 Headless(true). // 无头模式
Set("disable-web-security", ""). // 禁用网络安全限制 Set("disable-web-security", ""). // 禁用网络安全限制
Set("disable-gpu", ""). // 禁用 GPU 加速 Set("disable-gpu", ""). // 禁用 GPU 加速
Set("no-sandbox", ""). // 禁用沙箱模式 Set("no-sandbox", ""). // 禁用沙箱模式
Set("disable-setuid-sandbox", "").// 禁用 setuid 沙箱 Set("disable-setuid-sandbox", ""). // 禁用 setuid 沙箱
MustLaunch() MustLaunch()
browser := rod.New().ControlURL(u).MustConnect() browser := rod.New().ControlURL(u).MustConnect()
return &Service{ return &Service{
browser: browser, browser: browser,
}, nil }, nil
@@ -50,7 +50,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if keyword == "" { if keyword == "" {
return nil, errors.New("搜索关键词不能为空") return nil, errors.New("搜索关键词不能为空")
} }
if maxPages <= 0 { if maxPages <= 0 {
maxPages = 1 maxPages = 1
} }
@@ -59,18 +59,18 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
results := make([]SearchResult, 0) results := make([]SearchResult, 0)
// 使用百度搜索 // 使用百度搜索
searchURL := fmt.Sprintf("https://www.baidu.com/s?wd=%s", url.QueryEscape(keyword)) searchURL := fmt.Sprintf("https://www.baidu.com/s?wd=%s", url.QueryEscape(keyword))
// 设置页面超时 // 设置页面超时
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
// 创建页面 // 创建页面
page := s.browser.MustPage() page := s.browser.MustPage()
defer page.MustClose() defer page.MustClose()
// 设置视口大小 // 设置视口大小
err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{ err := page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
Width: 1280, Width: 1280,
@@ -79,19 +79,19 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if err != nil { if err != nil {
return nil, fmt.Errorf("设置视口失败: %v", err) return nil, fmt.Errorf("设置视口失败: %v", err)
} }
// 导航到搜索页面 // 导航到搜索页面
err = page.Context(ctx).Navigate(searchURL) err = page.Context(ctx).Navigate(searchURL)
if err != nil { if err != nil {
return nil, fmt.Errorf("导航到搜索页面失败: %v", err) return nil, fmt.Errorf("导航到搜索页面失败: %v", err)
} }
// 等待搜索结果加载完成 // 等待搜索结果加载完成
err = page.WaitLoad() err = page.WaitLoad()
if err != nil { if err != nil {
return nil, fmt.Errorf("等待页面加载完成失败: %v", err) return nil, fmt.Errorf("等待页面加载完成失败: %v", err)
} }
// 分析当前页面的搜索结果 // 分析当前页面的搜索结果
for i := 0; i < maxPages; i++ { for i := 0; i < maxPages; i++ {
if i > 0 { if i > 0 {
@@ -100,52 +100,52 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
if err != nil || nextPage == nil { if err != nil || nextPage == nil {
break // 没有下一页 break // 没有下一页
} }
err = nextPage.Click(proto.InputMouseButtonLeft, 1) err = nextPage.Click(proto.InputMouseButtonLeft, 1)
if err != nil { if err != nil {
break // 点击下一页失败 break // 点击下一页失败
} }
// 等待新页面加载 // 等待新页面加载
err = page.WaitLoad() err = page.WaitLoad()
if err != nil { if err != nil {
break break
} }
} }
// 提取搜索结果 // 提取搜索结果
resultElements, err := page.Elements(".result, .c-container") resultElements, err := page.Elements(".result, .c-container")
if err != nil || resultElements == nil { if err != nil || resultElements == nil {
continue continue
} }
for _, result := range resultElements { for _, result := range resultElements {
// 获取标题 // 获取标题
titleElement, err := result.Element("h3, .t") titleElement, err := result.Element("h3, .t")
if err != nil || titleElement == nil { if err != nil || titleElement == nil {
continue continue
} }
title, err := titleElement.Text() title, err := titleElement.Text()
if err != nil { if err != nil {
continue continue
} }
// 获取 URL // 获取 URL
linkElement, err := titleElement.Element("a") linkElement, err := titleElement.Element("a")
if err != nil || linkElement == nil { if err != nil || linkElement == nil {
continue continue
} }
href, err := linkElement.Attribute("href") href, err := linkElement.Attribute("href")
if err != nil || href == nil { if err != nil || href == nil {
continue continue
} }
// 获取内容摘要 - 尝试多个可能的选择器 // 获取内容摘要 - 尝试多个可能的选择器
var contentElement *rod.Element var contentElement *rod.Element
var content string var content string
// 尝试多个可能的选择器来适应不同版本的百度搜索结果 // 尝试多个可能的选择器来适应不同版本的百度搜索结果
selectors := []string{".content-right_8Zs40", ".c-abstract", ".content_LJ0WN", ".content"} selectors := []string{".content-right_8Zs40", ".c-abstract", ".content_LJ0WN", ".content"}
for _, selector := range selectors { for _, selector := range selectors {
@@ -157,7 +157,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
} }
} }
// 如果所有选择器都失败,尝试直接从结果块中提取文本 // 如果所有选择器都失败,尝试直接从结果块中提取文本
if content == "" { if content == "" {
// 获取结果元素的所有文本 // 获取结果元素的所有文本
@@ -173,21 +173,21 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
} }
} }
} }
// 添加到结果集 // 添加到结果集
results = append(results, SearchResult{ results = append(results, SearchResult{
Title: title, Title: title,
URL: *href, URL: *href,
Content: content, Content: content,
}) })
// 限制结果数量,每页最多 10 条 // 限制结果数量,每页最多 10 条
if len(results) >= 10*maxPages { if len(results) >= 10*maxPages {
break break
} }
} }
} }
// 获取真实 URL百度搜索结果中的 URL 是短链接,需要跳转获取真实 URL // 获取真实 URL百度搜索结果中的 URL 是短链接,需要跳转获取真实 URL
for i, result := range results { for i, result := range results {
realURL, err := s.getRedirectURL(result.URL) realURL, err := s.getRedirectURL(result.URL)
@@ -195,7 +195,7 @@ func (s *Service) WebSearch(keyword string, maxPages int) ([]SearchResult, error
results[i].URL = realURL results[i].URL = realURL
} }
} }
return results, nil return results, nil
} }
@@ -209,22 +209,22 @@ func (s *Service) getRedirectURL(shortURL string) (string, error) {
defer func() { defer func() {
_ = page.Close() _ = page.Close()
}() }()
// 导航到短链接 // 导航到短链接
err = page.Navigate(shortURL) err = page.Navigate(shortURL)
if err != nil { if err != nil {
return shortURL, err // 返回原始URL return shortURL, err // 返回原始URL
} }
// 等待重定向完成 // 等待重定向完成
time.Sleep(2 * time.Second) time.Sleep(2 * time.Second)
// 获取当前 URL // 获取当前 URL
info, err := page.Info() info, err := page.Info()
if err != nil { if err != nil {
return shortURL, err // 返回原始URL return shortURL, err // 返回原始URL
} }
return info.URL, nil return info.URL, nil
} }
@@ -247,21 +247,21 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
log.Errorf("爬虫服务崩溃: %v", r) log.Errorf("爬虫服务崩溃: %v", r)
} }
}() }()
service, err := NewService() service, err := NewService()
if err != nil { if err != nil {
return "", fmt.Errorf("创建爬虫服务失败: %v", err) return "", fmt.Errorf("创建爬虫服务失败: %v", err)
} }
defer service.Close() defer service.Close()
// 设置超时上下文 // 设置超时上下文
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel() defer cancel()
// 使用goroutine和通道来处理超时 // 使用goroutine和通道来处理超时
resultChan := make(chan []SearchResult, 1) resultChan := make(chan []SearchResult, 1)
errChan := make(chan error, 1) errChan := make(chan error, 1)
go func() { go func() {
results, err := service.WebSearch(keyword, maxPages) results, err := service.WebSearch(keyword, maxPages)
if err != nil { if err != nil {
@@ -270,7 +270,7 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
} }
resultChan <- results resultChan <- results
}() }()
// 等待结果或超时 // 等待结果或超时
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -281,32 +281,32 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
if len(results) == 0 { if len(results) == 0 {
return "未找到关于 \"" + keyword + "\" 的相关搜索结果", nil return "未找到关于 \"" + keyword + "\" 的相关搜索结果", nil
} }
// 格式化结果 // 格式化结果
var builder strings.Builder var builder strings.Builder
builder.WriteString(fmt.Sprintf("为您找到关于 \"%s\" 的 %d 条搜索结果:\n\n", keyword, len(results))) builder.WriteString(fmt.Sprintf("为您找到关于 \"%s\" 的 %d 条搜索结果:\n\n", keyword, len(results)))
for i, result := range results { for i, result := range results {
// // 尝试打开链接获取实际内容 // // 尝试打开链接获取实际内容
// page := service.browser.MustPage() // page := service.browser.MustPage()
// defer page.MustClose() // defer page.MustClose()
// // 设置页面超时 // // 设置页面超时
// pageCtx, pageCancel := context.WithTimeout(context.Background(), 10*time.Second) // pageCtx, pageCancel := context.WithTimeout(context.Background(), 10*time.Second)
// defer pageCancel() // defer pageCancel()
// // 导航到目标页面 // // 导航到目标页面
// err := page.Context(pageCtx).Navigate(result.URL) // err := page.Context(pageCtx).Navigate(result.URL)
// if err == nil { // if err == nil {
// // 等待页面加载 // // 等待页面加载
// _ = page.WaitLoad() // _ = page.WaitLoad()
// // 获取页面标题 // // 获取页面标题
// title, err := page.Eval("() => document.title") // title, err := page.Eval("() => document.title")
// if err == nil && title.Value.String() != "" { // if err == nil && title.Value.String() != "" {
// result.Title = title.Value.String() // result.Title = title.Value.String()
// } // }
// // 获取页面主要内容 // // 获取页面主要内容
// if content, err := page.Element("body"); err == nil { // if content, err := page.Element("body"); err == nil {
// if text, err := content.Text(); err == nil { // if text, err := content.Text(); err == nil {
@@ -315,11 +315,11 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
// if len(text) > 200 { // if len(text) > 200 {
// text = text[:200] + "..." // text = text[:200] + "..."
// } // }
// result.Content = text // result.Prompt = text
// } // }
// } // }
// } // }
builder.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, result.Title)) builder.WriteString(fmt.Sprintf("%d. **%s**\n", i+1, result.Title))
builder.WriteString(fmt.Sprintf(" 链接: %s\n", result.URL)) builder.WriteString(fmt.Sprintf(" 链接: %s\n", result.URL))
if result.Content != "" { if result.Content != "" {
@@ -327,7 +327,7 @@ func SearchWeb(keyword string, maxPages int) (string, error) {
} }
builder.WriteString("\n") builder.WriteString("\n")
} }
return builder.String(), nil return builder.String(), nil
} }
} }

View File

@@ -16,7 +16,6 @@ import (
"geekai/store" "geekai/store"
"geekai/store/model" "geekai/store/model"
"geekai/utils" "geekai/utils"
"io"
"time" "time"
"github.com/go-redis/redis/v8" "github.com/go-redis/redis/v8"
@@ -182,9 +181,6 @@ func (s *Service) Image(task types.DallTask, sync bool) (string, error) {
return "", fmt.Errorf("error with send request, status: %s, %+v", r.Status, errRes.Error) return "", fmt.Errorf("error with send request, status: %s, %+v", r.Status, errRes.Error)
} }
all, _ := io.ReadAll(r.Body)
logger.Debugf("response: %+v", string(all))
// update the api key last use time // update the api key last use time
s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix()) s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
var imgURL string var imgURL string

View File

@@ -94,6 +94,8 @@ func (s *Service) Run() {
continue continue
} }
logger.Infof("任务提交成功: %+v", r)
// 更新任务信息 // 更新任务信息
s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{ s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
"task_id": r.Data, "task_id": r.Data,
@@ -127,6 +129,7 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
"continue_clip_id": task.RefSongId, "continue_clip_id": task.RefSongId,
"continue_at": task.ExtendSecs, "continue_at": task.ExtendSecs,
"make_instrumental": task.Instrumental, "make_instrumental": task.Instrumental,
"mv": task.Model,
} }
// 灵感模式 // 灵感模式
if task.Type == 1 { if task.Type == 1 {
@@ -134,7 +137,6 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
} else { // 自定义模式 } else { // 自定义模式
reqBody["prompt"] = task.Lyrics reqBody["prompt"] = task.Lyrics
reqBody["tags"] = task.Tags reqBody["tags"] = task.Tags
reqBody["mv"] = task.Model
reqBody["title"] = task.Title reqBody["title"] = task.Title
} }

View File

@@ -113,7 +113,7 @@ Please remember, the final output must be the same language with users input.
- What kinds of examples may need to be included, how many, and whether they are complex enough to benefit from placeholders. - What kinds of examples may need to be included, how many, and whether they are complex enough to benefit from placeholders.
- Clarity and Conciseness: Use clear, specific language. Avoid unnecessary instructions or bland statements. - Clarity and Conciseness: Use clear, specific language. Avoid unnecessary instructions or bland statements.
- Formatting: Use markdown features for readability. DO NOT USE CODE BLOCKS UNLESS SPECIFICALLY REQUESTED. - Formatting: Use markdown features for readability. DO NOT USE CODE BLOCKS UNLESS SPECIFICALLY REQUESTED.
- Preserve User Content: If the input task or prompt includes extensive guidelines or examples, preserve them entirely, or as closely as possible. If they are vague, consider breaking down into sub-steps. Keep any details, guidelines, examples, variables, or placeholders provided by the user. - Preserve User Prompt: If the input task or prompt includes extensive guidelines or examples, preserve them entirely, or as closely as possible. If they are vague, consider breaking down into sub-steps. Keep any details, guidelines, examples, variables, or placeholders provided by the user.
- Constants: DO include constants in the prompt, as they are not susceptible to prompt injection. Such as guides, rubrics, and examples. - Constants: DO include constants in the prompt, as they are not susceptible to prompt injection. Such as guides, rubrics, and examples.
- Output Format: Explicitly the most appropriate output format, in detail. This should include length and syntax (e.g. short sentence, paragraph, JSON, etc.) - Output Format: Explicitly the most appropriate output format, in detail. This should include length and syntax (e.g. short sentence, paragraph, JSON, etc.)
- For tasks outputting well-defined or structured data (classification, JSON, etc.) bias toward outputting a JSON. - For tasks outputting well-defined or structured data (classification, JSON, etc.) bias toward outputting a JSON.

View File

@@ -1,13 +0,0 @@
package service
import "geekai/core/types"
type WebsocketService struct {
Clients *types.LMap[string, *types.WsClient] // clientId => Client
}
func NewWebsocketService() *WebsocketService {
return &WebsocketService{
Clients: types.NewLMap[string, *types.WsClient](),
}
}

View File

@@ -5,17 +5,17 @@ import (
) )
type ChatMessage struct { type ChatMessage struct {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserId uint `gorm:"column:user_id;type:int;not null;comment:用户 ID" json:"user_id"` UserId uint `gorm:"column:user_id;type:int;not null;comment:用户 ID" json:"user_id"`
ChatId string `gorm:"column:chat_id;type:char(40);not null;index;comment:会话 ID" json:"chat_id"` ChatId string `gorm:"column:chat_id;type:char(40);not null;index;comment:会话 ID" json:"chat_id"`
Type string `gorm:"column:type;type:varchar(10);not null;comment:类型prompt|reply" json:"type"` Type string `gorm:"column:type;type:varchar(10);not null;comment:类型prompt|reply" json:"type"`
Icon string `gorm:"column:icon;type:varchar(255);not null;comment:角色图标" json:"icon"` Icon string `gorm:"column:icon;type:varchar(255);not null;comment:角色图标" json:"icon"`
RoleId uint `gorm:"column:role_id;type:int;not null;comment:角色 ID" json:"role_id"` RoleId uint `gorm:"column:role_id;type:int;not null;comment:角色 ID" json:"role_id"`
Model string `gorm:"column:model;type:varchar(30);comment:模型名称" json:"model"` Model string `gorm:"column:model;type:varchar(255);comment:模型名称" json:"model"`
Content string `gorm:"column:content;type:text;not null;comment:聊天内容" json:"content"` Content string `gorm:"column:content;type:text;not null;comment:聊天内容" json:"content"`
Tokens int `gorm:"column:tokens;type:smallint;not null;comment:耗费 token 数量" json:"tokens"` Tokens int `gorm:"column:tokens;type:smallint;not null;comment:耗费 token 数量" json:"tokens"`
TotalTokens int `gorm:"column:total_tokens;type:int;not null;comment:消耗总Token长度" json:"total_tokens"` TotalTokens int `gorm:"column:total_tokens;type:int;not null;comment:消耗总Token长度" json:"total_tokens"`
UseContext bool `gorm:"column:use_context;type:tinyint(1);not null;comment:是否允许作为上下文语料" json:"use_context"` UseContext bool `gorm:"column:use_context;type:tinyint(1);not null;comment:是否允许作为上下文语料" json:"use_context"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"` 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"` UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
} }

View File

@@ -5,20 +5,20 @@ import (
) )
type ChatModel struct { type ChatModel struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Description string `gorm:"column:description;type:varchar(1024);not null;default:'';comment:模型类型描述" json:"description"` Desc string `gorm:"column:desc;type:varchar(1024);not null;default:'';comment:模型类型描述" json:"desc"`
Category string `gorm:"column:category;type:varchar(1024);not null;default:'';comment:模型类别" json:"category"` Tag string `gorm:"column:tag;type:varchar(1024);not null;default:'';comment:模型标签" json:"tag"`
Type string `gorm:"column:type;type:varchar(10);not null;default:chat;comment:模型类型chat,img" json:"type"` Type string `gorm:"column:type;type:varchar(10);not null;default:chat;comment:模型类型chat,img" json:"type"`
Name string `gorm:"column:name;type:varchar(255);not null;comment:模型名称" json:"name"` Name string `gorm:"column:name;type:varchar(255);not null;comment:模型名称" json:"name"`
Value string `gorm:"column:value;type:varchar(255);not null;comment:模型值" json:"value"` Value string `gorm:"column:value;type:varchar(255);not null;comment:模型值" json:"value"`
SortNum int `gorm:"column:sort_num;type:tinyint(1);not null;comment:排序数字" json:"sort_num"` SortNum int `gorm:"column:sort_num;type:tinyint(1);not null;comment:排序数字" json:"sort_num"`
Enabled bool `gorm:"column:enabled;type:tinyint(1);not null;default:0;comment:是否启用模型" json:"enabled"` Enabled bool `gorm:"column:enabled;type:tinyint(1);not null;default:0;comment:是否启用模型" json:"enabled"`
Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力点数" json:"power"` Power int `gorm:"column:power;type:smallint;not null;comment:消耗算力点数" json:"power"`
Temperature float32 `gorm:"column:temperature;type:float(3,1);not null;default:1.0;comment:模型创意度" json:"temperature"` Temperature float32 `gorm:"column:temperature;type:float(3,1);not null;default:1.0;comment:模型创意度" json:"temperature"`
MaxTokens int `gorm:"column:max_tokens;type:int;not null;default:1024;comment:最大响应长度" json:"max_tokens"` MaxTokens int `gorm:"column:max_tokens;type:int;not null;default:1024;comment:最大响应长度" json:"max_tokens"`
MaxContext int `gorm:"column:max_context;type:int;not null;default:4096;comment:最大上下文长度" json:"max_context"` MaxContext int `gorm:"column:max_context;type:int;not null;default:4096;comment:最大上下文长度" json:"max_context"`
Open bool `gorm:"column:open;type:tinyint(1);not null;comment:是否开放模型" json:"open"` Open bool `gorm:"column:open;type:tinyint(1);not null;comment:是否开放模型" json:"open"`
KeyId uint `gorm:"column:key_id;type:int;not null;comment:绑定API KEY ID" json:"key_id"` KeyId uint `gorm:"column:key_id;type:int;not null;comment:绑定API KEY ID" json:"key_id"`
Options string `gorm:"column:options;type:text;not null;comment:模型自定义选项" json:"options"` Options string `gorm:"column:options;type:text;not null;comment:模型自定义选项" json:"options"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at;type:datetime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at;type:datetime" json:"updated_at"`

View File

@@ -5,27 +5,27 @@ import (
) )
type User struct { type User struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Username string `gorm:"column:username;type:varchar(30);uniqueIndex;not null;comment:用户名" json:"username"` Username string `gorm:"column:username;type:varchar(30);uniqueIndex;not null;comment:用户名" json:"username"`
Mobile string `gorm:"column:mobile;type:char(11);comment:手机号" json:"mobile"` Mobile string `gorm:"column:mobile;type:char(11);comment:手机号" json:"mobile"`
Email string `gorm:"column:email;type:varchar(50);comment:邮箱地址" json:"email"` Email string `gorm:"column:email;type:varchar(50);comment:邮箱地址" json:"email"`
Nickname string `gorm:"column:nickname;type:varchar(30);not null;comment:昵称" json:"nickname"` Nickname string `gorm:"column:nickname;type:varchar(30);not null;comment:昵称" json:"nickname"`
Password string `gorm:"column:password;type:char(64);not null;comment:密码" json:"password"` Password string `gorm:"column:password;type:char(64);not null;comment:密码" json:"password"`
Avatar string `gorm:"column:avatar;type:varchar(255);not null;comment:头像" json:"avatar"` Avatar string `gorm:"column:avatar;type:varchar(255);not null;comment:头像" json:"avatar"`
Salt string `gorm:"column:salt;type:char(12);not null;comment:密码盐" json:"salt"` Salt string `gorm:"column:salt;type:char(12);not null;comment:密码盐" json:"salt"`
Power int `gorm:"column:power;type:int;not null;default:0;comment:剩余算力" json:"power"` Power int `gorm:"column:power;type:int;default:0;comment:剩余算力" json:"power"`
ExpiredTime int64 `gorm:"column:expired_time;type:int;not null;comment:用户过期时间" json:"expired_time"` ExpiredTime int64 `gorm:"column:expired_time;type:int;not null;comment:用户过期时间" json:"expired_time"`
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"` Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
ChatConfig string `gorm:"column:chat_config;type:text;not null;comment:聊天配置json" json:"chat_config"` ChatConfig string `gorm:"column:chat_config_json;type:text;default:null;comment:聊天配置json" json:"chat_config"`
ChatRoles string `gorm:"column:chat_roles_json;type:text;not null;comment:聊天角色 json" json:"chat_roles_json"` ChatRoles string `gorm:"column:chat_roles_json;type:text;default:null;comment:聊天角色 json" json:"chat_roles"`
ChatModels string `gorm:"column:chat_models_json;type:text;not null;comment:AI模型 json" json:"chat_models_json"` ChatModels string `gorm:"column:chat_models_json;type:text;default:null;comment:AI模型 json" json:"chat_models"`
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"` 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"` 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(16);not null;comment:最后登录 IP" json:"last_login_ip"`
OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"` OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"`
Platform string `gorm:"column:platform;type:varchar(30);comment:登录平台" json:"platform"` 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"` 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"` UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
} }
func (m *User) TableName() string { func (m *User) TableName() string {

View File

@@ -9,16 +9,17 @@ package store
import ( import (
"geekai/core/types" "geekai/core/types"
"time"
"gorm.io/driver/mysql" "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"gorm.io/gorm/schema" "gorm.io/gorm/schema"
"time"
) )
func NewGormConfig() *gorm.Config { func NewGormConfig() *gorm.Config {
return &gorm.Config{ return &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), Logger: logger.Default.LogMode(logger.Warn),
NamingStrategy: schema.NamingStrategy{ NamingStrategy: schema.NamingStrategy{
TablePrefix: "chatgpt_", // 设置表前缀 TablePrefix: "chatgpt_", // 设置表前缀
SingularTable: false, // 使用单数表名形式 SingularTable: false, // 使用单数表名形式
@@ -36,9 +37,9 @@ func NewMysql(config *gorm.Config, appConfig *types.AppConfig) (*gorm.DB, error)
if err != nil { if err != nil {
return nil, err return nil, err
} }
sqlDB.SetMaxIdleConns(32) sqlDB.SetMaxIdleConns(32)
sqlDB.SetMaxOpenConns(512) sqlDB.SetMaxOpenConns(512)
sqlDB.SetConnMaxLifetime(time.Hour) sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil return db, nil
} }

View File

@@ -1,14 +0,0 @@
package vo
type HistoryMessage struct {
BaseVo
ChatId string `json:"chat_id"`
UserId uint `json:"user_id"`
RoleId uint `json:"role_id"`
Model string `json:"model"`
Type string `json:"type"`
Icon string `json:"icon"`
Tokens int `json:"tokens"`
Content string `json:"content"`
UseContext bool `json:"use_context"`
}

View File

@@ -0,0 +1,21 @@
package vo
type MsgContent struct {
Text string `json:"text"`
Files []File `json:"files"`
}
type ChatMessage struct {
Id uint `json:"id"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ChatId string `json:"chat_id"`
UserId uint `json:"user_id"`
RoleId uint `json:"role_id"`
Model string `json:"model"`
Type string `json:"type"`
Icon string `json:"icon"`
Tokens int `json:"tokens"`
Content MsgContent `json:"content"`
UseContext bool `json:"use_context"`
}

View File

@@ -10,8 +10,8 @@ type ChatModel struct {
Open bool `json:"open"` Open bool `json:"open"`
MaxTokens int `json:"max_tokens"` // 最大响应长度 MaxTokens int `json:"max_tokens"` // 最大响应长度
MaxContext int `json:"max_context"` // 最大上下文长度 MaxContext int `json:"max_context"` // 最大上下文长度
Description string `json:"description"` // 模型描述 Desc string `json:"desc"` // 模型描述
Category string `json:"category"` //模型类别 Tag string `json:"tag"` //模型标签
Temperature float32 `json:"temperature"` // 模型温度 Temperature float32 `json:"temperature"` // 模型温度
KeyId uint `json:"key_id,omitempty"` KeyId uint `json:"key_id,omitempty"`
KeyName string `json:"key_name"` KeyName string `json:"key_name"`

196
build/geekai-install.sh Executable file
View File

@@ -0,0 +1,196 @@
#!/usr/bin/env bash
# install-docker.sh — 自动识别 Linux 发行版并安装 Docker (Compose v2)
# 支持国内镜像自动/手动切换,内置错误处理与友好提示。
# ---------------------------------------------------------------
set -Eeuo pipefail
# ────────────────────────── 彩色输出 ────────────────────────── #
info() { printf "\e[32m[INFO]\e[0m %s\n" "$*"; }
warn() { printf "\e[33m[WARN]\e[0m %s\n" "$*" >&2; }
error() { printf "\e[31m[ERROR]\e[0m %s\n" "$*" >&2; exit 1; }
trap 'error "脚本失败,命令:\"${BASH_COMMAND}\", 退出码 $?"' ERR
need_cmd() { command -v "$1" &>/dev/null; }
as_root() { ((EUID==0)) && "$@" || sudo "$@"; }
# ─────────── 部署 GeekAI-Plus 函数 ─────────── #
deploy_geekai_plus(){
local repo=https://gitee.com/blackfox/geekai-plus-open.git
local dir=${GEEKAI_DIR:-geekai-plus}
info "部署 GeekAI-Plus 到目录 \"$dir\""
need_cmd git || error "未找到 git请检查安装步骤。"
if [[ -d $dir ]]; then
warn "目录 $dir 已存在,跳过克隆。"
else
git clone --depth 1 "$repo" "$dir"
fi
pushd "$dir" >/dev/null
info "启动 docker compose…"
if docker compose up -d; then
info "GeekAI-Plus 部署完成!请访问 http://ip:8080。"
else
error "docker compose 启动失败。"
fi
popd >/dev/null
}
# ─────────────────── 检测 Docker 是否已安装 ─────────────────── #
if need_cmd docker && (docker compose version &>/dev/null || need_cmd docker-compose); then
info "Docker 与 Compose 已安装,无需重复操作。"
deploy_geekai_plus
exit 0
fi
# ────────────────────────── 解析发行版 ───────────────────────── #
[[ -r /etc/os-release ]] || error "无法识别系统:缺少 /etc/os-release"
. /etc/os-release
OS_ID=${ID,,}
OS_VER=${VERSION_ID:-unknown}
ARCH=$(uname -m)
info "检测到系统:$PRETTY_NAME ($OS_ID $OS_VER, $ARCH)"
# ──────────────────── 镜像域名与自动回退逻辑 ──────────────────── #
# ❶ 用户可通过 DOCKER_MIRROR 指定:
# - aliyun → https://mirrors.aliyun.com/docker-ce
# - tuna → https://mirrors.tuna.tsinghua.edu.cn/docker-ce
# - official (默认) → https://download.docker.com
#
# ❷ 若未指定,则先探测官方域名能否连通;失败则自动切换到 aliyun。
#
choose_mirror() {
local sel=${DOCKER_MIRROR:-auto}
case "$sel" in
aliyun) MIRROR="https://mirrors.aliyun.com/docker-ce" ;;
tuna) MIRROR="https://mirrors.tuna.tsinghua.edu.cn/docker-ce" ;;
official) MIRROR="https://download.docker.com" ;;
auto)
MIRROR="https://download.docker.com"
info "检测官方源连通性…"
if ! curl -m 3 -sfL "${MIRROR}/linux/${OS_ID}/gpg" -o /dev/null; then
warn "官方源不可达,回退至阿里云镜像。"
MIRROR="https://mirrors.aliyun.com/docker-ce"
fi ;;
*)
error "未知镜像标识:$sel(可选 aliyun|tuna|official" ;;
esac
info "使用镜像源:$MIRROR"
}
choose_mirror
# ────────────────────────── 安装函数 ────────────────────────── #
install_docker_debian_like() {
info "使用 APT 安装 Docker"
as_root apt-get update -y
as_root apt-get install -y ca-certificates curl git gnupg lsb-release
as_root install -m 0755 -d /etc/apt/keyrings
curl -fsSL "${MIRROR}/linux/${OS_ID}/gpg" \
| as_root gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
${MIRROR}/linux/${OS_ID} $(lsb_release -cs) stable" \
| as_root tee /etc/apt/sources.list.d/docker.list >/dev/null
as_root apt-get update -y
as_root apt-get install -y \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
}
install_docker_centos_like() {
info "使用 YUM/DNF 安装 Docker"
local pkgcmd
if need_cmd dnf; then pkgcmd=dnf; else pkgcmd=yum; fi
as_root $pkgcmd -y install ${pkgcmd}-plugins-core git
as_root $pkgcmd config-manager \
--add-repo "${MIRROR}/linux/centos/docker-ce.repo"
as_root $pkgcmd -y install \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
as_root systemctl enable --now docker
}
install_docker_fedora() {
info "使用 DNF 安装 Docker (Fedora)"
as_root dnf -y install dnf-plugins-core
as_root dnf config-manager --add-repo \
"${MIRROR}/linux/fedora/docker-ce.repo"
as_root dnf -y install \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin git
as_root systemctl enable --now docker
}
install_docker_arch() {
info "使用 pacman 安装 Docker"
as_root pacman -Sy --noconfirm docker docker-compose git
as_root systemctl enable --now docker
}
install_docker_opensuse() {
info "使用 zypper 安装 Docker"
as_root zypper -n in docker docker-compose git
as_root systemctl enable --now docker
}
install_docker_alpine() {
info "使用 apk 安装 Docker"
as_root apk add --no-cache docker docker-cli-compose git
as_root rc-update add docker boot git
as_root service docker start
}
install_docker_fallback() {
warn "发行版 \"$OS_ID\" 未做专门适配,执行官方一键脚本…"
curl -fsSL get.docker.com | as_root sh
}
# ────────────────────────── 分发安装 ────────────────────────── #
case "$OS_ID" in
debian|ubuntu|linuxmint) install_docker_debian_like ;;
centos|rocky|almalinux|rhel) install_docker_centos_like ;;
fedora) install_docker_fedora ;;
arch|manjaro) install_docker_arch ;;
opensuse*|suse|sles) install_docker_opensuse ;;
alpine) install_docker_alpine ;;
*) install_docker_fallback ;;
esac
# ──────────────────── 安装后检查 & docker 组 ─────────────────── #
need_cmd docker || error "Docker 安装后仍不可用,请检查日志。"
as_root usermod -aG docker "${SUDO_USER:-$USER}" || true
# ──────────────────── (可选) 镜像加速器配置 ─────────────────── #
if [[ "${ENABLE_REGISTRYMIRROR:-1}" == "1" ]]; then
as_root mkdir -p /etc/docker
cat <<-JSON | as_root tee /etc/docker/daemon.json >/dev/null
{
"registry-mirrors": [
"https://registry.docker-cn.com", "https://mirror.ccs.tencentyun.com","https://hub-mirror.c.163.com"
]
}
JSON
as_root systemctl restart docker
info "已为 Docker 配置国内镜像加速器。"
fi
# ────────────────────────── 最终信息 ────────────────────────── #
info "Docker 版本:$(docker --version | cut -d',' -f1)"
if docker compose version &>/dev/null; then
info "Compose 版本:$(docker compose version --short)"
elif need_cmd docker-compose; then
info "Compose 版本:$(docker-compose --version | awk '{print $3}')"
fi
cat <<'EOF'
╭─────────────────────────────────────────────────────────╮
│ 安装完成! │
│ · 请重新登录或执行 `newgrp docker` 以使用 docker 免 sudo │
│ · 如需跳过镜像加速可执行ENABLE_REGISTRYMIRROR=0 ... │
╰─────────────────────────────────────────────────────────╯
EOF
deploy_geekai_plus

View File

@@ -110,7 +110,7 @@ CREATE TABLE `chatgpt_chat_history` (
`type` varchar(10) NOT NULL COMMENT '类型prompt|reply', `type` varchar(10) NOT NULL COMMENT '类型prompt|reply',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色图标', `icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色图标',
`role_id` bigint NOT NULL COMMENT '角色 ID', `role_id` bigint NOT NULL COMMENT '角色 ID',
`model` varchar(30) DEFAULT NULL COMMENT '模型名称', `model` varchar(255) DEFAULT NULL COMMENT '模型名称',
`content` text NOT NULL COMMENT '聊天内容', `content` text NOT NULL COMMENT '聊天内容',
`tokens` smallint NOT NULL COMMENT '耗费 token 数量', `tokens` smallint NOT NULL COMMENT '耗费 token 数量',
`total_tokens` bigint NOT NULL COMMENT '消耗总Token长度', `total_tokens` bigint NOT NULL COMMENT '消耗总Token长度',
@@ -134,7 +134,7 @@ CREATE TABLE `chatgpt_chat_items` (
`role_id` bigint NOT NULL COMMENT '角色 ID', `role_id` bigint NOT NULL COMMENT '角色 ID',
`title` varchar(100) NOT NULL COMMENT '会话标题', `title` varchar(100) NOT NULL COMMENT '会话标题',
`model_id` bigint NOT NULL DEFAULT '0' COMMENT '模型 ID', `model_id` bigint NOT NULL DEFAULT '0' COMMENT '模型 ID',
`model` varchar(30) DEFAULT NULL COMMENT '模型名称', `model` varchar(255) DEFAULT NULL COMMENT '模型名称',
`created_at` datetime NOT NULL COMMENT '创建时间', `created_at` datetime NOT NULL COMMENT '创建时间',
`updated_at` datetime NOT NULL COMMENT '更新时间', `updated_at` datetime NOT NULL COMMENT '更新时间',
`deleted_at` datetime DEFAULT NULL `deleted_at` datetime DEFAULT NULL
@@ -465,7 +465,7 @@ CREATE TABLE `chatgpt_power_logs` (
`type` tinyint(1) NOT NULL COMMENT '类型1充值2消费3退费', `type` tinyint(1) NOT NULL COMMENT '类型1充值2消费3退费',
`amount` smallint NOT NULL COMMENT '算力数值', `amount` smallint NOT NULL COMMENT '算力数值',
`balance` bigint NOT NULL COMMENT '余额', `balance` bigint NOT NULL COMMENT '余额',
`model` varchar(30) NOT NULL COMMENT '模型', `model` varchar(255) NOT NULL COMMENT '模型',
`remark` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '备注', `remark` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '备注',
`mark` tinyint(1) NOT NULL COMMENT '资金类型0支出1收入', `mark` tinyint(1) NOT NULL COMMENT '资金类型0支出1收入',
`created_at` datetime NOT NULL COMMENT '创建时间' `created_at` datetime NOT NULL COMMENT '创建时间'

View File

@@ -0,0 +1,3 @@
INSERT INTO `chatgpt_configs` (`id`, `marker`, `config_json`) VALUES
(4, 'privacy', '{\"sd_neg_prompt\":\"\",\"mj_mode\":\"\",\"index_navs\":null,\"copyright\":\"\",\"default_nickname\":\"\",\"icp\":\"\",\"mark_map_text\":\"\",\"enabled_verify\":false,\"email_white_list\":null,\"translate_model_id\":0,\"max_file_size\":0,\"content\":\"# 隐私政策\\n\\n我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。\",\"updated\":true}'),
(5, 'agreement', '{\"sd_neg_prompt\":\"\",\"mj_mode\":\"\",\"index_navs\":null,\"copyright\":\"\",\"default_nickname\":\"\",\"icp\":\"\",\"mark_map_text\":\"\",\"enabled_verify\":false,\"email_white_list\":null,\"translate_model_id\":0,\"max_file_size\":0,\"content\":\"# 用户协议\\n\\n用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分与协议正文具有同等法律效力。\",\"updated\":true}');

View File

@@ -2,4 +2,4 @@ ALTER TABLE `chatgpt_chat_models` ADD `category` VARCHAR(1024) NOT NULL DEFAULT
ALTER TABLE `chatgpt_chat_models` ADD `description` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '模型类型描述' AFTER `id`; ALTER TABLE `chatgpt_chat_models` ADD `description` VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '模型类型描述' AFTER `id`;
ALTER TABLE `chatgpt_orders` DROP `deleted_at`; ALTER TABLE `chatgpt_orders` DROP `deleted_at`;
ALTER TABLE `chatgpt_chat_history` DROP `deleted_at`; ALTER TABLE `chatgpt_chat_history` DROP `deleted_at`;
ALTER TABLE `chatgpt_chat_items` DROP `deleted_at`; ALTER TABLE `chatgpt_chat_items` DROP `deleted_at`;

View File

@@ -46,7 +46,7 @@ services:
# 后端 API 程序 # 后端 API 程序
geekai-api: geekai-api:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.2.3-amd64 image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-api:v4.2.4-amd64
container_name: geekai-api container_name: geekai-api
restart: always restart: always
depends_on: depends_on:
@@ -69,7 +69,7 @@ services:
# 前端应用 # 前端应用
geekai-web: geekai-web:
image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.2.3-amd64 image: registry.cn-shenzhen.aliyuncs.com/geekmaster/geekai-web:v4.2.4-amd64
container_name: geekai-web container_name: geekai-web
restart: always restart: always
depends_on: depends_on:

View File

@@ -1,14 +1,14 @@
VUE_APP_API_HOST=http://localhost:5678 VITE_API_HOST=http://localhost:5678
VUE_APP_WS_HOST=ws://localhost:5678 VITE_WS_HOST=ws://localhost:5678
VUE_APP_USER=18888888888 VITE_USER=18888888888
VUE_APP_PASS=12345678 VITE_PASS=12345678
VUE_APP_ADMIN_USER=admin VITE_ADMIN_USER=admin
VUE_APP_ADMIN_PASS=admin123 VITE_ADMIN_PASS=admin123
VUE_APP_KEY_PREFIX=GeekAI_DEV_ VITE_KEY_PREFIX=GeekAI_DEV_
VUE_APP_TITLE="Geek-AI 创作系统" VITE_TITLE="Geek-AI 创作系统"
VUE_APP_VERSION=v4.2.2 VITE_VERSION=v4.2.4
VUE_APP_DOCS_URL=https://docs.geekai.me VITE_DOCS_URL=https://docs.geekai.me
VUE_APP_GITHUB_URL=https://github.com/yangjian102621/geekai VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
VUE_APP_GITEE_URL=https://gitee.com/blackfox/geekai VITE_GITEE_URL=https://gitee.com/blackfox/geekai
VUE_APP_GITCODE_URL=https://gitcode.com/yangjian102621/geekai VITE_GITCODE_URL=https://gitcode.com/yangjian102621/geekai

View File

@@ -1,9 +1,9 @@
VUE_APP_API_HOST= VITE_API_HOST=
VUE_APP_WS_HOST= VITE_WS_HOST=
VUE_APP_KEY_PREFIX=GeekAI_ VITE_KEY_PREFIX=GeekAI_
VUE_APP_VERSION=v4.2.2 VITE_VERSION=v4.2.4
VUE_APP_TITLE="Geek-AI 创作系统" VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_DOCS_URL=https://docs.geekai.me VITE_DOCS_URL=https://docs.geekai.me
VUE_APP_GITHUB_URL=https://github.com/yangjian102621/geekai VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
VUE_APP_GITEE_URL=https://gitee.com/blackfox/geekai VITE_GITEE_URL=https://gitee.com/blackfox/geekai
VUE_APP_GITCODE_URL=https://gitcode.com/yangjian102621/geekai VITE_GITCODE_URL=https://gitcode.com/yangjian102621/geekai

78
web/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,78 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useFetch: typeof import('@vueuse/core')['useFetch']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useMouse: typeof import('@vueuse/core')['useMouse']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

View File

@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@@ -8,6 +8,8 @@
content="width=device-width,initial-scale=1.0,user-scalable=no" content="width=device-width,initial-scale=1.0,user-scalable=no"
/> />
<title>Geek-AI 创作助手</title> <title>Geek-AI 创作助手</title>
<!-- Add any global CSS files here if needed, e.g., <link rel="stylesheet" href="/css/global.css"> -->
<!-- Favicon can be linked here, e.g., <link rel="icon" href="/favicon.ico"> -->
</head> </head>
<body> <body>
@@ -15,5 +17,6 @@
<strong>请开启JavaScript支持</strong> <strong>请开启JavaScript支持</strong>
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"jsx": "preserve",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

13767
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,18 @@
"name": "geekai-web", "name": "geekai-web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"scripts": { "scripts": {
"dev": "vue-cli-service serve", "dev": "vite --host",
"build": "vue-cli-service build", "build": "vite build",
"lint": "vue-cli-service lint" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
}, },
"dependencies": { "dependencies": {
"@better-scroll/core": "^2.5.1",
"@better-scroll/mouse-wheel": "^2.5.1",
"@better-scroll/observe-dom": "^2.5.1",
"@better-scroll/pull-up": "^2.5.1",
"@better-scroll/scroll-bar": "^2.5.1",
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"axios": "^0.27.2", "axios": "^0.27.2",
@@ -23,6 +29,7 @@
"markdown-it": "^13.0.1", "markdown-it": "^13.0.1",
"markdown-it-emoji": "^2.0.0", "markdown-it-emoji": "^2.0.0",
"markdown-it-mathjax3": "^4.3.2", "markdown-it-mathjax3": "^4.3.2",
"marked": "^15.0.11",
"markmap-common": "^0.16.0", "markmap-common": "^0.16.0",
"markmap-lib": "^0.16.1", "markmap-lib": "^0.16.1",
"markmap-toolbar": "^0.17.0", "markmap-toolbar": "^0.17.0",
@@ -32,51 +39,22 @@
"pinia": "^2.1.4", "pinia": "^2.1.4",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"qs": "^6.11.1", "qs": "^6.11.1",
"@better-scroll/core": "^2.5.1",
"@better-scroll/mouse-wheel": "^2.5.1",
"@better-scroll/observe-dom": "^2.5.1",
"@better-scroll/pull-up": "^2.5.1",
"@better-scroll/scroll-bar": "^2.5.1",
"sortablejs": "^1.15.0", "sortablejs": "^1.15.0",
"three": "^0.128.0", "three": "^0.128.0",
"vant": "^4.5.0", "vant": "^4.5.0",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.0.15", "vue-router": "^4.0.15",
"unplugin-auto-import": "^0.18.5",
"@microsoft/fetch-event-source": "^2.0.1",
"vue-waterfall-plugin-next": "^2.6.5" "vue-waterfall-plugin-next": "^2.6.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.18.6", "@vitejs/plugin-vue": "^5.2.4",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"stylus": "^0.58.1", "stylus": "^0.58.1",
"stylus-loader": "^7.0.0", "stylus-loader": "^7.0.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"webpack": "^5.90.3" "vite": "^5.4.10"
}, }
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
} }

View File

@@ -1,4 +1,4 @@
module.exports = { export default {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {}, autoprefixer: {},

View File

@@ -5,97 +5,58 @@
</template> </template>
<script setup> <script setup>
import { ElConfigProvider } from "element-plus"; import { checkSession, getSystemInfo } from '@/store/cache'
import { onMounted, ref, watch } from "vue"; import { useSharedStore } from '@/store/sharedata'
import { checkSession, getClientId, getSystemInfo } from "@/store/cache"; import { showMessageInfo } from '@/utils/dialog'
import { isChrome, isMobile } from "@/utils/libs"; import { isChrome, isMobile } from '@/utils/libs'
import { showMessageInfo } from "@/utils/dialog"; import { ElConfigProvider } from 'element-plus'
import { useSharedStore } from "@/store/sharedata"; import { onMounted } from 'vue'
import { getUserToken } from "@/store/session";
const debounce = (fn, delay) => { const debounce = (fn, delay) => {
let timer; let timer
return (...args) => { return (...args) => {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer)
} }
timer = setTimeout(() => { timer = setTimeout(() => {
fn(...args); fn(...args)
}, delay); }, delay)
}; }
}; }
const _ResizeObserver = window.ResizeObserver; const _ResizeObserver = window.ResizeObserver
window.ResizeObserver = class ResizeObserver extends _ResizeObserver { window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
constructor(callback) { constructor(callback) {
callback = debounce(callback, 200); callback = debounce(callback, 200)
super(callback); super(callback)
} }
}; }
const store = useSharedStore(); const store = useSharedStore()
onMounted(() => { onMounted(() => {
// 获取系统参数 // 获取系统参数
getSystemInfo().then((res) => { getSystemInfo().then((res) => {
const link = document.createElement("link"); const link = document.createElement('link')
link.rel = "shortcut icon"; link.rel = 'shortcut icon'
link.href = res.data.logo; link.href = res.data.logo
document.head.appendChild(link); document.head.appendChild(link)
}); })
if (!isChrome() && !isMobile()) { if (!isChrome() && !isMobile()) {
showMessageInfo("建议使用 Chrome 浏览器以获得最佳体验。"); showMessageInfo('建议使用 Chrome 浏览器以获得最佳体验。')
} }
checkSession() checkSession()
.then(() => { .then(() => {
store.setIsLogin(true); store.setIsLogin(true)
}) })
.catch(() => {}); .catch(() => {})
// 设置主题 // 设置主题
document.documentElement.setAttribute("data-theme", store.theme); document.documentElement.setAttribute('data-theme', store.theme)
}); })
watch(
() => store.isLogin,
(val) => {
if (val) {
connect();
}
}
);
const handler = ref(0);
// 初始化 websocket 连接
const connect = () => {
let host = process.env.VUE_APP_WS_HOST;
if (host === "") {
if (location.protocol === "https:") {
host = "wss://" + location.host;
} else {
host = "ws://" + location.host;
}
}
const clientId = getClientId();
const _socket = new WebSocket(host + `/api/ws?client_id=${clientId}`, ["token", getUserToken()]);
_socket.addEventListener("open", () => {
console.log("WebSocket 已连接");
handler.value = setInterval(() => {
if (_socket.readyState === WebSocket.OPEN) {
_socket.send(JSON.stringify({ type: "ping" }));
}
}, 5000);
});
_socket.addEventListener("close", () => {
clearInterval(handler.value);
connect();
});
store.setSocket(_socket);
};
// 打印 banner // 打印 banner
const banner = ` const banner = `
.oooooo. oooo .o. ooooo .oooooo. oooo .o. ooooo
d8P' 'Y8b 888 .888. 888 d8P' 'Y8b 888 .888. 888
888 .ooooo. .ooooo. 888 oooo .8"888. 888 888 .ooooo. .ooooo. 888 oooo .8"888. 888
@@ -103,18 +64,18 @@ const banner = `
888 ooooo 888ooo888 888ooo888 888888. .88ooo8888. 888 888 ooooo 888ooo888 888ooo888 888888. .88ooo8888. 888
'88. .88' 888 .o 888 .o 888 88b. .8' 888. 888 '88. .88' 888 .o 888 .o 888 88b. .8' 888. 888
Y8bood8P' Y8bod8P' Y8bod8P' o888o o888o o88o o8888o o888o Y8bood8P' Y8bod8P' Y8bod8P' o888o o888o o88o o8888o o888o
`
console.log('%c' + banner + '', 'color: purple;font-size: 18px;')
`;
console.log("%c" + banner + "", "color: purple;font-size: 18px;");
console.log("%c感谢大家为 GeekAI 做出的卓越贡献!", "color: green;font-size: 40px;font-family: '微软雅黑';");
console.log( console.log(
"%c项目源码https://github.com/yangjian102621/geekai %c 您的 star 对我们非常重要!", '%c感谢大家为 GeekAI 做出的卓越贡献!',
"color: green;font-size: 40px;font-family: '微软雅黑';"
)
console.log(
'%c项目源码https://github.com/yangjian102621/geekai %c 您的 star 对我们非常重要!',
"color: green;font-size: 20px;font-family: '微软雅黑';", "color: green;font-size: 20px;font-family: '微软雅黑';",
"color: red;font-size: 20px;font-family: '微软雅黑';" "color: red;font-size: 20px;font-family: '微软雅黑';"
); )
console.log("%c 愿你出走半生,归来仍是少年!大奉武夫许七安,前来凿阵!", "color: #7c39ed;font-size: 18px;font-family: '微软雅黑';");
</script> </script>
<style lang="stylus"> <style lang="stylus">
@@ -173,6 +134,4 @@ html, body {
background #D6FBCC background #D6FBCC
color #07C160 color #07C160
} }
@import '@/assets/iconfont/iconfont.css'
</style> </style>

View File

@@ -5,7 +5,7 @@
.chat-page { .chat-page {
height: 100%; height: 100%;
:deep (.el-message-box__message){ :deep(.el-message-box__message){
font-size: 18px !important font-size: 18px !important
} }
.newChat{ .newChat{

View File

@@ -6,7 +6,7 @@
</div> </div>
<div class="chat-item"> <div class="chat-item">
<div v-if="files.length > 0" class="file-list-box"> <div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url"> <div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)"> <div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" /> <el-image :src="file.url" fit="cover" />
@@ -29,7 +29,9 @@
</div> </div>
</div> </div>
</div> </div>
<div class="content" v-html="content"></div> <div class="content position-relative">
<div v-html="content"></div>
</div>
<div class="bar" v-if="data.created_at > 0"> <div class="bar" v-if="data.created_at > 0">
<span class="bar-item" <span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span ><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
@@ -47,7 +49,7 @@
</div> </div>
<div class="chat-item"> <div class="chat-item">
<div v-if="files.length > 0" class="file-list-box"> <div v-if="files && files.length > 0" class="file-list-box">
<div v-for="file in files" :key="file.url"> <div v-for="file in files" :key="file.url">
<div class="image" v-if="isImage(file.ext)"> <div class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" /> <el-image :src="file.url" fit="cover" />
@@ -71,7 +73,9 @@
</div> </div>
</div> </div>
<div class="content-wrapper"> <div class="content-wrapper">
<div class="content" v-html="content"></div> <div class="content position-relative">
<div v-html="content"></div>
</div>
</div> </div>
<div class="bar" v-if="data.created_at > 0"> <div class="bar" v-if="data.created_at > 0">
<span class="bar-item" <span class="bar-item"
@@ -86,7 +90,6 @@
<script setup> <script setup>
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system' import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { httpPost } from '@/utils/http'
import { dateFormat, isImage, processPrompt } from '@/utils/libs' import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import { Clock } from '@element-plus/icons-vue' import { Clock } from '@element-plus/icons-vue'
import hl from 'highlight.js' import hl from 'highlight.js'
@@ -111,7 +114,7 @@ const md = new MarkdownIt({
if (lang && hl.getLanguage(lang)) { if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>` const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮 // 处理代码高亮
const preCode = hl.highlight(lang, str, true).value const preCode = hl.highlight(str, { language: lang, ignoreIllegals: true }).value
// 将代码包裹在 pre 中 // 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>` return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
} }
@@ -124,11 +127,15 @@ const md = new MarkdownIt({
}) })
md.use(mathjaxPlugin) md.use(mathjaxPlugin)
md.use(emoji) md.use(emoji)
const props = defineProps({ const props = defineProps({
data: { data: {
type: Object, type: Object,
default: { default: {
content: '', content: {
text: '',
files: [],
},
created_at: '', created_at: '',
tokens: 0, tokens: 0,
model: '', model: '',
@@ -141,8 +148,11 @@ const props = defineProps({
}, },
}) })
const finalTokens = ref(props.data.tokens) const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content)) const content = ref(processPrompt(props.data.content.text))
const files = ref([]) const files = ref(props.data.content.files)
// 定义emit事件
const emit = defineEmits(['edit'])
onMounted(() => { onMounted(() => {
processFiles() processFiles()
@@ -152,38 +162,6 @@ const processFiles = () => {
if (!props.data.content) { if (!props.data.content) {
return return
} }
// 提取图片|文件链接
const linkRegex = /(https?:\/\/\S+)/g
const links = props.data.content.match(linkRegex)
const urlPrefix = `${window.location.protocol}//${window.location.host}`
if (links) {
// 把本地链接转换为相对路径
const _links = links.map((link) => {
if (link.startsWith(urlPrefix)) {
return link.replace(urlPrefix, '')
}
return link
})
// 合并数组并去重
const urls = [...new Set([...links, ..._links])]
httpPost('/api/upload/list', { urls: urls })
.then((res) => {
files.value = res.data.items
// for (let link of links) {
// if (isExternalImg(link, files.value)) {
// files.value.push({ url: link, ext: ".png" });
// }
// }
})
.catch(() => {})
// 替换图片|文件链接
for (let link of links) {
content.value = content.value.replace(link, '')
}
}
content.value = md.render(content.value.trim()) content.value = md.render(content.value.trim())
} }
const isExternalImg = (link, files) => { const isExternalImg = (link, files) => {
@@ -475,4 +453,39 @@ const isExternalImg = (link, files) => {
} }
} }
.operations
display none
position absolute
right 5px
top 5px
.text-box
&:hover
.operations
display flex
gap 5px
.op-edit
cursor pointer
color #409eff
font-size 16px
&:hover
color darken(#409eff, 10%)
.position-relative {
position: relative;
}
.action-buttons {
position: absolute;
top: 10px;
right: 10px;
display: none;
}
.content:hover .action-buttons {
display: block;
}
</style> </style>

View File

@@ -9,8 +9,8 @@
<div class="chat-item"> <div class="chat-item">
<div <div
class="content-wrapper" class="content-wrapper"
v-html="md.render(processContent(data.content))" v-html="md.render(processContent(data.content.text))"
v-if="data.content" v-if="data.content.text"
></div> ></div>
<div class="content-wrapper flex justify-start items-center" v-else> <div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" /> <span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -26,7 +26,7 @@
</el-tooltip> </el-tooltip>
</span> </span>
<span v-if="!readOnly" class="flex"> <span v-if="!readOnly" class="flex">
<span class="bar-item" @click="reGenerate(data.prompt)"> <span class="bar-item" @click="reGenerate(data.id)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom"> <el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
</el-tooltip> </el-tooltip>
@@ -48,18 +48,6 @@
</el-tooltip> </el-tooltip>
</span> </span>
</span> </span>
<!-- <span class="bar-item">-->
<!-- <el-dropdown trigger="click">-->
<!-- <span class="el-dropdown-link">-->
<!-- <el-icon><More/></el-icon>-->
<!-- </span>-->
<!-- <template #dropdown>-->
<!-- <el-dropdown-menu>-->
<!-- <el-dropdown-item :icon="Headset" @click="synthesis(orgContent)">生成语音</el-dropdown-item>-->
<!-- </el-dropdown-menu>-->
<!-- </template>-->
<!-- </el-dropdown>-->
<!-- </span>-->
</div> </div>
</div> </div>
</div> </div>
@@ -74,8 +62,8 @@
<div class="content-wrapper"> <div class="content-wrapper">
<div <div
class="content" class="content"
v-html="md.render(processContent(data.content))" v-html="md.render(processContent(data.content.text))"
v-if="data.content" v-if="data.content.text"
></div> ></div>
<div class="content flex justify-start items-center" v-else> <div class="content flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" /> <span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -83,16 +71,15 @@
</div> </div>
<div class="bar text-gray-500" v-if="data.created_at"> <div class="bar text-gray-500" v-if="data.created_at">
<span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span> <span class="bar-item text-sm"> {{ dateFormat(data.created_at) }}</span>
<!-- <span class="bar-item">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg"> <span class="bar-item bg">
<el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom"> <el-tooltip class="box-item" effect="dark" content="复制回答" placement="bottom">
<el-icon class="copy-reply" :data-clipboard-text="data.content"> <el-icon class="copy-reply" :data-clipboard-text="data.content.text">
<DocumentCopy /> <DocumentCopy />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</span> </span>
<span v-if="!readOnly" class="flex"> <span v-if="!readOnly" class="flex">
<span class="bar-item bg" @click="reGenerate(data.prompt)"> <span class="bar-item bg" @click="reGenerate(data.id)">
<el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom"> <el-tooltip class="box-item" effect="dark" content="重新生成" placement="bottom">
<el-icon><Refresh /></el-icon> <el-icon><Refresh /></el-icon>
</el-tooltip> </el-tooltip>
@@ -106,7 +93,7 @@
placement="bottom" placement="bottom"
v-if="!isPlaying" v-if="!isPlaying"
> >
<i class="iconfont icon-speaker" @click="synthesis(data.content)"></i> <i class="iconfont icon-speaker" @click="synthesis(data.content.text)"></i>
</el-tooltip> </el-tooltip>
<el-tooltip <el-tooltip
class="box-item" class="box-item"
@@ -145,7 +132,10 @@ const props = defineProps({
type: Object, type: Object,
default: { default: {
icon: '', icon: '',
content: '', content: {
text: '',
files: [],
},
created_at: '', created_at: '',
tokens: 0, tokens: 0,
}, },
@@ -233,9 +223,8 @@ const stopSynthesis = () => {
} }
// 重新生成 // 重新生成
const reGenerate = (prompt) => { const reGenerate = (messageId) => {
console.log(prompt) emits('regen', messageId)
emits('regen', prompt)
} }
</script> </script>

View File

@@ -1,22 +1,36 @@
<template> <template>
<el-dialog <el-dialog
class="config-dialog" class="config-dialog"
v-model="showDialog" v-model="showDialog"
:close-on-click-modal="true" :close-on-click-modal="true"
:before-close="close" :before-close="close"
style="max-width: 600px" style="max-width: 600px"
title="聊天配置" title="聊天配置"
> >
<div class="chat-setting"> <div class="chat-setting">
<el-form :model="data" label-width="100px" label-position="left"> <el-form :model="data" label-width="100px" label-position="left">
<el-form-item label="聊天样式:"> <el-form-item label="聊天样式:">
<el-radio-group v-model="data.style" @change="(val) => {store.setChatListStyle(val)}"> <el-radio-group
v-model="data.style"
@change="
(val) => {
store.setChatListStyle(val)
}
"
>
<el-radio value="list">列表样式</el-radio> <el-radio value="list">列表样式</el-radio>
<el-radio value="chat">对话样式</el-radio> <el-radio value="chat">对话样式</el-radio>
</el-radio-group> </el-radio-group>
</el-form-item> </el-form-item>
<el-form-item label="流式输出:"> <el-form-item label="流式输出:">
<el-switch v-model="data.stream" @change="(val) => {store.setChatStream(val)}" /> <el-switch
v-model="data.stream"
@change="
(val) => {
store.setChatStream(val)
}
"
/>
</el-form-item> </el-form-item>
<el-form-item label="语音音色:"> <el-form-item label="语音音色:">
<el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel"> <el-select v-model="data.ttsModel" placeholder="请选择语音音色" @change="changeTTSModel">
@@ -31,10 +45,10 @@
</template> </template>
<script setup> <script setup>
import {computed, ref, onMounted} from "vue" import { computed, ref, onMounted } from 'vue'
import {useSharedStore} from "@/store/sharedata"; import { useSharedStore } from '@/store/sharedata'
import {httpGet} from "@/utils/http"; import { httpGet } from '@/utils/http'
const store = useSharedStore(); const store = useSharedStore()
const data = ref({ const data = ref({
style: store.chatListStyle, style: store.chatListStyle,
@@ -44,28 +58,26 @@ const data = ref({
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
});
const showDialog = computed(() => {
return props.show
}) })
const emits = defineEmits(['hide']);
const showDialog = ref(props.show)
const emits = defineEmits(['hide'])
const close = function () { const close = function () {
emits('hide', false); emits('hide', false)
} }
const models = ref([]); const models = ref([])
onMounted(() => { onMounted(() => {
// 获取模型列表 // 获取模型列表
httpGet("/api/model/list?type=tts").then((res) => { httpGet('/api/model/list?type=tts').then((res) => {
models.value = res.data; models.value = res.data
if (!data.ttsModel) { if (!data.ttsModel && models.value.length > 0) {
store.setTtsModel(models.value[0].id); store.setTtsModel(models.value[0].id)
} }
}) })
}) })
const changeTTSModel = (item) => { const changeTTSModel = (item) => {
store.setTtsModel(item); store.setTtsModel(item)
} }
</script> </script>
@@ -73,4 +85,4 @@ const changeTTSModel = (item) => {
.chat-setting { .chat-setting {
} }
</style> </style>

View File

@@ -17,45 +17,45 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { getLicenseInfo, getSystemInfo } from '@/store/cache'
import { showMessageError } from "@/utils/dialog"; import { showMessageError } from '@/utils/dialog'
import { getLicenseInfo, getSystemInfo } from "@/store/cache"; import { ref } from 'vue'
const title = ref(""); const title = ref('')
const version = ref(process.env.VUE_APP_VERSION); const version = ref(import.meta.env.VITE_VERSION)
const gitURL = ref(process.env.VUE_APP_GITHUB_URL); const gitURL = ref(import.meta.env.VITE_GITHUB_URL)
const copyRight = ref(""); const copyRight = ref('')
const icp = ref(""); const icp = ref('')
const license = ref({}); const license = ref({})
const props = defineProps({ const props = defineProps({
textColor: { textColor: {
type: String, type: String,
default: "#ffffff" default: '#ffffff',
} },
}); })
// 获取系统配置 // 获取系统配置
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
title.value = res.data.title ?? process.env.VUE_APP_TITLE; title.value = res.data.title ?? import.meta.env.VITE_TITLE
copyRight.value = copyRight.value =
(res.data.copyright ? res.data.copyright : "极客学长") + (res.data.copyright ? res.data.copyright : '极客学长') +
" © 2023 - " + ' © 2023 - ' +
new Date().getFullYear() + new Date().getFullYear() +
" All rights reserved"; ' All rights reserved'
icp.value = res.data.icp; icp.value = res.data.icp
}) })
.catch((e) => { .catch((e) => {
showMessageError("获取系统配置失败:" + e.message); showMessageError('获取系统配置失败:' + e.message)
}); })
getLicenseInfo() getLicenseInfo()
.then((res) => { .then((res) => {
license.value = res.data; license.value = res.data
}) })
.catch((e) => { .catch((e) => {
showMessageError("获取 License 失败:" + e.message); showMessageError('获取 License 失败:' + e.message)
}); })
</script> </script>
<style scoped lang="stylus"> <style scoped lang="stylus">

View File

@@ -13,7 +13,14 @@
</div> </div>
<div class="block"> <div class="block">
<el-input placeholder="请输入密码(8-16位)" maxlength="16" size="large" v-model="data.password" show-password autocomplete="off"> <el-input
placeholder="请输入密码(8-16位)"
maxlength="16"
size="large"
v-model="data.password"
show-password
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Lock /> <Lock />
@@ -24,7 +31,9 @@
<el-row class="btn-row" :gutter="20"> <el-row class="btn-row" :gutter="20">
<el-col :span="24"> <el-col :span="24">
<el-button class="login-btn" type="primary" size="large" @click="submitLogin"> </el-button> <el-button class="login-btn" type="primary" size="large" @click="submitLogin"
> </el-button
>
</el-col> </el-col>
</el-row> </el-row>
@@ -33,7 +42,9 @@
还没有账号 还没有账号
<el-button size="small" @click="login = false">注册</el-button> <el-button size="small" @click="login = false">注册</el-button>
<el-button type="info" class="forget" size="small" @click="showResetPass = true">忘记密码</el-button> <el-button type="info" class="forget" size="small" @click="showResetPass = true"
>忘记密码</el-button
>
</div> </div>
<div v-if="wechatLoginURL !== ''"> <div v-if="wechatLoginURL !== ''">
<el-divider> <el-divider>
@@ -42,7 +53,11 @@
<div class="c-login flex justify-center"> <div class="c-login flex justify-center">
<div class="p-2 w-full"> <div class="p-2 w-full">
<a :href="wechatLoginURL"> <a :href="wechatLoginURL">
<el-button type="success" class="w-full" size="large" @click="setRoute(router.currentRoute.value.path)" <el-button
type="success"
class="w-full"
size="large"
@click="setRoute(router.currentRoute.value.path)"
><i class="iconfont icon-wechat mr-2"></i> 微信登录 ><i class="iconfont icon-wechat mr-2"></i> 微信登录
</el-button> </el-button>
</a> </a>
@@ -58,7 +73,13 @@
<el-tabs v-model="activeName" class="demo-tabs"> <el-tabs v-model="activeName" class="demo-tabs">
<el-tab-pane label="手机注册" name="mobile" v-if="enableMobile"> <el-tab-pane label="手机注册" name="mobile" v-if="enableMobile">
<div class="block"> <div class="block">
<el-input placeholder="手机号码" size="large" v-model="data.mobile" maxlength="11" autocomplete="off"> <el-input
placeholder="手机号码"
size="large"
v-model="data.mobile"
maxlength="11"
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Iphone /> <Iphone />
@@ -69,7 +90,13 @@
<div class="block"> <div class="block">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12"> <el-col :span="12">
<el-input placeholder="验证码" size="large" maxlength="30" v-model="data.code" autocomplete="off"> <el-input
placeholder="验证码"
size="large"
maxlength="30"
v-model="data.code"
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Checked /> <Checked />
@@ -96,7 +123,13 @@
<div class="block"> <div class="block">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12"> <el-col :span="12">
<el-input placeholder="验证码" size="large" maxlength="30" v-model="data.code" autocomplete="off"> <el-input
placeholder="验证码"
size="large"
maxlength="30"
v-model="data.code"
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Checked /> <Checked />
@@ -112,7 +145,12 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="用户名注册" name="username" v-if="enableUser"> <el-tab-pane label="用户名注册" name="username" v-if="enableUser">
<div class="block"> <div class="block">
<el-input placeholder="用户名" size="large" v-model="data.username" autocomplete="off"> <el-input
placeholder="用户名"
size="large"
v-model="data.username"
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Iphone /> <Iphone />
@@ -124,7 +162,14 @@
</el-tabs> </el-tabs>
<div class="block"> <div class="block">
<el-input placeholder="请输入密码(8-16位)" maxlength="16" size="large" v-model="data.password" show-password autocomplete="off"> <el-input
placeholder="请输入密码(8-16位)"
maxlength="16"
size="large"
v-model="data.password"
show-password
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Lock /> <Lock />
@@ -134,7 +179,14 @@
</div> </div>
<div class="block"> <div class="block">
<el-input placeholder="重复密码(8-16位)" size="large" maxlength="16" v-model="data.repass" show-password autocomplete="off"> <el-input
placeholder="重复密码(8-16位)"
size="large"
maxlength="16"
v-model="data.repass"
show-password
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Lock /> <Lock />
@@ -144,7 +196,12 @@
</div> </div>
<div class="block"> <div class="block">
<el-input placeholder="邀请码(可选)" size="large" v-model="data.invite_code" autocomplete="off"> <el-input
placeholder="邀请码(可选)"
size="large"
v-model="data.invite_code"
autocomplete="off"
>
<template #prefix> <template #prefix>
<el-icon> <el-icon>
<Message /> <Message />
@@ -154,7 +211,9 @@
</div> </div>
<div class="w-full"> <div class="w-full">
<el-button class="login-btn w-full" type="primary" size="large" @click="submitRegister"> </el-button> <el-button class="login-btn w-full" type="primary" size="large" @click="submitRegister"
> </el-button
>
</div> </div>
<div class="text text-sm flex justify-center items-center w-full pt-3"> <div class="text text-sm flex justify-center items-center w-full pt-3">
@@ -188,193 +247,193 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref, watch } from "vue"; import Captcha from '@/components/Captcha.vue'
import { httpGet, httpPost } from "@/utils/http"; import ResetPass from '@/components/ResetPass.vue'
import { ElMessage } from "element-plus"; import SendMsg from '@/components/SendMsg.vue'
import { setUserToken } from "@/store/session"; import { getSystemInfo } from '@/store/cache'
import { validateEmail, validateMobile } from "@/utils/validate"; import { setUserToken } from '@/store/session'
import { Checked, Close, Iphone, Lock, Message } from "@element-plus/icons-vue"; import { useSharedStore } from '@/store/sharedata'
import SendMsg from "@/components/SendMsg.vue"; import { setRoute } from '@/store/system'
import { arrayContains } from "@/utils/libs"; import { httpGet, httpPost } from '@/utils/http'
import { getSystemInfo } from "@/store/cache"; import { arrayContains } from '@/utils/libs'
import Captcha from "@/components/Captcha.vue"; import { validateEmail, validateMobile } from '@/utils/validate'
import ResetPass from "@/components/ResetPass.vue"; import { Checked, Iphone, Lock, Message } from '@element-plus/icons-vue'
import { setRoute } from "@/store/system"; import { ElMessage } from 'element-plus'
import { useRouter } from "vue-router"; import { onMounted, ref, watch } from 'vue'
import { useSharedStore } from "@/store/sharedata"; import { useRouter } from 'vue-router'
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const props = defineProps({ const props = defineProps({
show: Boolean, show: Boolean,
}); })
const showDialog = ref(false); const showDialog = ref(false)
watch( watch(
() => props.show, () => props.show,
(newValue) => { (newValue) => {
showDialog.value = newValue; showDialog.value = newValue
} }
); )
const login = ref(true); const login = ref(true)
const data = ref({ const data = ref({
username: process.env.VUE_APP_USER, username: import.meta.env.VITE_USER,
password: process.env.VUE_APP_PASS, password: import.meta.env.VITE_PASS,
mobile: "", mobile: '',
email: "", email: '',
repass: "", repass: '',
code: "", code: '',
invite_code: "", invite_code: '',
}); })
const enableMobile = ref(false); const enableMobile = ref(false)
const enableEmail = ref(false); const enableEmail = ref(false)
const enableUser = ref(false); const enableUser = ref(false)
const enableRegister = ref(true); const enableRegister = ref(true)
const wechatLoginURL = ref(""); const wechatLoginURL = ref('')
const activeName = ref(""); const activeName = ref('')
const wxImg = ref("/images/wx.png"); const wxImg = ref('/images/wx.png')
const captchaRef = ref(null); const captchaRef = ref(null)
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const emits = defineEmits(["hide", "success"]); const emits = defineEmits(['hide', 'success'])
const action = ref("login"); const action = ref('login')
const enableVerify = ref(false); const enableVerify = ref(false)
const showResetPass = ref(false); const showResetPass = ref(false)
const router = useRouter(); const router = useRouter()
const store = useSharedStore(); const store = useSharedStore()
onMounted(() => { onMounted(() => {
const returnURL = `${location.protocol}//${location.host}/login/callback?action=login`; const returnURL = `${location.protocol}//${location.host}/login/callback?action=login`
httpGet("/api/user/clogin?return_url=" + returnURL) httpGet('/api/user/clogin?return_url=' + returnURL)
.then((res) => { .then((res) => {
wechatLoginURL.value = res.data.url; wechatLoginURL.value = res.data.url
}) })
.catch((e) => { .catch((e) => {
console.log(e.message); console.log(e.message)
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
const registerWays = res.data["register_ways"]; const registerWays = res.data['register_ways']
if (arrayContains(registerWays, "username")) { if (arrayContains(registerWays, 'username')) {
enableUser.value = true; enableUser.value = true
activeName.value = "username"; activeName.value = 'username'
} }
if (arrayContains(registerWays, "email")) { if (arrayContains(registerWays, 'email')) {
enableEmail.value = true; enableEmail.value = true
activeName.value = "email"; activeName.value = 'email'
} }
if (arrayContains(registerWays, "mobile")) { if (arrayContains(registerWays, 'mobile')) {
enableMobile.value = true; enableMobile.value = true
activeName.value = "mobile"; activeName.value = 'mobile'
} }
// 是否启用注册 // 是否启用注册
enableRegister.value = res.data["enabled_register"]; enableRegister.value = res.data['enabled_register']
// 使用后台上传的客服微信二维码 // 使用后台上传的客服微信二维码
if (res.data["wechat_card_url"] !== "") { if (res.data['wechat_card_url'] !== '') {
wxImg.value = res.data["wechat_card_url"]; wxImg.value = res.data['wechat_card_url']
} }
enableVerify.value = res.data["enabled_verify"]; enableVerify.value = res.data['enabled_verify']
} }
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message); ElMessage.error('获取系统配置失败:' + e.message)
}); })
}); })
const submit = (verifyData) => { const submit = (verifyData) => {
if (action.value === "login") { if (action.value === 'login') {
doLogin(verifyData); doLogin(verifyData)
} else if (action.value === "register") { } else if (action.value === 'register') {
doRegister(verifyData); doRegister(verifyData)
} }
}; }
// 登录操作 // 登录操作
const submitLogin = () => { const submitLogin = () => {
if (!data.value.username) { if (!data.value.username) {
return ElMessage.error("请输入用户名"); return ElMessage.error('请输入用户名')
} }
if (!data.value.password) { if (!data.value.password) {
return ElMessage.error("请输入密码"); return ElMessage.error('请输入密码')
} }
if (enableVerify.value) { if (enableVerify.value) {
captchaRef.value.loadCaptcha(); captchaRef.value.loadCaptcha()
action.value = "login"; action.value = 'login'
} else { } else {
doLogin({}); doLogin({})
} }
}; }
const doLogin = (verifyData) => { const doLogin = (verifyData) => {
data.value.key = verifyData.key; data.value.key = verifyData.key
data.value.dots = verifyData.dots; data.value.dots = verifyData.dots
data.value.x = verifyData.x; data.value.x = verifyData.x
httpPost("/api/user/login", data.value) httpPost('/api/user/login', data.value)
.then((res) => { .then((res) => {
setUserToken(res.data.token); setUserToken(res.data.token)
store.setIsLogin(true); store.setIsLogin(true)
ElMessage.success("登录成功!"); ElMessage.success('登录成功!')
emits("hide"); emits('hide')
emits("success"); emits('success')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("登录失败," + e.message); ElMessage.error('登录失败,' + e.message)
}); })
}; }
// 注册操作 // 注册操作
const submitRegister = () => { const submitRegister = () => {
if (activeName.value === "username" && data.value.username === "") { if (activeName.value === 'username' && data.value.username === '') {
return ElMessage.error("请输入用户名"); return ElMessage.error('请输入用户名')
} }
if (activeName.value === "mobile" && !validateMobile(data.value.mobile)) { if (activeName.value === 'mobile' && !validateMobile(data.value.mobile)) {
return ElMessage.error("请输入合法的手机号"); return ElMessage.error('请输入合法的手机号')
} }
if (activeName.value === "email" && !validateEmail(data.value.email)) { if (activeName.value === 'email' && !validateEmail(data.value.email)) {
return ElMessage.error("请输入合法的邮箱地址"); return ElMessage.error('请输入合法的邮箱地址')
} }
if (data.value.password.length < 8) { if (data.value.password.length < 8) {
return ElMessage.error("密码的长度为8-16个字符"); return ElMessage.error('密码的长度为8-16个字符')
} }
if (data.value.repass !== data.value.password) { if (data.value.repass !== data.value.password) {
return ElMessage.error("两次输入密码不一致"); return ElMessage.error('两次输入密码不一致')
} }
if ((activeName.value === "mobile" || activeName.value === "email") && data.value.code === "") { if ((activeName.value === 'mobile' || activeName.value === 'email') && data.value.code === '') {
return ElMessage.error("请输入验证码"); return ElMessage.error('请输入验证码')
} }
if (enableVerify.value && activeName.value === "username") { if (enableVerify.value && activeName.value === 'username') {
captchaRef.value.loadCaptcha(); captchaRef.value.loadCaptcha()
action.value = "register"; action.value = 'register'
} else { } else {
doRegister({}); doRegister({})
} }
}; }
const doRegister = (verifyData) => { const doRegister = (verifyData) => {
data.value.key = verifyData.key; data.value.key = verifyData.key
data.value.dots = verifyData.dots; data.value.dots = verifyData.dots
data.value.x = verifyData.x; data.value.x = verifyData.x
data.value.reg_way = activeName.value; data.value.reg_way = activeName.value
httpPost("/api/user/register", data.value) httpPost('/api/user/register', data.value)
.then((res) => { .then((res) => {
setUserToken(res.data.token); setUserToken(res.data.token)
ElMessage.success({ ElMessage.success({
message: "注册成功!", message: '注册成功!',
onClose: () => { onClose: () => {
emits("hide"); emits('hide')
emits("success"); emits('success')
}, },
duration: 1000, duration: 1000,
}); })
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("注册失败," + e.message); ElMessage.error('注册失败,' + e.message)
}); })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus">

View File

@@ -38,11 +38,7 @@
<div class="call-controls"> <div class="call-controls">
<el-tooltip content="长按发送语音" placement="top"> <el-tooltip content="长按发送语音" placement="top">
<ripple-button> <ripple-button>
<button <button class="call-button answer" @mousedown="startRecording" @mouseup="stopRecording">
class="call-button answer"
@mousedown="startRecording"
@mouseup="stopRecording"
>
<i class="iconfont icon-mic-bold"></i> <i class="iconfont icon-mic-bold"></i>
</button> </button>
</ripple-button> </ripple-button>
@@ -58,159 +54,155 @@
</template> </template>
<script setup> <script setup>
import RippleButton from "@/components/ui/RippleButton.vue"; import RippleButton from '@/components/ui/RippleButton.vue'
import { ref, onMounted, onUnmounted } from "vue"; import { WavRecorder, WavStreamPlayer } from '@/lib/wavtools/index.js'
import { RealtimeClient } from "@openai/realtime-api-beta"; import { getUserToken } from '@/store/session'
import { WavRecorder, WavStreamPlayer } from "@/lib/wavtools/index.js"; import { instructions } from '@/utils/conversation_config.js'
import { instructions } from "@/utils/conversation_config.js"; import { showMessageError } from '@/utils/dialog'
import { WavRenderer } from "@/utils/wav_renderer"; import { WavRenderer } from '@/utils/wav_renderer'
import { showMessageError } from "@/utils/dialog"; import { RealtimeClient } from '@openai/realtime-api-beta'
import { getUserToken } from "@/store/session"; import { onMounted, onUnmounted, ref } from 'vue'
// eslint-disable-next-line no-unused-vars,no-undef // eslint-disable-next-line no-unused-vars,no-undef
const props = defineProps({ const props = defineProps({
height: { height: {
type: String, type: String,
default: "100vh" default: '100vh',
} },
}); })
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const emits = defineEmits(["close"]); const emits = defineEmits(['close'])
/********************** connection animation code *************************/ /********************** connection animation code *************************/
const fullText = "正在接通中..."; const fullText = '正在接通中...'
const connectingText = ref(""); const connectingText = ref('')
let index = 0; let index = 0
const typeText = () => { const typeText = () => {
if (index < fullText.length) { if (index < fullText.length) {
connectingText.value += fullText[index]; connectingText.value += fullText[index]
index++; index++
setTimeout(typeText, 200); // 每300毫秒显示一个字 setTimeout(typeText, 200) // 每300毫秒显示一个字
} else { } else {
setTimeout(() => { setTimeout(() => {
connectingText.value = ""; connectingText.value = ''
index = 0; index = 0
typeText(); typeText()
}, 1000); // 等待1秒后重新开始 }, 1000) // 等待1秒后重新开始
} }
}; }
/*************************** end of code ****************************************/ /*************************** end of code ****************************************/
/********************** conversation process code ***************************/ /********************** conversation process code ***************************/
const leftVoiceActive = ref(false); const leftVoiceActive = ref(false)
const rightVoiceActive = ref(false); const rightVoiceActive = ref(false)
const animateVoice = () => { const animateVoice = () => {
leftVoiceActive.value = Math.random() > 0.5; leftVoiceActive.value = Math.random() > 0.5
rightVoiceActive.value = Math.random() > 0.5; rightVoiceActive.value = Math.random() > 0.5
}; }
const wavRecorder = ref(new WavRecorder({ sampleRate: 24000 })); const wavRecorder = ref(new WavRecorder({ sampleRate: 24000 }))
const wavStreamPlayer = ref(new WavStreamPlayer({ sampleRate: 24000 })); const wavStreamPlayer = ref(new WavStreamPlayer({ sampleRate: 24000 }))
let host = process.env.VUE_APP_WS_HOST; let host = import.meta.env.VITE_WS_HOST
if (host === "") { if (host === '') {
if (location.protocol === "https:") { if (location.protocol === 'https:') {
host = "wss://" + location.host; host = 'wss://' + location.host
} else { } else {
host = "ws://" + location.host; host = 'ws://' + location.host
} }
} }
const client = ref( const client = ref(
new RealtimeClient({ new RealtimeClient({
url: `${host}/api/realtime`, url: `${host}/api/realtime`,
apiKey: getUserToken(), apiKey: getUserToken(),
dangerouslyAllowAPIKeyInBrowser: true dangerouslyAllowAPIKeyInBrowser: true,
}) })
); )
// // Set up client instructions and transcription // // Set up client instructions and transcription
client.value.updateSession({ client.value.updateSession({
instructions: instructions, instructions: instructions,
turn_detection: null, turn_detection: null,
input_audio_transcription: { model: "whisper-1" }, input_audio_transcription: { model: 'whisper-1' },
voice: "alloy" voice: 'alloy',
}); })
// set voice wave canvas // set voice wave canvas
const clientCanvasRef = ref(null); const clientCanvasRef = ref(null)
const serverCanvasRef = ref(null); const serverCanvasRef = ref(null)
const isConnected = ref(false); const isConnected = ref(false)
const isRecording = ref(false); const isRecording = ref(false)
const backgroundAudio = ref(null); const backgroundAudio = ref(null)
const hangUpAudio = ref(null); const hangUpAudio = ref(null)
function sleep(ms) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms)); return new Promise((resolve) => setTimeout(resolve, ms))
} }
const connect = async () => { const connect = async () => {
if (isConnected.value) { if (isConnected.value) {
return; return
} }
// 播放背景音乐 // 播放背景音乐
if (backgroundAudio.value) { if (backgroundAudio.value) {
backgroundAudio.value.play().catch((error) => { backgroundAudio.value.play().catch((error) => {
console.error("播放失败,可能是浏览器的自动播放策略导致的:", error); console.error('播放失败,可能是浏览器的自动播放策略导致的:', error)
}); })
} }
// 模拟拨号延时 // 模拟拨号延时
await sleep(3000); await sleep(3000)
try { try {
await client.value.connect(); await client.value.connect()
await wavRecorder.value.begin(); await wavRecorder.value.begin()
await wavStreamPlayer.value.connect(); await wavStreamPlayer.value.connect()
console.log("对话连接成功!"); console.log('对话连接成功!')
if (!client.value.isConnected()) { if (!client.value.isConnected()) {
return; return
} }
isConnected.value = true; isConnected.value = true
backgroundAudio.value?.pause(); backgroundAudio.value?.pause()
backgroundAudio.value.currentTime = 0; backgroundAudio.value.currentTime = 0
client.value.sendUserMessageContent([ client.value.sendUserMessageContent([
{ {
type: "input_text", type: 'input_text',
text: "你好,我是极客学长!" text: '你好,我是极客学长!',
} },
]); ])
if (client.value.getTurnDetectionType() === "server_vad") { if (client.value.getTurnDetectionType() === 'server_vad') {
await wavRecorder.value.record((data) => await wavRecorder.value.record((data) => client.value.appendInputAudio(data.mono))
client.value.appendInputAudio(data.mono)
);
} }
} catch (e) { } catch (e) {
console.error(e); console.error(e)
} }
}; }
// 开始语音输入 // 开始语音输入
const startRecording = async () => { const startRecording = async () => {
if (isRecording.value) { if (isRecording.value) {
return; return
} }
isRecording.value = true; isRecording.value = true
try { try {
const trackSampleOffset = await wavStreamPlayer.value.interrupt(); const trackSampleOffset = await wavStreamPlayer.value.interrupt()
if (trackSampleOffset?.trackId) { if (trackSampleOffset?.trackId) {
const { trackId, offset } = trackSampleOffset; const { trackId, offset } = trackSampleOffset
client.value.cancelResponse(trackId, offset); client.value.cancelResponse(trackId, offset)
} }
await wavRecorder.value.record((data) => await wavRecorder.value.record((data) => client.value.appendInputAudio(data.mono))
client.value.appendInputAudio(data.mono)
);
} catch (e) { } catch (e) {
console.error(e); console.error(e)
} }
}; }
// 结束语音输入 // 结束语音输入
const stopRecording = async () => { const stopRecording = async () => {
try { try {
isRecording.value = false; isRecording.value = false
await wavRecorder.value.pause(); await wavRecorder.value.pause()
client.value.createResponse(); client.value.createResponse()
} catch (e) { } catch (e) {
console.error(e); console.error(e)
} }
}; }
// const changeTurnEndType = async (value) => { // const changeTurnEndType = async (value) => {
// if (value === 'none' && wavRecorder.value.getStatus() === 'recording') { // if (value === 'none' && wavRecorder.value.getStatus() === 'recording') {
@@ -228,111 +220,110 @@ const stopRecording = async () => {
// 初始化 WaveRecorder 组件和 RealtimeClient 事件处理 // 初始化 WaveRecorder 组件和 RealtimeClient 事件处理
const initialize = async () => { const initialize = async () => {
// Set up render loops for the visualization canvas // Set up render loops for the visualization canvas
let isLoaded = true; let isLoaded = true
const render = () => { const render = () => {
if (isLoaded) { if (isLoaded) {
if (clientCanvasRef.value) { if (clientCanvasRef.value) {
const canvas = clientCanvasRef.value; const canvas = clientCanvasRef.value
if (!canvas.width || !canvas.height) { if (!canvas.width || !canvas.height) {
canvas.width = canvas.offsetWidth; canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight; canvas.height = canvas.offsetHeight
} }
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext('2d')
if (ctx) { if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height)
const result = wavRecorder.value.recording const result = wavRecorder.value.recording
? wavRecorder.value.getFrequencies("voice") ? wavRecorder.value.getFrequencies('voice')
: { values: new Float32Array([0]) }; : { values: new Float32Array([0]) }
WavRenderer.drawBars(canvas, ctx, result.values, "#0099ff", 10, 0, 8); WavRenderer.drawBars(canvas, ctx, result.values, '#0099ff', 10, 0, 8)
} }
} }
if (serverCanvasRef.value) { if (serverCanvasRef.value) {
const canvas = serverCanvasRef.value; const canvas = serverCanvasRef.value
if (!canvas.width || !canvas.height) { if (!canvas.width || !canvas.height) {
canvas.width = canvas.offsetWidth; canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight; canvas.height = canvas.offsetHeight
} }
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext('2d')
if (ctx) { if (ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height)
const result = wavStreamPlayer.value.analyser const result = wavStreamPlayer.value.analyser
? wavStreamPlayer.value.getFrequencies("voice") ? wavStreamPlayer.value.getFrequencies('voice')
: { values: new Float32Array([0]) }; : { values: new Float32Array([0]) }
WavRenderer.drawBars(canvas, ctx, result.values, "#009900", 10, 0, 8); WavRenderer.drawBars(canvas, ctx, result.values, '#009900', 10, 0, 8)
} }
} }
requestAnimationFrame(render); requestAnimationFrame(render)
} }
}; }
render(); render()
client.value.on("error", (event) => { client.value.on('error', (event) => {
showMessageError(event.error); showMessageError(event.error)
}); })
client.value.on("realtime.event", (re) => { client.value.on('realtime.event', (re) => {
if (re.event.type === "error") { if (re.event.type === 'error') {
showMessageError(re.event.error); showMessageError(re.event.error)
} }
}); })
client.value.on("conversation.interrupted", async () => { client.value.on('conversation.interrupted', async () => {
const trackSampleOffset = await wavStreamPlayer.value.interrupt(); const trackSampleOffset = await wavStreamPlayer.value.interrupt()
if (trackSampleOffset?.trackId) { if (trackSampleOffset?.trackId) {
const { trackId, offset } = trackSampleOffset; const { trackId, offset } = trackSampleOffset
client.value.cancelResponse(trackId, offset); client.value.cancelResponse(trackId, offset)
} }
}); })
client.value.on("conversation.updated", async ({ item, delta }) => { client.value.on('conversation.updated', async ({ item, delta }) => {
// console.log('item updated', item, delta) // console.log('item updated', item, delta)
if (delta?.audio) { if (delta?.audio) {
wavStreamPlayer.value.add16BitPCM(delta.audio, item.id); wavStreamPlayer.value.add16BitPCM(delta.audio, item.id)
} }
}); })
}; }
const voiceInterval = ref(null); const voiceInterval = ref(null)
onMounted(() => { onMounted(() => {
initialize(); initialize()
// 启动聊天进行中的动画 // 启动聊天进行中的动画
voiceInterval.value = setInterval(animateVoice, 200); voiceInterval.value = setInterval(animateVoice, 200)
typeText(); typeText()
}); })
onUnmounted(() => { onUnmounted(() => {
clearInterval(voiceInterval.value); clearInterval(voiceInterval.value)
client.value.reset(); client.value.reset()
}); })
// 挂断通话 // 挂断通话
const hangUp = async () => { const hangUp = async () => {
try { try {
isConnected.value = false; isConnected.value = false
// 停止播放拨号音乐 // 停止播放拨号音乐
if (backgroundAudio.value?.currentTime) { if (backgroundAudio.value?.currentTime) {
backgroundAudio.value?.pause(); backgroundAudio.value?.pause()
backgroundAudio.value.currentTime = 0; backgroundAudio.value.currentTime = 0
} }
// 断开客户端的连接 // 断开客户端的连接
client.value.reset(); client.value.reset()
// 中断语音输入和输出服务 // 中断语音输入和输出服务
await wavRecorder.value.end(); await wavRecorder.value.end()
await wavStreamPlayer.value.interrupt(); await wavStreamPlayer.value.interrupt()
} catch (e) { } catch (e) {
console.error(e); console.error(e)
} finally { } finally {
// 播放挂断音乐 // 播放挂断音乐
hangUpAudio.value?.play(); hangUpAudio.value?.play()
emits("close"); emits('close')
} }
}; }
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
defineExpose({ connect, hangUp }); defineExpose({ connect, hangUp })
</script> </script>
<style scoped lang="stylus"> <style lang="stylus" scoped>
@import "../assets/css/realtime.styl"
@import "@/assets/css/realtime.styl"
</style> </style>

View File

@@ -7,10 +7,10 @@
<el-progress type="circle" :percentage="item.progress" :width="100" color="#47fff1" /> <el-progress type="circle" :percentage="item.progress" :width="100" color="#47fff1" />
</div> </div>
</div> </div>
<el-image fit="cover" v-else> <el-image fit="cover" class="w-full h-full" v-else>
<template #error> <template #error>
<div class="image-slot"> <div class="image-slot flex flex-col justify-center items-center w-full h-full">
<i class="iconfont icon-quick-start"></i> <i class="iconfont icon-quick-start text-2xl mb-2"></i>
<span>任务正在排队中</span> <span>任务正在排队中</span>
</div> </div>
</template> </template>
@@ -22,7 +22,7 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const props = defineProps({ const props = defineProps({
@@ -30,9 +30,9 @@ const props = defineProps({
type: Array, type: Array,
default: [], default: [],
}, },
}); })
</script> </script>
<style scoped lang="stylus"> <style lang="stylus" scoped>
@import "~@/assets/css/running-job-list.styl" @import "../assets/css/running-job-list.styl"
</style> </style>

View File

@@ -58,63 +58,63 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import { getSystemInfo } from '@/store/cache'
import {ElMessage} from "element-plus"; import { ElMessage } from 'element-plus'
import {getSystemInfo} from "@/store/cache"; import { onMounted, ref } from 'vue'
const title = ref(process.env.VUE_APP_TITLE); const title = ref(import.meta.env.VITE_TITLE)
const version = ref(process.env.VUE_APP_VERSION); const version = ref(import.meta.env.VITE_VERSION)
const samples = ref([ const samples = ref([
"用小学生都能听懂的术语解释什么是量子纠缠", '用小学生都能听懂的术语解释什么是量子纠缠',
"能给一位6岁男孩的生日会提供一些创造性的建议吗", '能给一位6岁男孩的生日会提供一些创造性的建议吗',
"如何用 Go 语言实现支持代理 Http client 请求?" '如何用 Go 语言实现支持代理 Http client 请求?',
]); ])
const plugins = ref([ const plugins = ref([
{ {
value: "今日早报", value: '今日早报',
text: "今日早报:获取当天全球的热门新闻事件列表" text: '今日早报:获取当天全球的热门新闻事件列表',
}, },
{ {
value: "微博热搜", value: '微博热搜',
text: "微博热搜:新浪微博热搜榜,微博当日热搜榜单" text: '微博热搜:新浪微博热搜榜,微博当日热搜榜单',
}, },
{ {
value: "今日头条", value: '今日头条',
text: "今日头条:给用户推荐当天的头条新闻,周榜热文" text: '今日头条:给用户推荐当天的头条新闻,周榜热文',
} },
]); ])
const capabilities = ref([ const capabilities = ref([
{ {
text: "轻松扮演翻译专家程序员AI 女友,文案高手...", text: '轻松扮演翻译专家程序员AI 女友,文案高手...',
value: "" value: '',
}, },
{ {
text: "国产大语言模型支持百度文心科大讯飞ChatGLM...", text: '国产大语言模型支持百度文心科大讯飞ChatGLM...',
value: "" value: '',
}, },
{ {
text: "绘画马斯克开拖拉机20世纪中国农村。3:2", text: '绘画马斯克开拖拉机20世纪中国农村。3:2',
value: "绘画马斯克开拖拉机20世纪中国农村。3:2" value: '绘画马斯克开拖拉机20世纪中国农村。3:2',
} },
]); ])
onMounted(() => { onMounted(() => {
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
title.value = res.data.title; title.value = res.data.title
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message); ElMessage.error('获取系统配置失败:' + e.message)
}); })
}); })
const emits = defineEmits(["send"]); const emits = defineEmits(['send'])
const send = (text) => { const send = (text) => {
emits("send", text); emits('send', text)
}; }
</script> </script>
<style scoped lang="stylus"> <style scoped lang="stylus">
.welcome { .welcome {

View File

@@ -12,14 +12,22 @@
<div class="breadcrumb"> <div class="breadcrumb">
<el-breadcrumb :separator-icon="ArrowRight"> <el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item v-for="item in breadcrumb" :key="item.title">{{ item.title }}</el-breadcrumb-item> <el-breadcrumb-item v-for="item in breadcrumb" :key="item.title">{{
item.title
}}</el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
</div> </div>
<div class="header-right"> <div class="header-right">
<div class="header-user-con"> <div class="header-user-con">
<!-- 切换主题 --> <!-- 切换主题 -->
<el-switch style="margin-right: 10px" v-model="dark" inline-prompt :active-action-icon="Moon" <el-switch
:inactive-action-icon="Sunny" @change="changeTheme"/> style="margin-right: 10px"
v-model="dark"
inline-prompt
:active-action-icon="Moon"
:inactive-action-icon="Sunny"
@change="changeTheme"
/>
<!-- 用户名下拉菜单 --> <!-- 用户名下拉菜单 -->
<el-dropdown class="user-name" :hide-on-click="true" trigger="click"> <el-dropdown class="user-name" :hide-on-click="true" trigger="click">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
@@ -30,7 +38,9 @@
</span> </span>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item><i class="iconfont icon-version"></i> 当前版本{{ version }}</el-dropdown-item> <el-dropdown-item
><i class="iconfont icon-version"></i> 当前版本{{ version }}</el-dropdown-item
>
<el-dropdown-item divided @click="logout"> <el-dropdown-item divided @click="logout">
<i class="iconfont icon-logout"></i> <i class="iconfont icon-logout"></i>
<span>退出登录</span> <span>退出登录</span>
@@ -43,103 +53,103 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref, watch} from "vue"; import { removeAdminToken } from '@/store/session'
import {getMenuItems, useSidebarStore} from "@/store/sidebar"; import { useSharedStore } from '@/store/sharedata'
import {useRouter} from "vue-router"; import { getMenuItems, useSidebarStore } from '@/store/sidebar'
import {ArrowDown, ArrowRight, Expand, Fold, Moon, Sunny} from "@element-plus/icons-vue"; import { httpGet } from '@/utils/http'
import {httpGet} from "@/utils/http"; import { ArrowDown, ArrowRight, Expand, Fold, Moon, Sunny } from '@element-plus/icons-vue'
import {ElMessage} from "element-plus"; import { ElMessage } from 'element-plus'
import {removeAdminToken} from "@/store/session"; import { onMounted, ref, watch } from 'vue'
import {useSharedStore} from "@/store/sharedata"; import { useRouter } from 'vue-router'
const version = ref(process.env.VUE_APP_VERSION); const version = ref(import.meta.env.VITE_VERSION)
const avatar = ref("/images/user-info.jpg"); const avatar = ref('/images/user-info.jpg')
const sidebar = useSidebarStore(); const sidebar = useSidebarStore()
const router = useRouter(); const router = useRouter()
const breadcrumb = ref([]); const breadcrumb = ref([])
const store = useSharedStore(); const store = useSharedStore()
const dark = ref(store.theme === "dark"); const dark = ref(store.theme === 'dark')
const theme = ref(store.theme); const theme = ref(store.theme)
watch( watch(
() => store.theme, () => store.theme,
(val) => { (val) => {
theme.value = val; theme.value = val
} }
); )
const changeTheme = () => { const changeTheme = () => {
store.setTheme(dark.value ? "dark" : "light"); store.setTheme(dark.value ? 'dark' : 'light')
}; }
router.afterEach((to) => { router.afterEach((to) => {
initBreadCrumb(to.path); initBreadCrumb(to.path)
}); })
onMounted(() => { onMounted(() => {
initBreadCrumb(router.currentRoute.value.path); initBreadCrumb(router.currentRoute.value.path)
}); })
// 初始化面包屑导航 // 初始化面包屑导航
const initBreadCrumb = (path) => { const initBreadCrumb = (path) => {
breadcrumb.value = [{ title: "首页" }]; breadcrumb.value = [{ title: '首页' }]
const items = getMenuItems(); const items = getMenuItems()
if (items) { if (items) {
let bk = false; let bk = false
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (items[i].index === path) { if (items[i].index === path) {
breadcrumb.value.push({ breadcrumb.value.push({
title: items[i].title, title: items[i].title,
path: items[i].index, path: items[i].index,
}); })
break; break
} }
if (bk) { if (bk) {
break; break
} }
if (items[i]["subs"]) { if (items[i]['subs']) {
const subs = items[i]["subs"]; const subs = items[i]['subs']
for (let j = 0; j < subs.length; j++) { for (let j = 0; j < subs.length; j++) {
if (subs[j].index === path) { if (subs[j].index === path) {
breadcrumb.value.push({ breadcrumb.value.push({
title: items[i].title, title: items[i].title,
path: items[i].index, path: items[i].index,
}); })
breadcrumb.value.push({ breadcrumb.value.push({
title: subs[j].title, title: subs[j].title,
path: subs[j].index, path: subs[j].index,
}); })
bk = true; bk = true
break; break
} }
} }
} }
} }
} }
}; }
// 侧边栏折叠 // 侧边栏折叠
const collapseChange = () => { const collapseChange = () => {
sidebar.handleCollapse(); sidebar.handleCollapse()
}; }
onMounted(() => { onMounted(() => {
if (document.body.clientWidth < 1024) { if (document.body.clientWidth < 1024) {
collapseChange(); collapseChange()
} }
}); })
const logout = function () { const logout = function () {
httpGet("/api/admin/logout") httpGet('/api/admin/logout')
.then(() => { .then(() => {
removeAdminToken(); removeAdminToken()
router.replace("/admin/login"); router.replace('/admin/login')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("注销失败: " + e.message); ElMessage.error('注销失败: ' + e.message)
}); })
}; }
</script> </script>
<style scoped lang="stylus"> <style scoped lang="stylus">
.admin-header { .admin-header {

View File

@@ -6,35 +6,41 @@
</div> </div>
<div class="chat-icon"> <div class="chat-icon">
<van-image :src="icon"/> <van-image :src="icon" />
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import {onMounted, ref} from "vue"; import Clipboard from 'clipboard'
import Clipboard from "clipboard"; import { showNotify } from 'vant'
import {showNotify} from "vant"; import { onMounted, ref } from 'vue'
// eslint-disable-next-line no-unused-vars,no-undef // eslint-disable-next-line no-unused-vars,no-undef
const props = defineProps({ const props = defineProps({
content: { content: {
type: String, type: Object,
default: '', default: {
text: '',
files: [],
},
}, },
icon: { icon: {
type: String, type: String,
default: '/images/user-icon.png', default: '/images/user-icon.png',
} },
}); })
const contentRef = ref(null) const contentRef = ref(null)
const content = computed(() => {
return props.content.text
})
onMounted(() => { onMounted(() => {
const clipboard = new Clipboard(contentRef.value); const clipboard = new Clipboard(contentRef.value)
clipboard.on('success', () => { clipboard.on('success', () => {
showNotify({type: 'success', message: '复制成功', duration: 1000}) showNotify({ type: 'success', message: '复制成功', duration: 1000 })
}) })
clipboard.on('error', () => { clipboard.on('error', () => {
showNotify({type: 'danger', message: '复制失败', duration: 2000}) showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
}) })
}) })
</script> </script>
@@ -100,4 +106,4 @@ onMounted(() => {
} }
} }
} }
</style> </style>

View File

@@ -28,8 +28,11 @@ import { showImagePreview } from 'vant'
import Thinking from '../Thinking.vue' import Thinking from '../Thinking.vue'
const props = defineProps({ const props = defineProps({
content: { content: {
type: String, type: Object,
default: '', default: {
text: '',
files: [],
},
}, },
orgContent: { orgContent: {
type: String, type: String,
@@ -41,6 +44,9 @@ const props = defineProps({
}, },
}) })
const content = computed(() => {
return props.content.text
})
const contentRef = ref(null) const contentRef = ref(null)
onMounted(() => { onMounted(() => {
const imgs = contentRef.value.querySelectorAll('img') const imgs = contentRef.value.querySelectorAll('img')

View File

@@ -1,39 +1,39 @@
import {randString} from "@/utils/libs"; import { removeAdminInfo } from '@/store/cache'
import Storage from "good-storage"; import { randString } from '@/utils/libs'
import {removeAdminInfo} from "@/store/cache"; import Storage from 'good-storage'
/** /**
* storage handler * storage handler
*/ */
const UserTokenKey = process.env.VUE_APP_KEY_PREFIX + "Authorization"; const UserTokenKey = import.meta.env.VITE_KEY_PREFIX + 'Authorization'
const AdminTokenKey = process.env.VUE_APP_KEY_PREFIX + "Admin-Authorization" const AdminTokenKey = import.meta.env.VITE_KEY_PREFIX + 'Admin-Authorization'
export function getSessionId() { export function getSessionId() {
return randString(42) return randString(42)
} }
export function getUserToken() { export function getUserToken() {
return Storage.get(UserTokenKey) ?? "" return Storage.get(UserTokenKey) ?? ''
} }
export function setUserToken(token) { export function setUserToken(token) {
// 刷新 session 缓存 // 刷新 session 缓存
Storage.set(UserTokenKey, token) Storage.set(UserTokenKey, token)
} }
export function removeUserToken() { export function removeUserToken() {
Storage.remove(UserTokenKey) Storage.remove(UserTokenKey)
} }
export function getAdminToken() { export function getAdminToken() {
return Storage.get(AdminTokenKey) ?? "" return Storage.get(AdminTokenKey) ?? ''
} }
export function setAdminToken(token) { export function setAdminToken(token) {
Storage.set(AdminTokenKey, token) Storage.set(AdminTokenKey, token)
} }
export function removeAdminToken() { export function removeAdminToken() {
Storage.remove(AdminTokenKey) Storage.remove(AdminTokenKey)
removeAdminInfo() removeAdminInfo()
} }

View File

@@ -1,11 +1,11 @@
import {defineStore} from "pinia"; import errorIcon from '@/assets/img/failed.png'
import Storage from "good-storage"; import loadingIcon from '@/assets/img/loading.gif'
import errorIcon from "@/assets/img/failed.png"; import Storage from 'good-storage'
import loadingIcon from "@/assets/img/loading.gif"; import { defineStore } from 'pinia'
let waterfallOptions = { let waterfallOptions = {
// 唯一key值 // 唯一key值
rowKey: "id", rowKey: 'id',
// 卡片之间的间隙 // 卡片之间的间隙
gutter: 10, gutter: 10,
// 是否有周围的gutter // 是否有周围的gutter
@@ -44,16 +44,16 @@ let waterfallOptions = {
}, },
}, },
// 动画效果 // 动画效果
animationEffect: "animate__fadeInUp", animationEffect: 'animate__fadeInUp',
// 动画时间 // 动画时间
animationDuration: 1000, animationDuration: 1000,
// 动画延迟 // 动画延迟
animationDelay: 300, animationDelay: 300,
animationCancel: false, animationCancel: false,
// 背景色 // 背景色
backgroundColor: "", backgroundColor: '',
// imgSelector // imgSelector
imgSelector: "img_thumb", imgSelector: 'img_thumb',
// 是否跨域 // 是否跨域
crossOrigin: true, crossOrigin: true,
// 加载配置 // 加载配置
@@ -61,102 +61,62 @@ let waterfallOptions = {
loading: loadingIcon, loading: loadingIcon,
error: errorIcon, error: errorIcon,
ratioCalculator: (width, height) => { ratioCalculator: (width, height) => {
const minRatio = 3 / 4; const minRatio = 3 / 4
const maxRatio = 4 / 3; const maxRatio = 4 / 3
const curRatio = height / width; const curRatio = height / width
if (curRatio < minRatio) { if (curRatio < minRatio) {
return minRatio; return minRatio
} else if (curRatio > maxRatio) { } else if (curRatio > maxRatio) {
return maxRatio; return maxRatio
} else { } else {
return curRatio; return curRatio
} }
}, },
}, },
// 是否懒加载 // 是否懒加载
lazyload: true, lazyload: true,
align: "center", align: 'center',
} }
export const useSharedStore = defineStore("shared", { export const useSharedStore = defineStore('shared', {
state: () => ({ state: () => ({
showLoginDialog: false, showLoginDialog: false,
chatListStyle: Storage.get("chat_list_style", "chat"), chatListStyle: Storage.get('chat_list_style', 'chat'),
chatStream: Storage.get("chat_stream", true), chatStream: Storage.get('chat_stream', true),
socket: { conn: null, handlers: {} }, theme: Storage.get('theme', 'light'),
theme: Storage.get("theme", "light"),
isLogin: false, isLogin: false,
chatListExtend: Storage.get("chat_list_extend", true), chatListExtend: Storage.get('chat_list_extend', true),
ttsModel: Storage.get("tts_model", ""), ttsModel: Storage.get('tts_model', ''),
waterfallOptions, waterfallOptions,
}), }),
getters: {}, getters: {},
actions: { actions: {
setShowLoginDialog(value) { setShowLoginDialog(value) {
this.showLoginDialog = value; this.showLoginDialog = value
}, },
setChatListStyle(value) { setChatListStyle(value) {
this.chatListStyle = value; this.chatListStyle = value
Storage.set("chat_list_style", value); Storage.set('chat_list_style', value)
}, },
setChatStream(value) { setChatStream(value) {
this.chatStream = value; this.chatStream = value
Storage.set("chat_stream", value); Storage.set('chat_stream', value)
},
setSocket(value) {
for (const key in this.socket.handlers) {
this.setMessageHandler(value, this.socket.handlers[key]);
}
this.socket.conn = value;
}, },
setChatListExtend(value) { setChatListExtend(value) {
this.chatListExtend = value; this.chatListExtend = value
Storage.set("chat_list_extend", value); Storage.set('chat_list_extend', value)
},
addMessageHandler(key, callback) {
if (!this.socket.handlers[key]) {
this.socket.handlers[key] = callback;
}
this.setMessageHandler(this.socket.conn, callback);
},
setMessageHandler(conn, callback) {
if (!conn) {
return;
}
conn.addEventListener("message", (event) => {
try {
if (event.data instanceof Blob) {
const reader = new FileReader();
reader.readAsText(event.data, "UTF-8");
reader.onload = () => {
callback(JSON.parse(String(reader.result)));
};
}
} catch (e) {
console.warn(e);
}
});
},
removeMessageHandler(key) {
if (this.socket.conn && this.socket.conn.readyState === WebSocket.OPEN) {
this.socket.conn.removeEventListener("message", this.socket.handlers[key]);
}
delete this.socket.handlers[key];
}, },
setTheme(theme) { setTheme(theme) {
this.theme = theme; this.theme = theme
document.documentElement.setAttribute("data-theme", theme); // 设置 HTML 的 data-theme 属性 document.documentElement.setAttribute('data-theme', theme) // 设置 HTML 的 data-theme 属性
Storage.set("theme", theme); Storage.set('theme', theme)
}, },
setIsLogin(value) { setIsLogin(value) {
this.isLogin = value; this.isLogin = value
}, },
setTtsModel(value) { setTtsModel(value) {
this.ttsModel = value; this.ttsModel = value
Storage.set("tts_model", value); Storage.set('tts_model', value)
}, },
}, },
}); })

View File

@@ -5,50 +5,50 @@
// * @Author yangjian102621@163.com // * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import Storage from "good-storage"; import Storage from 'good-storage'
export function GetFileIcon(ext) { export function GetFileIcon(ext) {
const files = { const files = {
".docx": "doc.png", '.docx': 'doc.png',
".doc": "doc.png", '.doc': 'doc.png',
".xls": "xls.png", '.xls': 'xls.png',
".xlsx": "xls.png", '.xlsx': 'xls.png',
".csv": "xls.png", '.csv': 'xls.png',
".ppt": "ppt.png", '.ppt': 'ppt.png',
".pptx": "ppt.png", '.pptx': 'ppt.png',
".md": "md.png", '.md': 'md.png',
".pdf": "pdf.png", '.pdf': 'pdf.png',
".sql": "sql.png", '.sql': 'sql.png',
".mp3": "mp3.png", '.mp3': 'mp3.png',
".wav": "mp3.png", '.wav': 'mp3.png',
".mp4": "mp4.png", '.mp4': 'mp4.png',
".avi": "mp4.png", '.avi': 'mp4.png',
} }
if (files[ext]) { if (files[ext]) {
return '/images/ext/' + files[ext] return '/images/ext/' + files[ext]
} }
return '/images/ext/file.png' return '/images/ext/file.png'
} }
// 获取文件类型 // 获取文件类型
export function GetFileType (ext) { export function GetFileType(ext) {
return ext.replace(".", "").toUpperCase() return ext.replace('.', '').toUpperCase()
} }
// 将文件大小转成字符 // 将文件大小转成字符
export function FormatFileSize(bytes) { export function FormatFileSize(bytes) {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes'
const k = 1024; const k = 1024
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']; const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
} }
export function setRoute(path) { export function setRoute(path) {
Storage.set(process.env.VUE_APP_KEY_PREFIX + 'ROUTE_',path) Storage.set(import.meta.env.VITE_KEY_PREFIX + 'ROUTE_', path)
} }
export function getRoute() { export function getRoute() {
return Storage.get(process.env.VUE_APP_KEY_PREFIX + 'ROUTE_') return Storage.get(import.meta.env.VITE_KEY_PREFIX + 'ROUTE_')
} }

View File

@@ -6,95 +6,106 @@
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ // * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import axios from 'axios' import axios from 'axios'
import {getAdminToken, getUserToken, removeAdminToken, removeUserToken} from "@/store/session"; import { getAdminToken, getUserToken, removeAdminToken, removeUserToken } from '@/store/session'
axios.defaults.timeout = 180000 axios.defaults.timeout = 180000
axios.defaults.baseURL = process.env.VUE_APP_API_HOST // axios.defaults.baseURL = process.env.VUE_APP_API_HOST
axios.defaults.withCredentials = true; axios.defaults.withCredentials = true
//axios.defaults.headers.post['Content-Type'] = 'application/json' //axios.defaults.headers.post['Content-Type'] = 'application/json'
// HTTP拦截器 // HTTP拦截器
axios.interceptors.request.use( axios.interceptors.request.use(
config => { (config) => {
// set token // set token
config.headers['Authorization'] = getUserToken(); config.headers['Authorization'] = getUserToken()
config.headers['Admin-Authorization'] = getAdminToken(); config.headers['Admin-Authorization'] = getAdminToken()
return config return config
}, error => { },
return Promise.reject(error) (error) => {
}) return Promise.reject(error)
}
)
axios.interceptors.response.use( axios.interceptors.response.use(
response => { (response) => {
return response return response
}, error => { },
if (error.response.status === 401) { (error) => {
if (error.response.request.responseURL.indexOf("/api/admin") !== -1) { if (error.response.status === 401) {
removeAdminToken() if (error.response.request.responseURL.indexOf('/api/admin') !== -1) {
} else { removeAdminToken()
console.log("FUCK") } else {
removeUserToken() removeUserToken()
} }
error.response.data.message = "请先登录" error.response.data.message = '请先登录'
return Promise.reject(error.response.data) return Promise.reject(error.response.data)
} }
if (error.response.status === 400) { if (error.response.status === 400) {
return Promise.reject(new Error(error.response.data.message)) return Promise.reject(new Error(error.response.data.message))
} else { } else {
return Promise.reject(error) return Promise.reject(error)
} }
}) }
)
// send a http get request // send a http get request
export function httpGet(url, params = {}) { export function httpGet(url, params = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.get(url, { axios
params: params .get(url, {
}).then(response => { params: params,
resolve(response.data) })
}).catch(err => { .then((response) => {
reject(err) resolve(response.data)
}) })
}) .catch((err) => {
reject(err)
})
})
} }
// send a http post request // send a http post request
export function httpPost(url, data = {}, options = {}) { export function httpPost(url, data = {}, options = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.post(url, data, options).then(response => { axios
resolve(response.data) .post(url, data, options)
}).catch(err => { .then((response) => {
reject(err) resolve(response.data)
}) })
}) .catch((err) => {
reject(err)
})
})
} }
export function httpDownload(url) { export function httpDownload(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios({ axios({
method: 'GET', method: 'GET',
url: url, url: url,
responseType: 'blob' // 将响应类型设置为 `blob` responseType: 'blob', // 将响应类型设置为 `blob`
}).then(response => {
resolve(response)
}).catch(err => {
reject(err)
})
}) })
.then((response) => {
resolve(response)
})
.catch((err) => {
reject(err)
})
})
} }
export function httpPostDownload(url, data) { export function httpPostDownload(url, data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios({ axios({
method: 'POST', method: 'POST',
url: url, url: url,
data: data, data: data,
responseType: 'blob' // 将响应类型设置为 `blob` responseType: 'blob', // 将响应类型设置为 `blob`
}).then(response => {
resolve(response)
}).catch(err => {
reject(err)
})
}) })
} .then((response) => {
resolve(response)
})
.catch((err) => {
reject(err)
})
})
}

View File

@@ -8,252 +8,250 @@
/** /**
* Util lib functions * Util lib functions
*/ */
import { showConfirmDialog } from "vant"; import { showConfirmDialog } from 'vant'
// generate a random string // generate a random string
export function randString(length) { export function randString(length) {
const str = "0123456789abcdefghijklmnopqrstuvwxyz"; const str = '0123456789abcdefghijklmnopqrstuvwxyz'
const size = str.length; const size = str.length
let buf = []; let buf = []
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const rand = Math.random() * size; const rand = Math.random() * size
buf.push(str.charAt(rand)); buf.push(str.charAt(rand))
} }
return buf.join(""); return buf.join('')
} }
export function UUID() { export function UUID() {
let d = new Date().getTime(); let d = new Date().getTime()
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (d + Math.random() * 16) % 16 | 0; const r = (d + Math.random() * 16) % 16 | 0
d = Math.floor(d / 16); d = Math.floor(d / 16)
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16); return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
}); })
} }
// 判断是否是移动设备 // 判断是否是移动设备
export function isMobile() { export function isMobile() {
const userAgent = navigator.userAgent; const userAgent = navigator.userAgent
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i; const mobileRegex =
return mobileRegex.test(userAgent); /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i
return mobileRegex.test(userAgent)
} }
// 格式化日期 // 格式化日期
export function dateFormat(timestamp, format) { export function dateFormat(timestamp, format) {
if (!timestamp) { if (!timestamp) {
return ""; return ''
} else if (timestamp < 9680917502) { } else if (timestamp < 9680917502) {
timestamp = timestamp * 1000; timestamp = timestamp * 1000
} }
let year, month, day, HH, mm, ss; let year, month, day, HH, mm, ss
let time = new Date(timestamp); let time = new Date(timestamp)
let timeDate; let timeDate
year = time.getFullYear(); // 年 year = time.getFullYear() // 年
month = time.getMonth() + 1; // 月 month = time.getMonth() + 1 // 月
day = time.getDate(); // 日 day = time.getDate() // 日
HH = time.getHours(); // 时 HH = time.getHours() // 时
mm = time.getMinutes(); // 分 mm = time.getMinutes() // 分
ss = time.getSeconds(); // 秒 ss = time.getSeconds() // 秒
month = month < 10 ? "0" + month : month; month = month < 10 ? '0' + month : month
day = day < 10 ? "0" + day : day; day = day < 10 ? '0' + day : day
HH = HH < 10 ? "0" + HH : HH; // 时 HH = HH < 10 ? '0' + HH : HH // 时
mm = mm < 10 ? "0" + mm : mm; // 分 mm = mm < 10 ? '0' + mm : mm // 分
ss = ss < 10 ? "0" + ss : ss; // 秒 ss = ss < 10 ? '0' + ss : ss // 秒
switch (format) { switch (format) {
case "yyyy": case 'yyyy':
timeDate = String(year); timeDate = String(year)
break; break
case "yyyy-MM": case 'yyyy-MM':
timeDate = year + "-" + month; timeDate = year + '-' + month
break; break
case "yyyy-MM-dd": case 'yyyy-MM-dd':
timeDate = year + "-" + month + "-" + day; timeDate = year + '-' + month + '-' + day
break; break
case "yyyy/MM/dd": case 'yyyy/MM/dd':
timeDate = year + "/" + month + "/" + day; timeDate = year + '/' + month + '/' + day
break; break
case "yyyy-MM-dd HH:mm:ss": case 'yyyy-MM-dd HH:mm:ss':
timeDate = year + "-" + month + "-" + day + " " + HH + ":" + mm + ":" + ss; timeDate = year + '-' + month + '-' + day + ' ' + HH + ':' + mm + ':' + ss
break; break
case "HH:mm:ss": case 'HH:mm:ss':
timeDate = HH + ":" + mm + ":" + ss; timeDate = HH + ':' + mm + ':' + ss
break; break
case "MM": case 'MM':
timeDate = String(month); timeDate = String(month)
break; break
default: default:
timeDate = year + "-" + month + "-" + day + " " + HH + ":" + mm + ":" + ss; timeDate = year + '-' + month + '-' + day + ' ' + HH + ':' + mm + ':' + ss
break; break
} }
return timeDate; return timeDate
} }
export function formatTime(time) { export function formatTime(time) {
const minutes = Math.floor(time / 60); const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60); const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`; return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
} }
// 判断数组中是否包含某个元素 // 判断数组中是否包含某个元素
export function arrayContains(array, value, compare) { export function arrayContains(array, value, compare) {
if (!array) { if (!array) {
return false; return false
} }
if (typeof compare !== "function") { if (typeof compare !== 'function') {
compare = function (v1, v2) { compare = function (v1, v2) {
return v1 === v2; return v1 === v2
}; }
} }
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
if (compare(array[i], value)) { if (compare(array[i], value)) {
return true; return true
} }
} }
return false; return false
} }
// 删除数组中指定的元素 // 删除数组中指定的元素
export function removeArrayItem(array, value, compare) { export function removeArrayItem(array, value, compare) {
if (typeof compare !== "function") { if (typeof compare !== 'function') {
compare = function (v1, v2) { compare = function (v1, v2) {
return v1 === v2; return v1 === v2
}; }
} }
for (let i = 0; i < array.length; i++) { for (let i = 0; i < array.length; i++) {
if (compare(array[i], value)) { if (compare(array[i], value)) {
array.splice(i, 1); array.splice(i, 1)
break; break
} }
} }
return array; return array
} }
// 渲染输入的换行符 // 渲染输入的换行符
export function renderInputText(text) { export function renderInputText(text) {
const replaceRegex = /(\n\r|\r\n|\r|\n)/g; const replaceRegex = /(\n\r|\r\n|\r|\n)/g
text = text || ""; text = text || ''
return text.replace(replaceRegex, "<br/>"); return text.replace(replaceRegex, '<br/>')
} }
// 拷贝对象 // 拷贝对象
export function copyObj(origin) { export function copyObj(origin) {
return JSON.parse(JSON.stringify(origin)); return JSON.parse(JSON.stringify(origin))
} }
export function disabledDate(time) { export function disabledDate(time) {
return time.getTime() < Date.now(); return time.getTime() < Date.now()
} }
// 字符串截取 // 字符串截取
export function substr(str, length) { export function substr(str, length) {
let result = ""; let result = ''
let count = 0; let count = 0
for (let i = 0; i < str.length; i++) { for (let i = 0; i < str.length; i++) {
const char = str.charAt(i); const char = str.charAt(i)
const charCode = str.charCodeAt(i); const charCode = str.charCodeAt(i)
// 判断字符是否为中文字符 // 判断字符是否为中文字符
if (charCode >= 0x4e00 && charCode <= 0x9fff) { if (charCode >= 0x4e00 && charCode <= 0x9fff) {
// 中文字符算两个字符 // 中文字符算两个字符
count += 2; count += 2
} else { } else {
count++; count++
} }
if (count <= length) { if (count <= length) {
result += char; result += char
} else { } else {
result += " ..."; result += ' ...'
break; break
} }
} }
return result; return result
} }
export function isImage(url) { export function isImage(url) {
const expr = /\.(jpg|jpeg|png|gif|bmp|svg)$/i; const expr = /\.(jpg|jpeg|png|gif|bmp|svg)$/i
return expr.test(url); return expr.test(url)
} }
export function processContent(content) { export function processContent(content) {
if (!content) { if (!content) {
return ""; return ''
} }
// 如果是图片链接地址,则直接替换成图片标签 // 如果是图片链接地址,则直接替换成图片标签
const linkRegex = /(https?:\/\/\S+)/g; const linkRegex = /(https?:\/\/\S+)/g
const links = content.match(linkRegex); const links = content.match(linkRegex)
if (links) { if (links) {
for (let link of links) { for (let link of links) {
if (isImage(link)) { if (isImage(link)) {
const index = content.indexOf(link); const index = content.indexOf(link)
if (content.substring(index - 1, 2) !== "]") { if (content.substring(index - 1, 2) !== ']') {
content = content.replace(link, "\n![](" + link + ")\n"); content = content.replace(link, '\n![](' + link + ')\n')
} }
} }
} }
} }
// 处理推理标签 // 处理推理标签
if (content.includes("<think>")) { if (content.includes('<think>')) {
content = content.replace(/<think>(.*?)<\/think>/gs, (match, content) => { content = content.replace(/<think>(.*?)<\/think>/gs, (match, content) => {
if (content.length > 10) { if (content.length > 10) {
return `<blockquote>${content}</blockquote>`; return `<blockquote>${content}</blockquote>`
} }
return ""; return ''
}); })
content = content.replace(/<think>(.*?)$/gs, (match, content) => { content = content.replace(/<think>(.*?)$/gs, (match, content) => {
if (content.length > 10) { if (content.length > 10) {
return `<blockquote>${content}</blockquote>`; return `<blockquote>${content}</blockquote>`
} }
return ""; return ''
}); })
} }
// 支持 \[ 公式标签 // 支持 \[ 公式标签
content = content.replace(/\\\[/g, "$$").replace(/\\\]/g, "$$"); content = content.replace(/\\\[/g, '$$').replace(/\\\]/g, '$$')
content = content.replace(/\\\(\\boxed\{(\d+)\}\\\)/g, '<span class="boxed">$1</span>'); content = content.replace(/\\\(\\boxed\{(\d+)\}\\\)/g, '<span class="boxed">$1</span>')
return content; return content
} }
export function processPrompt(prompt) { export function processPrompt(prompt) {
return prompt.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); return prompt.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
} }
// 判断是否为微信浏览器 // 判断是否为微信浏览器
export function isWeChatBrowser() { export function isWeChatBrowser() {
return /MicroMessenger/i.test(navigator.userAgent); return /MicroMessenger/i.test(navigator.userAgent)
} }
export function showLoginDialog(router) { export function showLoginDialog(router) {
showConfirmDialog({ showConfirmDialog({
title: "登录", title: '登录',
message: "此操作需要登录才能进行,前往登录?", message: '此操作需要登录才能进行,前往登录?',
}) })
.then(() => { .then(() => {
router.push("/mobile/login"); router.push('/mobile/login')
}) })
.catch(() => { .catch(() => {
// on cancel // on cancel
}); })
} }
export const replaceImg = (img) => { export const replaceImg = (img) => {
if (!img.startsWith("http")) { if (img.startsWith('http')) {
img = `${location.protocol}//${location.host}${img}`; return img
} }
const devHost = process.env.VUE_APP_API_HOST; return `${location.protocol}//${location.host}${img}`
const localhost = "http://localhost:5678"; }
if (img.includes(localhost)) {
return img?.replace(localhost, devHost); // 判断是否 google 浏览器
} export function isChrome() {
return img; const userAgent = navigator.userAgent.toLowerCase()
}; return /chrome/.test(userAgent) && !/edg/.test(userAgent)
export function isChrome() {
const userAgent = navigator.userAgent.toLowerCase();
return /chrome/.test(userAgent) && !/edg/.test(userAgent);
} }

View File

@@ -4,7 +4,12 @@
<div class="flex h-20"> <div class="flex h-20">
<ul class="scrollbar-type-nav"> <ul class="scrollbar-type-nav">
<li :class="{ active: typeId === '' }" @click="getAppList('')">全部分类</li> <li :class="{ active: typeId === '' }" @click="getAppList('')">全部分类</li>
<li v-for="item in appTypes" :key="item.id" :class="{ active: typeId === item.id }" @click="getAppList(item.id)"> <li
v-for="item in appTypes"
:key="item.id"
:class="{ active: typeId === item.id }"
@click="getAppList(item.id)"
>
<div class="image" v-if="item.icon"> <div class="image" v-if="item.icon">
<el-image :src="item.icon" fit="cover" /> <el-image :src="item.icon" fit="cover" />
</div> </div>
@@ -27,37 +32,25 @@
<div class="info-text">{{ scope.item.hello_msg }}</div> <div class="info-text">{{ scope.item.hello_msg }}</div>
</div> </div>
<div class="btn"> <div class="btn">
<el-button size="small" class="sm-btn-theme" @click="useRole(scope.item)">使用</el-button> <el-button size="small" class="sm-btn-theme" @click="useRole(scope.item)"
>使用</el-button
>
<el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.key)"> <el-tooltip content="从工作区移除" placement="top" v-if="hasRole(scope.item.key)">
<el-button size="small" type="danger" @click="updateRole(scope.item, 'remove')">移除</el-button> <el-button size="small" type="danger" @click="updateRole(scope.item, 'remove')"
>移除</el-button
>
</el-tooltip> </el-tooltip>
<el-tooltip content="添加到工作区" placement="top" v-else> <el-tooltip content="添加到工作区" placement="top" v-else>
<el-button size="small" style="--el-color-primary: #009999" @click="updateRole(scope.item, 'add')">添加</el-button> <el-button
size="small"
style="--el-color-primary: #009999"
@click="updateRole(scope.item, 'add')"
>添加</el-button
>
</el-tooltip> </el-tooltip>
</div> </div>
</div> </div>
</div> </div>
<!-- <div class="app-item">-->
<!-- <el-image :src="scope.item.icon" fit="cover"/>-->
<!-- <div class="title">-->
<!-- <span class="name">{{ scope.item.name }}</span>-->
<!-- <div class="opt">-->
<!-- <div v-if="hasRole(scope.item.key)">-->
<!-- <el-button size="small" type="success" @click="useRole(scope.item)">使用</el-button>-->
<!-- <el-button size="small" type="danger" @click="updateRole(scope.item,'remove')">移除</el-button>-->
<!-- </div>-->
<!-- <el-button v-else size="small"-->
<!-- style="&#45;&#45;el-color-primary:#009999"-->
<!-- @click="updateRole(scope.item, 'add')">-->
<!-- <el-icon>-->
<!-- <Plus/>-->
<!-- </el-icon>-->
<!-- <span>添加应用</span>-->
<!-- </el-button>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div class="hello-msg" ref="elements">{{ scope.item.intro }}</div>-->
<!-- </div>-->
</template> </template>
</ItemList> </ItemList>
<div v-else style="width: 100%"> <div v-else style="width: 100%">
@@ -69,112 +62,112 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
import { onMounted, ref } from "vue"; import ItemList from '@/components/ItemList.vue'
import { ElMessage } from "element-plus"; import { checkSession } from '@/store/cache'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import { checkSession } from "@/store/cache"; import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, substr } from "@/utils/libs"; import { arrayContains, removeArrayItem, substr } from '@/utils/libs'
import { useRouter } from "vue-router"; import { ElMessage } from 'element-plus'
import { useSharedStore } from "@/store/sharedata"; import { onMounted, ref } from 'vue'
import ItemList from "@/components/ItemList.vue"; import { useRouter } from 'vue-router'
const listBoxHeight = window.innerHeight - 133; const listBoxHeight = window.innerHeight - 133
const typeId = ref(""); const typeId = ref('')
const appTypes = ref([]); const appTypes = ref([])
const list = ref([]); const list = ref([])
const roles = ref([]); const roles = ref([])
const store = useSharedStore(); const store = useSharedStore()
onMounted(() => { onMounted(() => {
getAppType(); getAppType()
getAppList(); getAppList()
getRoles(); getRoles()
}); })
const getRoles = () => { const getRoles = () => {
checkSession() checkSession()
.then((user) => { .then((user) => {
roles.value = user.chat_roles; roles.value = user.chat_roles
}) })
.catch((e) => { .catch((e) => {
console.log(e.message); console.log(e.message)
}); })
}; }
const getAppType = () => { const getAppType = () => {
httpGet("/api/app/type/list") httpGet('/api/app/type/list')
.then((res) => { .then((res) => {
appTypes.value = res.data; appTypes.value = res.data
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取分类失败:" + e.message); ElMessage.error('获取分类失败:' + e.message)
}); })
}; }
const getAppList = (tid = "") => { const getAppList = (tid = '') => {
typeId.value = tid; typeId.value = tid
httpGet("/api/app/list", { tid }) httpGet('/api/app/list', { tid })
.then((res) => { .then((res) => {
const items = res.data; const items = res.data
// 处理 hello message // 处理 hello message
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
items[i].intro = substr(items[i].hello_msg, 80); items[i].intro = substr(items[i].hello_msg, 80)
} }
list.value = items; list.value = items
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取应用失败:" + e.message); ElMessage.error('获取应用失败:' + e.message)
}); })
}; }
const updateRole = (row, opt) => { const updateRole = (row, opt) => {
checkSession() checkSession()
.then(() => { .then(() => {
const title = ref(""); const title = ref('')
if (opt === "add") { if (opt === 'add') {
title.value = "添加应用"; title.value = '添加应用'
const exists = arrayContains(roles.value, row.key); const exists = arrayContains(roles.value, row.key)
if (exists) { if (exists) {
return; return
} }
roles.value.push(row.key); roles.value.push(row.key)
} else { } else {
title.value = "移除应用"; title.value = '移除应用'
const exists = arrayContains(roles.value, row.key); const exists = arrayContains(roles.value, row.key)
if (!exists) { if (!exists) {
return; return
} }
roles.value = removeArrayItem(roles.value, row.key); roles.value = removeArrayItem(roles.value, row.key)
} }
httpPost("/api/app/update", { keys: roles.value }) httpPost('/api/app/update', { keys: roles.value })
.then(() => { .then(() => {
ElMessage.success({ ElMessage.success({
message: title.value + "成功!", message: title.value + '成功!',
duration: 1000, duration: 1000,
}); })
}) })
.catch((e) => { .catch((e) => {
ElMessage.error(title.value + "失败:" + e.message); ElMessage.error(title.value + '失败:' + e.message)
}); })
}) })
.catch(() => { .catch(() => {
store.setShowLoginDialog(true); store.setShowLoginDialog(true)
}); })
}; }
const hasRole = (roleKey) => { const hasRole = (roleKey) => {
return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2); return arrayContains(roles.value, roleKey, (v1, v2) => v1 === v2)
}; }
const router = useRouter(); const router = useRouter()
const useRole = (role) => { const useRole = (role) => {
router.push(`/chat?role_id=${role.id}`); router.push(`/chat?role_id=${role.id}`)
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/chat-app.styl" @import "../assets/css/chat-app.styl"
@import "@/assets/css/custom-scroll.styl" @import "../assets/css/custom-scroll.styl"
</style> </style>

View File

@@ -147,7 +147,7 @@
type="info" type="info"
style="margin-left: 8px; flex-shrink: 0" style="margin-left: 8px; flex-shrink: 0"
> >
{{ getSelectedModel()?.power }}算力 {{ getSelectedModel() && getSelectedModel().power }}算力
</el-tag> </el-tag>
</div> </div>
</el-button> </el-button>
@@ -264,7 +264,12 @@
<welcome @send="autofillPrompt" /> <welcome @send="autofillPrompt" />
</div> </div>
<div v-for="item in chatData" :key="item.id" v-else> <div v-for="item in chatData" :key="item.id" v-else>
<chat-prompt v-if="item.type === 'prompt'" :data="item" :list-style="listStyle" /> <chat-prompt
v-if="item.type === 'prompt'"
:data="item"
:list-style="listStyle"
@edit="editUserPrompt"
/>
<chat-reply <chat-reply
v-else-if="item.type === 'reply'" v-else-if="item.type === 'reply'"
:data="item" :data="item"
@@ -315,7 +320,10 @@
<span class="tool-item-btn"> <span class="tool-item-btn">
<el-tooltip class="box-item" effect="dark" content="上传附件"> <el-tooltip class="box-item" effect="dark" content="上传附件">
<file-select :user-id="loginUser?.id" @selected="insertFile" /> <file-select
:user-id="loginUser && loginUser.id"
@selected="insertFile"
/>
</el-tooltip> </el-tooltip>
</span> </span>
</div> </div>
@@ -414,13 +422,15 @@ import {
Share, Share,
VideoPause, VideoPause,
} from '@element-plus/icons-vue' } from '@element-plus/icons-vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import Clipboard from 'clipboard' import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import 'highlight.js/styles/a11y-dark.css' import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it' import MarkdownIt from 'markdown-it'
import emoji from 'markdown-it-emoji' import emoji from 'markdown-it-emoji'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { getUserToken } from '../store/session'
const title = ref('GeekAI-智能助手') const title = ref('GeekAI-智能助手')
const logo = ref('') const logo = ref('')
@@ -694,153 +704,216 @@ onMounted(() => {
}) })
window.onresize = () => resizeElement() window.onresize = () => resizeElement()
store.addMessageHandler('chat', (data) => { })
// 丢去非本频道和本客户端的消息
if (data.channel !== 'chat' || data.clientId !== getClientId()) { // 初始化数据
return const initData = async () => {
try {
// 获取角色列表
const roleRes = await httpGet('/api/app/list')
roles.value = roleRes.data
if (roles.value.length > 0) {
roleId.value = roles.value[0].id
} }
if (data.type === 'error') { // 获取模型列表
ElMessage.error(data.body) const modelRes = await httpGet('/api/model/list')
return models.value = modelRes.data
if (models.value.length > 0) {
modelID.value = models.value[0].id
} }
const chatRole = getRoleById(roleId.value) // 获取用户信息
if (isNewMsg.value && data.type !== 'end') { const user = await checkSession()
const prePrompt = chatData.value[chatData.value.length - 1]?.content loginUser.value = user
isNewMsg.value = false isLogin.value = true
lineBuffer.value = data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'] = lineBuffer.value
}
} else if (data.type === 'end') {
// 消息接收完毕
// 追加当前会话到会话列表
if (newChatItem.value !== null) {
newChatItem.value['title'] = tmpChatTitle.value
newChatItem.value['chat_id'] = chatId.value
chatList.value.unshift(newChatItem.value)
newChatItem.value = null // 只追加一次
}
enableInput() // 获取聊天列表
lineBuffer.value = '' // 清空缓冲 const chatRes = await httpGet('/api/chat/list')
allChats.value = chatRes.data
chatList.value = allChats.value
if (chatId.value) {
loadChatHistory(chatId.value)
}
} catch (error) {
if (error.response?.status === 401) {
isLogin.value = false
} else {
console.warn('初始化数据失败:' + error.message)
}
}
}
// 发送 SSE 请求
const sendSSERequest = async (message) => {
try {
await fetchEventSource('/api/chat/message', {
method: 'POST',
headers: {
Authorization: getUserToken(),
},
body: JSON.stringify(message),
openWhenHidden: true,
onopen(response) {
if (response.ok && response.status === 200) {
console.log('SSE connection opened')
} else {
throw new Error(`Failed to open SSE connection: ${response.status}`)
}
},
onmessage(msg) {
try {
const data = JSON.parse(msg.data)
if (data.type === 'error') {
ElMessage.error(data.body)
enableInput()
return
}
if (data.type === 'end') {
enableInput()
lineBuffer.value = '' // 清空缓冲
// 获取 token
const reply = chatData.value[chatData.value.length - 1]
httpPost('/api/chat/tokens', {
text: '',
model: getModelValue(modelID.value),
chat_id: chatId.value,
})
.then((res) => {
reply['created_at'] = new Date().getTime()
reply['tokens'] = res.data
// 将聊天框的滚动条滑动到最底部
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
})
.catch(() => {})
isNewMsg.value = true
tmpChatTitle.value = message.prompt
return
}
if (data.type === 'text') {
if (isNewMsg.value) {
isNewMsg.value = false
lineBuffer.value = data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = lineBuffer.value
}
} else {
lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'].text = lineBuffer.value
}
}
}
// 获取 token
const reply = chatData.value[chatData.value.length - 1]
httpPost('/api/chat/tokens', {
text: '',
model: getModelValue(modelID.value),
chat_id: chatId.value,
})
.then((res) => {
reply['created_at'] = new Date().getTime()
reply['tokens'] = res.data
// 将聊天框的滚动条滑动到最底部 // 将聊天框的滚动条滑动到最底部
nextTick(() => { nextTick(() => {
document document
.getElementById('chat-box') .getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight) .scrollTo(0, document.getElementById('chat-box').scrollHeight)
localStorage.setItem('chat_id', chatId.value)
}) })
}) } catch (error) {
.catch(() => {}) console.error('Error processing message:', error)
isNewMsg.value = true enableInput()
} else if (data.type === 'text') { ElMessage.error('消息处理出错,请重试')
lineBuffer.value += data.body }
const reply = chatData.value[chatData.value.length - 1] },
if (reply) { onerror(err) {
reply['content'] = lineBuffer.value console.error('SSE Error:', err)
} enableInput()
} ElMessage.error('连接已断开,请重试')
// 将聊天框的滚动条滑动到最底部 },
nextTick(() => { onclose() {
document console.log('SSE connection closed')
.getElementById('chat-box') enableInput()
.scrollTo(0, document.getElementById('chat-box').scrollHeight) },
localStorage.setItem('chat_id', chatId.value)
}) })
} catch (error) {
console.error('Failed to send message:', error)
enableInput()
ElMessage.error('发送消息失败,请重试')
}
}
// 发送消息
const sendMessage = (messageId) => {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (canSend.value === false) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value.trim().length === 0 || canSend.value === false) {
showMessageError('请输入要发送的消息!')
return false
}
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: {
text: prompt.value,
files: files.value,
},
model: getModelValue(modelID.value),
created_at: new Date().getTime() / 1000,
}) })
// 初始化模型分类和分组 // 添加空回复消息
updateModelCategories() const _role = getRoleById(roleId.value)
updateGroupedModels() chatData.value.push({
}) chat_id: chatId,
role_id: roleId.value,
onUnmounted(() => { type: 'reply',
store.removeMessageHandler('chat') id: randString(32),
}) icon: _role['icon'],
// 初始化数据 content: {
const initData = () => { text: '',
// 加载模型 files: [],
httpGet('/api/model/list?type=chat') },
.then((res) => {
models.value = res.data
if (!modelID.value) {
modelID.value = models.value[0].id
}
// 加载角色列表
httpGet(`/api/app/list/user`, { id: roleId.value })
.then((res) => {
roles.value = res.data
if (!roleId.value) {
roleId.value = roles.value[0]['id']
}
// 如果登录状态就创建对话连接
checkSession()
.then((user) => {
loginUser.value = user
isLogin.value = true
newChat()
})
.catch(() => {})
})
.catch((e) => {
ElMessage.error('获取聊天角色失败: ' + e.messages)
})
})
.catch((e) => {
ElMessage.error('加载模型失败: ' + e.message)
})
// 获取会话列表
httpGet('/api/chat/list')
.then((res) => {
if (res.data) {
chatList.value = res.data
allChats.value = res.data
}
})
.catch(() => {
ElMessage.error('加载会话列表失败!')
})
// 允许在输入框粘贴文件
inputRef.value.addEventListener('paste', (event) => {
const items = (event.clipboardData || window.clipboardData).items
for (let item of items) {
if (item.kind === 'file') {
const file = item.getAsFile()
const formData = new FormData()
formData.append('file', file)
loading.value = true
// 执行上传操作
httpPost('/api/upload', formData)
.then((res) => {
files.value.push(res.data)
ElMessage.success({ message: '上传成功', duration: 500 })
loading.value = false
})
.catch((e) => {
ElMessage.error('文件上传失败:' + e.message)
loading.value = false
})
break
}
}
}) })
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
showHello.value = false
disableInput(false)
// 异步发送 SSE 请求
sendSSERequest({
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
prompt: prompt.value,
tools: toolSelected.value,
stream: stream.value,
files: files.value,
last_msg_id: messageId,
})
prompt.value = ''
files.value = []
row.value = 1
} }
const getRoleById = function (rid) { const getRoleById = function (rid) {
@@ -854,7 +927,6 @@ const getRoleById = function (rid) {
const resizeElement = function () { const resizeElement = function () {
chatListHeight.value = window.innerHeight - 240 chatListHeight.value = window.innerHeight - 240
// chatBoxHeight.value = window.innerHeight;
mainWinHeight.value = window.innerHeight - 50 mainWinHeight.value = window.innerHeight - 50
chatBoxHeight.value = window.innerHeight - 101 - 82 - 38 chatBoxHeight.value = window.innerHeight - 101 - 82 - 38
} }
@@ -1036,85 +1108,6 @@ const autofillPrompt = (text) => {
inputRef.value.focus() inputRef.value.focus()
sendMessage() sendMessage()
} }
// 发送消息
const sendMessage = function () {
if (!isLogin.value) {
console.log('未登录')
store.setShowLoginDialog(true)
return
}
if (store.socket.conn.readyState !== WebSocket.OPEN) {
ElMessage.warning('连接断开,正在重连...')
return
}
if (canSend.value === false) {
ElMessage.warning('AI 正在作答中,请稍后...')
return
}
if (prompt.value.trim().length === 0 || canSend.value === false) {
showMessageError('请输入要发送的消息!')
return false
}
// 如果携带了文件,则串上文件地址
let content = prompt.value
if (files.value.length > 0) {
content += files.value.map((file) => file.url).join(' ')
}
// else if (files.value.length > 1) {
// showMessageError("当前只支持上传一个文件!");
// return false;
// }
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: content,
model: getModelValue(modelID.value),
created_at: new Date().getTime() / 1000,
})
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
})
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
})
showHello.value = false
disableInput(false)
store.socket.conn.send(
JSON.stringify({
channel: 'chat',
type: 'text',
body: {
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
content: content,
tools: toolSelected.value,
stream: stream.value,
},
})
)
tmpChatTitle.value = content
prompt.value = ''
files.value = []
row.value = 1
return true
}
const clearAllChats = function () { const clearAllChats = function () {
ElMessageBox.confirm('清除所有对话?此操作不可撤销!', '警告', { ElMessageBox.confirm('清除所有对话?此操作不可撤销!', '警告', {
@@ -1157,7 +1150,10 @@ const loadChatHistory = function (chatId) {
type: 'reply', type: 'reply',
id: randString(32), id: randString(32),
icon: _role['icon'], icon: _role['icon'],
content: _role['hello_msg'], content: {
text: _role['hello_msg'],
files: [],
},
}) })
return return
} }
@@ -1181,6 +1177,7 @@ const loadChatHistory = function (chatId) {
}) })
} }
// 停止生成
const stopGenerate = function () { const stopGenerate = function () {
showStopGenerate.value = false showStopGenerate.value = false
httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => { httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => {
@@ -1189,30 +1186,100 @@ const stopGenerate = function () {
} }
// 重新生成 // 重新生成
const reGenerate = function (prompt) { const reGenerate = function (messageId) {
disableInput(false) // 恢复发送按钮状态
const text = '重新回答下述问题:' + prompt canSend.value = true
// 追加消息 showStopGenerate.value = false
chatData.value.push({ console.log(messageId)
type: 'prompt',
id: randString(32), chatData.value = chatData.value.filter((item) => item.id < messageId)
icon: loginUser.value.avatar, // 保存用户消息内容,填入输入框
content: text, const userPrompt = chatData.value[chatData.value.length - 1].content.text
// 删除用户消息
const lastMessage = chatData.value.pop()
// 填入输入框
prompt.value = userPrompt
sendMessage(lastMessage.id)
// 将光标定位到输入框并聚焦
nextTick(() => {
if (inputRef.value) {
inputRef.value.focus()
// 触发输入事件以更新文本高度
onInput({ keyCode: null })
}
}) })
store.socket.conn.send( }
JSON.stringify({
channel: 'chat', // 编辑用户消息
type: 'text', const editUserPrompt = function (messageId) {
body: { // 找到要编辑的消息及其索引
let messageIndex = -1
let messageContent = ''
for (let i = 0; i < chatData.value.length; i++) {
if (chatData.value[i].id === messageId) {
messageIndex = i
messageContent = chatData.value[i].content
break
}
}
if (messageIndex === -1) return
// 弹出编辑对话框
ElMessageBox.prompt('', '编辑消息', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: messageContent,
inputType: 'textarea',
customClass: 'edit-prompt-dialog',
roundButton: true,
})
.then(({ value }) => {
if (value.trim() === '') {
ElMessage.warning('消息内容不能为空')
return
}
// 更新用户消息
chatData.value[messageIndex].content = value
// 移除该消息之后的所有消息
chatData.value = chatData.value.slice(0, messageIndex + 1)
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value, role_id: roleId.value,
model_id: modelID.value, type: 'reply',
chat_id: chatId.value, id: randString(32),
content: text, icon: _role['icon'],
tools: toolSelected.value, content: '',
stream: stream.value, })
},
disableInput(false)
// 发送编辑后的消息
store.socket.conn.send(
JSON.stringify({
channel: 'chat',
type: 'text',
body: {
role_id: roleId.value,
model_id: modelID.value,
chat_id: chatId.value,
content: value,
tools: toolSelected.value,
stream: stream.value,
edit_message: true,
},
})
)
})
.catch(() => {
// 取消编辑
}) })
)
} }
const chatName = ref('') const chatName = ref('')
@@ -1296,11 +1363,11 @@ const realtimeChat = () => {
</script> </script>
<style scoped lang="stylus"> <style scoped lang="stylus">
@import "@/assets/css/chat-plus.styl" @import '../assets/css/chat-plus.styl'
</style> </style>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/markdown/vue.css'; @import '../assets/css/markdown/vue.css';
.notice-dialog { .notice-dialog {
.el-dialog__header { .el-dialog__header {
padding-bottom 0 padding-bottom 0

View File

@@ -288,16 +288,16 @@
<script setup> <script setup>
import nodata from '@/assets/img/no-data.png' import nodata from '@/assets/img/no-data.png'
import { nextTick, onMounted, onUnmounted, ref } from 'vue' import BackTop from '@/components/BackTop.vue'
import { Delete, InfoFilled, Picture } from '@element-plus/icons-vue' import TaskList from '@/components/TaskList.vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import Clipboard from 'clipboard'
import { checkSession, getSystemInfo } from '@/store/cache' import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import TaskList from '@/components/TaskList.vue'
import BackTop from '@/components/BackTop.vue'
import { showMessageError, showMessageOK } from '@/utils/dialog' import { showMessageError, showMessageOK } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { Delete, InfoFilled } 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 { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css' import 'vue-waterfall-plugin-next/dist/style.css'
@@ -585,7 +585,7 @@ const changeModel = (model) => {
} }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import '@/assets/css/image-dall.styl'; @import '../assets/css/image-dall.styl';
@import '@/assets/css/custom-scroll.styl'; @import '../assets/css/custom-scroll.styl';
</style> </style>

View File

@@ -168,7 +168,7 @@ const routerViewKey = ref(0)
const showConfigDialog = ref(false) const showConfigDialog = ref(false)
const license = ref({ de_copy: true }) const license = ref({ de_copy: true })
const showLoginDialog = ref(false) const showLoginDialog = ref(false)
const githubURL = ref(process.env.VUE_APP_GITHUB_URL) const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
/** /**
* 从路径名中提取第一个路径段 * 从路径名中提取第一个路径段
@@ -297,6 +297,6 @@ const loginSuccess = () => {
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/custom-scroll.styl" @import "../assets/css/custom-scroll.styl"
@import "@/assets/css/home.styl" @import "../assets/css/home.styl"
</style> </style>

View File

@@ -204,7 +204,10 @@
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
<div class="flex-row justify-start items-center"> <div class="flex-row justify-start items-center">
<span>如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h 例如: 1 cat --ar 21:9 </span> <span
>如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h
例如: 1 cat --ar 21:9
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -330,8 +333,11 @@
</el-tooltip> </el-tooltip>
</div> </div>
<div class="flex-row justify-start items-center"> <div class="flex-row justify-start items-center">
<span>如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h 例如: 1 cat --ar 21:9 </span> <span
</div> >如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h
例如: 1 cat --ar 21:9
</span>
</div>
</div> </div>
</div> </div>
@@ -545,7 +551,10 @@
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
<div class="flex-row justify-start items-center"> <div class="flex-row justify-start items-center">
<span>如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h 例如: 1 cat --ar 21:9 </span> <span
>如需自定义比例在绘画指令最后加一个空格然后加上指令(宽高比) --ar w:h
例如: 1 cat --ar 21:9
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1213,8 +1222,8 @@ const generate = () => {
return ElMessage.error('换脸操作需要上传两张图片') return ElMessage.error('换脸操作需要上传两张图片')
} }
const regex = /(^|\s)--ar\s+(\d+:\d+)/; const regex = /(^|\s)--ar\s+(\d+:\d+)/
const match = regex.exec(params.value.prompt); const match = regex.exec(params.value.prompt)
if (match) { if (match) {
params.value.rate = match[2] params.value.rate = match[2]
} }
@@ -1349,6 +1358,6 @@ const generatePrompt = () => {
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/image-mj.styl'; @import '../assets/css/image-mj.styl';
@import '@/assets/css/custom-scroll.styl'; @import '../assets/css/custom-scroll.styl';
</style> </style>

View File

@@ -471,21 +471,21 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { Delete, DocumentCopy, InfoFilled, Orange } from '@element-plus/icons-vue'
import nodata from '@/assets/img/no-data.png' import nodata from '@/assets/img/no-data.png'
import { Delete, InfoFilled, Orange } from '@element-plus/icons-vue'
import { onMounted, onUnmounted, ref } from 'vue'
import { httpGet, httpPost } from '@/utils/http' import BackTop from '@/components/BackTop.vue'
import { ElMessage, ElMessageBox } from 'element-plus' import SdTaskView from '@/components/SdTaskView.vue'
import Clipboard from 'clipboard' import TaskList from '@/components/TaskList.vue'
import { checkSession, getSystemInfo } from '@/store/cache' import { checkSession, getSystemInfo } from '@/store/cache'
import { useRouter } from 'vue-router'
import { getSessionId } from '@/store/session' import { getSessionId } from '@/store/session'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import TaskList from '@/components/TaskList.vue'
import BackTop from '@/components/BackTop.vue'
import { showMessageError } from '@/utils/dialog' import { showMessageError } from '@/utils/dialog'
import SdTaskView from '@/components/SdTaskView.vue' import { httpGet, httpPost } from '@/utils/http'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from 'vue-router'
import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next' import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import 'vue-waterfall-plugin-next/dist/style.css' import 'vue-waterfall-plugin-next/dist/style.css'
@@ -753,6 +753,6 @@ const generatePrompt = () => {
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/image-sd.styl'; @import '../assets/css/image-sd.styl';
@import '@/assets/css/custom-scroll.styl'; @import '../assets/css/custom-scroll.styl';
</style> </style>

View File

@@ -11,11 +11,7 @@
</el-radio-group> </el-radio-group>
</div> </div>
</div> </div>
<div <div class="waterfall" :style="{ height: listBoxHeight + 'px' }" id="waterfall-box">
class="waterfall"
:style="{ height: listBoxHeight + 'px' }"
id="waterfall-box"
>
<Waterfall <Waterfall
v-if="imgType === 'mj'" v-if="imgType === 'mj'"
id="waterfall-mj" id="waterfall-mj"
@@ -55,11 +51,7 @@
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50" class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
> >
<div class="opt"> <div class="opt">
<el-tooltip <el-tooltip class="box-item" content="复制提示词" placement="top">
class="box-item"
content="复制提示词"
placement="top"
>
<el-button <el-button
type="info" type="info"
circle circle
@@ -70,16 +62,8 @@
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip <el-tooltip class="box-item" content="画同款" placement="top">
class="box-item" <el-button type="primary" circle @click="drawSameMj(item)">
content="画同款"
placement="top"
>
<el-button
type="primary"
circle
@click="drawSameMj(item)"
>
<i class="iconfont icon-palette"></i> <i class="iconfont icon-palette"></i>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
@@ -129,11 +113,7 @@
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50" class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
> >
<div class="opt"> <div class="opt">
<el-tooltip <el-tooltip class="box-item" content="复制提示词" placement="top">
class="box-item"
content="复制提示词"
placement="top"
>
<el-button <el-button
type="info" type="info"
circle circle
@@ -144,16 +124,8 @@
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-tooltip <el-tooltip class="box-item" content="画同款" placement="top">
class="box-item" <el-button type="primary" circle @click="drawSameSd(item)">
content="画同款"
placement="top"
>
<el-button
type="primary"
circle
@click="drawSameSd(item)"
>
<i class="iconfont icon-palette"></i> <i class="iconfont icon-palette"></i>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
@@ -203,11 +175,7 @@
class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50" class="pt-3 flex justify-center items-center border-t border-t-gray-600 border-opacity-50"
> >
<div class="opt"> <div class="opt">
<el-tooltip <el-tooltip class="box-item" content="复制提示词" placement="top">
class="box-item"
content="复制提示词"
placement="top"
>
<el-button <el-button
type="info" type="info"
circle circle
@@ -261,7 +229,7 @@
<el-image-viewer <el-image-viewer
@close=" @close="
() => { () => {
previewURL = ''; previewURL = ''
} }
" "
v-if="previewURL !== ''" v-if="previewURL !== ''"
@@ -271,142 +239,139 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import BackTop from '@/components/BackTop.vue'
import { nextTick, onMounted, onUnmounted, ref } from "vue"; import SdTaskView from '@/components/SdTaskView.vue'
import { DocumentCopy, Picture } from "@element-plus/icons-vue"; import { useSharedStore } from '@/store/sharedata'
import { httpGet } from "@/utils/http"; import { httpGet } from '@/utils/http'
import { ElMessage } from "element-plus"; import Clipboard from 'clipboard'
import Clipboard from "clipboard"; import { ElMessage } from 'element-plus'
import { useRouter } from "vue-router"; import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import BackTop from "@/components/BackTop.vue"; import { useRouter } from 'vue-router'
import SdTaskView from "@/components/SdTaskView.vue"; import { LazyImg, Waterfall } from 'vue-waterfall-plugin-next'
import { LazyImg, Waterfall } from "vue-waterfall-plugin-next"; import 'vue-waterfall-plugin-next/dist/style.css'
import "vue-waterfall-plugin-next/dist/style.css";
import { useSharedStore } from "@/store/sharedata";
const store = useSharedStore(); const store = useSharedStore()
const waterfallOptions = store.waterfallOptions; const waterfallOptions = store.waterfallOptions
const data = ref({ const data = ref({
mj: [], mj: [],
sd: [], sd: [],
dall: [], dall: [],
}); })
const loading = ref(true); const loading = ref(true)
const isOver = ref(false); const isOver = ref(false)
const imgType = ref("mj"); // 图片类别 const imgType = ref('mj') // 图片类别
const listBoxHeight = window.innerHeight - 124; const listBoxHeight = window.innerHeight - 124
const showTaskDialog = ref(false); const showTaskDialog = ref(false)
const item = ref({}); const item = ref({})
const previewURL = ref(""); const previewURL = ref('')
const previewImg = (item) => { const previewImg = (item) => {
previewURL.value = item.img_url; previewURL.value = item.img_url
}; }
const page = ref(0); const page = ref(0)
const pageSize = ref(15); const pageSize = ref(15)
// 获取下一页数据 // 获取下一页数据
const getNext = () => { const getNext = () => {
if (isOver.value) { if (isOver.value) {
return; return
} }
loading.value = true; loading.value = true
page.value = page.value + 1; page.value = page.value + 1
let url = ""; let url = ''
switch (imgType.value) { switch (imgType.value) {
case "mj": case 'mj':
url = "/api/mj/imgWall"; url = '/api/mj/imgWall'
break; break
case "sd": case 'sd':
url = "/api/sd/imgWall"; url = '/api/sd/imgWall'
break; break
case "dall": case 'dall':
url = "/api/dall/imgWall"; url = '/api/dall/imgWall'
break; break
} }
httpGet(`${url}?page=${page.value}&page_size=${pageSize.value}`) httpGet(`${url}?page=${page.value}&page_size=${pageSize.value}`)
.then((res) => { .then((res) => {
if (!res.data.items || res.data.items.length === 0) { if (!res.data.items || res.data.items.length === 0) {
isOver.value = true; isOver.value = true
loading.value = false; loading.value = false
return; return
} }
// 生成缩略图 // 生成缩略图
const imageList = res.data.items; const imageList = res.data.items
for (let i = 0; i < imageList.length; i++) { for (let i = 0; i < imageList.length; i++) {
imageList[i]["img_thumb"] = imageList[i]['img_thumb'] = imageList[i]['img_url'] + '?imageView2/4/w/300/h/0/q/75'
imageList[i]["img_url"] + "?imageView2/4/w/300/h/0/q/75";
} }
if (data.value[imgType.value].length === 0) { if (data.value[imgType.value].length === 0) {
data.value[imgType.value] = imageList; data.value[imgType.value] = imageList
return; return
} }
if (imageList.length < pageSize.value) { if (imageList.length < pageSize.value) {
isOver.value = true; isOver.value = true
} }
data.value[imgType.value] = data.value[imgType.value].concat(imageList); data.value[imgType.value] = data.value[imgType.value].concat(imageList)
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取图片失败:" + e.message); ElMessage.error('获取图片失败:' + e.message)
loading.value = false; loading.value = false
}); })
}; }
getNext(); getNext()
const clipboard = ref(null); const clipboard = ref(null)
onMounted(() => { onMounted(() => {
clipboard.value = new Clipboard(".copy-prompt-wall"); clipboard.value = new Clipboard('.copy-prompt-wall')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
ElMessage.success("复制成功!"); ElMessage.success('复制成功!')
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
ElMessage.error("复制失败!"); ElMessage.error('复制失败!')
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
}); })
const changeImgType = () => { const changeImgType = () => {
console.log(imgType.value); console.log(imgType.value)
document.getElementById("waterfall-box").scrollTo(0, 0); document.getElementById('waterfall-box').scrollTo(0, 0)
page.value = 0; page.value = 0
data.value = { data.value = {
mj: [], mj: [],
sd: [], sd: [],
dall: [], dall: [],
}; }
loading.value = true; loading.value = true
isOver.value = false; isOver.value = false
nextTick(() => getNext()); nextTick(() => getNext())
}; }
const showTask = (row) => { const showTask = (row) => {
item.value = row; item.value = row
showTaskDialog.value = true; showTaskDialog.value = true
}; }
const router = useRouter(); const router = useRouter()
const drawSameSd = (row) => { const drawSameSd = (row) => {
router.push({ router.push({
name: "image-sd", name: 'image-sd',
params: { copyParams: JSON.stringify(row.params) }, params: { copyParams: JSON.stringify(row.params) },
}); })
}; }
const drawSameMj = (row) => { const drawSameMj = (row) => {
router.push({ name: "image-mj", params: { prompt: row.prompt } }); router.push({ name: 'image-mj', params: { prompt: row.prompt } })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/css/images-wall.styl'; @import '../assets/css/images-wall.styl';
@import '@/assets/css/custom-scroll.styl'; @import '../assets/css/custom-scroll.styl';
</style> </style>

View File

@@ -34,6 +34,15 @@
>登录/注册</el-button >登录/注册</el-button
> >
</span> </span>
<span v-if="isLogin">
<el-button
@click="logout"
class="btn-go animate__animated animate__pulse animate__infinite"
round
>
退出登录
</el-button>
</span>
</div> </div>
</el-menu> </el-menu>
</div> </div>
@@ -75,17 +84,17 @@
import FooterBar from '@/components/FooterBar.vue' import FooterBar from '@/components/FooterBar.vue'
import ThemeChange from '@/components/ThemeChange.vue' import ThemeChange from '@/components/ThemeChange.vue'
import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache' import { checkSession, getLicenseInfo, getSystemInfo } from '@/store/cache'
import { removeUserToken } from '@/store/session'
import { httpGet } from '@/utils/http' import { httpGet } from '@/utils/http'
import { isMobile } from '@/utils/libs'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
if (isMobile()) { // if (isMobile()) {
router.push('/mobile/index') // router.push('/mobile/index')
} // }
const title = ref('') const title = ref('')
const logo = ref('') const logo = ref('')
@@ -93,9 +102,9 @@ const slogan = ref('')
const license = ref({ de_copy: true }) const license = ref({ de_copy: true })
const isLogin = ref(false) const isLogin = ref(false)
const docsURL = ref(process.env.VUE_APP_DOCS_URL) const docsURL = ref(import.meta.env.VITE_DOCS_URL)
const githubURL = ref(process.env.VUE_APP_GITHUB_URL) const githubURL = ref(import.meta.env.VITE_GITHUB_URL)
const giteeURL = ref(process.env.VUE_APP_GITEE_URL) const giteeURL = ref(import.meta.env.VITE_GITEE_URL)
const navs = ref([]) const navs = ref([])
const iconMap = ref({ const iconMap = ref({
@@ -195,8 +204,19 @@ const rainbowColor = (index) => {
const hue = (index * 40) % 360 // 每个字符间隔40度形成彩虹色 const hue = (index * 40) % 360 // 每个字符间隔40度形成彩虹色
return `hsl(${hue}, 90%, 50%)` // 色调(hue),饱和度(70%),亮度(50%) return `hsl(${hue}, 90%, 50%)` // 色调(hue),饱和度(70%),亮度(50%)
} }
const logout = function () {
httpGet('/api/user/logout')
.then(() => {
removeUserToken()
router.push('/login')
})
.catch((e) => {
ElMessage.error('注销失败:' + e.message)
})
}
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/index.styl" @import '../assets/css/index.styl'
</style> </style>

View File

@@ -7,7 +7,8 @@
<div class="info"> <div class="info">
我们非常欢迎您把此应用分享给您身边的朋友分享成功注册后您和被邀请人都将获得 我们非常欢迎您把此应用分享给您身边的朋友分享成功注册后您和被邀请人都将获得
<strong>{{ invitePower }}</strong> <strong>{{ invitePower }}</strong>
算力额度作为奖励 你可以保存下面的二维码或者直接复制分享您的专属推广链接发送给微信好友 算力额度作为奖励
你可以保存下面的二维码或者直接复制分享您的专属推广链接发送给微信好友
</div> </div>
<div class="invite-qrcode"> <div class="invite-qrcode">
@@ -16,7 +17,9 @@
<div class="invite-url"> <div class="invite-url">
<span>{{ inviteURL }}</span> <span>{{ inviteURL }}</span>
<el-button type="primary" plain class="copy-link" :data-clipboard-text="inviteURL">复制链接</el-button> <el-button type="primary" plain class="copy-link" :data-clipboard-text="inviteURL"
>复制链接</el-button
>
</div> </div>
</div> </div>
@@ -88,79 +91,79 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from "vue"; import InviteList from '@/components/InviteList.vue'
import QRCode from "qrcode"; import { checkSession, getSystemInfo } from '@/store/cache'
import { httpGet } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import { ElMessage } from "element-plus"; import { httpGet } from '@/utils/http'
import Clipboard from "clipboard"; import Clipboard from 'clipboard'
import InviteList from "@/components/InviteList.vue"; import { ElMessage } from 'element-plus'
import { checkSession, getSystemInfo } from "@/store/cache"; import QRCode from 'qrcode'
import { useSharedStore } from "@/store/sharedata"; import { onMounted, ref } from 'vue'
const inviteURL = ref(""); const inviteURL = ref('')
const qrImg = ref("/images/wx.png"); const qrImg = ref('/images/wx.png')
const invitePower = ref(0); const invitePower = ref(0)
const hits = ref(0); const hits = ref(0)
const regNum = ref(0); const regNum = ref(0)
const rate = ref(0); const rate = ref(0)
const isLogin = ref(false); const isLogin = ref(false)
const store = useSharedStore(); const store = useSharedStore()
onMounted(() => { onMounted(() => {
initData(); initData()
// 复制链接 // 复制链接
const clipboard = new Clipboard(".copy-link"); const clipboard = new Clipboard('.copy-link')
clipboard.on("success", () => { clipboard.on('success', () => {
ElMessage.success("复制成功!"); ElMessage.success('复制成功!')
}); })
clipboard.on("error", () => { clipboard.on('error', () => {
ElMessage.error("复制失败!"); ElMessage.error('复制失败!')
}); })
}); })
const initData = () => { const initData = () => {
checkSession() checkSession()
.then(() => { .then(() => {
isLogin.value = true; isLogin.value = true
httpGet("/api/invite/code") httpGet('/api/invite/code')
.then((res) => { .then((res) => {
const text = `${location.protocol}//${location.host}/register?invite_code=${res.data.code}`; const text = `${location.protocol}//${location.host}/register?invite_code=${res.data.code}`
hits.value = res.data["hits"]; hits.value = res.data['hits']
regNum.value = res.data["reg_num"]; regNum.value = res.data['reg_num']
if (hits.value > 0) { if (hits.value > 0) {
rate.value = ((regNum.value / hits.value) * 100).toFixed(2); rate.value = ((regNum.value / hits.value) * 100).toFixed(2)
} }
QRCode.toDataURL(text, { width: 400, height: 400, margin: 2 }, (error, url) => { QRCode.toDataURL(text, { width: 400, height: 400, margin: 2 }, (error, url) => {
if (error) { if (error) {
console.error(error); console.error(error)
} else { } else {
qrImg.value = url; qrImg.value = url
} }
}); })
inviteURL.value = text; inviteURL.value = text
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取邀请码失败:" + e.message); ElMessage.error('获取邀请码失败:' + e.message)
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
invitePower.value = res.data["invite_power"]; invitePower.value = res.data['invite_power']
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message); ElMessage.error('获取系统配置失败:' + e.message)
}); })
}) })
.catch(() => { .catch(() => {
store.setShowLoginDialog(true); store.setShowLoginDialog(true)
}); })
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/custom-scroll.styl" @import '../assets/css/custom-scroll.styl'
.page-invitation { .page-invitation {
display: flex; display: flex;
justify-content: center; justify-content: center;

View File

@@ -22,7 +22,9 @@
<el-col :span="8" v-for="item in rates" :key="item.value"> <el-col :span="8" v-for="item in rates" :key="item.value">
<div <div
class="flex-col items-center" class="flex-col items-center"
:class="item.value === params.aspect_ratio ? 'grid-content active' : 'grid-content'" :class="
item.value === params.aspect_ratio ? 'grid-content active' : 'grid-content'
"
@click="changeRate(item)" @click="changeRate(item)"
> >
<el-image class="icon proportion" :src="item.img" fit="cover"></el-image> <el-image class="icon proportion" :src="item.img" fit="cover"></el-image>
@@ -35,8 +37,17 @@
<!-- 模型选择 --> <!-- 模型选择 -->
<div class="param-line"> <div class="param-line">
<el-form-item label="模型选择"> <el-form-item label="模型选择">
<el-select v-model="params.model" placeholder="请选择模型" @change="updateModelPower"> <el-select
<el-option v-for="item in models" :key="item.value" :label="item.text" :value="item.value" /> v-model="params.model"
placeholder="请选择模型"
@change="updateModelPower"
>
<el-option
v-for="item in models"
:key="item.value"
:label="item.text"
:value="item.value"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</div> </div>
@@ -44,7 +55,11 @@
<!-- 视频时长 --> <!-- 视频时长 -->
<div class="param-line"> <div class="param-line">
<el-form-item label="视频时长"> <el-form-item label="视频时长">
<el-select v-model="params.duration" placeholder="请选择时长" @change="updateModelPower"> <el-select
v-model="params.duration"
placeholder="请选择时长"
@change="updateModelPower"
>
<el-option label="5秒" value="5" /> <el-option label="5秒" value="5" />
<el-option label="10秒" value="10" /> <el-option label="10秒" value="10" />
</el-select> </el-select>
@@ -54,7 +69,11 @@
<!-- 生成模式 --> <!-- 生成模式 -->
<div class="param-line"> <div class="param-line">
<el-form-item label="生成模式"> <el-form-item label="生成模式">
<el-select v-model="params.mode" placeholder="请选择模式" @change="updateModelPower"> <el-select
v-model="params.mode"
placeholder="请选择模式"
@change="updateModelPower"
>
<el-option label="标准模式" value="std" /> <el-option label="标准模式" value="std" />
<el-option label="专业模式" value="pro" /> <el-option label="专业模式" value="pro" />
</el-select> </el-select>
@@ -94,7 +113,11 @@
<!-- 仅在simple模式下显示详细配置 --> <!-- 仅在simple模式下显示详细配置 -->
<div class="camera-control mt-2" v-if="params.camera_control.type === 'simple'"> <div class="camera-control mt-2" v-if="params.camera_control.type === 'simple'">
<el-form-item label="水平移动"> <el-form-item label="水平移动">
<el-slider v-model="params.camera_control.config.horizontal" :min="-10" :max="10" /> <el-slider
v-model="params.camera_control.config.horizontal"
:min="-10"
:max="10"
/>
</el-form-item> </el-form-item>
<el-form-item label="垂直移动"> <el-form-item label="垂直移动">
<el-slider v-model="params.camera_control.config.vertical" :min="-10" :max="10" /> <el-slider v-model="params.camera_control.config.vertical" :min="-10" :max="10" />
@@ -126,7 +149,10 @@
<div class="text">使用文字描述想要生成视频的内容</div> <div class="text">使用文字描述想要生成视频的内容</div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="图生视频" name="image2video"> <el-tab-pane label="图生视频" name="image2video">
<div class="text">以某张图片为底稿参考来创作视频生成类似风格或类型视频支持 PNG /JPG/JPEG 格式图片</div> <div class="text">
以某张图片为底稿参考来创作视频生成类似风格或类型视频支持 PNG /JPG/JPEG
格式图片
</div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
</div> </div>
@@ -142,7 +168,13 @@
placeholder="请在此输入视频提示词,您也可以点击下面的提示词助手生成视频提示词" placeholder="请在此输入视频提示词,您也可以点击下面的提示词助手生成视频提示词"
/> />
<el-row class="text-info"> <el-row class="text-info">
<el-button class="generate-btn" @click="generatePrompt" :loading="isGenerating" size="small" color="#5865f2"> <el-button
class="generate-btn"
@click="generatePrompt"
:loading="isGenerating"
size="small"
color="#5865f2"
>
<i class="iconfont icon-chuangzuo"></i> <i class="iconfont icon-chuangzuo"></i>
生成专业视频提示词 生成专业视频提示词
</el-button> </el-button>
@@ -152,10 +184,18 @@
<div v-else class="image2video"> <div v-else class="image2video">
<div class="image-upload img-inline"> <div class="image-upload img-inline">
<div class="upload-box img-uploader video-img-box"> <div class="upload-box img-uploader video-img-box">
<el-icon v-if="params.image" @click="removeImage('start')" class="removeimg"><CircleCloseFilled /></el-icon> <el-icon v-if="params.image" @click="removeImage('start')" class="removeimg"
><CircleCloseFilled
/></el-icon>
<h4>起始帧</h4> <h4>起始帧</h4>
<el-upload class="uploader img-uploader" :auto-upload="true" :show-file-list="false" :http-request="uploadStartImage" accept=".jpg,.png,.jpeg"> <el-upload
class="uploader img-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadStartImage"
accept=".jpg,.png,.jpeg"
>
<img v-if="params.image" :src="params.image" class="preview" /> <img v-if="params.image" :src="params.image" class="preview" />
<el-icon v-else class="upload-icon"><Plus /></el-icon> <el-icon v-else class="upload-icon"><Plus /></el-icon>
@@ -165,9 +205,17 @@
<i class="iconfont icon-exchange" @click="switchReverse"></i> <i class="iconfont icon-exchange" @click="switchReverse"></i>
</div> </div>
<div class="upload-box img-uploader video-img-box"> <div class="upload-box img-uploader video-img-box">
<el-icon v-if="params.image_tail" @click="removeImage('end')" class="removeimg"><CircleCloseFilled /></el-icon> <el-icon v-if="params.image_tail" @click="removeImage('end')" class="removeimg"
><CircleCloseFilled
/></el-icon>
<h4>结束帧</h4> <h4>结束帧</h4>
<el-upload class="uploader" :auto-upload="true" :show-file-list="false" :http-request="uploadEndImage" accept=".jpg,.png,.jpeg"> <el-upload
class="uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadEndImage"
accept=".jpg,.png,.jpeg"
>
<img v-if="params.image_tail" :src="params.image_tail" class="preview" /> <img v-if="params.image_tail" :src="params.image_tail" class="preview" />
<el-icon v-else class="upload-icon"><Plus /></el-icon> <el-icon v-else class="upload-icon"><Plus /></el-icon>
</el-upload> </el-upload>
@@ -186,7 +234,12 @@
</div> </div>
</div> </div>
<div class="param-line pt"> <div class="param-line pt">
<el-input v-model="params.prompt" type="textarea" :autosize="{ minRows: 4, maxRows: 6 }" placeholder="描述视频画面细节" /> <el-input
v-model="params.prompt"
type="textarea"
:autosize="{ minRows: 4, maxRows: 6 }"
placeholder="描述视频画面细节"
/>
</div> </div>
</div> </div>
@@ -238,8 +291,19 @@
<div class="left"> <div class="left">
<div class="container"> <div class="container">
<div v-if="item.progress === 100"> <div v-if="item.progress === 100">
<video class="video" :src="item.video_url" preload="auto" loop="loop" muted="muted">您的浏览器不支持视频播放</video> <video
<button class="play flex justify-center items-center" @click="previewVideo(item)"> class="video"
:src="item.video_url"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button
class="play flex justify-center items-center"
@click="previewVideo(item)"
>
<img src="/images/play.svg" alt="" /> <img src="/images/play.svg" alt="" />
</button> </button>
</div> </div>
@@ -255,7 +319,9 @@
<el-tag class="mr-1">{{ item.raw_data.duration }}</el-tag> <el-tag class="mr-1">{{ item.raw_data.duration }}</el-tag>
<el-tag class="mr-1">{{ item.raw_data.mode }}</el-tag> <el-tag class="mr-1">{{ item.raw_data.mode }}</el-tag>
</div> </div>
<div class="failed" v-if="item.progress === 101">任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}</div> <div class="failed" v-if="item.progress === 101">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else> <div class="prompt" v-else>
{{ substr(item.prompt, 1000) }} {{ substr(item.prompt, 1000) }}
</div> </div>
@@ -275,9 +341,18 @@
</button> --> </button> -->
<el-tooltip content="下载视频" placement="top"> <el-tooltip content="下载视频" placement="top">
<button class="btn btn-icon" @click="downloadVideo(item)" :disabled="item.downloading"> <button
class="btn btn-icon"
@click="downloadVideo(item)"
:disabled="item.downloading"
>
<i class="iconfont icon-download" v-if="!item.downloading"></i> <i class="iconfont icon-download" v-if="!item.downloading"></i>
<el-image src="/images/loading.gif" class="downloading" fit="cover" v-else /> <el-image
src="/images/loading.gif"
class="downloading"
fit="cover"
v-else
/>
</button> </button>
</el-tooltip> </el-tooltip>
@@ -298,7 +373,12 @@
</div> </div>
</div> </div>
<el-empty :image-size="100" :image="nodata" description="没有任何作品,赶紧去创作吧!" v-else /> <el-empty
:image-size="100"
:image="nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
@@ -318,7 +398,13 @@
</div> </div>
<!-- 视频预览对话框 --> <!-- 视频预览对话框 -->
<black-dialog v-model:show="previewVisible" title="视频预览" hide-footer @cancal="previewVisible = false" width="auto"> <black-dialog
v-model:show="previewVisible"
title="视频预览"
hide-footer
@cancal="previewVisible = false"
width="auto"
>
<video <video
v-if="currentVideo" v-if="currentVideo"
:src="currentVideo" :src="currentVideo"
@@ -336,47 +422,44 @@
</template> </template>
<script setup> <script setup>
import failed from "@/assets/img/failed.png"; import nodata from '@/assets/img/no-data.png'
import TaskList from "@/components/TaskList.vue"; import BlackDialog from '@/components/ui/BlackDialog.vue'
import { ref, reactive, onMounted, onUnmounted, watch } from "vue"; import Generating from '@/components/ui/Generating.vue'
import { Plus, Delete, InfoFilled, ChromeFilled, DocumentCopy, Download, WarnTriangleFilled, CircleCloseFilled } from "@element-plus/icons-vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import { httpGet, httpPost, httpDownload } from "@/utils/http"; import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { ElMessage, ElMessageBox } from "element-plus"; import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { checkSession, getSystemInfo } from "@/store/cache"; import { replaceImg, substr } from '@/utils/libs'
import Clipboard from "clipboard"; import { CircleCloseFilled, InfoFilled, Plus } from '@element-plus/icons-vue'
import BlackDialog from "@/components/ui/BlackDialog.vue"; import Clipboard from 'clipboard'
import BlackSwitch from "@/components/ui/BlackSwitch.vue"; import { ElMessage, ElMessageBox } from 'element-plus'
import { closeLoading, showLoading, showMessageError, showMessageOK } from "@/utils/dialog"; import { onMounted, onUnmounted, reactive, ref } from 'vue'
import { replaceImg, substr } from "@/utils/libs";
import Generating from "@/components/ui/Generating.vue";
import nodata from "@/assets/img/no-data.png";
const models = ref([ const models = ref([
{ {
text: "可灵 1.6", text: '可灵 1.6',
value: "kling-v1-6", value: 'kling-v1-6',
}, },
{ {
text: "可灵 1.5", text: '可灵 1.5',
value: "kling-v1-5", value: 'kling-v1-5',
}, },
{ {
text: "可灵 1.0", text: '可灵 1.0',
value: "kling-v1", value: 'kling-v1',
}, },
]); ])
// 参数设置 // 参数设置
const params = reactive({ const params = reactive({
task_type: "text2video", task_type: 'text2video',
model: models.value[0].value, model: models.value[0].value,
prompt: "", prompt: '',
negative_prompt: "", negative_prompt: '',
cfg_scale: 0.7, cfg_scale: 0.7,
mode: "std", mode: 'std',
aspect_ratio: "16:9", aspect_ratio: '16:9',
duration: "5", duration: '5',
camera_control: { camera_control: {
type: "", type: '',
config: { config: {
horizontal: 0, horizontal: 0,
vertical: 0, vertical: 0,
@@ -386,142 +469,142 @@ const params = reactive({
zoom: 0, zoom: 0,
}, },
}, },
image: "", image: '',
image_tail: "", image_tail: '',
}); })
const rates = [ const rates = [
{ css: "square", value: "1:1", text: "1:1", img: "/images/mj/rate_1_1.png" }, { css: 'square', value: '1:1', text: '1:1', img: '/images/mj/rate_1_1.png' },
{ {
css: "size16-9", css: 'size16-9',
value: "16:9", value: '16:9',
text: "16:9", text: '16:9',
img: "/images/mj/rate_16_9.png", img: '/images/mj/rate_16_9.png',
}, },
{ {
css: "size9-16", css: 'size9-16',
value: "9:16", value: '9:16',
text: "9:16", text: '9:16',
img: "/images/mj/rate_9_16.png", img: '/images/mj/rate_9_16.png',
}, },
]; ]
// 切换图片比例 // 切换图片比例
const changeRate = (item) => { const changeRate = (item) => {
params.aspect_ratio = item.value; params.aspect_ratio = item.value
}; }
const generating = ref(false); const generating = ref(false)
const isGenerating = ref(false); const isGenerating = ref(false)
const powerCost = ref(10); const powerCost = ref(10)
const availablePower = ref(100); const availablePower = ref(100)
const taskFilter = ref("all"); const taskFilter = ref('all')
const loading = ref(false); const loading = ref(false)
const list = ref([]); const list = ref([])
const noData = ref(true); const noData = ref(true)
const page = ref(1); const page = ref(1)
const pageSize = ref(10); const pageSize = ref(10)
const total = ref(0); const total = ref(0)
const taskPulling = ref(true); const taskPulling = ref(true)
const pullHandler = ref(null); const pullHandler = ref(null)
const previewVisible = ref(false); const previewVisible = ref(false)
const currentVideo = ref(""); const currentVideo = ref('')
const showCameraControl = ref(false); const showCameraControl = ref(false)
const keLingPowers = ref({}); const keLingPowers = ref({})
const isLogin = ref(false); const isLogin = ref(false)
// 动态更新模型消耗的算力 // 动态更新模型消耗的算力
const updateModelPower = () => { const updateModelPower = () => {
showCameraControl.value = params.model === "kling-v1-5" && params.mode === "pro"; showCameraControl.value = params.model === 'kling-v1-5' && params.mode === 'pro'
powerCost.value = keLingPowers.value[`${params.model}_${params.mode}_${params.duration}`] || {}; powerCost.value = keLingPowers.value[`${params.model}_${params.mode}_${params.duration}`] || {}
}; }
// tab切换 // tab切换
const tabChange = (tab) => { const tabChange = (tab) => {
params.task_type = tab; params.task_type = tab
}; }
const uploadStartImage = async (file) => { const uploadStartImage = async (file) => {
const formData = new FormData(); const formData = new FormData()
formData.append("file", file.file); formData.append('file', file.file)
try { try {
showLoading("图片上传中..."); showLoading('图片上传中...')
const res = await httpPost("/api/upload", formData); const res = await httpPost('/api/upload', formData)
params.image = res.data.url; params.image = res.data.url
ElMessage.success("上传成功"); ElMessage.success('上传成功')
closeLoading(); closeLoading()
} catch (e) { } catch (e) {
showMessageError("上传失败: " + e.message); showMessageError('上传失败: ' + e.message)
closeLoading(); closeLoading()
} }
}; }
//移除图片 //移除图片
const removeImage = (type) => { const removeImage = (type) => {
if (type === "start") { if (type === 'start') {
params.image = ""; params.image = ''
} else if (type === "end") { } else if (type === 'end') {
params.image_tail = ""; params.image_tail = ''
} }
}; }
//图片交换方法 //图片交换方法
const switchReverse = () => { const switchReverse = () => {
[params.image, params.image_tail] = [params.image_tail, params.image]; ;[params.image, params.image_tail] = [params.image_tail, params.image]
}; }
const uploadEndImage = async (file) => { const uploadEndImage = async (file) => {
const formData = new FormData(); const formData = new FormData()
formData.append("file", file.file); formData.append('file', file.file)
try { try {
const res = await httpPost("/api/upload", formData); const res = await httpPost('/api/upload', formData)
params.image_tail = res.data.url; params.image_tail = res.data.url
ElMessage.success("上传成功"); ElMessage.success('上传成功')
} catch (e) { } catch (e) {
showMessageError("上传失败: " + e.message); showMessageError('上传失败: ' + e.message)
} }
}; }
const generatePrompt = async () => { const generatePrompt = async () => {
if (isGenerating.value) return; if (isGenerating.value) return
if (!params.prompt) { if (!params.prompt) {
return showMessageError("请输入视频描述"); return showMessageError('请输入视频描述')
} }
isGenerating.value = true; isGenerating.value = true
try { try {
const res = await httpPost("/api/prompt/video", { prompt: params.prompt }); const res = await httpPost('/api/prompt/video', { prompt: params.prompt })
params.prompt = res.data; params.prompt = res.data
} catch (e) { } catch (e) {
showMessageError("生成失败: " + e.message); showMessageError('生成失败: ' + e.message)
} finally { } finally {
isGenerating.value = false; isGenerating.value = false
} }
}; }
const generate = async () => { const generate = async () => {
//增加防抖 //增加防抖
if (generating.value) return; if (generating.value) return
if (!params.prompt?.trim()) { if (!params.prompt?.trim()) {
return ElMessage.error("请输入视频描述"); return ElMessage.error('请输入视频描述')
} }
// 提示词长度不能超过 500 // 提示词长度不能超过 500
if (params.prompt.length > 500) { if (params.prompt.length > 500) {
return ElMessage.error("视频描述不能超过 500 个字符"); return ElMessage.error('视频描述不能超过 500 个字符')
} }
if (params.task_type === "image2video" && !params.image) { if (params.task_type === 'image2video' && !params.image) {
return ElMessage.error("请上传起始帧图片"); return ElMessage.error('请上传起始帧图片')
} }
generating.value = true; generating.value = true
// 处理图片链接 // 处理图片链接
if (params.image) { if (params.image) {
params.image = replaceImg(params.image); params.image = replaceImg(params.image)
} }
if (params.image_tail) { if (params.image_tail) {
params.image_tail = replaceImg(params.image_tail); params.image_tail = replaceImg(params.image_tail)
} }
try { try {
await httpPost("/api/video/keling/create", params); await httpPost('/api/video/keling/create', params)
showMessageOK("任务创建成功"); showMessageOK('任务创建成功')
// 新增重置 // 新增重置
page.value = 1; page.value = 1
list.value.unshift({ list.value.unshift({
progress: 0, progress: 0,
prompt: params.prompt, prompt: params.prompt,
@@ -531,132 +614,132 @@ const generate = async () => {
duration: params.duration, duration: params.duration,
mode: params.mode, mode: params.mode,
}, },
}); })
taskPulling.value = true; taskPulling.value = true
} catch (e) { } catch (e) {
showMessageError("创建失败: " + e.message); showMessageError('创建失败: ' + e.message)
} finally { } finally {
generating.value = false; generating.value = false
} }
}; }
const fetchData = (_page) => { const fetchData = (_page) => {
if (_page) { if (_page) {
page.value = _page; page.value = _page
} }
httpGet("/api/video/list", { httpGet('/api/video/list', {
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
type: "keling", type: 'keling',
task_type: taskFilter.value === "all" ? "" : taskFilter.value, task_type: taskFilter.value === 'all' ? '' : taskFilter.value,
}) })
.then((res) => { .then((res) => {
total.value = res.data.total; total.value = res.data.total
let needPull = false; let needPull = false
const items = []; const items = []
for (let v of res.data.items) { for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) { if (v.progress === 0 || v.progress === 102) {
needPull = true; needPull = true
} }
items.push({ items.push({
...v, ...v,
downloading: false, downloading: false,
}); })
} }
loading.value = false; loading.value = false
taskPulling.value = needPull; taskPulling.value = needPull
if (JSON.stringify(list.value) !== JSON.stringify(items)) { if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items; list.value = items
} }
noData.value = list.value.length === 0; noData.value = list.value.length === 0
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false
noData.value = true; noData.value = true
}); })
}; }
const previewVideo = (task) => { const previewVideo = (task) => {
currentVideo.value = task.video_url; currentVideo.value = task.video_url
previewVisible.value = true; previewVisible.value = true
}; }
const downloadVideo = async (task) => { const downloadVideo = async (task) => {
try { try {
const res = await httpDownload(`/api/download?url=${replaceImg(task.video_url)}`); const res = await httpDownload(`/api/download?url=${replaceImg(task.video_url)}`)
const blob = new Blob([res.data]); const blob = new Blob([res.data])
const link = document.createElement("a"); const link = document.createElement('a')
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob)
link.download = `video_${task.id}.mp4`; link.download = `video_${task.id}.mp4`
link.click(); link.click()
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href)
} catch (e) { } catch (e) {
showMessageError("下载失败: " + e.message); showMessageError('下载失败: ' + e.message)
} }
}; }
// 删除任务 // 删除任务
const removeJob = (item) => { const removeJob = (item) => {
ElMessageBox.confirm("此操作将会删除任务相关文件,继续操作码?", "删除提示", { ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: "确认", confirmButtonText: '确认',
cancelButtonText: "取消", cancelButtonText: '取消',
type: "warning", type: 'warning',
}) })
.then(() => { .then(() => {
httpGet("/api/video/remove", { id: item.id }) httpGet('/api/video/remove', { id: item.id })
.then(() => { .then(() => {
ElMessage.success("任务删除成功"); ElMessage.success('任务删除成功')
fetchData(page.value); fetchData(page.value)
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("任务删除失败:" + e.message); ElMessage.error('任务删除失败:' + e.message)
}); })
}) })
.catch(() => {}); .catch(() => {})
}; }
const clipboard = ref(null); const clipboard = ref(null)
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
checkSession() checkSession()
.then((u) => { .then((u) => {
isLogin.value = true; isLogin.value = true
availablePower.value = u.power; availablePower.value = u.power
fetchData(1); fetchData(1)
// 设置轮询 // 设置轮询
pullHandler.value = setInterval(() => { pullHandler.value = setInterval(() => {
if (taskPulling.value) { if (taskPulling.value) {
fetchData(page.value); fetchData(page.value)
} }
}, 5000); }, 5000)
}) })
.catch((e) => { .catch((e) => {
console.log(e); console.log(e)
}); })
clipboard.value = new Clipboard(".copy-prompt"); clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
ElMessage.success("复制成功!"); ElMessage.success('复制成功!')
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
ElMessage.error("复制失败!"); ElMessage.error('复制失败!')
}); })
getSystemInfo().then((res) => { getSystemInfo().then((res) => {
keLingPowers.value = res.data.keling_powers; keLingPowers.value = res.data.keling_powers
updateModelPower(); updateModelPower()
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (pullHandler.value) { if (pullHandler.value) {
clearInterval(pullHandler.value); clearInterval(pullHandler.value)
} }
}); })
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/keling.styl" @import '../assets/css/keling.styl'
</style> </style>

View File

@@ -40,6 +40,18 @@
@keyup="handleKeyup" @keyup="handleKeyup"
/> />
</el-form-item> </el-form-item>
<el-form-item label="" prop="agreement" :class="{ 'agreement-error': agreementError }">
<div class="agreement-box" :class="{ shake: isShaking }">
<el-checkbox v-model="ruleForm.agreement" @change="handleAgreementChange">
我已阅读并同意
<span class="agreement-link" @click.stop.prevent="openAgreement"
>用户协议</span
>
<span class="agreement-link" @click.stop.prevent="openPrivacy">隐私政策</span>
</el-checkbox>
</div>
</el-form-item>
<el-form-item> <el-form-item>
<el-button class="login-btn" size="large" type="primary" @click="login" <el-button class="login-btn" size="large" type="primary" @click="login"
>登录</el-button >登录</el-button
@@ -63,6 +75,8 @@ import { useSharedStore } from '@/store/sharedata'
import { setRoute } from '@/store/system' import { setRoute } from '@/store/system'
import { showMessageError } from '@/utils/dialog' import { showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http' import { httpGet, httpPost } from '@/utils/http'
import { ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -71,7 +85,6 @@ import Captcha from '@/components/Captcha.vue'
const router = useRouter() const router = useRouter()
const title = ref('') const title = ref('')
const logo = ref('') const logo = ref('')
const licenseConfig = ref({}) const licenseConfig = ref({})
const wechatLoginURL = ref('') const wechatLoginURL = ref('')
@@ -79,13 +92,24 @@ const enableVerify = ref(false)
const captchaRef = ref(null) const captchaRef = ref(null)
const ruleFormRef = ref(null) const ruleFormRef = ref(null)
const ruleForm = reactive({ const ruleForm = reactive({
username: process.env.VUE_APP_USER, username: import.meta.env.VITE_USER,
password: process.env.VUE_APP_PASS, password: import.meta.env.VITE_PASS,
agreement: false,
}) })
const rules = { const rules = {
username: [{ required: true, trigger: 'blur', message: '请输入账号' }], username: [{ required: true, trigger: 'blur', message: '请输入账号' }],
password: [{ required: true, trigger: 'blur', message: '请输入密码' }], password: [{ required: true, trigger: 'blur', message: '请输入密码' }],
agreement: [{ required: true, trigger: 'change', message: '请同意用户协议' }],
} }
const agreementContent = ref('')
const privacyContent = ref('')
// 初始化markdown解析器
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
onMounted(() => { onMounted(() => {
// 检查URL中是否存在token参数 // 检查URL中是否存在token参数
@@ -110,6 +134,36 @@ onMounted(() => {
title.value = 'Geek-AI' title.value = 'Geek-AI'
}) })
// 获取用户协议
httpGet('/api/config/get?key=agreement')
.then((res) => {
if (res.data && res.data.content) {
agreementContent.value = res.data.content
} else {
agreementContent.value =
'用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。'
}
})
.catch((e) => {
agreementContent.value =
'用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。'
})
// 获取隐私政策
httpGet('/api/config/get?key=privacy')
.then((res) => {
if (res.data && res.data.content) {
privacyContent.value = res.data.content
} else {
privacyContent.value =
'我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
}
})
.catch((e) => {
privacyContent.value =
'我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时,我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
})
getLicenseInfo() getLicenseInfo()
.then((res) => { .then((res) => {
licenseConfig.value = res.data licenseConfig.value = res.data
@@ -141,6 +195,16 @@ const handleKeyup = (e) => {
} }
const login = async function () { const login = async function () {
if (!ruleForm.agreement) {
agreementError.value = true
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
showMessageError('请先阅读并同意用户协议')
return
}
await ruleFormRef.value.validate(async (valid) => { await ruleFormRef.value.validate(async (valid) => {
if (valid) { if (valid) {
if (enableVerify.value) { if (enableVerify.value) {
@@ -170,8 +234,127 @@ const doLogin = (verifyData) => {
showMessageError('登录失败,' + e.message) showMessageError('登录失败,' + e.message)
}) })
} }
const agreementError = ref(false)
const isShaking = ref(false)
const handleAgreementChange = () => {
agreementError.value = !ruleForm.agreement
if (agreementError.value) {
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
}
}
const openAgreement = () => {
// 使用弹窗显示用户协议内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(agreementContent.value)}</div>`,
'用户协议',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
const openPrivacy = () => {
// 使用弹窗显示隐私政策内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(privacyContent.value)}</div>`,
'隐私政策',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/login.styl" @import '../assets/css/login.styl'
.agreement-box
margin-bottom: 10px
transition: all 0.3s
.agreement-link
color: var(--el-color-primary)
cursor: pointer
.agreement-error
.el-checkbox
.el-checkbox__input
.el-checkbox__inner
border-color: #F56C6C !important
.shake
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both
@keyframes shake
10%, 90%
transform: translate3d(-1px, 0, 0)
20%, 80%
transform: translate3d(2px, 0, 0)
30%, 50%, 70%
transform: translate3d(-4px, 0, 0)
40%, 60%
transform: translate3d(4px, 0, 0)
</style>
<style>
/* 全局样式用于Markdown内容显示 */
.markdown-content {
text-align: left;
max-height: 60vh;
overflow-y: auto;
padding: 10px;
}
.markdown-content h1 {
font-size: 1.5em;
margin-bottom: 15px;
}
.markdown-content h2 {
font-size: 1.3em;
margin: 15px 0 10px;
}
.markdown-content p {
margin-bottom: 10px;
line-height: 1.5;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin-bottom: 10px;
}
.markdown-content blockquote {
border-left: 4px solid #ccc;
padding-left: 10px;
color: #666;
margin: 10px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
</style> </style>

View File

@@ -15,11 +15,25 @@
<div class="prompt-container"> <div class="prompt-container">
<div class="input-container"> <div class="input-container">
<div class="upload-icon" v-if="images.length < 2"> <div class="upload-icon" v-if="images.length < 2">
<el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false" :http-request="upload" accept=".jpg,.png,.jpeg"> <el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="upload"
accept=".jpg,.png,.jpeg"
>
<i class="iconfont icon-image"></i> <i class="iconfont icon-image"></i>
</el-upload> </el-upload>
</div> </div>
<textarea class="prompt-input" :rows="row" v-model="formData.prompt" maxlength="2000" placeholder="请输入提示词或者上传图片" autofocus> </textarea> <textarea
class="prompt-input"
:rows="row"
v-model="formData.prompt"
maxlength="2000"
placeholder="请输入提示词或者上传图片"
autofocus
>
</textarea>
<div class="send-icon" @click="create"> <div class="send-icon" @click="create">
<i class="iconfont icon-send"></i> <i class="iconfont icon-send"></i>
</div> </div>
@@ -44,7 +58,11 @@
</div> </div>
</div> </div>
<el-container class="video-container" v-loading="loading" element-loading-background="rgba(100,100,100,0.3)"> <el-container
class="video-container"
v-loading="loading"
element-loading-background="rgba(100,100,100,0.3)"
>
<h2 class="h-title text-2xl mb-5 mt-2">你的作品</h2> <h2 class="h-title text-2xl mb-5 mt-2">你的作品</h2>
<div class="list-box" v-if="!noData"> <div class="list-box" v-if="!noData">
@@ -53,7 +71,15 @@
<div class="left"> <div class="left">
<div class="container"> <div class="container">
<div v-if="item.progress === 100"> <div v-if="item.progress === 100">
<video class="video" :src="replaceImg(item.video_url)" preload="auto" loop="loop" muted="muted">您的浏览器不支持视频播放</video> <video
class="video"
:src="replaceImg(item.video_url)"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button class="play flex justify-center items-center" @click="play(item)"> <button class="play flex justify-center items-center" @click="play(item)">
<img src="/images/play.svg" alt="" /> <img src="/images/play.svg" alt="" />
</button> </button>
@@ -63,7 +89,9 @@
</div> </div>
</div> </div>
<div class="center"> <div class="center">
<div class="failed" v-if="item.progress === 101">任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}</div> <div class="failed" v-if="item.progress === 101">
任务执行失败{{ item.err_msg }}任务提示词{{ item.prompt }}
</div>
<div class="prompt" v-else>{{ item.prompt }}</div> <div class="prompt" v-else>{{ item.prompt }}</div>
</div> </div>
<div class="right" v-if="item.progress === 100"> <div class="right" v-if="item.progress === 100">
@@ -100,7 +128,12 @@
</div> </div>
</div> </div>
</div> </div>
<el-empty :image-size="100" :image="nodata" description="没有任何作品,赶紧去创作吧!" v-else /> <el-empty
:image-size="100"
:image="nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
@@ -116,8 +149,22 @@
/> />
</div> </div>
</el-container> </el-container>
<black-dialog v-model:show="showDialog" title="预览视频" hide-footer @cancal="showDialog = false" width="auto"> <black-dialog
<video style="max-width: 90vw; max-height: 90vh" :src="currentVideoUrl" preload="auto" :autoplay="true" loop="loop" muted="muted" v-show="showDialog"> v-model:show="showDialog"
title="预览视频"
hide-footer
@cancal="showDialog = false"
width="auto"
>
<video
style="max-width: 90vw; max-height: 90vh"
:src="currentVideoUrl"
preload="auto"
:autoplay="true"
loop="loop"
muted="muted"
v-show="showDialog"
>
您的浏览器不支持视频播放 您的浏览器不支持视频播放
</video> </video>
</black-dialog> </black-dialog>
@@ -125,217 +172,216 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
import { onMounted, onUnmounted, reactive, ref } from "vue"; import BlackDialog from '@/components/ui/BlackDialog.vue'
import { CircleCloseFilled } from "@element-plus/icons-vue"; import Generating from '@/components/ui/Generating.vue'
import { httpDownload, httpPost, httpGet } from "@/utils/http"; import { checkSession } from '@/store/cache'
import { checkSession } from "@/store/cache"; import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { closeLoading, showLoading, showMessageError, showMessageOK } from "@/utils/dialog"; import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from "@/utils/libs"; import { replaceImg } from '@/utils/libs'
import { ElMessage, ElMessageBox } from "element-plus"; import { CircleCloseFilled } from '@element-plus/icons-vue'
import BlackSwitch from "@/components/ui/BlackSwitch.vue"; import Clipboard from 'clipboard'
import Generating from "@/components/ui/Generating.vue"; import { ElMessage, ElMessageBox } from 'element-plus'
import BlackDialog from "@/components/ui/BlackDialog.vue"; import { onMounted, onUnmounted, reactive, ref } from 'vue'
import Clipboard from "clipboard"; const showDialog = ref(false)
const showDialog = ref(false); const currentVideoUrl = ref('')
const currentVideoUrl = ref(""); const row = ref(1)
const row = ref(1); const images = ref([])
const images = ref([]);
const formData = reactive({ const formData = reactive({
prompt: "", prompt: '',
expand_prompt: false, expand_prompt: false,
loop: false, loop: false,
first_frame_img: "", first_frame_img: '',
end_frame_img: "", end_frame_img: '',
}); })
const loading = ref(false); const loading = ref(false)
const list = ref([]); const list = ref([])
const noData = ref(true); const noData = ref(true)
const page = ref(1); const page = ref(1)
const pageSize = ref(10); const pageSize = ref(10)
const total = ref(0); const total = ref(0)
const taskPulling = ref(true); const taskPulling = ref(true)
const clipboard = ref(null); const clipboard = ref(null)
const pullHandler = ref(null); const pullHandler = ref(null)
onMounted(() => { onMounted(() => {
checkSession().then(() => { checkSession().then(() => {
fetchData(1); fetchData(1)
// 设置轮询 // 设置轮询
pullHandler.value = setInterval(() => { pullHandler.value = setInterval(() => {
if (taskPulling.value) { if (taskPulling.value) {
fetchData(1); fetchData(1)
} }
}, 5000); }, 5000)
}); })
clipboard.value = new Clipboard(".copy-prompt"); clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
ElMessage.success("复制成功!"); ElMessage.success('复制成功!')
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (pullHandler.value) { if (pullHandler.value) {
clearInterval(pullHandler.value); clearInterval(pullHandler.value)
} }
}); })
const download = (item) => { const download = (item) => {
const url = replaceImg(item.video_url); const url = replaceImg(item.video_url)
const downloadURL = `${process.env.VUE_APP_API_HOST}/api/download?url=${url}`; const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
const urlObj = new URL(url); const urlObj = new URL(url)
const fileName = urlObj.pathname.split("/").pop(); const fileName = urlObj.pathname.split('/').pop()
item.downloading = true; item.downloading = true
httpDownload(downloadURL) httpDownload(downloadURL)
.then((response) => { .then((response) => {
const blob = new Blob([response.data]); const blob = new Blob([response.data])
const link = document.createElement("a"); const link = document.createElement('a')
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob)
link.download = fileName; link.download = fileName
document.body.appendChild(link); document.body.appendChild(link)
link.click(); link.click()
document.body.removeChild(link); document.body.removeChild(link)
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href)
item.downloading = false; item.downloading = false
}) })
.catch(() => { .catch(() => {
showMessageError("下载失败"); showMessageError('下载失败')
item.downloading = false; item.downloading = false
}); })
}; }
const play = (item) => { const play = (item) => {
currentVideoUrl.value = replaceImg(item.video_url); currentVideoUrl.value = replaceImg(item.video_url)
showDialog.value = true; showDialog.value = true
}; }
const removeJob = (item) => { const removeJob = (item) => {
ElMessageBox.confirm("此操作将会删除任务相关文件,继续操作码?", "删除提示", { ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: "确认", confirmButtonText: '确认',
cancelButtonText: "取消", cancelButtonText: '取消',
type: "warning", type: 'warning',
}) })
.then(() => { .then(() => {
httpGet("/api/video/remove", { id: item.id }) httpGet('/api/video/remove', { id: item.id })
.then(() => { .then(() => {
ElMessage.success("任务删除成功"); ElMessage.success('任务删除成功')
fetchData(); fetchData()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("任务删除失败:" + e.message); ElMessage.error('任务删除失败:' + e.message)
}); })
}) })
.catch(() => {}); .catch(() => {})
}; }
const publishJob = (item) => { const publishJob = (item) => {
httpGet("/api/video/publish", { id: item.id, publish: item.publish }) httpGet('/api/video/publish', { id: item.id, publish: item.publish })
.then(() => { .then(() => {
ElMessage.success("操作成功"); ElMessage.success('操作成功')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败:" + e.message); ElMessage.error('操作失败:' + e.message)
}); })
}; }
const upload = (file) => { const upload = (file) => {
const formData = new FormData(); const formData = new FormData()
formData.append("file", file.file, file.name); formData.append('file', file.file, file.name)
showLoading("正在上传文件..."); showLoading('正在上传文件...')
httpPost("/api/upload", formData) httpPost('/api/upload', formData)
.then((res) => { .then((res) => {
images.value.push(res.data.url); images.value.push(res.data.url)
ElMessage.success({ message: "上传成功", duration: 500 }); ElMessage.success({ message: '上传成功', duration: 500 })
closeLoading(); closeLoading()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("图片上传失败:" + e.message); ElMessage.error('图片上传失败:' + e.message)
closeLoading(); closeLoading()
}); })
}; }
const remove = (img) => { const remove = (img) => {
images.value = images.value.filter((item) => item !== img); images.value = images.value.filter((item) => item !== img)
}; }
const switchReverse = () => { const switchReverse = () => {
images.value = images.value.reverse(); images.value = images.value.reverse()
}; }
const fetchData = (_page) => { const fetchData = (_page) => {
if (_page) { if (_page) {
page.value = _page; page.value = _page
} }
httpGet("/api/video/list", { httpGet('/api/video/list', {
page: page.value, page: page.value,
page_size: pageSize.value, page_size: pageSize.value,
type: "luma", type: 'luma',
}) })
.then((res) => { .then((res) => {
total.value = res.data.total; total.value = res.data.total
let needPull = false; let needPull = false
const items = []; const items = []
for (let v of res.data.items) { for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) { if (v.progress === 0 || v.progress === 102) {
needPull = true; needPull = true
} }
items.push(v); items.push(v)
} }
loading.value = false; loading.value = false
taskPulling.value = needPull; taskPulling.value = needPull
if (JSON.stringify(list.value) !== JSON.stringify(items)) { if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items; list.value = items
} }
noData.value = list.value.length === 0; noData.value = list.value.length === 0
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false
noData.value = true; noData.value = true
}); })
}; }
const create = () => { const create = () => {
const len = images.value.length; const len = images.value.length
if (len) { if (len) {
formData.first_frame_img = replaceImg(images.value[0]); formData.first_frame_img = replaceImg(images.value[0])
if (len === 2) { if (len === 2) {
formData.end_frame_img = replaceImg(images.value[1]); formData.end_frame_img = replaceImg(images.value[1])
} }
} }
httpPost("/api/video/luma/create", formData) httpPost('/api/video/luma/create', formData)
.then(() => { .then(() => {
fetchData(1); fetchData(1)
taskPulling.value = true; taskPulling.value = true
showMessageOK("创建任务成功"); showMessageOK('创建任务成功')
}) })
.catch((e) => { .catch((e) => {
showMessageError("创建任务失败:" + e.message); showMessageError('创建任务失败:' + e.message)
}); })
}; }
const generatePrompt = () => { const generatePrompt = () => {
if (formData.prompt === "") { if (formData.prompt === '') {
return showMessageError("请输入原始提示词"); return showMessageError('请输入原始提示词')
} }
showLoading("正在生成视频脚本..."); showLoading('正在生成视频脚本...')
httpPost("/api/prompt/video", { prompt: formData.prompt }) httpPost('/api/prompt/video', { prompt: formData.prompt })
.then((res) => { .then((res) => {
formData.prompt = res.data; formData.prompt = res.data
closeLoading(); closeLoading()
}) })
.catch((e) => { .catch((e) => {
showMessageError("生成提示词失败:" + e.message); showMessageError('生成提示词失败:' + e.message)
closeLoading(); closeLoading()
}); })
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/luma.styl" @import "../assets/css/luma.styl"
</style> </style>

View File

@@ -109,15 +109,15 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, ref } from 'vue'
import { Markmap } from 'markmap-view'
import { Transformer } from 'markmap-lib'
import { checkSession, getSystemInfo } from '@/store/cache' import { checkSession, getSystemInfo } from '@/store/cache'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { Toolbar } from 'markmap-toolbar'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import { Download } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { Transformer } from 'markmap-lib'
import { Toolbar } from 'markmap-toolbar'
import { Markmap } from 'markmap-view'
import { nextTick, onMounted, ref } from 'vue'
const leftBoxHeight = ref(window.innerHeight - 105) const leftBoxHeight = ref(window.innerHeight - 105)
//const rightBoxHeight = ref(window.innerHeight - 115); //const rightBoxHeight = ref(window.innerHeight - 115);
@@ -263,9 +263,9 @@ const getModelById = (modelId) => {
} }
// download SVG to png file // download SVG to png file
const downloadImage = async() => { const downloadImage = async () => {
// 先自适应思维导图到可视化区域 // 先自适应思维导图到可视化区域
await markMap.value.fit() await markMap.value.fit()
const svgElement = document.getElementById('markmap') const svgElement = document.getElementById('markmap')
const serializer = new XMLSerializer() const serializer = new XMLSerializer()
@@ -275,7 +275,7 @@ const downloadImage = async() => {
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source) image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source)
// 分辨率倍数,越高图片越清晰,但文件越大 // 分辨率倍数,越高图片越清晰,但文件越大
const scale = 4 const scale = 4
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = svgElement.offsetWidth * scale canvas.width = svgElement.offsetWidth * scale
canvas.height = svgElement.offsetHeight * scale canvas.height = svgElement.offsetHeight * scale
@@ -298,6 +298,6 @@ const downloadImage = async() => {
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import "@/assets/css/mark-map.styl" @import '../assets/css/mark-map.styl'
@import "@/assets/css/custom-scroll.styl" @import '../assets/css/custom-scroll.styl'
</style> </style>

View File

@@ -1,6 +1,11 @@
<template> <template>
<div> <div>
<div class="member custom-scroll" v-loading="loading" element-loading-background="rgba(255,255,255,.3)" :element-loading-text="loadingText"> <div
class="member custom-scroll"
v-loading="loading"
element-loading-background="rgba(255,255,255,.3)"
:element-loading-text="loadingText"
>
<div class="inner"> <div class="inner">
<div class="user-profile"> <div class="user-profile">
<user-profile :key="profileKey" /> <user-profile :key="profileKey" />
@@ -26,7 +31,9 @@
<div class="product-box"> <div class="product-box">
<div class="info" v-if="orderPayInfoText !== ''"> <div class="info" v-if="orderPayInfoText !== ''">
<el-alert type="success" show-icon :closable="false" effect="dark"> <strong>说明:</strong> {{ vipInfoText }} </el-alert> <el-alert type="success" show-icon :closable="false" effect="dark">
<strong>说明:</strong> {{ vipInfoText }}
</el-alert>
</div> </div>
<el-row v-if="list.length > 0" :gutter="20" class="list-box"> <el-row v-if="list.length > 0" :gutter="20" class="list-box">
@@ -61,7 +68,12 @@
</div> </div>
<div class="pay-way"> <div class="pay-way">
<span type="primary" v-for="payWay in payWays" @click="pay(item, payWay)" :key="payWay"> <span
type="primary"
v-for="payWay in payWays"
@click="pay(item, payWay)"
:key="payWay"
>
<el-button v-if="payWay.pay_type === 'alipay'" color="#15A6E8" circle> <el-button v-if="payWay.pay_type === 'alipay'" color="#15A6E8" circle>
<i class="iconfont icon-alipay"></i> <i class="iconfont icon-alipay"></i>
</el-button> </el-button>
@@ -97,14 +109,33 @@
</div> </div>
</div> </div>
<password-dialog v-if="isLogin" :show="showPasswordDialog" @hide="showPasswordDialog = false" /> <password-dialog
<bind-mobile v-if="isLogin" :show="showBindMobileDialog" @hide="showBindMobileDialog = false" /> v-if="isLogin"
:show="showPasswordDialog"
@hide="showPasswordDialog = false"
/>
<bind-mobile
v-if="isLogin"
:show="showBindMobileDialog"
@hide="showBindMobileDialog = false"
/>
<bind-email v-if="isLogin" :show="showBindEmailDialog" @hide="showBindEmailDialog = false" /> <bind-email v-if="isLogin" :show="showBindEmailDialog" @hide="showBindEmailDialog = false" />
<third-login v-if="isLogin" :show="showThirdLoginDialog" @hide="showThirdLoginDialog = false" /> <third-login
v-if="isLogin"
:show="showThirdLoginDialog"
@hide="showThirdLoginDialog = false"
/>
<redeem-verify v-if="isLogin" :show="showRedeemVerifyDialog" @hide="redeemCallback" /> <redeem-verify v-if="isLogin" :show="showRedeemVerifyDialog" @hide="redeemCallback" />
</div> </div>
<el-dialog v-model="showDialog" :show-close="false" :close-on-click-modal="false" hide-footer width="auto" class="pay-dialog"> <el-dialog
v-model="showDialog"
:show-close="false"
:close-on-click-modal="false"
hide-footer
width="auto"
class="pay-dialog"
>
<div v-if="qrImg !== ''"> <div v-if="qrImg !== ''">
<div class="product-info"> <div class="product-info">
请使用微信扫码支付<span class="price">{{ price }}</span> 请使用微信扫码支付<span class="price">{{ price }}</span>
@@ -120,148 +151,148 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
import { onMounted, ref } from "vue"; import BindEmail from '@/components/BindEmail.vue'
import { ElMessage } from "element-plus"; import BindMobile from '@/components/BindMobile.vue'
import { httpGet, httpPost } from "@/utils/http"; import PasswordDialog from '@/components/PasswordDialog.vue'
import { checkSession, getSystemInfo } from "@/store/cache"; import RedeemVerify from '@/components/RedeemVerify.vue'
import UserProfile from "@/components/UserProfile.vue"; import ThirdLogin from '@/components/ThirdLogin.vue'
import PasswordDialog from "@/components/PasswordDialog.vue"; import UserOrder from '@/components/UserOrder.vue'
import BindMobile from "@/components/BindMobile.vue"; import UserProfile from '@/components/UserProfile.vue'
import RedeemVerify from "@/components/RedeemVerify.vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import UserOrder from "@/components/UserOrder.vue"; import { useSharedStore } from '@/store/sharedata'
import { useSharedStore } from "@/store/sharedata"; import { httpGet, httpPost } from '@/utils/http'
import BindEmail from "@/components/BindEmail.vue"; import { ElMessage } from 'element-plus'
import ThirdLogin from "@/components/ThirdLogin.vue"; import QRCode from 'qrcode'
import QRCode from "qrcode"; import { onMounted, ref } from 'vue'
const list = ref([]); const list = ref([])
const vipImg = ref("/images/menu/member.png"); const vipImg = ref('/images/menu/member.png')
const enableReward = ref(false); // 是否启用众筹功能 const enableReward = ref(false) // 是否启用众筹功能
const rewardImg = ref("/images/reward.png"); const rewardImg = ref('/images/reward.png')
const showPasswordDialog = ref(false); const showPasswordDialog = ref(false)
const showBindMobileDialog = ref(false); const showBindMobileDialog = ref(false)
const showBindEmailDialog = ref(false); const showBindEmailDialog = ref(false)
const showRedeemVerifyDialog = ref(false); const showRedeemVerifyDialog = ref(false)
const showThirdLoginDialog = ref(false); const showThirdLoginDialog = ref(false)
const user = ref(null); const user = ref(null)
const isLogin = ref(false); const isLogin = ref(false)
const orderTimeout = ref(1800); const orderTimeout = ref(1800)
const loading = ref(true); const loading = ref(true)
const loadingText = ref("加载中..."); const loadingText = ref('加载中...')
const orderPayInfoText = ref(""); const orderPayInfoText = ref('')
const payWays = ref([]); const payWays = ref([])
const vipInfoText = ref(""); const vipInfoText = ref('')
const store = useSharedStore(); const store = useSharedStore()
const profileKey = ref(0); const profileKey = ref(0)
const userOrderKey = ref(0); const userOrderKey = ref(0)
const showDialog = ref(false); const showDialog = ref(false)
const qrImg = ref(""); const qrImg = ref('')
const price = ref(0); const price = ref(0)
onMounted(() => { onMounted(() => {
checkSession() checkSession()
.then((_user) => { .then((_user) => {
user.value = _user; user.value = _user
isLogin.value = true; isLogin.value = true
}) })
.catch(() => { .catch(() => {
store.setShowLoginDialog(true); store.setShowLoginDialog(true)
}); })
httpGet("/api/product/list") httpGet('/api/product/list')
.then((res) => { .then((res) => {
list.value = res.data; list.value = res.data
loading.value = false; loading.value = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取产品套餐失败:" + e.message); ElMessage.error('获取产品套餐失败:' + e.message)
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
rewardImg.value = res.data["reward_img"]; rewardImg.value = res.data['reward_img']
enableReward.value = res.data["enabled_reward"]; enableReward.value = res.data['enabled_reward']
orderPayInfoText.value = res.data["order_pay_info_text"]; orderPayInfoText.value = res.data['order_pay_info_text']
if (res.data["order_pay_timeout"] > 0) { if (res.data['order_pay_timeout'] > 0) {
orderTimeout.value = res.data["order_pay_timeout"]; orderTimeout.value = res.data['order_pay_timeout']
} }
vipInfoText.value = res.data["vip_info_text"]; vipInfoText.value = res.data['vip_info_text']
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message); ElMessage.error('获取系统配置失败:' + e.message)
}); })
httpGet("/api/payment/payWays") httpGet('/api/payment/payWays')
.then((res) => { .then((res) => {
payWays.value = res.data; payWays.value = res.data
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取支付方式失败:" + e.message); ElMessage.error('获取支付方式失败:' + e.message)
}); })
}); })
const pay = (product, payWay) => { const pay = (product, payWay) => {
if (!isLogin.value) { if (!isLogin.value) {
store.setShowLoginDialog(true); store.setShowLoginDialog(true)
return; return
} }
loading.value = true; loading.value = true
loadingText.value = "正在生成支付订单..."; loadingText.value = '正在生成支付订单...'
let host = process.env.VUE_APP_API_HOST; let host = import.meta.env.VITE_API_HOST
if (host === "") { if (host === '') {
host = `${location.protocol}//${location.host}`; host = `${location.protocol}//${location.host}`
} }
httpPost(`${process.env.VUE_APP_API_HOST}/api/payment/doPay`, { httpPost(`${import.meta.env.VITE_API_HOST}/api/payment/doPay`, {
product_id: product.id, product_id: product.id,
pay_way: payWay.pay_way, pay_way: payWay.pay_way,
pay_type: payWay.pay_type, pay_type: payWay.pay_type,
user_id: user.value.id, user_id: user.value.id,
host: host, host: host,
device: "jump", device: 'jump',
}) })
.then((res) => { .then((res) => {
showDialog.value = true; showDialog.value = true
loading.value = false; loading.value = false
if (payWay.pay_way === "wechat") { if (payWay.pay_way === 'wechat') {
price.value = Number(product.discount); price.value = Number(product.discount)
QRCode.toDataURL(res.data, { width: 300, height: 300, margin: 2 }, (error, url) => { QRCode.toDataURL(res.data, { width: 300, height: 300, margin: 2 }, (error, url) => {
if (error) { if (error) {
console.error(error); console.error(error)
} else { } else {
qrImg.value = url; qrImg.value = url
} }
}); })
} else { } else {
window.open(res.data, "_blank"); window.open(res.data, '_blank')
} }
}) })
.catch((e) => { .catch((e) => {
setTimeout(() => { setTimeout(() => {
ElMessage.error("生成支付订单失败:" + e.message); ElMessage.error('生成支付订单失败:' + e.message)
loading.value = false; loading.value = false
}, 500); }, 500)
}); })
}; }
const redeemCallback = (success) => { const redeemCallback = (success) => {
showRedeemVerifyDialog.value = false; showRedeemVerifyDialog.value = false
if (success) { if (success) {
profileKey.value += 1; profileKey.value += 1
} }
}; }
const payCallback = (success) => { const payCallback = (success) => {
showDialog.value = false; showDialog.value = false
if (success) { if (success) {
profileKey.value += 1; profileKey.value += 1
userOrderKey.value += 1; userOrderKey.value += 1
} }
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/custom-scroll.styl" @import "../assets/css/custom-scroll.styl"
@import "@/assets/css/member.styl" @import "../assets/css/member.styl"
</style> </style>

View File

@@ -4,7 +4,12 @@
<div class="inner"> <div class="inner">
<div class="list-box"> <div class="list-box">
<div class="handle-box"> <div class="handle-box">
<el-input v-model="query.model" placeholder="模型" class="handle-input mr10" clearable></el-input> <el-input
v-model="query.model"
placeholder="模型"
class="handle-input mr10"
clearable
></el-input>
<el-date-picker <el-date-picker
v-model="query.date" v-model="query.date"
type="daterange" type="daterange"
@@ -23,21 +28,27 @@
<el-table-column prop="model" label="模型" width="130px" /> <el-table-column prop="model" label="模型" width="130px" />
<el-table-column prop="type" label="类型"> <el-table-column prop="type" label="类型">
<template #default="scope"> <template #default="scope">
<el-tag size="small" :type="tagColors[scope.row.type]">{{ scope.row.type_str }}</el-tag> <el-tag size="small" :type="tagColors[scope.row.type]">{{
scope.row.type_str
}}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="数额"> <el-table-column label="数额">
<template #default="scope"> <template #default="scope">
<div> <div>
<el-text type="success" v-if="scope.row.mark === 1">+{{ scope.row.amount }}</el-text> <el-text type="success" v-if="scope.row.mark === 1"
<el-text type="danger" v-if="scope.row.mark === 0">-{{ scope.row.amount }}</el-text> >+{{ scope.row.amount }}</el-text
>
<el-text type="danger" v-if="scope.row.mark === 0"
>-{{ scope.row.amount }}</el-text
>
</div> </div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="balance" label="余额" /> <el-table-column prop="balance" label="余额" />
<el-table-column label="发生时间" width="160px"> <el-table-column label="发生时间" width="160px">
<template #default="scope"> <template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span> <span>{{ dateFormat(scope.row['created_at']) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="remark" label="备注" /> <el-table-column prop="remark" label="备注" />
@@ -64,48 +75,48 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
import { onMounted, ref } from "vue"; import { checkSession } from '@/store/cache'
import { dateFormat } from "@/utils/libs"; import { httpPost } from '@/utils/http'
import { Search } from "@element-plus/icons-vue"; import { dateFormat } from '@/utils/libs'
import Clipboard from "clipboard"; import { Search } from '@element-plus/icons-vue'
import { ElMessage } from "element-plus"; import Clipboard from 'clipboard'
import { httpPost } from "@/utils/http"; import { ElMessage } from 'element-plus'
import { checkSession } from "@/store/cache"; import { onMounted, ref } from 'vue'
const items = ref([]); const items = ref([])
const total = ref(0); const total = ref(0)
const page = ref(1); const page = ref(1)
const pageSize = ref(20); const pageSize = ref(20)
const loading = ref(false); const loading = ref(false)
const listBoxHeight = window.innerHeight - 87; const listBoxHeight = window.innerHeight - 87
const query = ref({ const query = ref({
model: "", model: '',
date: [], date: [],
}); })
const tagColors = ref(["primary", "success", "primary", "danger", "info", "warning"]); const tagColors = ref(['primary', 'success', 'primary', 'danger', 'info', 'warning'])
onMounted(() => { onMounted(() => {
checkSession() checkSession()
.then(() => { .then(() => {
fetchData(); fetchData()
}) })
.catch(() => {}); .catch(() => {})
const clipboard = new Clipboard(".copy-order-no"); const clipboard = new Clipboard('.copy-order-no')
clipboard.on("success", () => { clipboard.on('success', () => {
ElMessage.success("复制成功"); ElMessage.success('复制成功')
}); })
clipboard.on("error", () => { clipboard.on('error', () => {
ElMessage.error("复制失败"); ElMessage.error('复制失败')
}); })
}); })
// 获取数据 // 获取数据
const fetchData = () => { const fetchData = () => {
loading.value = true; loading.value = true
httpPost("/api/powerLog/list", { httpPost('/api/powerLog/list', {
model: query.value.model, model: query.value.model,
date: query.value.date, date: query.value.date,
page: page.value, page: page.value,
@@ -113,22 +124,22 @@ const fetchData = () => {
}) })
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
items.value = res.data.items; items.value = res.data.items
total.value = res.data.total; total.value = res.data.total
page.value = res.data.page; page.value = res.data.page
pageSize.value = res.data.page_size; pageSize.value = res.data.page_size
} }
loading.value = false; loading.value = false
}) })
.catch((e) => { .catch((e) => {
loading.value = false; loading.value = false
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/custom-scroll.styl" @import "../assets/css/custom-scroll.styl"
.power-log { .power-log {
color #ffffff color #ffffff
.inner { .inner {

View File

@@ -10,12 +10,27 @@
<el-tab-pane label="手机注册" name="mobile" v-if="enableMobile"> <el-tab-pane label="手机注册" name="mobile" v-if="enableMobile">
<el-form-item> <el-form-item>
<div class="form-title">手机号码</div> <div class="form-title">手机号码</div>
<el-input placeholder="请输入手机号码" size="large" v-model="data.mobile" maxlength="11" autocomplete="off"> </el-input> <el-input
placeholder="请输入手机号码"
size="large"
v-model="data.mobile"
maxlength="11"
autocomplete="off"
>
</el-input>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<div class="form-title">验证码</div> <div class="form-title">验证码</div>
<div class="flex w100"> <div class="flex w100">
<el-input placeholder="请输入验证码" size="large" maxlength="30" class="code-input" v-model="data.code" autocomplete="off"> </el-input> <el-input
placeholder="请输入验证码"
size="large"
maxlength="30"
class="code-input"
v-model="data.code"
autocomplete="off"
>
</el-input>
<send-msg size="large" :receiver="data.mobile" type="mobile" /> <send-msg size="large" :receiver="data.mobile" type="mobile" />
</div> </div>
@@ -24,12 +39,26 @@
<el-tab-pane label="邮箱注册" name="email" v-if="enableEmail"> <el-tab-pane label="邮箱注册" name="email" v-if="enableEmail">
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">邮箱</div> <div class="form-title">邮箱</div>
<el-input placeholder="请输入邮箱地址" size="large" v-model="data.email" autocomplete="off"> </el-input> <el-input
placeholder="请输入邮箱地址"
size="large"
v-model="data.email"
autocomplete="off"
>
</el-input>
</el-form-item> </el-form-item>
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">验证码</div> <div class="form-title">验证码</div>
<div class="flex w100"> <div class="flex w100">
<el-input placeholder="请输入验证码" size="large" maxlength="30" class="code-input" v-model="data.code" autocomplete="off"> </el-input> <el-input
placeholder="请输入验证码"
size="large"
maxlength="30"
class="code-input"
v-model="data.code"
autocomplete="off"
>
</el-input>
<send-msg size="large" :receiver="data.email" type="email" /> <send-msg size="large" :receiver="data.email" type="email" />
</div> </div>
@@ -39,7 +68,13 @@
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">用户名</div> <div class="form-title">用户名</div>
<el-input placeholder="请输入用户名" size="large" v-model="data.username" autocomplete="off"> </el-input> <el-input
placeholder="请输入用户名"
size="large"
v-model="data.username"
autocomplete="off"
>
</el-input>
</el-form-item> </el-form-item>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
@@ -47,24 +82,67 @@
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">密码</div> <div class="form-title">密码</div>
<el-input placeholder="请输入密码(8-16位)" maxlength="16" size="large" v-model="data.password" show-password autocomplete="off"> </el-input> <el-input
placeholder="请输入密码(8-16位)"
maxlength="16"
size="large"
v-model="data.password"
show-password
autocomplete="off"
>
</el-input>
</el-form-item> </el-form-item>
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">重复密码</div> <div class="form-title">重复密码</div>
<el-input placeholder="请再次输入密码(8-16位)" size="large" maxlength="16" v-model="data.repass" show-password autocomplete="off"> </el-input> <el-input
placeholder="请再次输入密码(8-16位)"
size="large"
maxlength="16"
v-model="data.repass"
show-password
autocomplete="off"
>
</el-input>
</el-form-item> </el-form-item>
<el-form-item class="block"> <el-form-item class="block">
<div class="form-title">邀请码</div> <div class="form-title">邀请码</div>
<el-input placeholder="请输入邀请码(可选)" size="large" v-model="data.invite_code" autocomplete="off"> </el-input> <el-input
placeholder="请输入邀请码(可选)"
size="large"
v-model="data.invite_code"
autocomplete="off"
>
</el-input>
</el-form-item>
<el-form-item
label=""
prop="agreement"
:class="{ 'agreement-error': agreementError }"
>
<div class="agreement-box" :class="{ shake: isShaking }">
<el-checkbox v-model="data.agreement" @change="handleAgreementChange">
我已阅读并同意
<span class="agreement-link" @click.stop.prevent="openAgreement"
>用户协议</span
>
<span class="agreement-link" @click.stop.prevent="openPrivacy"
>隐私政策</span
>
</el-checkbox>
</div>
</el-form-item> </el-form-item>
<el-row class="btn-row" :gutter="20"> <el-row class="btn-row" :gutter="20">
<el-col :span="24"> <el-col :span="24">
<el-button class="login-btn" type="primary" size="large" @click="submitRegister">注册</el-button> <el-button class="login-btn" type="primary" size="large" @click="submitRegister"
>注册</el-button
>
</el-col> </el-col>
</el-row> </el-row>
</el-form> </el-form>
@@ -80,7 +158,9 @@
</div> </div>
<div class="mt-3"> <div class="mt-3">
<el-button type="primary" @click="router.push('/')"><i class="iconfont icon-home mr-1"></i> 返回首页</el-button> <el-button type="primary" @click="router.push('/')"
><i class="iconfont icon-home mr-1"></i> 返回首页</el-button
>
</div> </div>
</template> </template>
</el-result> </el-result>
@@ -92,146 +172,232 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import AccountBg from '@/components/AccountBg.vue'
import AccountTop from "@/components/AccountTop.vue"; import AccountTop from '@/components/AccountTop.vue'
import AccountBg from "@/components/AccountBg.vue"; import { ref } from 'vue'
import { httpGet, httpPost } from "@/utils/http"; import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from 'element-plus'
import { useRouter } from "vue-router"; import MarkdownIt from 'markdown-it'
import { useRouter } from 'vue-router'
import SendMsg from "@/components/SendMsg.vue"; import Captcha from '@/components/Captcha.vue'
import { arrayContains, isMobile } from "@/utils/libs"; import SendMsg from '@/components/SendMsg.vue'
import { setUserToken } from "@/store/session"; import { getLicenseInfo, getSystemInfo } from '@/store/cache'
import { validateEmail, validateMobile } from "@/utils/validate"; import { setUserToken } from '@/store/session'
import { showMessageError, showMessageOK } from "@/utils/dialog"; import { showMessageError, showMessageOK } from '@/utils/dialog'
import { getLicenseInfo, getSystemInfo } from "@/store/cache"; import { arrayContains, isMobile } from '@/utils/libs'
import Captcha from "@/components/Captcha.vue"; import { validateEmail, validateMobile } from '@/utils/validate'
const router = useRouter(); const router = useRouter()
const title = ref(""); const title = ref('')
const logo = ref(""); const logo = ref('')
const data = ref({ const data = ref({
username: "", username: '',
mobile: "", mobile: '',
email: "", email: '',
password: "", password: '',
code: "", code: '',
repass: "", repass: '',
invite_code: router.currentRoute.value.query["invite_code"], invite_code: router.currentRoute.value.query['invite_code'],
}); agreement: false,
})
const enableMobile = ref(false); const enableMobile = ref(false)
const enableEmail = ref(false); const enableEmail = ref(false)
const enableUser = ref(false); const enableUser = ref(false)
const enableRegister = ref(true); const enableRegister = ref(true)
const activeName = ref("mobile"); const activeName = ref('mobile')
const wxImg = ref("/images/wx.png"); const wxImg = ref('/images/wx.png')
const licenseConfig = ref({}); const licenseConfig = ref({})
const enableVerify = ref(false); const enableVerify = ref(false)
const captchaRef = ref(null); const captchaRef = ref(null)
const agreementError = ref(false)
const isShaking = ref(false)
// 初始化markdown解析器
const md = new MarkdownIt({
html: true,
linkify: true,
typographer: true,
})
const agreementContent = ref('')
const privacyContent = ref('')
// 记录邀请码点击次数 // 记录邀请码点击次数
if (data.value.invite_code) { if (data.value.invite_code) {
httpGet("/api/invite/hits", { code: data.value.invite_code }); httpGet('/api/invite/hits', { code: data.value.invite_code })
} }
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
title.value = res.data.title; title.value = res.data.title
logo.value = res.data.logo; logo.value = res.data.logo
const registerWays = res.data["register_ways"]; const registerWays = res.data['register_ways']
if (arrayContains(registerWays, "username")) { if (arrayContains(registerWays, 'username')) {
enableUser.value = true; enableUser.value = true
activeName.value = "username"; activeName.value = 'username'
} }
if (arrayContains(registerWays, "email")) { if (arrayContains(registerWays, 'email')) {
enableEmail.value = true; enableEmail.value = true
activeName.value = "email"; activeName.value = 'email'
} }
if (arrayContains(registerWays, "mobile")) { if (arrayContains(registerWays, 'mobile')) {
enableMobile.value = true; enableMobile.value = true
activeName.value = "mobile"; activeName.value = 'mobile'
} }
// 是否启用注册 // 是否启用注册
enableRegister.value = res.data["enabled_register"]; enableRegister.value = res.data['enabled_register']
// 使用后台上传的客服微信二维码 // 使用后台上传的客服微信二维码
if (res.data["wechat_card_url"] !== "") { if (res.data['wechat_card_url'] !== '') {
wxImg.value = res.data["wechat_card_url"]; wxImg.value = res.data['wechat_card_url']
} }
enableVerify.value = res.data["enabled_verify"]; enableVerify.value = res.data['enabled_verify']
} }
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取系统配置失败:" + e.message); ElMessage.error('获取系统配置失败:' + e.message)
}); })
// 获取用户协议
httpGet('/api/config/get?key=agreement')
.then((res) => {
if (res.data && res.data.content) {
agreementContent.value = res.data.content
} else {
agreementContent.value =
'# 用户协议\n\n用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分与协议正文具有同等法律效力。'
}
})
.catch((e) => {
console.warn(e)
agreementContent.value =
'# 用户协议\n\n用户在使用本服务前应当阅读并同意本协议。本协议内容包括协议正文及所有本平台已经发布的或将来可能发布的各类规则。所有规则为本协议不可分割的组成部分与协议正文具有同等法律效力。'
})
// 获取隐私政策
httpGet('/api/config/get?key=privacy')
.then((res) => {
if (res.data && res.data.content) {
privacyContent.value = res.data.content
} else {
privacyContent.value =
'# 隐私政策\n\n我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
}
})
.catch((e) => {
console.warn(e)
privacyContent.value =
'# 隐私政策\n\n我们非常重视用户的隐私和个人信息保护。您在使用我们的产品与服务时我们可能会收集和使用您的相关信息。我们希望通过本《隐私政策》向您说明我们在收集和使用您相关信息时对应的处理规则。'
})
getLicenseInfo() getLicenseInfo()
.then((res) => { .then((res) => {
licenseConfig.value = res.data; licenseConfig.value = res.data
}) })
.catch((e) => { .catch((e) => {
showMessageError("获取 License 配置:" + e.message); showMessageError('获取 License 配置:' + e.message)
}); })
// 注册操作 // 注册操作
const submitRegister = () => { const submitRegister = () => {
if (activeName.value === "username" && data.value.username === "") { if (activeName.value === 'username' && data.value.username === '') {
return showMessageError("请输入用户名"); return showMessageError('请输入用户名')
} }
if (activeName.value === "mobile" && !validateMobile(data.value.mobile)) { if (activeName.value === 'mobile' && !validateMobile(data.value.mobile)) {
return showMessageError("请输入合法的手机号"); return showMessageError('请输入合法的手机号')
} }
if (activeName.value === "email" && !validateEmail(data.value.email)) { if (activeName.value === 'email' && !validateEmail(data.value.email)) {
return showMessageError("请输入合法的邮箱地址"); return showMessageError('请输入合法的邮箱地址')
} }
if (data.value.password.length < 8) { if (data.value.password.length < 8) {
return showMessageError("密码的长度为8-16个字符"); return showMessageError('密码的长度为8-16个字符')
} }
if (data.value.repass !== data.value.password) { if (data.value.repass !== data.value.password) {
return showMessageError("两次输入密码不一致"); return showMessageError('两次输入密码不一致')
} }
if ((activeName.value === "mobile" || activeName.value === "email") && data.value.code === "") { if ((activeName.value === 'mobile' || activeName.value === 'email') && data.value.code === '') {
return showMessageError("请输入验证码"); return showMessageError('请输入验证码')
}
if (!data.value.agreement) {
agreementError.value = true
isShaking.value = true
setTimeout(() => {
isShaking.value = false
}, 500)
showMessageError('请先阅读并同意用户协议和隐私政策')
return
} }
// 如果是用户名和密码登录,那么需要加载验证码 // 如果是用户名和密码登录,那么需要加载验证码
if (enableVerify.value && activeName.value === "username") { if (enableVerify.value && activeName.value === 'username') {
captchaRef.value.loadCaptcha(); captchaRef.value.loadCaptcha()
} else { } else {
doSubmitRegister({}); doSubmitRegister({})
} }
}; }
const doSubmitRegister = (verifyData) => { const doSubmitRegister = (verifyData) => {
data.value.key = verifyData.key; data.value.key = verifyData.key
data.value.dots = verifyData.dots; data.value.dots = verifyData.dots
data.value.x = verifyData.x; data.value.x = verifyData.x
data.value.reg_way = activeName.value; data.value.reg_way = activeName.value
httpPost("/api/user/register", data.value) httpPost('/api/user/register', data.value)
.then((res) => { .then((res) => {
setUserToken(res.data.token); setUserToken(res.data.token)
showMessageOK("注册成功,即将跳转到对话主界面..."); showMessageOK('注册成功,即将跳转到对话主界面...')
if (isMobile()) { if (isMobile()) {
router.push("/mobile/index"); router.push('/mobile/index')
} else { } else {
router.push("/chat"); router.push('/chat')
} }
}) })
.catch((e) => { .catch((e) => {
showMessageError("注册失败," + e.message); showMessageError('注册失败,' + e.message)
}); })
}; }
const handleAgreementChange = () => {
agreementError.value = !data.value.agreement
}
const openAgreement = () => {
// 使用弹窗显示用户协议内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(agreementContent.value)}</div>`,
'用户协议',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
const openPrivacy = () => {
// 使用弹窗显示隐私政策内容支持Markdown格式
ElMessageBox.alert(
`<div class="markdown-content">${md.render(privacyContent.value)}</div>`,
'隐私政策',
{
confirmButtonText: '我已阅读',
dangerouslyUseHTMLString: true,
callback: () => {},
}
)
}
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/login.styl" @import '../assets/css/login.styl'
:deep(.back){ :deep(.back){
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -243,4 +409,84 @@ const doSubmitRegister = (verifyData) => {
margin-top: 20px margin-top: 20px
} }
.agreement-box
margin-bottom: 10px
transition: all 0.3s
.agreement-link
color: var(--el-color-primary)
cursor: pointer
.agreement-error
.el-checkbox
.el-checkbox__input
.el-checkbox__inner
border-color: #F56C6C !important
.shake
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both
@keyframes shake
10%, 90%
transform: translate3d(-1px, 0, 0)
20%, 80%
transform: translate3d(2px, 0, 0)
30%, 50%, 70%
transform: translate3d(-4px, 0, 0)
40%, 60%
transform: translate3d(4px, 0, 0)
</style>
<style>
/* 全局样式用于Markdown内容显示 */
.markdown-content {
text-align: left;
max-height: 60vh;
overflow-y: auto;
padding: 10px;
}
.markdown-content h1 {
font-size: 1.5em;
margin-bottom: 15px;
}
.markdown-content h2 {
font-size: 1.3em;
margin: 15px 0 10px;
}
.markdown-content p {
margin-bottom: 10px;
line-height: 1.5;
}
.markdown-content ul,
.markdown-content ol {
padding-left: 20px;
margin-bottom: 10px;
}
.markdown-content blockquote {
border-left: 4px solid #ccc;
padding-left: 10px;
color: #666;
margin: 10px 0;
}
.markdown-content code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
}
.markdown-content pre {
background-color: #f0f0f0;
padding: 10px;
border-radius: 5px;
overflow-x: auto;
margin: 10px 0;
}
</style> </style>

View File

@@ -10,11 +10,7 @@
<el-tab-pane label="手机号验证" name="mobile"> <el-tab-pane label="手机号验证" name="mobile">
<el-form-item> <el-form-item>
<div class="form-title">手机号码</div> <div class="form-title">手机号码</div>
<el-input <el-input v-model="form.mobile" size="large" placeholder="请输入手机号" />
v-model="form.mobile"
size="large"
placeholder="请输入手机号"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<div class="form-title">验证码</div> <div class="form-title">验证码</div>
@@ -26,11 +22,7 @@
placeholder="请输入验证码" placeholder="请输入验证码"
class="code-input" class="code-input"
/> />
<send-msg <send-msg size="large" :receiver="form.mobile" type="mobile" />
size="large"
:receiver="form.mobile"
type="mobile"
/>
</div> </div>
</el-form-item> </el-form-item>
</el-tab-pane> </el-tab-pane>
@@ -38,11 +30,7 @@
<el-form-item> <el-form-item>
<div class="form-title">邮箱</div> <div class="form-title">邮箱</div>
<el-input <el-input v-model="form.email" placeholder="请输入邮箱" size="large" />
v-model="form.email"
placeholder="请输入邮箱"
size="large"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<div class="form-title">验证码</div> <div class="form-title">验证码</div>
@@ -54,11 +42,7 @@
placeholder="请输入验证码" placeholder="请输入验证码"
class="code-input" class="code-input"
/> />
<send-msg <send-msg size="large" :receiver="form.email" type="email" />
size="large"
:receiver="form.email"
type="email"
/>
</div> </div>
</el-form-item> </el-form-item>
</el-tab-pane> </el-tab-pane>
@@ -85,12 +69,7 @@
/> />
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<el-button <el-button class="login-btn" size="large" type="primary" @click="save">
class="login-btn"
size="large"
type="primary"
@click="save"
>
重置密码 重置密码
</el-button> </el-button>
</el-form-item> </el-form-item>
@@ -103,50 +82,49 @@
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import AccountTop from '@/components/AccountTop.vue'
import SendMsg from "@/components/SendMsg.vue"; import SendMsg from '@/components/SendMsg.vue'
import AccountTop from "@/components/AccountTop.vue"; import { ref } from 'vue'
import { ElMessage } from "element-plus"; import AccountBg from '@/components/AccountBg.vue'
import { httpPost } from "@/utils/http"; import { httpPost } from '@/utils/http'
import AccountBg from "@/components/AccountBg.vue"; import { ElMessage } from 'element-plus'
import { validateEmail, validateMobile } from "@/utils/validate";
const form = ref({ const form = ref({
mobile: "", mobile: '',
email: "", email: '',
type: "mobile", type: 'mobile',
code: "", code: '',
password: "", password: '',
repass: "" repass: '',
}); })
const save = () => { const save = () => {
if (form.value.code === "") { if (form.value.code === '') {
return ElMessage.error("请输入验证码"); return ElMessage.error('请输入验证码')
} }
if (form.value.password.length < 8) { if (form.value.password.length < 8) {
return ElMessage.error("密码长度必须大于8位"); return ElMessage.error('密码长度必须大于8位')
} }
if (form.value.repass !== form.value.password) { if (form.value.repass !== form.value.password) {
return ElMessage.error("两次输入密码不一致"); return ElMessage.error('两次输入密码不一致')
} }
httpPost("/api/user/resetPass", form.value) httpPost('/api/user/resetPass', form.value)
.then(() => { .then(() => {
ElMessage.success({ ElMessage.success({
message: "重置密码成功", message: '重置密码成功',
duration: 1000 duration: 1000,
}); })
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("重置密码失败:" + e.message); ElMessage.error('重置密码失败:' + e.message)
}); })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/login.styl" @import "../assets/css/login.styl"
::v-deep(.el-tabs__item.is-active, .el-tabs__item:hover){ ::v-deep(.el-tabs__item.is-active, .el-tabs__item:hover){
color: var(--common-text-color) !important; color: var(--common-text-color) !important;
} }

View File

@@ -11,7 +11,9 @@
<el-avatar :size="32" :src="song.user?.avatar" /> <el-avatar :size="32" :src="song.user?.avatar" />
</span> </span>
<span class="nickname">{{ song.user?.nickname }}</span> <span class="nickname">{{ song.user?.nickname }}</span>
<button class="btn btn-icon" @click="play"><i class="iconfont icon-play"></i> {{ song.play_times }}</button> <button class="btn btn-icon" @click="play">
<i class="iconfont icon-play"></i> {{ song.play_times }}
</button>
<el-tooltip content="复制歌曲链接" placement="top"> <el-tooltip content="复制歌曲链接" placement="top">
<button class="btn btn-icon copy-link" :data-clipboard-text="getShareURL(song)"> <button class="btn btn-icon copy-link" :data-clipboard-text="getShareURL(song)">
@@ -37,58 +39,58 @@
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from "vue"; import MusicPlayer from '@/components/MusicPlayer.vue'
import { useRouter } from "vue-router"; import { showMessageError } from '@/utils/dialog'
import { httpGet } from "@/utils/http"; import { httpGet } from '@/utils/http'
import { showMessageError } from "@/utils/dialog"; import { dateFormat } from '@/utils/libs'
import { dateFormat } from "@/utils/libs"; import Clipboard from 'clipboard'
import Clipboard from "clipboard"; import { ElMessage } from 'element-plus'
import { ElMessage } from "element-plus"; import { onMounted, onUnmounted, ref } from 'vue'
import MusicPlayer from "@/components/MusicPlayer.vue"; import { useRouter } from 'vue-router'
const router = useRouter(); const router = useRouter()
const id = router.currentRoute.value.params.id; const id = router.currentRoute.value.params.id
const song = ref({ title: "" }); const song = ref({ title: '' })
const playList = ref([]); const playList = ref([])
const playerRef = ref(null); const playerRef = ref(null)
httpGet("/api/suno/detail", { song_id: id }) httpGet('/api/suno/detail', { song_id: id })
.then((res) => { .then((res) => {
song.value = res.data; song.value = res.data
playList.value = [song.value]; playList.value = [song.value]
document.title = song.value?.title + " | By " + song.value?.user.nickname + " | Suno音乐"; document.title = song.value?.title + ' | By ' + song.value?.user.nickname + ' | Suno音乐'
}) })
.catch((e) => { .catch((e) => {
showMessageError("获取歌曲详情失败:" + e.message); showMessageError('获取歌曲详情失败:' + e.message)
}); })
const clipboard = ref(null); const clipboard = ref(null)
onMounted(() => { onMounted(() => {
clipboard.value = new Clipboard(".copy-link"); clipboard.value = new Clipboard('.copy-link')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
ElMessage.success("复制歌曲链接成功!"); ElMessage.success('复制歌曲链接成功!')
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
ElMessage.error("复制失败!"); ElMessage.error('复制失败!')
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
}); })
// 播放歌曲 // 播放歌曲
const play = () => { const play = () => {
playerRef.value.play(); playerRef.value.play()
}; }
const winHeight = ref(window.innerHeight - 50); const winHeight = ref(window.innerHeight - 50)
const getShareURL = (item) => { const getShareURL = (item) => {
return `${location.protocol}//${location.host}/song/${item.id}`; return `${location.protocol}//${location.host}/song/${item.id}`
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/song.styl" @import '../assets/css/song.styl'
</style> </style>

View File

@@ -5,27 +5,48 @@
<el-tooltip content="定义模式" placement="top"> <el-tooltip content="定义模式" placement="top">
<black-switch v-model:value="custom" size="large" /> <black-switch v-model:value="custom" size="large" />
</el-tooltip> </el-tooltip>
<el-tooltip content="请上传6-60秒的原始音频检测到人声的音频将仅设为私人音频。" placement="bottom-end"> <el-tooltip
<el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false" :http-request="uploadAudio" accept=".wav,.mp3"> content="请上传6-60秒的原始音频检测到人声的音频将仅设为私人音频。"
placement="bottom-end"
>
<el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadAudio"
accept=".wav,.mp3"
>
<el-button class="upload-music" round type="primary"> <el-button class="upload-music" round type="primary">
<i class="iconfont icon-upload"></i> <i class="iconfont icon-upload"></i>
<span>上传音乐</span> <span>上传音乐</span>
</el-button> </el-button>
</el-upload> </el-upload>
</el-tooltip> </el-tooltip>
<black-select v-model:value="data.model" :options="models" placeholder="请选择模型" style="width: 100px" /> <black-select
v-model:value="data.model"
:options="models"
placeholder="请选择模型"
style="width: 100px"
/>
</div> </div>
<div class="params"> <div class="params">
<div class="pure-music"> <div class="pure-music">
<span class="switch"><black-switch v-model:value="data.instrumental" size="default" /></span> <span class="switch"
><black-switch v-model:value="data.instrumental" size="default"
/></span>
<span class="text">纯音乐</span> <span class="text">纯音乐</span>
</div> </div>
<div v-if="custom"> <div v-if="custom">
<div class="item-group" v-if="!data.instrumental"> <div class="item-group" v-if="!data.instrumental">
<div class="label"> <div class="label">
<span class="text">歌词</span> <span class="text">歌词</span>
<el-popover placement="right" :width="200" trigger="hover" content="自己写歌词或寻求 AI 的帮助。使用两节歌词8 行)可获得最佳效果。"> <el-popover
placement="right"
:width="200"
trigger="hover"
content="自己写歌词或寻求 AI 的帮助。使用两节歌词8 行)可获得最佳效果。"
>
<template #reference> <template #reference>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
@@ -34,7 +55,12 @@
</el-popover> </el-popover>
</div> </div>
<div class="item" v-loading="isGenerating" element-loading-text="正在生成歌词..."> <div class="item" v-loading="isGenerating" element-loading-text="正在生成歌词...">
<black-input v-model:value="data.lyrics" type="textarea" :rows="10" :placeholder="promptPlaceholder" /> <black-input
v-model:value="data.lyrics"
type="textarea"
:rows="10"
:placeholder="promptPlaceholder"
/>
<button class="btn btn-lyric" @click="createLyric">生成歌词</button> <button class="btn btn-lyric" @click="createLyric">生成歌词</button>
</div> </div>
</div> </div>
@@ -46,7 +72,7 @@
placement="right" placement="right"
:width="200" :width="200"
trigger="hover" trigger="hover"
content="描述您想要的音乐风格(例如原声流行音乐。Sunos 模特无法识别艺术家的名字,但能够理解音乐流派和氛围。" content="描述您想要的音乐风格(例如原声流行音乐。Sunos 模特无法识别艺术家的名字,但能够理解音乐流派和氛围。"
> >
<template #reference> <template #reference>
<el-icon> <el-icon>
@@ -56,12 +82,20 @@
</el-popover> </el-popover>
</div> </div>
<div class="item"> <div class="item">
<black-input v-model:value="data.tags" type="textarea" :maxlength="120" :rows="3" placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..." /> <black-input
v-model:value="data.tags"
type="textarea"
:maxlength="120"
:rows="3"
placeholder="请输入音乐风格,多个风格之间用英文逗号隔开..."
/>
</div> </div>
<div class="tag-select"> <div class="tag-select">
<div class="inner"> <div class="inner">
<span class="tag" @click="selectTag(tag)" v-for="tag in tags" :key="tag.value">{{ tag.label }}</span> <span class="tag" @click="selectTag(tag)" v-for="tag in tags" :key="tag.value">{{
tag.label
}}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -69,7 +103,12 @@
<div class="item-group"> <div class="item-group">
<div class="label"> <div class="label">
<span class="text">歌曲名称</span> <span class="text">歌曲名称</span>
<el-popover placement="right" :width="200" trigger="hover" content="给你的歌曲起一个标题,以便于分享、发现和组织。"> <el-popover
placement="right"
:width="200"
trigger="hover"
content="给你的歌曲起一个标题,以便于分享、发现和组织。"
>
<template #reference> <template #reference>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
@@ -78,7 +117,12 @@
</el-popover> </el-popover>
</div> </div>
<div class="item"> <div class="item">
<black-input v-model:value="data.title" type="textarea" :rows="1" placeholder="请输入歌曲名称..." /> <black-input
v-model:value="data.title"
type="textarea"
:rows="1"
placeholder="请输入歌曲名称..."
/>
</div> </div>
</div> </div>
</div> </div>
@@ -100,14 +144,24 @@
</el-popover> </el-popover>
</div> </div>
<div class="item"> <div class="item">
<black-input v-model:value="data.prompt" type="textarea" :rows="10" placeholder="例如:一首关于爱情的摇滚歌曲..." /> <black-input
v-model:value="data.prompt"
type="textarea"
:rows="10"
placeholder="例如:一首关于爱情的摇滚歌曲..."
/>
</div> </div>
</div> </div>
<div class="ref-song" v-if="refSong"> <div class="ref-song" v-if="refSong">
<div class="label"> <div class="label">
<span class="text">续写</span> <span class="text">续写</span>
<el-popover placement="right" :width="200" trigger="hover" content="输入额外的歌词,根据您之前的歌词来扩展歌曲。"> <el-popover
placement="right"
:width="200"
trigger="hover"
content="输入额外的歌词,根据您之前的歌词来扩展歌曲。"
>
<template #reference> <template #reference>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
@@ -122,7 +176,9 @@
<span class="title">{{ refSong.title }}</span> <span class="title">{{ refSong.title }}</span>
<el-button type="info" @click="removeRefSong" size="small" :icon="Delete" circle /> <el-button type="info" @click="removeRefSong" size="small" :icon="Delete" circle />
</div> </div>
<div class="extend-secs"> <input v-model="refSong.extend_secs" type="text" /> 秒开始续写</div> <div class="extend-secs">
<input v-model="refSong.extend_secs" type="text" /> 秒开始续写
</div>
</div> </div>
</div> </div>
@@ -134,7 +190,11 @@
</div> </div>
</div> </div>
</div> </div>
<div class="right-box h-dvh" v-loading="loading" element-loading-background="rgba(100,100,100,0.3)"> <div
class="right-box h-dvh"
v-loading="loading"
element-loading-background="rgba(100,100,100,0.3)"
>
<div class="list-box" v-if="!noData"> <div class="list-box" v-if="!noData">
<div v-for="item in list" :key="item.id"> <div v-for="item in list" :key="item.id">
<div class="item" v-if="item.progress === 100"> <div class="item" v-if="item.progress === 100">
@@ -150,7 +210,9 @@
<div class="center"> <div class="center">
<div class="title"> <div class="title">
<a :href="'/song/' + item.song_id" target="_blank">{{ item.title }}</a> <a :href="'/song/' + item.song_id" target="_blank">{{ item.title }}</a>
<span class="model" v-if="item.major_model_version">{{ item.major_model_version }}</span> <span class="model" v-if="item.major_model_version">{{
item.major_model_version
}}</span>
<span class="model" v-if="item.type === 4">用户上传</span> <span class="model" v-if="item.type === 4">用户上传</span>
<span class="model" v-if="item.type === 3"> <span class="model" v-if="item.type === 3">
<i class="iconfont icon-mp3"></i> <i class="iconfont icon-mp3"></i>
@@ -171,7 +233,11 @@
<button class="btn btn-publish"> <button class="btn btn-publish">
<span class="text">发布</span> <span class="text">发布</span>
<black-switch v-model:value="item.publish" @change="publishJob(item)" size="small" /> <black-switch
v-model:value="item.publish"
@change="publishJob(item)"
size="small"
/>
</button> </button>
<el-tooltip content="下载歌曲" placement="top"> <el-tooltip content="下载歌曲" placement="top">
@@ -228,7 +294,12 @@
</div> </div>
</div> </div>
</div> </div>
<el-empty :image-size="100" :image="nodata" description="没有任何作品,赶紧去创作吧!" v-else /> <el-empty
:image-size="100"
:image="nodata"
description="没有任何作品,赶紧去创作吧!"
v-else
/>
<div class="pagination"> <div class="pagination">
<el-pagination <el-pagination
@@ -245,11 +316,22 @@
</div> </div>
<div class="music-player" v-if="showPlayer"> <div class="music-player" v-if="showPlayer">
<music-player :songs="playList" ref="playerRef" :show-close="true" @close="showPlayer = false" /> <music-player
:songs="playList"
ref="playerRef"
:show-close="true"
@close="showPlayer = false"
/>
</div> </div>
</div> </div>
<black-dialog v-model:show="showDialog" title="修改歌曲" @cancal="showDialog = false" @confirm="updateSong" :width="500 + 'px'"> <black-dialog
v-model:show="showDialog"
title="修改歌曲"
@cancal="showDialog = false"
@confirm="updateSong"
:width="500 + 'px'"
>
<form class="form"> <form class="form">
<div class="form-item"> <div class="form-item">
<div class="label">歌曲名称</div> <div class="label">歌曲名称</div>
@@ -258,7 +340,13 @@
<div class="form-item"> <div class="form-item">
<div class="label">封面图片</div> <div class="label">封面图片</div>
<el-upload class="avatar-uploader" :auto-upload="true" :show-file-list="false" :http-request="uploadCover" accept=".png,.jpg,.jpeg,.bmp"> <el-upload
class="avatar-uploader"
:auto-upload="true"
:show-file-list="false"
:http-request="uploadCover"
accept=".png,.jpg,.jpeg,.bmp"
>
<el-avatar :src="editData.cover" shape="square" :size="100" /> <el-avatar :src="editData.cover" shape="square" :size="100" />
</el-upload> </el-upload>
</div> </div>
@@ -268,385 +356,386 @@
</template> </template>
<script setup> <script setup>
import nodata from "@/assets/img/no-data.png"; import nodata from '@/assets/img/no-data.png'
import { nextTick, onMounted, onUnmounted, ref, watch } from "vue"; import MusicPlayer from '@/components/MusicPlayer.vue'
import { Delete, InfoFilled } from "@element-plus/icons-vue"; import BlackDialog from '@/components/ui/BlackDialog.vue'
import BlackSelect from "@/components/ui/BlackSelect.vue"; import BlackInput from '@/components/ui/BlackInput.vue'
import BlackSwitch from "@/components/ui/BlackSwitch.vue"; import BlackSelect from '@/components/ui/BlackSelect.vue'
import BlackInput from "@/components/ui/BlackInput.vue"; import BlackSwitch from '@/components/ui/BlackSwitch.vue'
import MusicPlayer from "@/components/MusicPlayer.vue"; import Generating from '@/components/ui/Generating.vue'
import { compact } from "lodash"; import { checkSession } from '@/store/cache'
import { httpDownload, httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import { closeLoading, showLoading, showMessageError, showMessageOK } from "@/utils/dialog"; import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { checkSession } from "@/store/cache"; import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from "element-plus"; import { formatTime, replaceImg } from '@/utils/libs'
import { formatTime, replaceImg } from "@/utils/libs"; import { Delete, InfoFilled } from '@element-plus/icons-vue'
import Clipboard from "clipboard"; import Clipboard from 'clipboard'
import BlackDialog from "@/components/ui/BlackDialog.vue"; import Compressor from 'compressorjs'
import Compressor from "compressorjs"; import { ElMessage, ElMessageBox } from 'element-plus'
import Generating from "@/components/ui/Generating.vue"; import { compact } from 'lodash'
import { useSharedStore } from "@/store/sharedata"; import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const custom = ref(false); const custom = ref(false)
const models = ref([ const models = ref([
{ label: "v3.0", value: "chirp-v3-0" }, { label: 'v3.0', value: 'chirp-v3-0' },
{ label: "v3.5", value: "chirp-v3-5" }, { label: 'v3.5', value: 'chirp-v3-5' },
{ label: "v4.0", value: "chirp-v4" }, { label: 'v4.0', value: 'chirp-v4' },
]); { label: 'v4.5', value: 'chirp-auk' },
])
const tags = ref([ const tags = ref([
{ label: "女声", value: "female vocals" }, { label: '女声', value: 'female vocals' },
{ label: "男声", value: "male vocals" }, { label: '男声', value: 'male vocals' },
{ label: "流行", value: "pop" }, { label: '流行', value: 'pop' },
{ label: "摇滚", value: "rock" }, { label: '摇滚', value: 'rock' },
{ label: "硬摇滚", value: "hard rock" }, { label: '硬摇滚', value: 'hard rock' },
{ label: "电音", value: "electronic" }, { label: '电音', value: 'electronic' },
{ label: "金属", value: "metal" }, { label: '金属', value: 'metal' },
{ label: "重金属", value: "heavy metal" }, { label: '重金属', value: 'heavy metal' },
{ label: "节拍", value: "beat" }, { label: '节拍', value: 'beat' },
{ label: "弱拍", value: "upbeat" }, { label: '弱拍', value: 'upbeat' },
{ label: "合成器", value: "synth" }, { label: '合成器', value: 'synth' },
{ label: "吉他", value: "guitar" }, { label: '吉他', value: 'guitar' },
{ label: "钢琴", value: "piano" }, { label: '钢琴', value: 'piano' },
{ label: "小提琴", value: "violin" }, { label: '小提琴', value: 'violin' },
{ label: "贝斯", value: "bass" }, { label: '贝斯', value: 'bass' },
{ label: "嘻哈", value: "hip hop" }, { label: '嘻哈', value: 'hip hop' },
]); ])
const data = ref({ const data = ref({
model: "chirp-v3-0", model: 'chirp-auk',
tags: "", tags: '',
lyrics: "", lyrics: '',
prompt: "", prompt: '',
title: "", title: '',
instrumental: false, instrumental: false,
ref_task_id: "", ref_task_id: '',
extend_secs: 0, extend_secs: 0,
ref_song_id: "", ref_song_id: '',
}); })
const loading = ref(false); const loading = ref(false)
const noData = ref(true); const noData = ref(true)
const playList = ref([]); const playList = ref([])
const playerRef = ref(null); const playerRef = ref(null)
const showPlayer = ref(false); const showPlayer = ref(false)
const list = ref([]); const list = ref([])
const taskPulling = ref(true); const taskPulling = ref(true)
const tastPullHandler = ref(null); const tastPullHandler = ref(null)
const btnText = ref("开始创作"); const btnText = ref('开始创作')
const refSong = ref(null); const refSong = ref(null)
const showDialog = ref(false); const showDialog = ref(false)
const editData = ref({ title: "", cover: "", id: 0 }); const editData = ref({ title: '', cover: '', id: 0 })
const promptPlaceholder = ref("请在这里输入你自己写的歌词..."); const promptPlaceholder = ref('请在这里输入你自己写的歌词...')
const store = useSharedStore(); const store = useSharedStore()
const clipboard = ref(null); const clipboard = ref(null)
onMounted(() => { onMounted(() => {
clipboard.value = new Clipboard(".copy-link"); clipboard.value = new Clipboard('.copy-link')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
ElMessage.success("复制歌曲链接成功!"); ElMessage.success('复制歌曲链接成功!')
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
ElMessage.error("复制失败!"); ElMessage.error('复制失败!')
}); })
checkSession() checkSession()
.then(() => { .then(() => {
fetchData(1); fetchData(1)
tastPullHandler.value = setInterval(() => { tastPullHandler.value = setInterval(() => {
if (taskPulling.value) { if (taskPulling.value) {
fetchData(1); fetchData(1)
} }
}, 5000); }, 5000)
}) })
.catch(() => {}); .catch(() => {})
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (tastPullHandler.value) { if (tastPullHandler.value) {
clearInterval(tastPullHandler.value); clearInterval(tastPullHandler.value)
} }
}); })
const page = ref(1); const page = ref(1)
const pageSize = ref(10); const pageSize = ref(10)
const total = ref(0); const total = ref(0)
const fetchData = (_page) => { const fetchData = (_page) => {
if (_page) { if (_page) {
page.value = _page; page.value = _page
} }
loading.value = true; loading.value = true
httpGet("/api/suno/list", { page: page.value, page_size: pageSize.value }) httpGet('/api/suno/list', { page: page.value, page_size: pageSize.value })
.then((res) => { .then((res) => {
total.value = res.data.total; total.value = res.data.total
let needPull = false; let needPull = false
const items = []; const items = []
for (let v of res.data.items) { for (let v of res.data.items) {
if (v.progress === 100) { if (v.progress === 100) {
v.major_model_version = v["raw_data"]["major_model_version"]; v.major_model_version = v['raw_data']['major_model_version']
} }
if (v.progress === 0 || v.progress === 102) { if (v.progress === 0 || v.progress === 102) {
needPull = true; needPull = true
} }
items.push(v); items.push(v)
} }
loading.value = false; loading.value = false
taskPulling.value = needPull; taskPulling.value = needPull
// 如果任务有变化,则刷新任务列表 // 如果任务有变化,则刷新任务列表
if (JSON.stringify(list.value) !== JSON.stringify(items)) { if (JSON.stringify(list.value) !== JSON.stringify(items)) {
list.value = items; list.value = items
} }
noData.value = list.value.length === 0; noData.value = list.value.length === 0
}) })
.catch((e) => { .catch((e) => {
loading.value = false; loading.value = false
noData.value = true; noData.value = true
showMessageError("获取作品列表失败:" + e.message); showMessageError('获取作品列表失败:' + e.message)
}); })
}; }
// 创建新的歌曲 // 创建新的歌曲
const create = () => { const create = () => {
data.value.type = custom.value ? 2 : 1; data.value.type = custom.value ? 2 : 1
data.value.ref_task_id = refSong.value ? refSong.value.task_id : ""; data.value.ref_task_id = refSong.value ? refSong.value.task_id : ''
data.value.ref_song_id = refSong.value ? refSong.value.song_id : ""; data.value.ref_song_id = refSong.value ? refSong.value.song_id : ''
data.value.extend_secs = refSong.value ? refSong.value.extend_secs : 0; data.value.extend_secs = refSong.value ? refSong.value.extend_secs : 0
if (refSong.value) { if (refSong.value) {
if (data.value.extend_secs > refSong.value.duration) { if (data.value.extend_secs > refSong.value.duration) {
return showMessageError("续写开始时间不能超过原歌曲长度"); return showMessageError('续写开始时间不能超过原歌曲长度')
} }
} else if (custom.value) { } else if (custom.value) {
if (data.value.lyrics === "") { if (data.value.lyrics === '') {
return showMessageError("请输入歌词"); return showMessageError('请输入歌词')
} }
if (data.value.title === "") { if (data.value.title === '') {
return showMessageError("请输入歌曲标题"); return showMessageError('请输入歌曲标题')
} }
} else { } else {
if (data.value.prompt === "") { if (data.value.prompt === '') {
return showMessageError("请输入歌曲描述"); return showMessageError('请输入歌曲描述')
} }
} }
httpPost("/api/suno/create", data.value) httpPost('/api/suno/create', data.value)
.then(() => { .then(() => {
fetchData(1); fetchData(1)
taskPulling.value = true; taskPulling.value = true
showMessageOK("创建任务成功"); showMessageOK('创建任务成功')
}) })
.catch((e) => { .catch((e) => {
showMessageError("创建任务失败:" + e.message); showMessageError('创建任务失败:' + e.message)
}); })
}; }
// 拼接歌曲 // 拼接歌曲
const merge = (item) => { const merge = (item) => {
httpPost("/api/suno/create", { song_id: item.song_id, type: 3 }) httpPost('/api/suno/create', { song_id: item.song_id, type: 3 })
.then(() => { .then(() => {
fetchData(1); fetchData(1)
taskPulling.value = true; taskPulling.value = true
showMessageOK("创建任务成功"); showMessageOK('创建任务成功')
}) })
.catch((e) => { .catch((e) => {
showMessageError("合并歌曲失败:" + e.message); showMessageError('合并歌曲失败:' + e.message)
}); })
}; }
// 下载歌曲 // 下载歌曲
const download = (item) => { const download = (item) => {
const url = replaceImg(item.audio_url); const url = replaceImg(item.audio_url)
const downloadURL = `${process.env.VUE_APP_API_HOST}/api/download?url=${url}`; const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
// parse filename // parse filename
const urlObj = new URL(url); const urlObj = new URL(url)
const fileName = urlObj.pathname.split("/").pop(); const fileName = urlObj.pathname.split('/').pop()
item.downloading = true; item.downloading = true
httpDownload(downloadURL) httpDownload(downloadURL)
.then((response) => { .then((response) => {
const blob = new Blob([response.data]); const blob = new Blob([response.data])
const link = document.createElement("a"); const link = document.createElement('a')
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob)
link.download = fileName; link.download = fileName
document.body.appendChild(link); document.body.appendChild(link)
link.click(); link.click()
document.body.removeChild(link); document.body.removeChild(link)
URL.revokeObjectURL(link.href); URL.revokeObjectURL(link.href)
item.downloading = false; item.downloading = false
}) })
.catch(() => { .catch(() => {
showMessageError("下载失败"); showMessageError('下载失败')
item.downloading = false; item.downloading = false
}); })
}; }
const uploadAudio = (file) => { const uploadAudio = (file) => {
const formData = new FormData(); const formData = new FormData()
formData.append("file", file.file, file.name); formData.append('file', file.file, file.name)
showLoading("正在上传文件..."); showLoading('正在上传文件...')
// 执行上传操作 // 执行上传操作
httpPost("/api/upload", formData) httpPost('/api/upload', formData)
.then((res) => { .then((res) => {
httpPost("/api/suno/create", { httpPost('/api/suno/create', {
audio_url: res.data.url, audio_url: res.data.url,
title: res.data.name, title: res.data.name,
type: 4, type: 4,
}) })
.then(() => { .then(() => {
fetchData(1); fetchData(1)
showMessageOK("歌曲上传成功"); showMessageOK('歌曲上传成功')
closeLoading(); closeLoading()
}) })
.catch((e) => { .catch((e) => {
showMessageError("歌曲上传失败:" + e.message); showMessageError('歌曲上传失败:' + e.message)
closeLoading(); closeLoading()
}); })
removeRefSong(); removeRefSong()
ElMessage.success({ message: "上传成功", duration: 500 }); ElMessage.success({ message: '上传成功', duration: 500 })
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("文件传失败:" + e.message); ElMessage.error('文件传失败:' + e.message)
}); })
}; }
// 续写歌曲 // 续写歌曲
const extend = (item) => { const extend = (item) => {
refSong.value = item; refSong.value = item
refSong.value.extend_secs = item.duration; refSong.value.extend_secs = item.duration
data.value.title = item.title; data.value.title = item.title
custom.value = true; custom.value = true
btnText.value = "续写歌曲"; btnText.value = '续写歌曲'
promptPlaceholder.value = "输入额外的歌词,根据您之前的歌词来扩展歌曲..."; promptPlaceholder.value = '输入额外的歌词,根据您之前的歌词来扩展歌曲...'
}; }
// 更细歌曲 // 更细歌曲
const update = (item) => { const update = (item) => {
showDialog.value = true; showDialog.value = true
editData.value.title = item.title; editData.value.title = item.title
editData.value.cover = item.cover_url; editData.value.cover = item.cover_url
editData.value.id = item.id; editData.value.id = item.id
}; }
const updateSong = () => { const updateSong = () => {
if (editData.value.title === "" || editData.value.cover === "") { if (editData.value.title === '' || editData.value.cover === '') {
return showMessageError("歌曲标题和封面不能为空"); return showMessageError('歌曲标题和封面不能为空')
} }
httpPost("/api/suno/update", editData.value) httpPost('/api/suno/update', editData.value)
.then(() => { .then(() => {
showMessageOK("更新歌曲成功"); showMessageOK('更新歌曲成功')
showDialog.value = false; showDialog.value = false
fetchData(); fetchData()
}) })
.catch((e) => { .catch((e) => {
showMessageError("更新歌曲失败:" + e.message); showMessageError('更新歌曲失败:' + e.message)
}); })
}; }
watch( watch(
() => custom.value, () => custom.value,
(newValue) => { (newValue) => {
if (!newValue) { if (!newValue) {
removeRefSong(); removeRefSong()
} }
} }
); )
const removeRefSong = () => { const removeRefSong = () => {
refSong.value = null; refSong.value = null
btnText.value = "开始创作"; btnText.value = '开始创作'
promptPlaceholder.value = "请在这里输入你自己写的歌词..."; promptPlaceholder.value = '请在这里输入你自己写的歌词...'
}; }
const play = (item) => { const play = (item) => {
playList.value = [item]; playList.value = [item]
showPlayer.value = true; showPlayer.value = true
nextTick(() => playerRef.value.play()); nextTick(() => playerRef.value.play())
}; }
const selectTag = (tag) => { const selectTag = (tag) => {
if (data.value.tags.length + tag.value.length >= 119) { if (data.value.tags.length + tag.value.length >= 119) {
return; return
} }
data.value.tags = compact([...data.value.tags.split(","), tag.value]).join(","); data.value.tags = compact([...data.value.tags.split(','), tag.value]).join(',')
}; }
const removeJob = (item) => { const removeJob = (item) => {
ElMessageBox.confirm("此操作将会删除任务相关文件,继续操作码?", "删除提示", { ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: "确认", confirmButtonText: '确认',
cancelButtonText: "取消", cancelButtonText: '取消',
type: "warning", type: 'warning',
}) })
.then(() => { .then(() => {
httpGet("/api/suno/remove", { id: item.id }) httpGet('/api/suno/remove', { id: item.id })
.then(() => { .then(() => {
ElMessage.success("任务删除成功"); ElMessage.success('任务删除成功')
fetchData(); fetchData()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("任务删除失败:" + e.message); ElMessage.error('任务删除失败:' + e.message)
}); })
}) })
.catch(() => {}); .catch(() => {})
}; }
const publishJob = (item) => { const publishJob = (item) => {
httpGet("/api/suno/publish", { id: item.id, publish: item.publish }) httpGet('/api/suno/publish', { id: item.id, publish: item.publish })
.then(() => { .then(() => {
ElMessage.success("操作成功"); ElMessage.success('操作成功')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败:" + e.message); ElMessage.error('操作失败:' + e.message)
}); })
}; }
const getShareURL = (item) => { const getShareURL = (item) => {
return `${location.protocol}//${location.host}/song/${item.song_id}`; return `${location.protocol}//${location.host}/song/${item.song_id}`
}; }
const uploadCover = (file) => { const uploadCover = (file) => {
// 压缩图片并上传 // 压缩图片并上传
new Compressor(file.file, { new Compressor(file.file, {
quality: 0.6, quality: 0.6,
success(result) { success(result) {
const formData = new FormData(); const formData = new FormData()
formData.append("file", result, result.name); formData.append('file', result, result.name)
showLoading("图片上传中..."); showLoading('图片上传中...')
// 执行上传操作 // 执行上传操作
httpPost("/api/upload", formData) httpPost('/api/upload', formData)
.then((res) => { .then((res) => {
editData.value.cover = res.data.url; editData.value.cover = res.data.url
ElMessage.success({ message: "上传成功", duration: 500 }); ElMessage.success({ message: '上传成功', duration: 500 })
closeLoading(); closeLoading()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("图片上传失败:" + e.message); ElMessage.error('图片上传失败:' + e.message)
closeLoading(); closeLoading()
}); })
}, },
error(err) { error(err) {
console.log(err.message); console.log(err.message)
}, },
}); })
}; }
const isGenerating = ref(false); const isGenerating = ref(false)
const createLyric = () => { const createLyric = () => {
if (data.value.lyrics === "") { if (data.value.lyrics === '') {
return showMessageError("请输入歌词描述"); return showMessageError('请输入歌词描述')
} }
isGenerating.value = true; isGenerating.value = true
httpPost("/api/prompt/lyric", { prompt: data.value.lyrics }) httpPost('/api/prompt/lyric', { prompt: data.value.lyrics })
.then((res) => { .then((res) => {
const lines = res.data.split("\n"); const lines = res.data.split('\n')
data.value.title = lines.shift().replace(/\*/g, ""); data.value.title = lines.shift().replace(/\*/g, '')
lines.shift(); lines.shift()
data.value.lyrics = lines.join("\n"); data.value.lyrics = lines.join('\n')
isGenerating.value = false; isGenerating.value = false
}) })
.catch((e) => { .catch((e) => {
showMessageError("歌词生成失败:" + e.message); showMessageError('歌词生成失败:' + e.message)
isGenerating.value = false; isGenerating.value = false
}); })
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/suno.styl" @import '../assets/css/suno.styl'
</style> </style>

View File

@@ -3,6 +3,12 @@
<div class="handle-box"> <div class="handle-box">
<el-input v-model="query.name" placeholder="模型名称" class="handle-input" /> <el-input v-model="query.name" placeholder="模型名称" class="handle-input" />
<el-select v-model="query.type" placeholder="模型类型" class="handle-input" clearable>
<el-option v-for="v in modelTypes" :value="v.value" :label="v.label" :key="v.value">
{{ v.label }}
</el-option>
</el-select>
<el-button :icon="Search" @click="fetchData">搜索</el-button> <el-button :icon="Search" @click="fetchData">搜索</el-button>
<el-button type="primary" :icon="Plus" @click="add">新增</el-button> <el-button type="primary" :icon="Plus" @click="add">新增</el-button>
</div> </div>
@@ -25,7 +31,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="category" label="标签" /> <el-table-column prop="tag" label="标签" />
<el-table-column prop="value" label="模型值"> <el-table-column prop="value" label="模型值">
<template #default="scope"> <template #default="scope">
<span>{{ scope.row.value }}</span> <span>{{ scope.row.value }}</span>
@@ -34,13 +40,6 @@
</el-icon> </el-icon>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="description" label="模型描述" width="180">
<template #default="scope">
<el-tooltip :content="scope.row.description || ''" placement="top" :show-after="200">
<div class="description-cell">{{ scope.row.description }}</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column prop="power" label="费率" /> <el-table-column prop="power" label="费率" />
<el-table-column prop="max_tokens" label="最大响应长度" /> <el-table-column prop="max_tokens" label="最大响应长度" />
<el-table-column prop="max_context" label="最大上下文长度" /> <el-table-column prop="max_context" label="最大上下文长度" />
@@ -79,7 +78,7 @@
<el-form :model="item" label-width="120px" ref="formRef" :rules="rules"> <el-form :model="item" label-width="120px" ref="formRef" :rules="rules">
<el-form-item label="模型类型" prop="type"> <el-form-item label="模型类型" prop="type">
<el-select v-model="item.type" placeholder="请选择模型类型"> <el-select v-model="item.type" placeholder="请选择模型类型">
<el-option v-for="v in type" :value="v.value" :label="v.label" :key="v.value"> <el-option v-for="v in modelTypes" :value="v.value" :label="v.label" :key="v.value">
{{ v.label }} {{ v.label }}
</el-option> </el-option>
</el-select> </el-select>
@@ -92,8 +91,8 @@
<el-input v-model="item.value" autocomplete="off" /> <el-input v-model="item.value" autocomplete="off" />
</el-form-item> </el-form-item>
<el-form-item label="模型标签" prop="category"> <el-form-item label="模型标签" prop="tag">
<el-input v-model="item.category" autocomplete="off" /> <el-input v-model="item.tag" autocomplete="off" />
</el-form-item> </el-form-item>
<el-form-item label="消耗算力" prop="power"> <el-form-item label="消耗算力" prop="power">
@@ -137,8 +136,8 @@
/> />
</el-form-item> </el-form-item>
<el-form-item label="模型描述" prop="description"> <el-form-item label="模型简介" prop="desc">
<el-input v-model="item.description" autocomplete="off" /> <el-input v-model="item.desc" type="textarea" :rows="3" autocomplete="off" />
</el-form-item> </el-form-item>
<el-form-item label="创意度" prop="temperature"> <el-form-item label="创意度" prop="temperature">
@@ -237,7 +236,7 @@ const rules = reactive({
}) })
const loading = ref(true) const loading = ref(true)
const formRef = ref(null) const formRef = ref(null)
const type = ref([ const modelTypes = ref([
{ label: '聊天', value: 'chat' }, { label: '聊天', value: 'chat' },
{ label: '绘图', value: 'img' }, { label: '绘图', value: 'img' },
{ label: '语音', value: 'tts' }, { label: '语音', value: 'tts' },
@@ -266,16 +265,18 @@ const fetchData = () => {
httpGet('/api/admin/model/list', query.value) httpGet('/api/admin/model/list', query.value)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
// 初始化数据 res.data.forEach((item) => {
const arr = res.data if (!item.options) {
for (let i = 0; i < arr.length; i++) { item.options = {}
arr[i].last_used_at = dateFormat(arr[i].last_used_at) }
} item.last_used_at = dateFormat(item.last_used_at)
items.value = arr })
items.value = res.data
} }
loading.value = false loading.value = false
}) })
.catch(() => { .catch((e) => {
console.error(e)
ElMessage.error('获取数据失败') ElMessage.error('获取数据失败')
}) })
} }
@@ -344,7 +345,7 @@ const add = function () {
const edit = function (row) { const edit = function (row) {
title.value = '修改模型' title.value = '修改模型'
showDialog.value = true showDialog.value = true
item.value = row item.value = Object.assign({}, row)
} }
const save = function () { const save = function () {
@@ -392,7 +393,7 @@ const remove = function (row) {
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/admin/form.styl"; @import "../../assets/css/admin/form.styl";
.model-list { .model-list {
.handle-box { .handle-box {

View File

@@ -4,10 +4,7 @@
<div class="content-box" :class="{ 'content-collapse': sidebar.collapse }"> <div class="content-box" :class="{ 'content-collapse': sidebar.collapse }">
<admin-header /> <admin-header />
<admin-tags /> <admin-tags />
<div <div :class="'content ' + theme" :style="{ height: contentHeight + 'px' }">
:class="'content ' + theme"
:style="{ height: contentHeight + 'px' }"
>
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="move" mode="out-in"> <transition name="move" mode="out-in">
<keep-alive :include="tags.nameList"> <keep-alive :include="tags.nameList">
@@ -20,41 +17,41 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import {useSidebarStore} from "@/store/sidebar"; import AdminHeader from '@/components/admin/AdminHeader.vue'
import {useTagsStore} from "@/store/tags"; import AdminSidebar from '@/components/admin/AdminSidebar.vue'
import AdminHeader from "@/components/admin/AdminHeader.vue"; import AdminTags from '@/components/admin/AdminTags.vue'
import AdminSidebar from "@/components/admin/AdminSidebar.vue"; import { checkAdminSession } from '@/store/cache'
import AdminTags from "@/components/admin/AdminTags.vue"; import { useSharedStore } from '@/store/sharedata'
import {useRouter} from "vue-router"; import { useSidebarStore } from '@/store/sidebar'
import {checkAdminSession} from "@/store/cache"; import { useTagsStore } from '@/store/tags'
import {ref, watch} from "vue"; import { ref, watch } from 'vue'
import {useSharedStore} from "@/store/sharedata"; import { useRouter } from 'vue-router'
const sidebar = useSidebarStore(); const sidebar = useSidebarStore()
const tags = useTagsStore(); const tags = useTagsStore()
const isLogin = ref(false); const isLogin = ref(false)
const contentHeight = window.innerHeight - 80; const contentHeight = window.innerHeight - 80
const store = useSharedStore(); const store = useSharedStore()
const theme = ref(store.theme); const theme = ref(store.theme)
// 获取会话信息 // 获取会话信息
const router = useRouter(); const router = useRouter()
checkAdminSession() checkAdminSession()
.then(() => { .then(() => {
isLogin.value = true; isLogin.value = true
}) })
.catch(() => { .catch(() => {
router.replace("/admin/login"); router.replace('/admin/login')
}); })
watch( watch(
() => store.theme, () => store.theme,
(val) => { (val) => {
theme.value = val; theme.value = val
} }
); )
</script> </script>
<style scoped lang="stylus"> <style lang="stylus" scoped>
@import '@/assets/css/main.styl'; @import "../../assets/css/main.styl";
</style> </style>

View File

@@ -68,8 +68,8 @@ import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const title = ref('Geek-AI Console') const title = ref('Geek-AI Console')
const username = ref(process.env.VUE_APP_ADMIN_USER) const username = ref(import.meta.env.VITE_ADMIN_USER)
const password = ref(process.env.VUE_APP_ADMIN_PASS) const password = ref(import.meta.env.VITE_ADMIN_PASS)
const logo = ref('') const logo = ref('')
const enableVerify = ref(false) const enableVerify = ref(false)
const captchaRef = ref(null) const captchaRef = ref(null)
@@ -133,7 +133,7 @@ const doLogin = function (verifyData) {
background #8d4bbb background #8d4bbb
// background-image url("~@/assets/img/transparent-bg.png") // background-image url("~@/assets/img/transparent-bg.png")
// background-repeat:repeat; // background-repeat:repeat;
background-image url("~@/assets/img/admin-login-bg.jpg") background-image url("@/assets/img/admin-login-bg.jpg")
background-size cover background-size cover
background-position center background-position center
background-repeat no-repeat background-repeat no-repeat

View File

@@ -5,7 +5,13 @@
</div> </div>
<el-row> <el-row>
<el-table :data="items" :row-key="(row) => row.id" table-layout="auto" border style="width: 100%"> <el-table
:data="items"
:row-key="(row) => row.id"
table-layout="auto"
border
style="width: 100%"
>
<el-table-column prop="name" label="菜单名称"> <el-table-column prop="name" label="菜单名称">
<template #default="scope"> <template #default="scope">
<span class="sort" :data-id="scope.row.id"> <span class="sort" :data-id="scope.row.id">
@@ -51,7 +57,12 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
开放注册 开放注册
<el-tooltip effect="dark" content="可以填写 iconfont 图标名称也可以自己上传图片" raw-content placement="right"> <el-tooltip
effect="dark"
content="可以填写 iconfont 图标名称也可以自己上传图片"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -89,49 +100,49 @@
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import { httpGet, httpPost } from '@/utils/http'
import { httpGet, httpPost } from "@/utils/http"; import { dateFormat, removeArrayItem } from '@/utils/libs'
import { ElMessage } from "element-plus"; import { InfoFilled, Plus, UploadFilled } from '@element-plus/icons-vue'
import { dateFormat, removeArrayItem } from "@/utils/libs"; import Compressor from 'compressorjs'
import { InfoFilled, Plus, UploadFilled } from "@element-plus/icons-vue"; import { ElMessage } from 'element-plus'
import { Sortable } from "sortablejs"; import { Sortable } from 'sortablejs'
import Compressor from "compressorjs"; import { onMounted, reactive, ref } from 'vue'
// 变量定义 // 变量定义
const items = ref([]); const items = ref([])
const item = ref({}); const item = ref({})
const showDialog = ref(false); const showDialog = ref(false)
const title = ref(""); const title = ref('')
const rules = reactive({ const rules = reactive({
name: [{ required: true, message: "请输入菜单名称", trigger: "change" }], name: [{ required: true, message: '请输入菜单名称', trigger: 'change' }],
icon: [{ required: true, message: "请上传菜单图标", trigger: "change" }], icon: [{ required: true, message: '请上传菜单图标', trigger: 'change' }],
url: [{ required: true, message: "请输入菜单地址", trigger: "change" }], url: [{ required: true, message: '请输入菜单地址', trigger: 'change' }],
}); })
const loading = ref(true); const loading = ref(true)
const formRef = ref(null); const formRef = ref(null)
const fetchData = () => { const fetchData = () => {
// 获取数据 // 获取数据
httpGet("/api/admin/menu/list") httpGet('/api/admin/menu/list')
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
// 初始化数据 // 初始化数据
const arr = res.data; const arr = res.data
for (let i = 0; i < arr.length; i++) { for (let i = 0; i < arr.length; i++) {
arr[i].last_used_at = dateFormat(arr[i].last_used_at); arr[i].last_used_at = dateFormat(arr[i].last_used_at)
} }
items.value = arr; items.value = arr
} }
loading.value = false; loading.value = false
}) })
.catch(() => { .catch(() => {
ElMessage.error("获取数据失败"); ElMessage.error('获取数据失败')
}); })
}; }
onMounted(() => { onMounted(() => {
const drawBodyWrapper = document.querySelector(".el-table__body tbody"); const drawBodyWrapper = document.querySelector('.el-table__body tbody')
fetchData(); fetchData()
// 初始化拖动排序插件 // 初始化拖动排序插件
Sortable.create(drawBodyWrapper, { Sortable.create(drawBodyWrapper, {
@@ -139,80 +150,82 @@ onMounted(() => {
animation: 500, animation: 500,
onEnd({ newIndex, oldIndex, from }) { onEnd({ newIndex, oldIndex, from }) {
if (oldIndex === newIndex) { if (oldIndex === newIndex) {
return; return
} }
const sortedData = Array.from(from.children).map((row) => row.querySelector(".sort").getAttribute("data-id")); const sortedData = Array.from(from.children).map((row) =>
const ids = []; row.querySelector('.sort').getAttribute('data-id')
const sorts = []; )
const ids = []
const sorts = []
sortedData.forEach((id, index) => { sortedData.forEach((id, index) => {
ids.push(parseInt(id)); ids.push(parseInt(id))
sorts.push(index + 1); sorts.push(index + 1)
items.value[index].sort_num = index + 1; items.value[index].sort_num = index + 1
}); })
httpPost("/api/admin/menu/sort", { ids: ids, sorts: sorts }).catch((e) => { httpPost('/api/admin/menu/sort', { ids: ids, sorts: sorts }).catch((e) => {
ElMessage.error("排序失败" + e.message); ElMessage.error('排序失败' + e.message)
}); })
}, },
}); })
}); })
const add = function () { const add = function () {
title.value = "新增菜单"; title.value = '新增菜单'
showDialog.value = true; showDialog.value = true
item.value = {}; item.value = {}
}; }
const edit = function (row) { const edit = function (row) {
title.value = "修改菜单"; title.value = '修改菜单'
showDialog.value = true; showDialog.value = true
item.value = row; item.value = row
}; }
const save = function () { const save = function () {
formRef.value.validate((valid) => { formRef.value.validate((valid) => {
if (valid) { if (valid) {
showDialog.value = false; showDialog.value = false
if (!item.value.id) { if (!item.value.id) {
item.value.sort_num = items.value.length + 1; item.value.sort_num = items.value.length + 1
} }
httpPost("/api/admin/menu/save", item.value) httpPost('/api/admin/menu/save', item.value)
.then(() => { .then(() => {
ElMessage.success("操作成功"); ElMessage.success('操作成功')
fetchData(); fetchData()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败" + e.message); ElMessage.error('操作失败' + e.message)
}); })
} else { } else {
return false; return false
} }
}); })
}; }
const enable = (row) => { const enable = (row) => {
httpPost("/api/admin/menu/enable", { id: row.id, enabled: row.enabled }) httpPost('/api/admin/menu/enable', { id: row.id, enabled: row.enabled })
.then(() => { .then(() => {
ElMessage.success("操作成功"); ElMessage.success('操作成功')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败" + e.message); ElMessage.error('操作失败' + e.message)
}); })
}; }
const remove = function (row) { const remove = function (row) {
httpGet("/api/admin/menu/remove?id=" + row.id) httpGet('/api/admin/menu/remove?id=' + row.id)
.then(() => { .then(() => {
ElMessage.success("删除成功"); ElMessage.success('删除成功')
items.value = removeArrayItem(items.value, row, (v1, v2) => { items.value = removeArrayItem(items.value, row, (v1, v2) => {
return v1.id === v2.id; return v1.id === v2.id
}); })
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("删除失败" + e.message); ElMessage.error('删除失败' + e.message)
}); })
}; }
// 图片上传 // 图片上传
const uploadImg = (file) => { const uploadImg = (file) => {
@@ -220,28 +233,28 @@ const uploadImg = (file) => {
new Compressor(file.file, { new Compressor(file.file, {
quality: 0.6, quality: 0.6,
success(result) { success(result) {
const formData = new FormData(); const formData = new FormData()
formData.append("file", result, result.name); formData.append('file', result, result.name)
// 执行上传操作 // 执行上传操作
httpPost("/api/admin/upload", formData) httpPost('/api/admin/upload', formData)
.then((res) => { .then((res) => {
item.value.icon = res.data.url; item.value.icon = res.data.url
ElMessage.success("上传成功"); ElMessage.success('上传成功')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("上传失败:" + e.message); ElMessage.error('上传失败:' + e.message)
}); })
}, },
error(e) { error(e) {
ElMessage.error("上传失败:" + e.message); ElMessage.error('上传失败:' + e.message)
}, },
}); })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/admin/form.styl" @import "../../assets/css/admin/form.styl"
@import "@/assets/css/main.styl" @import "../../assets/css/main.styl"
.menu { .menu {
.handle-box { .handle-box {

View File

@@ -3,7 +3,13 @@
<el-tabs v-model="activeName" class="sys-tabs"> <el-tabs v-model="activeName" class="sys-tabs">
<el-tab-pane label="系统配置" name="basic"> <el-tab-pane label="系统配置" name="basic">
<div class="container"> <div class="container">
<el-form :model="system" label-width="150px" label-position="right" ref="systemFormRef" :rules="rules"> <el-form
:model="system"
label-width="150px"
label-position="right"
ref="systemFormRef"
:rules="rules"
>
<el-tabs type="border-card"> <el-tabs type="border-card">
<el-tab-pane label="基础配置"> <el-tab-pane label="基础配置">
<el-form-item label="网站标题" prop="title"> <el-form-item label="网站标题" prop="title">
@@ -18,7 +24,12 @@
<el-form-item label="圆形 LOGO" prop="logo"> <el-form-item label="圆形 LOGO" prop="logo">
<el-input v-model="system['logo']" placeholder="正方形或者圆形 Logo"> <el-input v-model="system['logo']" placeholder="正方形或者圆形 Logo">
<template #append> <template #append>
<el-upload :auto-upload="true" :show-file-list="false" @click="beforeUpload('logo')" :http-request="uploadImg"> <el-upload
:auto-upload="true"
:show-file-list="false"
@click="beforeUpload('logo')"
:http-request="uploadImg"
>
<el-icon class="uploader-icon"> <el-icon class="uploader-icon">
<UploadFilled /> <UploadFilled />
</el-icon> </el-icon>
@@ -29,7 +40,12 @@
<el-form-item label="条形 LOGO" prop="logo"> <el-form-item label="条形 LOGO" prop="logo">
<el-input v-model="system['bar_logo']" placeholder="长方形 Logo"> <el-input v-model="system['bar_logo']" placeholder="长方形 Logo">
<template #append> <template #append>
<el-upload :auto-upload="true" :show-file-list="false" @click="beforeUpload('bar_logo')" :http-request="uploadImg"> <el-upload
:auto-upload="true"
:show-file-list="false"
@click="beforeUpload('bar_logo')"
:http-request="uploadImg"
>
<el-icon class="uploader-icon"> <el-icon class="uploader-icon">
<UploadFilled /> <UploadFilled />
</el-icon> </el-icon>
@@ -41,20 +57,43 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
首页导航菜单 首页导航菜单
<el-tooltip effect="dark" content="被选中的菜单将会在首页导航栏显示" raw-content placement="right"> <el-tooltip
effect="dark"
content="被选中的菜单将会在首页导航栏显示"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-select v-model="system['index_navs']" multiple :filterable="true" placeholder="请选择菜单,多选" style="width: 100%"> <el-select
<el-option v-for="item in menus" :key="item.id" :label="item.name" :value="item.id" /> v-model="system['index_navs']"
multiple
:filterable="true"
placeholder="请选择菜单,多选"
style="width: 100%"
>
<el-option
v-for="item in menus"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="版权信息" prop="copyright"> <el-form-item label="版权信息" prop="copyright">
<el-input v-model="system['copyright']" placeholder="更改此选项需要获取 License 授权" /> <el-input
v-model="system['copyright']"
placeholder="更改此选项需要获取 License 授权"
/>
</el-form-item>
<el-form-item label="默认昵称" prop="default_nickname">
<el-input v-model="system['default_nickname']" placeholder="默认昵称" />
</el-form-item> </el-form-item>
<el-form-item label="ICP 备案号" prop="icp"> <el-form-item label="ICP 备案号" prop="icp">
@@ -65,7 +104,12 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
开放注册 开放注册
<el-tooltip effect="dark" content="关闭注册之后只能通过管理后台添加用户" raw-content placement="right"> <el-tooltip
effect="dark"
content="关闭注册之后只能通过管理后台添加用户"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -109,7 +153,12 @@
<el-form-item label="微信客服二维码" prop="wechat_card_url"> <el-form-item label="微信客服二维码" prop="wechat_card_url">
<el-input v-model="system['wechat_card_url']" placeholder="微信客服二维码"> <el-input v-model="system['wechat_card_url']" placeholder="微信客服二维码">
<template #append> <template #append>
<el-upload :auto-upload="true" :show-file-list="false" @click="beforeUpload('wechat_card_url')" :http-request="uploadImg"> <el-upload
:auto-upload="true"
:show-file-list="false"
@click="beforeUpload('wechat_card_url')"
:http-request="uploadImg"
>
<el-icon class="uploader-icon"> <el-icon class="uploader-icon">
<UploadFilled /> <UploadFilled />
</el-icon> </el-icon>
@@ -121,15 +170,30 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
默认翻译模型 默认翻译模型
<el-tooltip effect="dark" content="选择一个默认模型来翻译提示词" raw-content placement="right"> <el-tooltip
effect="dark"
content="选择一个默认模型来翻译提示词"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-select v-model.number="system['translate_model_id']" :filterable="true" placeholder="选择一个默认模型来翻译提示词" style="width: 100%"> <el-select
<el-option v-for="item in models" :key="item.id" :label="item.name" :value="item.id" /> v-model.number="system['translate_model_id']"
:filterable="true"
placeholder="选择一个默认模型来翻译提示词"
style="width: 100%"
>
<el-option
v-for="item in models"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
@@ -140,8 +204,8 @@
<div class="tip-input-line"> <div class="tip-input-line">
<el-input-number v-model="system['context_deep']" :min="0" :max="10" /> <el-input-number v-model="system['context_deep']" :min="0" :max="10" />
<div class="tip"> <div class="tip">
会话上下文深度在老会话中继续会话默认加载多少条聊天记录作为上下文如果设置为 0 会话上下文深度在老会话中继续会话默认加载多少条聊天记录作为上下文如果设置为
则不加载聊天记录仅仅使用当前角色的上下文该配置参数必须设置需要为偶数 0 则不加载聊天记录仅仅使用当前角色的上下文该配置参数必须设置需要为偶数
</div> </div>
</div> </div>
</el-form-item> </el-form-item>
@@ -150,55 +214,98 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
SD反向提示词 SD反向提示词
<el-tooltip effect="dark" content="Stable-Diffusion 绘画默认反向提示词" raw-content placement="right"> <el-tooltip
effect="dark"
content="Stable-Diffusion 绘画默认反向提示词"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-input type="textarea" :rows="2" v-model="system['sd_neg_prompt']" placeholder="" /> <el-input
type="textarea"
:rows="2"
v-model="system['sd_neg_prompt']"
placeholder=""
/>
</el-form-item> </el-form-item>
<el-form-item label="会员充值说明" prop="order_pay_timeout"> <el-form-item label="会员充值说明" prop="order_pay_timeout">
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
会员充值说明 会员充值说明
<el-tooltip effect="dark" content="会员充值页面的充值说明文字" raw-content placement="right"> <el-tooltip
effect="dark"
content="会员充值页面的充值说明文字"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-input type="textarea" :rows="2" v-model="system['vip_info_text']" placeholder="" /> <el-input
type="textarea"
:rows="2"
v-model="system['vip_info_text']"
placeholder=""
/>
</el-form-item> </el-form-item>
<el-form-item label="MJ默认API模式" prop="mj_mode"> <el-form-item label="MJ默认API模式" prop="mj_mode">
<el-select v-model="system['mj_mode']" placeholder="请选择模式"> <el-select v-model="system['mj_mode']" placeholder="请选择模式">
<el-option v-for="item in mjModels" :value="item.value" :label="item.name" :key="item.value">{{ item.name }} </el-option> <el-option
v-for="item in mjModels"
:value="item.value"
:label="item.name"
:key="item.value"
>{{ item.name }}
</el-option>
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="上传文件限制" prop="max_file_size"> <el-form-item label="上传文件限制" prop="max_file_size">
<el-input v-model.number="system['max_file_size']" placeholder="最大上传文件大小单位MB" /> <el-input
v-model.number="system['max_file_size']"
placeholder="最大上传文件大小单位MB"
/>
</el-form-item> </el-form-item>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="算力配置"> <el-tab-pane label="算力配置">
<el-form-item label="注册赠送算力" prop="init_power"> <el-form-item label="注册赠送算力" prop="init_power">
<el-input v-model.number="system['init_power']" placeholder="新用户注册赠送算力" /> <el-input
v-model.number="system['init_power']"
placeholder="新用户注册赠送算力"
/>
</el-form-item> </el-form-item>
<el-form-item label="邀请赠送算力" prop="invite_power"> <el-form-item label="邀请赠送算力" prop="invite_power">
<el-input v-model.number="system['invite_power']" placeholder="邀请新用户注册赠送算力" /> <el-input
v-model.number="system['invite_power']"
placeholder="邀请新用户注册赠送算力"
/>
</el-form-item> </el-form-item>
<el-form-item label="VIP每月赠送算力" prop="vip_month_power"> <el-form-item label="VIP每月赠送算力" prop="vip_month_power">
<el-input v-model.number="system['vip_month_power']" placeholder="VIP用户每月赠送算力" /> <el-input
v-model.number="system['vip_month_power']"
placeholder="VIP用户每月赠送算力"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
签到赠送算力 签到赠送算力
<el-tooltip effect="dark" content="每日签到赠送算力" raw-content placement="right"> <el-tooltip
effect="dark"
content="每日签到赠送算力"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -211,7 +318,12 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
MJ绘图算力 MJ绘图算力
<el-tooltip effect="dark" content="使用MidJourney画一张图消耗算力" raw-content placement="right"> <el-tooltip
effect="dark"
content="使用MidJourney画一张图消耗算力"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -222,32 +334,54 @@
</el-form-item> </el-form-item>
<el-form-item label="Stable-Diffusion算力" prop="sd_power"> <el-form-item label="Stable-Diffusion算力" prop="sd_power">
<el-input v-model.number="system['sd_power']" placeholder="使用Stable-Diffusion画一张图消耗算力" /> <el-input
v-model.number="system['sd_power']"
placeholder="使用Stable-Diffusion画一张图消耗算力"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
DALL-E-3算力 DALL-E-3算力
<el-tooltip effect="dark" content="使用DALL-E-3画一张图消耗算力" raw-content placement="right"> <el-tooltip
effect="dark"
content="使用DALL-E-3画一张图消耗算力"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<el-input v-model.number="system['dall_power']" placeholder="使用DALL-E-3画一张图消耗算力" /> <el-input
v-model.number="system['dall_power']"
placeholder="使用DALL-E-3画一张图消耗算力"
/>
</el-form-item> </el-form-item>
<el-form-item label="Suno 算力" prop="suno_power"> <el-form-item label="Suno 算力" prop="suno_power">
<el-input v-model.number="system['suno_power']" placeholder="使用 Suno 生成一首音乐消耗算力" /> <el-input
v-model.number="system['suno_power']"
placeholder="使用 Suno 生成一首音乐消耗算力"
/>
</el-form-item> </el-form-item>
<el-form-item label="Luma 算力" prop="luma_power"> <el-form-item label="Luma 算力" prop="luma_power">
<el-input v-model.number="system['luma_power']" placeholder="使用 Luma 生成一段视频消耗算力" /> <el-input
v-model.number="system['luma_power']"
placeholder="使用 Luma 生成一段视频消耗算力"
/>
</el-form-item> </el-form-item>
<el-form-item> <el-form-item>
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
可灵算力 可灵算力
<el-tooltip effect="dark" content="可灵每个模型价格不一样具体请参考https://api.geekai.pro/models" raw-content placement="right"> <el-tooltip
effect="dark"
content="可灵每个模型价格不一样具体请参考https://api.geekai.pro/models"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -255,7 +389,11 @@
</div> </div>
</template> </template>
<el-row :gutter="20" v-if="system['keling_powers']"> <el-row :gutter="20" v-if="system['keling_powers']">
<el-col :span="6" v-for="[key] in Object.entries(system['keling_powers'])" :key="key"> <el-col
:span="6"
v-for="[key] in Object.entries(system['keling_powers'])"
:key="key"
>
<el-form-item :label="key" label-position="left"> <el-form-item :label="key" label-position="left">
<el-input v-model.number="system['keling_powers'][key]" size="small" /> <el-input v-model.number="system['keling_powers'][key]" size="small" />
</el-form-item> </el-form-item>
@@ -266,7 +404,12 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
高级语音算力 高级语音算力
<el-tooltip effect="dark" content="使用一次 OpenAI 高级语音对话消耗的算力" raw-content placement="right"> <el-tooltip
effect="dark"
content="使用一次 OpenAI 高级语音对话消耗的算力"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -279,7 +422,12 @@
<template #label> <template #label>
<div class="label-title"> <div class="label-title">
提示词算力 提示词算力
<el-tooltip effect="dark" content="生成AI绘图提示词歌词视频描述消耗的算力" raw-content placement="right"> <el-tooltip
effect="dark"
content="生成AI绘图提示词歌词视频描述消耗的算力"
raw-content
placement="right"
>
<el-icon> <el-icon>
<InfoFilled /> <InfoFilled />
</el-icon> </el-icon>
@@ -300,15 +448,54 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="公告配置" name="notice"> <el-tab-pane label="公告配置" name="notice">
<md-editor class="mgb20" v-model="notice" :theme="store.theme" @on-upload-img="onUploadImg" /> <md-editor
class="mgb20"
v-model="notice"
:theme="store.theme"
@on-upload-img="onUploadImg"
/>
<el-form-item> <el-form-item>
<div style="padding-top: 10px; margin-left: 150px"> <div style="padding-top: 10px; margin-left: 150px">
<el-button type="primary" @click="save('notice')">保存</el-button> <el-button type="primary" @click="save('notice')">保存</el-button>
</div> </div>
</el-form-item> </el-form-item>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="用户协议" name="agreement">
<md-editor
class="mgb20"
v-model="agreement"
:theme="store.theme"
@on-upload-img="onUploadImg"
/>
<el-form-item>
<div style="padding-top: 10px; margin-left: 150px">
<el-button type="primary" @click="save('agreement')">保存</el-button>
</div>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="隐私声明" name="privacy">
<md-editor
class="mgb20"
v-model="privacy"
:theme="store.theme"
@on-upload-img="onUploadImg"
/>
<el-form-item>
<div style="padding-top: 10px; margin-left: 150px">
<el-button type="primary" @click="save('privacy')">保存</el-button>
</div>
</el-form-item>
</el-tab-pane>
<el-tab-pane label="思维导图" name="mark_map"> <el-tab-pane label="思维导图" name="mark_map">
<md-editor class="mgb20" :theme="store.theme" v-model="system['mark_map_text']" @on-upload-img="onUploadImg" /> <md-editor
class="mgb20"
:theme="store.theme"
v-model="system['mark_map_text']"
@on-upload-img="onUploadImg"
/>
<el-form-item> <el-form-item>
<div style="padding-top: 10px; margin-left: 150px"> <div style="padding-top: 10px; margin-left: 150px">
<el-button type="primary" @click="save('system')">保存</el-button> <el-button type="primary" @click="save('system')">保存</el-button>
@@ -321,7 +508,13 @@
<el-tab-pane label="授权激活" name="license"> <el-tab-pane label="授权激活" name="license">
<div class="container"> <div class="container">
<el-descriptions v-if="license.is_active" class="margin-top" title="已授权信息" :column="1" border> <el-descriptions
v-if="license.is_active"
class="margin-top"
title="已授权信息"
:column="1"
border
>
<el-descriptions-item> <el-descriptions-item>
<template #label> <template #label>
<div class="cell-item">License Key</div> <div class="cell-item">License Key</div>
@@ -374,168 +567,213 @@
</div> </div>
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="修复数据" name="fixData"> <!-- <el-tab-pane label="修复数据" name="fixData">
<div class="container"> <div class="container">
<p class="text"> <p class="text">
有些版本升级的时候更新了数据库的结构比如字段名字改了需要把之前的字段的值转移到其他字段这些无法通过简单的 SQL 有些版本升级的时候更新了数据库的结构比如字段名字改了需要把之前的字段的值转移到其他字段这些无法通过简单的
语句可以实现的需要手动写程序修正数据 SQL 语句可以实现的需要手动写程序修正数据
</p> </p>
<!-- <p class="text">当前版本 v4.1.4 需要修正用户数据增加了 mobile email 字段需要把之前用手机号或者邮箱注册的用户的 username 字段数据初始化到 mobile 或者 email 字段另外需要把订单的支付渠道从名字称修正为 key</p>-->
<!-- <el-text type="danger">请注意在修复数据前请先备份好数据库以免数据丢失</el-text>-->
<div class="mt-3"> <div class="mt-3">
<el-button type="primary" @click="fixData">立即修复</el-button> <el-button type="primary" @click="fixData">立即修复</el-button>
</div> </div>
</div> </div>
</el-tab-pane> </el-tab-pane> -->
</el-tabs> </el-tabs>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, reactive, ref } from "vue"; import ItemsInput from '@/components/ui/ItemsInput.vue'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import Compressor from "compressorjs"; import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from "element-plus"; import { copyObj, dateFormat } from '@/utils/libs'
import { CloseBold, InfoFilled, Select, UploadFilled } from "@element-plus/icons-vue"; import Menu from '@/views/admin/Menu.vue'
import MdEditor from "md-editor-v3"; import { CloseBold, InfoFilled, Select, UploadFilled } from '@element-plus/icons-vue'
import "md-editor-v3/lib/style.css"; import Compressor from 'compressorjs'
import Menu from "@/views/admin/Menu.vue"; import { ElMessage } from 'element-plus'
import { copyObj, dateFormat } from "@/utils/libs"; import MdEditor from 'md-editor-v3'
import ItemsInput from "@/components/ui/ItemsInput.vue"; import 'md-editor-v3/lib/style.css'
import { useSharedStore } from "@/store/sharedata"; import { onMounted, reactive, ref } from 'vue'
const activeName = ref("basic"); const activeName = ref('basic')
const system = ref({ models: [] }); const system = ref({ models: [] })
const configBak = ref({}); const configBak = ref({})
const loading = ref(true); const loading = ref(true)
const systemFormRef = ref(null); const systemFormRef = ref(null)
const models = ref([]); const models = ref([])
const notice = ref(""); const notice = ref('')
const license = ref({ is_active: false }); const agreement = ref('')
const menus = ref([]); const privacy = ref('')
const license = ref({ is_active: false })
const menus = ref([])
const mjModels = ref([ const mjModels = ref([
{ name: "慢速Relax", value: "relax" }, { name: '慢速Relax', value: 'relax' },
{ name: "快速Fast", value: "fast" }, { name: '快速Fast', value: 'fast' },
{ name: "急速Turbo", value: "turbo" }, { name: '急速Turbo', value: 'turbo' },
]); ])
const store = useSharedStore(); const store = useSharedStore()
onMounted(() => { onMounted(() => {
// 加载系统配置 // 加载系统配置
httpGet("/api/admin/config/get?key=system") httpGet('/api/admin/config/get?key=system')
.then((res) => { .then((res) => {
system.value = res.data; system.value = res.data
system.value.keling_powers = system.value.keling_powers || { system.value.keling_powers = system.value.keling_powers || {
"kling-v1-6_std_5": 240, 'kling-v1-6_std_5': 240,
"kling-v1-6_std_10": 480, 'kling-v1-6_std_10': 480,
"kling-v1-6_pro_5": 420, 'kling-v1-6_pro_5': 420,
"kling-v1-6_pro_10": 840, 'kling-v1-6_pro_10': 840,
"kling-v1-5_std_5": 240, 'kling-v1-5_std_5': 240,
"kling-v1-5_std_10": 480, 'kling-v1-5_std_10': 480,
"kling-v1-5_pro_5": 420, 'kling-v1-5_pro_5': 420,
"kling-v1-5_pro_10": 840, 'kling-v1-5_pro_10': 840,
"kling-v1_std_5": 120, 'kling-v1_std_5': 120,
"kling-v1_std_10": 240, 'kling-v1_std_10': 240,
"kling-v1_pro_5": 420, 'kling-v1_pro_5': 420,
"kling-v1_pro_10": 840, 'kling-v1_pro_10': 840,
}; }
configBak.value = copyObj(system.value); configBak.value = copyObj(system.value)
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("加载系统配置失败: " + e.message); ElMessage.error('加载系统配置失败: ' + e.message)
}); })
// 加载聊天配置 // 加载聊天配置
httpGet("/api/admin/config/get?key=notice") httpGet('/api/admin/config/get?key=notice')
.then((res) => { .then((res) => {
notice.value = res.data["content"]; notice.value = res.data['content']
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("公告信息失败: " + e.message); ElMessage.error('公告信息失败: ' + e.message)
}); })
httpGet("/api/admin/model/list") // 加载用户协议
httpGet('/api/admin/config/get?key=agreement')
.then((res) => { .then((res) => {
models.value = res.data; agreement.value = res.data['content'] || ''
loading.value = false;
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取模型失败:" + e.message); console.warn('加载用户协议失败: ' + e.message)
}); })
httpGet("/api/admin/menu/list") // 加载隐私政策
httpGet('/api/admin/config/get?key=privacy')
.then((res) => { .then((res) => {
menus.value = res.data; privacy.value = res.data['content'] || ''
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取模型失败:" + e.message); console.warn('加载隐私政策失败: ' + e.message)
}); })
fetchLicense(); httpGet('/api/admin/model/list')
}); .then((res) => {
models.value = res.data
loading.value = false
})
.catch((e) => {
ElMessage.error('获取模型失败:' + e.message)
})
httpGet('/api/admin/menu/list')
.then((res) => {
menus.value = res.data
})
.catch((e) => {
ElMessage.error('获取模型失败:' + e.message)
})
fetchLicense()
})
const fetchLicense = () => { const fetchLicense = () => {
httpGet("/api/admin/config/license") httpGet('/api/admin/config/license')
.then((res) => { .then((res) => {
license.value = res.data; license.value = res.data
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取 License 失败:" + e.message); ElMessage.error('获取 License 失败:' + e.message)
}); })
}; }
const rules = reactive({ const rules = reactive({
title: [{ required: true, message: "请输入网站标题", trigger: "blur" }], title: [{ required: true, message: '请输入网站标题', trigger: 'blur' }],
admin_title: [{ required: true, message: "请输入控制台标题", trigger: "blur" }], admin_title: [{ required: true, message: '请输入控制台标题', trigger: 'blur' }],
init_chat_calls: [{ required: true, message: "请输入赠送对话次数", trigger: "blur" }], init_chat_calls: [{ required: true, message: '请输入赠送对话次数', trigger: 'blur' }],
user_img_calls: [{ required: true, message: "请输入赠送绘图次数", trigger: "blur" }], user_img_calls: [{ required: true, message: '请输入赠送绘图次数', trigger: 'blur' }],
}); })
const save = function (key) { const save = function (key) {
if (key === "system") { if (key === 'system') {
systemFormRef.value.validate((valid) => { systemFormRef.value.validate((valid) => {
if (valid) { if (valid) {
httpPost("/api/admin/config/update", { key: key, config: system.value, config_bak: configBak.value }) httpPost('/api/admin/config/update', {
key: key,
config: system.value,
config_bak: configBak.value,
})
.then(() => { .then(() => {
ElMessage.success("操作成功!"); ElMessage.success('操作成功!')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败:" + e.message); ElMessage.error('操作失败:' + e.message)
}); })
} }
}); })
} else if (key === "notice") { } else if (key === 'notice') {
httpPost("/api/admin/config/update", { key: key, config: { content: notice.value, updated: true } }) httpPost('/api/admin/config/update', {
key: key,
config: { content: notice.value, updated: true },
})
.then(() => { .then(() => {
ElMessage.success("操作成功!"); ElMessage.success('操作成功!')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("操作失败:" + e.message); ElMessage.error('操作失败:' + e.message)
}); })
} else if (key === 'agreement') {
httpPost('/api/admin/config/update', {
key: key,
config: { content: agreement.value, updated: true },
})
.then(() => {
ElMessage.success('操作成功!')
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
} else if (key === 'privacy') {
httpPost('/api/admin/config/update', {
key: key,
config: { content: privacy.value, updated: true },
})
.then(() => {
ElMessage.success('操作成功!')
})
.catch((e) => {
ElMessage.error('操作失败:' + e.message)
})
} }
}; }
// 激活授权 // 激活授权
const licenseKey = ref(""); const licenseKey = ref('')
const active = () => { const active = () => {
if (licenseKey.value === "") { if (licenseKey.value === '') {
return ElMessage.error("请输入授权码"); return ElMessage.error('请输入授权码')
} }
httpPost("/api/admin/config/active", { license: licenseKey.value }) httpPost('/api/admin/config/active', { license: licenseKey.value })
.then((res) => { .then((res) => {
ElMessage.success("授权成功,机器编码为:" + res.data); ElMessage.success('授权成功,机器编码为:' + res.data)
fetchLicense(); fetchLicense()
}) })
.catch((e) => { .catch((e) => {
ElMessage.error(e.message); ElMessage.error(e.message)
}); })
}; }
const configKey = ref(""); const configKey = ref('')
const beforeUpload = (key) => { const beforeUpload = (key) => {
configKey.value = key; configKey.value = key
}; }
// 图片上传 // 图片上传
const uploadImg = (file) => { const uploadImg = (file) => {
@@ -543,70 +781,70 @@ const uploadImg = (file) => {
new Compressor(file.file, { new Compressor(file.file, {
quality: 0.6, quality: 0.6,
success(result) { success(result) {
const formData = new FormData(); const formData = new FormData()
formData.append("file", result, result.name); formData.append('file', result, result.name)
// 执行上传操作 // 执行上传操作
httpPost("/api/admin/upload", formData) httpPost('/api/admin/upload', formData)
.then((res) => { .then((res) => {
system.value[configKey.value] = res.data.url; system.value[configKey.value] = res.data.url
ElMessage.success("上传成功"); ElMessage.success('上传成功')
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("上传失败:" + e.message); ElMessage.error('上传失败:' + e.message)
}); })
}, },
error(e) { error(e) {
ElMessage.error("上传失败:" + e.message); ElMessage.error('上传失败:' + e.message)
}, },
}); })
}; }
// 编辑期文件上传处理 // 编辑期文件上传处理
const onUploadImg = (files, callback) => { const onUploadImg = (files, callback) => {
Promise.all( Promise.all(
files.map((file) => { files.map((file) => {
return new Promise((rev, rej) => { return new Promise((rev, rej) => {
const formData = new FormData(); const formData = new FormData()
formData.append("file", file, file.name); formData.append('file', file, file.name)
// 执行上传操作 // 执行上传操作
httpPost("/api/admin/upload", formData) httpPost('/api/admin/upload', formData)
.then((res) => rev(res)) .then((res) => rev(res))
.catch((error) => rej(error)); .catch((error) => rej(error))
}); })
}) })
) )
.then((res) => { .then((res) => {
ElMessage.success({ message: "上传成功", duration: 500 }); ElMessage.success({ message: '上传成功', duration: 500 })
callback(res.map((item) => item.data.url)); callback(res.map((item) => item.data.url))
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("图片上传失败:" + e.message); ElMessage.error('图片上传失败:' + e.message)
}); })
}; }
const fixData = () => { // const fixData = () => {
ElMessageBox.confirm("在修复数据前,请先备份好数据库,以免数据丢失!是否继续操作?", "警告", { // ElMessageBox.confirm('在修复数据前,请先备份好数据库,以免数据丢失!是否继续操作?', '警告', {
confirmButtonText: "确定", // confirmButtonText: '确定',
cancelButtonText: "取消", // cancelButtonText: '取消',
type: "warning", // type: 'warning',
}).then(() => { // }).then(() => {
loading.value = true; // loading.value = true
httpGet("/api/admin/config/fixData") // httpGet('/api/admin/config/fixData')
.then(() => { // .then(() => {
ElMessage.success("数据修复成功"); // ElMessage.success('数据修复成功')
loading.value = false; // loading.value = false
}) // })
.catch((e) => { // .catch((e) => {
loading.value = false; // loading.value = false
ElMessage.error("数据修复失败:" + e.message); // ElMessage.error('数据修复失败:' + e.message)
}); // })
}); // })
}; // }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/admin/form.styl" @import '../../assets/css/admin/form.styl'
@import "@/assets/css/main.styl" @import '../../assets/css/main.styl'
.system-config { .system-config {
display flex display flex
justify-content center justify-content center

View File

@@ -186,7 +186,7 @@
class="chat-dialog" class="chat-dialog"
style="--el-dialog-width: 60%" style="--el-dialog-width: 60%"
> >
<div class="chat-box chat-page"> <div class="chat-box chat-page p-2">
<div v-for="item in messages" :key="item.id"> <div v-for="item in messages" :key="item.id">
<chat-prompt v-if="item.type === 'prompt'" :data="item" /> <chat-prompt v-if="item.type === 'prompt'" :data="item" />
<chat-reply v-else-if="item.type === 'reply'" :read-only="true" :data="item" /> <chat-reply v-else-if="item.type === 'reply'" :read-only="true" :data="item" />
@@ -206,6 +206,8 @@ import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import hl from 'highlight.js' import hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css' import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
// 变量定义 // 变量定义
@@ -316,8 +318,7 @@ const removeMessage = function (row) {
}) })
} }
const mathjaxPlugin = require('markdown-it-mathjax3') const md = MarkdownIt({
const md = require('markdown-it')({
breaks: true, breaks: true,
html: true, html: true,
linkify: true, linkify: true,
@@ -325,7 +326,7 @@ const md = require('markdown-it')({
highlight: function (str, lang) { highlight: function (str, lang) {
if (lang && hl.getLanguage(lang)) { if (lang && hl.getLanguage(lang)) {
// 处理代码高亮 // 处理代码高亮
const preCode = hl.highlight(lang, str, true).value const preCode = hl.highlight(str, { language: lang }).value
// 将代码包裹在 pre 中 // 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>` return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code></pre>`
} }

View File

@@ -3,8 +3,20 @@
<el-tabs v-model="activeName" @tab-change="handleChange"> <el-tabs v-model="activeName" @tab-change="handleChange">
<el-tab-pane label="Midjourney" name="mj" v-loading="data.mj.loading"> <el-tab-pane label="Midjourney" name="mj" v-loading="data.mj.loading">
<div class="handle-box"> <div class="handle-box">
<el-input v-model="data.mj.query.username" placeholder="用户名" class="handle-input mr10" @keyup="search($event, 'mj')" clearable /> <el-input
<el-input v-model="data.mj.query.prompt" placeholder="提示词" class="handle-input mr10" @keyup="search($event, 'mj')" clearable /> v-model="data.mj.query.username"
placeholder="用户名"
class="handle-input mr10"
@keyup="search($event, 'mj')"
clearable
/>
<el-input
v-model="data.mj.query.prompt"
placeholder="提示词"
class="handle-input mr10"
@keyup="search($event, 'mj')"
clearable
/>
<el-date-picker <el-date-picker
v-model="data.mj.query.created_at" v-model="data.mj.query.created_at"
type="daterange" type="daterange"
@@ -23,7 +35,9 @@
<el-table-column prop="user_id" label="用户ID" /> <el-table-column prop="user_id" label="用户ID" />
<el-table-column label="任务类型"> <el-table-column label="任务类型">
<template #default="scope"> <template #default="scope">
<el-button :color="taskTypeTheme[scope.row.type].color" size="small" plain>{{ taskTypeTheme[scope.row.type].text }}</el-button> <el-button :color="taskTypeTheme[scope.row.type].color" size="small" plain>{{
taskTypeTheme[scope.row.type].text
}}</el-button>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="progress" label="任务进度"> <el-table-column prop="progress" label="任务进度">
@@ -35,12 +49,25 @@
<el-table-column prop="power" label="消耗算力" /> <el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片"> <el-table-column label="结果图片">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="success" @click="showImage(scope.row.img_url)" v-if="scope.row.img_url !== ''" plain>预览图片</el-button> <el-button
size="small"
type="success"
@click="showImage(scope.row.img_url)"
v-if="scope.row.img_url !== ''"
plain
>预览图片</el-button
>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="提示词"> <el-table-column label="提示词">
<template #default="scope"> <template #default="scope">
<el-popover placement="top-start" title="绘画提示词" :width="300" trigger="hover" :content="scope.row.prompt"> <el-popover
placement="top-start"
title="绘画提示词"
:width="300"
trigger="hover"
:content="scope.row.prompt"
>
<template #reference> <template #reference>
<span>{{ substr(scope.row.prompt, 20) }}</span> <span>{{ substr(scope.row.prompt, 20) }}</span>
</template> </template>
@@ -49,7 +76,7 @@
</el-table-column> </el-table-column>
<el-table-column label="创建时间"> <el-table-column label="创建时间">
<template #default="scope"> <template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span> <span>{{ dateFormat(scope.row['created_at']) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="失败原因"> <el-table-column label="失败原因">
@@ -98,8 +125,20 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="Stable-Diffusion" name="sd" v-loading="data.sd.loading"> <el-tab-pane label="Stable-Diffusion" name="sd" v-loading="data.sd.loading">
<div class="handle-box"> <div class="handle-box">
<el-input v-model="data.sd.query.username" placeholder="用户名" class="handle-input mr10" @keyup="search($event, 'sd')" clearable /> <el-input
<el-input v-model="data.sd.query.prompt" placeholder="提示词" class="handle-input mr10" @keyup="search($event, 'sd')" clearable /> v-model="data.sd.query.username"
placeholder="用户名"
class="handle-input mr10"
@keyup="search($event, 'sd')"
clearable
/>
<el-input
v-model="data.sd.query.prompt"
placeholder="提示词"
class="handle-input mr10"
@keyup="search($event, 'sd')"
clearable
/>
<el-date-picker <el-date-picker
v-model="data.sd.query.created_at" v-model="data.sd.query.created_at"
type="daterange" type="daterange"
@@ -125,12 +164,25 @@
<el-table-column prop="power" label="消耗算力" /> <el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片"> <el-table-column label="结果图片">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="success" @click="showImage(scope.row.img_url)" v-if="scope.row.img_url !== ''" plain>预览图片</el-button> <el-button
size="small"
type="success"
@click="showImage(scope.row.img_url)"
v-if="scope.row.img_url !== ''"
plain
>预览图片</el-button
>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="提示词"> <el-table-column label="提示词">
<template #default="scope"> <template #default="scope">
<el-popover placement="top-start" title="绘画提示词" :width="300" trigger="hover" :content="scope.row.prompt"> <el-popover
placement="top-start"
title="绘画提示词"
:width="300"
trigger="hover"
:content="scope.row.prompt"
>
<template #reference> <template #reference>
<span>{{ substr(scope.row.prompt, 20) }}</span> <span>{{ substr(scope.row.prompt, 20) }}</span>
</template> </template>
@@ -139,7 +191,7 @@
</el-table-column> </el-table-column>
<el-table-column label="创建时间"> <el-table-column label="创建时间">
<template #default="scope"> <template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span> <span>{{ dateFormat(scope.row['created_at']) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="失败原因"> <el-table-column label="失败原因">
@@ -188,8 +240,20 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="DALL-E" name="dall"> <el-tab-pane label="DALL-E" name="dall">
<div class="handle-box"> <div class="handle-box">
<el-input v-model="data.dall.query.username" placeholder="用户名" class="handle-input mr10" @keyup="search($event, 'dall')" clearable /> <el-input
<el-input v-model="data.dall.query.prompt" placeholder="提示词" class="handle-input mr10" @keyup="search($event, 'dall')" clearable /> v-model="data.dall.query.username"
placeholder="用户名"
class="handle-input mr10"
@keyup="search($event, 'dall')"
clearable
/>
<el-input
v-model="data.dall.query.prompt"
placeholder="提示词"
class="handle-input mr10"
@keyup="search($event, 'dall')"
clearable
/>
<el-date-picker <el-date-picker
v-model="data.dall.query.created_at" v-model="data.dall.query.created_at"
type="daterange" type="daterange"
@@ -215,12 +279,25 @@
<el-table-column prop="power" label="消耗算力" /> <el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片"> <el-table-column label="结果图片">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="success" @click="showImage(scope.row.img_url)" v-if="scope.row.img_url !== ''" plain>预览图片</el-button> <el-button
size="small"
type="success"
@click="showImage(scope.row.img_url)"
v-if="scope.row.img_url !== ''"
plain
>预览图片</el-button
>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="提示词"> <el-table-column label="提示词">
<template #default="scope"> <template #default="scope">
<el-popover placement="top-start" title="绘画提示词" :width="300" trigger="hover" :content="scope.row.prompt"> <el-popover
placement="top-start"
title="绘画提示词"
:width="300"
trigger="hover"
:content="scope.row.prompt"
>
<template #reference> <template #reference>
<span>{{ substr(scope.row.prompt, 20) }}</span> <span>{{ substr(scope.row.prompt, 20) }}</span>
</template> </template>
@@ -229,7 +306,7 @@
</el-table-column> </el-table-column>
<el-table-column label="创建时间"> <el-table-column label="创建时间">
<template #default="scope"> <template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span> <span>{{ dateFormat(scope.row['created_at']) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="失败原因"> <el-table-column label="失败原因">
@@ -278,24 +355,32 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-dialog v-model="showImageDialog" title="图片预览"> <el-dialog v-model="showImageDialog" title="图片预览" style="height: 95vh; overflow: auto">
<el-image :src="imgURL" :zoom-rate="1.2" :max-scale="7" :min-scale="0.2" :preview-src-list="[imgURL]" :initial-index="0" fit="cover" /> <el-image
:src="imgURL"
:zoom-rate="1.2"
:max-scale="7"
:min-scale="0.2"
:preview-src-list="[imgURL]"
:initial-index="0"
fit="cover"
/>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from 'vue'
import { httpGet, httpPost } from "@/utils/http"; import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from "element-plus"; import { ElMessage } from 'element-plus'
import { dateFormat, substr } from "@/utils/libs"; import { dateFormat, substr } from '@/utils/libs'
import { Search } from "@element-plus/icons-vue"; import { Search } from '@element-plus/icons-vue'
// 变量定义 // 变量定义
const data = ref({ const data = ref({
mj: { mj: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 15, pageSize: 15,
@@ -303,7 +388,7 @@ const data = ref({
}, },
sd: { sd: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 15, pageSize: 15,
@@ -311,122 +396,122 @@ const data = ref({
}, },
dall: { dall: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 15, pageSize: 15,
loading: true, loading: true,
}, },
}); })
const activeName = ref("mj"); const activeName = ref('mj')
const taskTypeTheme = { const taskTypeTheme = {
image: { text: "绘图", color: "#2185d0" }, image: { text: '绘图', color: '#2185d0' },
upscale: { text: "放大", color: "#f2711c" }, upscale: { text: '放大', color: '#f2711c' },
variation: { text: "变换", color: "#00b5ad" }, variation: { text: '变换', color: '#00b5ad' },
blend: { text: "融图", color: "#21ba45" }, blend: { text: '融图', color: '#21ba45' },
swapFace: { text: "换脸", color: "#a333c8" }, swapFace: { text: '换脸', color: '#a333c8' },
}; }
onMounted(() => { onMounted(() => {
fetchMjData(); fetchMjData()
}); })
const handleChange = (tab) => { const handleChange = (tab) => {
switch (tab) { switch (tab) {
case "mj": case 'mj':
fetchMjData(); fetchMjData()
break; break
case "sd": case 'sd':
fetchSdData(); fetchSdData()
break; break
case "dall": case 'dall':
fetchDallData(); fetchDallData()
break; break
} }
}; }
// 搜索对话 // 搜索对话
const search = (evt, tab) => { const search = (evt, tab) => {
if (evt.keyCode === 13) { if (evt.keyCode === 13) {
handleChange(tab); handleChange(tab)
} }
}; }
// 获取数据 // 获取数据
const fetchMjData = () => { const fetchMjData = () => {
const d = data.value.mj; const d = data.value.mj
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
httpPost("/api/admin/image/list/mj", d.query) httpPost('/api/admin/image/list/mj', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const fetchSdData = () => { const fetchSdData = () => {
const d = data.value.sd; const d = data.value.sd
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
httpPost("/api/admin/image/list/sd", d.query) httpPost('/api/admin/image/list/sd', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const fetchDallData = () => { const fetchDallData = () => {
const d = data.value.dall; const d = data.value.dall
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
httpPost("/api/admin/image/list/dall", d.query) httpPost('/api/admin/image/list/dall', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const remove = function (row, tab) { const remove = function (row, tab) {
httpGet(`/api/admin/image/remove?id=${row.id}&tab=${tab}`) httpGet(`/api/admin/image/remove?id=${row.id}&tab=${tab}`)
.then(() => { .then(() => {
ElMessage.success("删除成功"); ElMessage.success('删除成功')
handleChange(tab); handleChange(tab)
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("删除失败" + e.message); ElMessage.error('删除失败' + e.message)
}); })
}; }
const showImageDialog = ref(false); const showImageDialog = ref(false)
const imgURL = ref(""); const imgURL = ref('')
const showImage = (url) => { const showImage = (url) => {
showImageDialog.value = true; showImageDialog.value = true
imgURL.value = url; imgURL.value = url
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>

View File

@@ -1,10 +1,28 @@
<template> <template>
<div class="container media-page"> <div class="container media-page">
<el-tabs v-model="activeName" @tab-change="handleChange"> <el-tabs v-model="activeName" @tab-change="handleChange">
<el-tab-pane v-for="media in mediaTypes" :key="media.name" :label="media.label" :name="media.name" v-loading="data[media.name].loading"> <el-tab-pane
v-for="media in mediaTypes"
:key="media.name"
:label="media.label"
:name="media.name"
v-loading="data[media.name].loading"
>
<div class="handle-box"> <div class="handle-box">
<el-input v-model="data[media.name].query.username" placeholder="用户名" class="handle-input mr10" @keyup="search($event, media.name)" clearable /> <el-input
<el-input v-model="data[media.name].query.prompt" placeholder="提示词" class="handle-input mr10" @keyup="search($event, media.name)" clearable /> v-model="data[media.name].query.username"
placeholder="用户名"
class="handle-input mr10"
@keyup="search($event, media.name)"
clearable
/>
<el-input
v-model="data[media.name].query.prompt"
placeholder="提示词"
class="handle-input mr10"
@keyup="search($event, media.name)"
clearable
/>
<el-date-picker <el-date-picker
v-model="data[media.name].query.created_at" v-model="data[media.name].query.created_at"
type="daterange" type="daterange"
@@ -28,7 +46,10 @@
<div class="duration"> <div class="duration">
{{ formatTime(scope.row.duration) }} {{ formatTime(scope.row.duration) }}
</div> </div>
<button class="play" @click="playMusic(scope.row)"> <button
class="play flex justify-center items-center"
@click="playMusic(scope.row)"
>
<img src="/images/play.svg" alt="" /> <img src="/images/play.svg" alt="" />
</button> </button>
</div> </div>
@@ -37,12 +58,27 @@
<template #default="scope" v-if="media.previewComponent === 'VideoPreview'"> <template #default="scope" v-if="media.previewComponent === 'VideoPreview'">
<div class="container"> <div class="container">
<div v-if="scope.row.progress === 100"> <div v-if="scope.row.progress === 100">
<video class="video" :src="replaceImg(scope.row.video_url)" preload="auto" loop="loop" muted="muted">您的浏览器不支持视频播放</video> <video
<button class="play" @click="playVideo(scope.row)"> class="video"
:src="replaceImg(scope.row.video_url)"
preload="auto"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放
</video>
<button
class="play flex justify-center items-center"
@click="playVideo(scope.row)"
>
<img src="/images/play.svg" alt="" /> <img src="/images/play.svg" alt="" />
</button> </button>
</div> </div>
<el-image :src="scope.row.cover_url" fit="cover" v-else-if="scope.row.progress > 100" /> <el-image
:src="scope.row.cover_url"
fit="cover"
v-else-if="scope.row.progress > 100"
/>
<generating message="正在生成视频" v-else /> <generating message="正在生成视频" v-else />
</div> </div>
</template> </template>
@@ -59,13 +95,21 @@
<el-table-column prop="play_times" label="播放次数" /> <el-table-column prop="play_times" label="播放次数" />
<el-table-column label="歌词"> <el-table-column label="歌词">
<template #default="scope"> <template #default="scope">
<el-button size="small" type="primary" plain @click="showLyric(scope.row)">查看歌词</el-button> <el-button size="small" type="primary" plain @click="showLyric(scope.row)"
>查看歌词</el-button
>
</template> </template>
</el-table-column> </el-table-column>
</template> </template>
<el-table-column label="提示词" v-if="media.previewComponent === 'VideoPreview'"> <el-table-column label="提示词" v-if="media.previewComponent === 'VideoPreview'">
<template #default="scope"> <template #default="scope">
<el-popover placement="top-start" title="提示词" :width="300" trigger="hover" :content="scope.row.prompt"> <el-popover
placement="top-start"
title="提示词"
:width="300"
trigger="hover"
:content="scope.row.prompt"
>
<template #reference> <template #reference>
<span>{{ substr(scope.row.prompt, 20) }}</span> <span>{{ substr(scope.row.prompt, 20) }}</span>
</template> </template>
@@ -74,7 +118,7 @@
</el-table-column> </el-table-column>
<el-table-column label="创建时间"> <el-table-column label="创建时间">
<template #default="scope"> <template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span> <span>{{ dateFormat(scope.row['created_at']) }}</span>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="失败原因"> <el-table-column label="失败原因">
@@ -96,7 +140,10 @@
</el-table-column> </el-table-column>
<el-table-column label="操作" width="180"> <el-table-column label="操作" width="180">
<template #default="scope"> <template #default="scope">
<el-popconfirm title="确定要删除当前记录吗?" @confirm="remove(scope.row, media.name)"> <el-popconfirm
title="确定要删除当前记录吗?"
@confirm="remove(scope.row, media.name)"
>
<template #reference> <template #reference>
<el-button size="small" type="danger">删除</el-button> <el-button size="small" type="danger">删除</el-button>
</template> </template>
@@ -123,13 +170,25 @@
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<el-dialog v-model="showVideoDialog" title="视频预览"> <el-dialog v-model="showVideoDialog" title="视频预览">
<video style="width: 100%; max-height: 90vh" :src="currentVideoUrl" preload="auto" :autoplay="true" loop="loop" muted="muted"> <video
style="width: 100%; max-height: 90vh"
:src="currentVideoUrl"
preload="auto"
:autoplay="true"
loop="loop"
muted="muted"
>
您的浏览器不支持视频播放 您的浏览器不支持视频播放
</video> </video>
</el-dialog> </el-dialog>
<div class="music-player" v-if="showPlayer"> <div class="music-player" v-if="showPlayer">
<music-player :songs="playList" ref="playerRef" :show-close="true" @close="showPlayer = false" /> <music-player
:songs="playList"
ref="playerRef"
:show-close="true"
@close="showPlayer = false"
/>
</div> </div>
<el-dialog v-model="showLyricDialog" title="歌词"> <el-dialog v-model="showLyricDialog" title="歌词">
@@ -139,18 +198,19 @@
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, ref } from "vue"; import MusicPlayer from '@/components/MusicPlayer.vue'
import { httpGet, httpPost } from "@/utils/http"; import Generating from '@/components/ui/Generating.vue'
import { ElMessage } from "element-plus"; import { httpGet, httpPost } from '@/utils/http'
import { dateFormat, formatTime, replaceImg, substr } from "@/utils/libs"; import { dateFormat, formatTime, replaceImg, substr } from '@/utils/libs'
import { Search } from "@element-plus/icons-vue"; import { Search } from '@element-plus/icons-vue'
import MusicPlayer from "@/components/MusicPlayer.vue"; import { ElMessage } from 'element-plus'
import Generating from "@/components/ui/Generating.vue"; import MarkdownIt from 'markdown-it'
import { nextTick, onMounted, ref } from 'vue'
const data = ref({ const data = ref({
suno: { suno: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -158,7 +218,7 @@ const data = ref({
}, },
luma: { luma: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
@@ -166,169 +226,169 @@ const data = ref({
}, },
keling: { keling: {
items: [], items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 }, query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0, total: 0,
page: 1, page: 1,
pageSize: 10, pageSize: 10,
loading: true, loading: true,
}, },
}); })
const mediaTypes = [ const mediaTypes = [
{ {
name: "suno", name: 'suno',
label: "Suno音乐", label: 'Suno音乐',
fetchData: () => fetchSunoData(), fetchData: () => fetchSunoData(),
previewComponent: "MusicPreview", previewComponent: 'MusicPreview',
}, },
{ {
name: "luma", name: 'luma',
label: "Luma视频", label: 'Luma视频',
fetchData: () => fetchLumaData(), fetchData: () => fetchLumaData(),
previewComponent: "VideoPreview", previewComponent: 'VideoPreview',
}, },
{ {
name: "keling", name: 'keling',
label: "可灵视频", label: '可灵视频',
fetchData: () => fetchKelingData(), fetchData: () => fetchKelingData(),
previewComponent: "VideoPreview", previewComponent: 'VideoPreview',
}, },
]; ]
const activeName = ref("suno"); const activeName = ref('suno')
const playList = ref([]); const playList = ref([])
const playerRef = ref(null); const playerRef = ref(null)
const showPlayer = ref(false); const showPlayer = ref(false)
const showLyricDialog = ref(false); const showLyricDialog = ref(false)
const lyrics = ref(""); const lyrics = ref('')
const showVideoDialog = ref(false); const showVideoDialog = ref(false)
const currentVideoUrl = ref(""); const currentVideoUrl = ref('')
onMounted(() => { onMounted(() => {
fetchSunoData(); fetchSunoData()
}); })
const handleChange = (tab) => { const handleChange = (tab) => {
switch (tab) { switch (tab) {
case "suno": case 'suno':
fetchSunoData(); fetchSunoData()
break; break
case "luma": case 'luma':
fetchLumaData(); fetchLumaData()
break; break
case "keling": case 'keling':
fetchKelingData(); fetchKelingData()
break; break
} }
}; }
// 搜索对话 // 搜索对话
const search = (evt, tab) => { const search = (evt, tab) => {
if (evt.keyCode === 13) { if (evt.keyCode === 13) {
handleChange(tab); handleChange(tab)
} }
}; }
// 获取数据 // 获取数据
const fetchSunoData = () => { const fetchSunoData = () => {
const d = data.value.suno; const d = data.value.suno
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
httpPost("/api/admin/media/suno", d.query) httpPost('/api/admin/media/suno', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const fetchLumaData = () => { const fetchLumaData = () => {
const d = data.value.luma; const d = data.value.luma
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
d.query.type = "luma"; d.query.type = 'luma'
httpPost("/api/admin/media/videos", d.query) httpPost('/api/admin/media/videos', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const fetchKelingData = () => { const fetchKelingData = () => {
const d = data.value.keling; const d = data.value.keling
d.query.page = d.page; d.query.page = d.page
d.query.page_size = d.pageSize; d.query.page_size = d.pageSize
d.query.type = "keling"; d.query.type = 'keling'
httpPost("/api/admin/media/videos", d.query) httpPost('/api/admin/media/videos', d.query)
.then((res) => { .then((res) => {
if (res.data) { if (res.data) {
d.items = res.data.items; d.items = res.data.items
d.total = res.data.total; d.total = res.data.total
d.page = res.data.page; d.page = res.data.page
d.pageSize = res.data.page_size; d.pageSize = res.data.page_size
} }
d.loading = false; d.loading = false
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取数据失败" + e.message); ElMessage.error('获取数据失败' + e.message)
}); })
}; }
const remove = function (row, tab) { const remove = function (row, tab) {
httpGet(`/api/admin/media/remove?id=${row.id}&tab=${tab}`) httpGet(`/api/admin/media/remove?id=${row.id}&tab=${tab}`)
.then(() => { .then(() => {
ElMessage.success("删除成功"); ElMessage.success('删除成功')
handleChange(tab); handleChange(tab)
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("删除失败" + e.message); ElMessage.error('删除失败' + e.message)
}) })
.finally(() => { .finally(() => {
nextTick(() => { nextTick(() => {
// data.value[tab].page = 1; // data.value[tab].page = 1;
// data.value[tab].pageSize = 10; // data.value[tab].pageSize = 10;
const mediaType = mediaTypes.find((type) => type.name === tab); const mediaType = mediaTypes.find((type) => type.name === tab)
if (mediaType && mediaType.fetchData) { if (mediaType && mediaType.fetchData) {
mediaType.fetchData(); mediaType.fetchData()
} }
}); })
}); })
}; }
const playMusic = (item) => { const playMusic = (item) => {
playList.value = [item]; playList.value = [item]
showPlayer.value = true; showPlayer.value = true
nextTick(() => playerRef.value.play()); nextTick(() => playerRef.value.play())
}; }
const playVideo = (item) => { const playVideo = (item) => {
currentVideoUrl.value = replaceImg(item.video_url); currentVideoUrl.value = replaceImg(item.video_url)
showVideoDialog.value = true; showVideoDialog.value = true
}; }
const md = require("markdown-it")({ const md = MarkdownIt({
breaks: true, breaks: true,
html: true, html: true,
linkify: true, linkify: true,
}); })
const showLyric = (item) => { const showLyric = (item) => {
showLyricDialog.value = true; showLyricDialog.value = true
lyrics.value = md.render(item.prompt); lyrics.value = md.render(item.prompt)
}; }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>

View File

@@ -15,43 +15,47 @@
<div class="chat-list-wrapper"> <div class="chat-list-wrapper">
<div id="message-list-box" class="message-list-box"> <div id="message-list-box" class="message-list-box">
<van-list <van-list
v-model:error="error" v-model:error="error"
:finished="finished" :finished="finished"
error-text="请求失败点击重新加载" error-text="请求失败点击重新加载"
@load="onLoad" @load="onLoad"
> >
<van-cell v-for="item in chatData" :key="item" :border="false" class="message-line"> <van-cell v-for="item in chatData" :key="item" :border="false" class="message-line">
<chat-prompt <chat-prompt
v-if="item.type==='prompt'" v-if="item.type === 'prompt'"
:content="item.content" :content="item.content"
:created-at="dateFormat(item['created_at'])" :created-at="dateFormat(item['created_at'])"
:icon="item.icon" :icon="item.icon"
:tokens="item['tokens']"/> :tokens="item['tokens']"
<chat-reply v-else-if="item.type==='reply'" />
:content="item.content" <chat-reply
:created-at="dateFormat(item['created_at'])" v-else-if="item.type === 'reply'"
:icon="item.icon" :content="item.content"
:org-content="item.orgContent" :created-at="dateFormat(item['created_at'])"
:tokens="item['tokens']"/> :icon="item.icon"
:org-content="item.orgContent"
:tokens="item['tokens']"
/>
</van-cell> </van-cell>
</van-list> </van-list>
</div> </div>
</div> </div>
</div>
</div><!-- end chat box --> <!-- end chat box -->
</div> </div>
</template> </template>
<script setup> <script setup>
import ChatPrompt from '@/components/mobile/ChatPrompt.vue'
import {dateFormat, processContent} from "@/utils/libs"; import ChatReply from '@/components/mobile/ChatReply.vue'
import ChatReply from "@/components/mobile/ChatReply.vue"; import { httpGet } from '@/utils/http'
import ChatPrompt from "@/components/mobile/ChatPrompt.vue"; import { dateFormat, processContent } from '@/utils/libs'
import {nextTick, ref} from "vue"; import hl from 'highlight.js'
import {useRouter} from "vue-router";
import {httpGet} from "@/utils/http";
import 'highlight.js/styles/a11y-dark.css' import 'highlight.js/styles/a11y-dark.css'
import hl from "highlight.js"; import MarkdownIt from 'markdown-it'
import {showFailToast} from "vant"; import mathjaxPlugin from 'markdown-it-mathjax3'
import { showFailToast } from 'vant'
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
const chatData = ref([]) const chatData = ref([])
const router = useRouter() const router = useRouter()
@@ -62,8 +66,7 @@ const model = ref('')
const finished = ref(false) const finished = ref(false)
const error = ref(false) const error = ref(false)
const mathjaxPlugin = require('markdown-it-mathjax3') const md = new MarkdownIt({
const md = require('markdown-it')({
breaks: true, breaks: true,
html: true, html: true,
linkify: true, linkify: true,
@@ -72,11 +75,14 @@ const md = require('markdown-it')({
const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000) const codeIndex = parseInt(Date.now()) + Math.floor(Math.random() * 10000000)
// 显示复制代码按钮 // 显示复制代码按钮
const copyBtn = `<span class="copy-code-mobile" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span> const copyBtn = `<span class="copy-code-mobile" data-clipboard-action="copy" data-clipboard-target="#copy-target-${codeIndex}">复制</span>
<textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(/<\/textarea>/g, '&lt;/textarea>')}</textarea>` <textarea style="position: absolute;top: -9999px;left: -9999px;z-index: -9999;" id="copy-target-${codeIndex}">${str.replace(
/<\/textarea>/g,
'&lt;/textarea>'
)}</textarea>`
if (lang && hl.getLanguage(lang)) { if (lang && hl.getLanguage(lang)) {
const langHtml = `<span class="lang-name">${lang}</span>` const langHtml = `<span class="lang-name">${lang}</span>`
// 处理代码高亮 // 处理代码高亮
const preCode = hl.highlight(lang, str, true).value const preCode = hl.highlight(str, { language: lang }).value
// 将代码包裹在 pre 中 // 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>` return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn} ${langHtml}</pre>`
} }
@@ -84,50 +90,52 @@ const md = require('markdown-it')({
// 处理代码高亮 // 处理代码高亮
const preCode = md.utils.escapeHtml(str) const preCode = md.utils.escapeHtml(str)
// 将代码包裹在 pre 中 // 将代码包裹在 pre 中
return `<pre class="code-container"><code class="language-${lang} hljs">${preCode}</code>${copyBtn}</pre>` return `<pre class="code-container">${code}${copyBtn}</pre>`
} },
}); })
md.use(mathjaxPlugin) md.use(mathjaxPlugin)
const onLoad = () => { const onLoad = () => {
httpGet('/api/chat/history?chat_id=' + chatId).then(res => { httpGet('/api/chat/history?chat_id=' + chatId)
// 加载状态结束 .then((res) => {
finished.value = true; // 加载状态结束
const data = res.data finished.value = true
if (data && data.length > 0) { const data = res.data
for (let i = 0; i < data.length; i++) { if (data && data.length > 0) {
if (data[i].type === "prompt") { for (let i = 0; i < data.length; i++) {
chatData.value.push(data[i]); if (data[i].type === 'prompt') {
continue; chatData.value.push(data[i])
continue
}
data[i].orgContent = data[i].content
data[i].content = md.render(processContent(data[i].content))
chatData.value.push(data[i])
} }
data[i].orgContent = data[i].content; nextTick(() => {
data[i].content = md.render(processContent(data[i].content)) hl.configure({ ignoreUnescapedHTML: true })
chatData.value.push(data[i]); const blocks = document.querySelector('#message-list-box').querySelectorAll('pre code')
} blocks.forEach((block) => {
hl.highlightElement(block)
nextTick(() => { })
hl.configure({ignoreUnescapedHTML: true})
const blocks = document.querySelector("#message-list-box").querySelectorAll('pre code');
blocks.forEach((block) => {
hl.highlightElement(block)
}) })
}) }
} })
}).catch(() => { .catch(() => {
error.value = true error.value = true
}) })
httpGet(`/api/chat/detail?chat_id=${chatId}`).then(res => {
title.value = res.data.title
model.value = res.data.model
role.value = res.data.role_name
}).catch(e => {
showFailToast('加载对话失败:' + e.message)
})
};
httpGet(`/api/chat/detail?chat_id=${chatId}`)
.then((res) => {
title.value = res.data.title
model.value = res.data.model
role.value = res.data.role_name
})
.catch((e) => {
showFailToast('加载对话失败:' + e.message)
})
}
</script> </script>
<style scoped lang="stylus"> <style scoped lang="stylus">
.chat-export-mobile { .chat-export-mobile {
@@ -171,4 +179,4 @@ const onLoad = () => {
} }
} }
} }
</style> </style>

View File

@@ -2,46 +2,43 @@
<div class="app-background"> <div class="app-background">
<div class="container mobile-chat-list"> <div class="container mobile-chat-list">
<van-nav-bar <van-nav-bar
:title="title" :title="title"
left-text="新建会话" left-text="新建会话"
@click-left="showPicker = true" @click-left="showPicker = true"
custom-class="navbar" custom-class="navbar"
> >
<template #right> <template #right>
<van-icon name="delete-o" @click="clearAllChatHistory"/> <van-icon name="delete-o" @click="clearAllChatHistory" />
</template> </template>
</van-nav-bar> </van-nav-bar>
<div class="content"> <div class="content">
<van-search <van-search
v-model="chatName" v-model="chatName"
input-align="center" input-align="center"
placeholder="请输入会话标题" placeholder="请输入会话标题"
custom-class="van-search" custom-class="van-search"
@input="search" @input="search"
/> />
<van-list <van-list
v-model:error="error" v-model:error="error"
v-model:loading="loading" v-model:loading="loading"
:finished="finished" :finished="finished"
error-text="请求失败点击重新加载" error-text="请求失败点击重新加载"
finished-text="没有更多了" finished-text="没有更多了"
@load="onLoad" @load="onLoad"
> >
<van-swipe-cell v-for="item in chats" :key="item.id"> <van-swipe-cell v-for="item in chats" :key="item.id">
<van-cell @click="changeChat(item)"> <van-cell @click="changeChat(item)">
<div class="chat-list-item"> <div class="chat-list-item">
<van-image <van-image :src="item.icon" round />
:src="item.icon"
round
/>
<div class="van-ellipsis">{{ item.title }}</div> <div class="van-ellipsis">{{ item.title }}</div>
</div> </div>
</van-cell> </van-cell>
<template #right> <template #right>
<van-button square text="修改" type="primary" @click="editChat(item)"/> <van-button square text="修改" type="primary" @click="editChat(item)" />
<van-button square text="删除" type="danger" @click="removeChat(item)"/> <van-button square text="删除" type="danger" @click="removeChat(item)" />
</template> </template>
</van-swipe-cell> </van-swipe-cell>
</van-list> </van-list>
@@ -50,43 +47,43 @@
<van-popup v-model:show="showPicker" position="bottom" class="popup"> <van-popup v-model:show="showPicker" position="bottom" class="popup">
<van-picker <van-picker
:columns="columns" :columns="columns"
title="选择模型和角色" title="选择模型和角色"
@change="onChange" @change="onChange"
@cancel="showPicker = false" @cancel="showPicker = false"
@confirm="newChat" @confirm="newChat"
> >
<template #option="item"> <template #option="item">
<div class="picker-option"> <div class="picker-option">
<van-image <van-image v-if="item.icon" :src="item.icon" fit="cover" round />
v-if="item.icon"
:src="item.icon"
fit="cover"
round
/>
<span>{{ item.text }}</span> <span>{{ item.text }}</span>
</div> </div>
</template> </template>
</van-picker> </van-picker>
</van-popup> </van-popup>
<van-dialog v-model:show="showEditChat" title="修改对话标题" show-cancel-button class="dialog" @confirm="saveTitle"> <van-dialog
<van-field v-model="tmpChatTitle" label="" placeholder="请输入对话标题" class="field"/> v-model:show="showEditChat"
title="修改对话标题"
show-cancel-button
class="dialog"
@confirm="saveTitle"
>
<van-field v-model="tmpChatTitle" label="" placeholder="请输入对话标题" class="field" />
</van-dialog> </van-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import {ref} from "vue"; import { router } from '@/router'
import {httpGet, httpPost} from "@/utils/http"; import { checkSession } from '@/store/cache'
import {showConfirmDialog, showFailToast, showSuccessToast} from "vant"; import { httpGet, httpPost } from '@/utils/http'
import {checkSession} from "@/store/cache"; import { removeArrayItem, showLoginDialog } from '@/utils/libs'
import {router} from "@/router"; import { showConfirmDialog, showFailToast, showSuccessToast } from 'vant'
import {removeArrayItem, showLoginDialog} from "@/utils/libs"; import { ref } from 'vue'
const title = ref("会话列表") const title = ref('会话列表')
const chatName = ref("") const chatName = ref('')
const chats = ref([]) const chats = ref([])
const allChats = ref([]) const allChats = ref([])
const loading = ref(false) const loading = ref(false)
@@ -100,107 +97,118 @@ const showPicker = ref(false)
const columns = ref([roles.value, models.value]) const columns = ref([roles.value, models.value])
const showEditChat = ref(false) const showEditChat = ref(false)
const item = ref({}) const item = ref({})
const tmpChatTitle = ref("") const tmpChatTitle = ref('')
checkSession().then((user) => { checkSession()
loginUser.value = user .then((user) => {
isLogin.value = true loginUser.value = user
// 加载角色列表 isLogin.value = true
httpGet(`/api/app/list/user`).then((res) => { // 加载角色列表
if (res.data) { httpGet(`/api/app/list/user`)
const items = res.data .then((res) => {
for (let i = 0; i < items.length; i++) { if (res.data) {
// console.log(items[i]) const items = res.data
roles.value.push({ for (let i = 0; i < items.length; i++) {
text: items[i].name, // console.log(items[i])
value: items[i].id, roles.value.push({
icon: items[i].icon, text: items[i].name,
helloMsg: items[i].hello_msg, value: items[i].id,
model_id: items[i].model_id icon: items[i].icon,
}) helloMsg: items[i].hello_msg,
} model_id: items[i].model_id,
} })
}).catch(() => { }
showFailToast("加载聊天角色失败") }
})
.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)
})
}) })
.catch(() => {
loading.value = false
finished.value = true
// 加载模型 // 加载角色列表
httpGet('/api/model/list?enable=1').then(res => { httpGet('/api/app/list/user')
if (res.data) { .then((res) => {
const items = res.data if (res.data) {
for (let i = 0; i < items.length; i++) { const items = res.data
models.value.push({text: items[i].name, value: items[i].id}) for (let i = 0; i < items.length; i++) {
} // console.log(items[i])
} roles.value.push({
}).catch(e => { text: items[i].name,
showFailToast("加载模型失败: " + e.message) value: items[i].id,
icon: items[i].icon,
helloMsg: items[i].hello_msg,
})
}
}
})
.catch(() => {
showFailToast('加载聊天角色失败')
})
// 加载模型
httpGet('/api/model/list')
.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)
})
}) })
}).catch(() => {
loading.value = false
finished.value = true
// 加载角色列表
httpGet('/api/app/list/user').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
})
}
}
}).catch(() => {
showFailToast("加载聊天角色失败")
})
// 加载模型
httpGet('/api/model/list').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 = () => { const onLoad = () => {
checkSession().then((user) => { checkSession()
httpGet("/api/chat/list?user_id=" + user.id).then((res) => { .then(() => {
if (res.data) { httpGet('/api/chat/list?user_id=' + loginUser.value.id)
chats.value = res.data; .then((res) => {
allChats.value = res.data; if (res.data) {
finished.value = true chats.value = res.data
} allChats.value = res.data
loading.value = false; finished.value = true
}).catch(() => { }
error.value = true loading.value = false
showFailToast("加载会话列表失败") })
.catch(() => {
error.value = true
showFailToast('加载会话列表失败')
})
}) })
}).catch(() => { .catch(() => {})
finished.value = true }
})
};
const search = () => { const search = () => {
if (chatName.value === '') { if (chatName.value === '') {
chats.value = allChats.value chats.value = allChats.value
return return
} }
const items = []; const items = []
for (let i = 0; i < allChats.value.length; i++) { for (let i = 0; i < allChats.value.length; i++) {
if (allChats.value[i].title.toLowerCase().indexOf(chatName.value.toLowerCase()) !== -1) { if (allChats.value[i].title.toLowerCase().indexOf(chatName.value.toLowerCase()) !== -1) {
items.push(allChats.value[i]); items.push(allChats.value[i])
} }
} }
chats.value = items; chats.value = items
} }
const clearAllChatHistory = () => { const clearAllChatHistory = () => {
@@ -210,17 +218,21 @@ const clearAllChatHistory = () => {
showConfirmDialog({ showConfirmDialog({
title: '操作提示', title: '操作提示',
message: '确定要删除所有的会话记录吗?' message: '确定要删除所有的会话记录吗?',
}).then(() => {
httpGet("/api/chat/clear").then(() => {
showSuccessToast('所有聊天记录已清空')
chats.value = [];
}).catch(e => {
showFailToast("操作失败:" + e.message)
})
}).catch(() => {
// on cancel
}) })
.then(() => {
httpGet('/api/chat/clear')
.then(() => {
showSuccessToast('所有聊天记录已清空')
chats.value = []
})
.catch((e) => {
showFailToast('操作失败:' + e.message)
})
})
.catch(() => {
// on cancel
})
} }
const newChat = (item) => { const newChat = (item) => {
@@ -229,7 +241,9 @@ const newChat = (item) => {
} }
showPicker.value = false showPicker.value = false
const options = item.selectedOptions const options = item.selectedOptions
router.push(`/mobile/chat/session?title=新对话&role_id=${options[0].value}&model_id=${options[1].value}`) router.push(
`/mobile/chat/session?title=新对话&role_id=${options[0].value}&model_id=${options[1].value}`
)
} }
const changeChat = (chat) => { const changeChat = (chat) => {
@@ -242,40 +256,42 @@ const editChat = (row) => {
tmpChatTitle.value = row.title tmpChatTitle.value = row.title
} }
const saveTitle = () => { const saveTitle = () => {
httpPost('/api/chat/update', {chat_id: item.value.chat_id, title: tmpChatTitle.value}).then(() => { httpPost('/api/chat/update', { chat_id: item.value.chat_id, title: tmpChatTitle.value })
showSuccessToast("操作成功!"); .then(() => {
item.value.title = tmpChatTitle.value; showSuccessToast('操作成功!')
}).catch(e => { item.value.title = tmpChatTitle.value
showFailToast("操作失败:" + e.message); })
}) .catch((e) => {
showFailToast('操作失败:' + e.message)
})
} }
const removeChat = (item) => { const removeChat = (item) => {
httpGet('/api/chat/remove?chat_id=' + item.chat_id).then(() => { httpGet('/api/chat/remove?chat_id=' + item.chat_id)
chats.value = removeArrayItem(chats.value, item, function (e1, e2) { .then(() => {
return e1.id === e2.id chats.value = removeArrayItem(chats.value, item, function (e1, e2) {
return e1.id === e2.id
})
})
.catch((e) => {
showFailToast('操作失败:' + e.message)
}) })
}).catch(e => {
showFailToast('操作失败:' + e.message);
})
} }
const onChange = (item) => { const onChange = (item) => {
const selectedValues = item.selectedOptions const selectedValues = item.selectedOptions
if (selectedValues[0].model_id) { if (selectedValues[0].model_id) {
for (let i = 0; i < columns.value[1].length; i++) { for (let i = 0; i < columns.value[1].length; i++) {
columns.value[1][i].disabled = columns.value[1][i].value !== selectedValues[0].model_id; columns.value[1][i].disabled = columns.value[1][i].value !== selectedValues[0].model_id
} }
} else { } else {
for (let i = 0; i < columns.value[1].length; i++) { for (let i = 0; i < columns.value[1].length; i++) {
columns.value[1][i].disabled = false; columns.value[1][i].disabled = false
} }
} }
} }
</script> </script>
<style lang="stylus" scoped> <style lang="stylus" scoped>
@import "@/assets/css/mobile/chat-list.styl" @import '../../assets/css/mobile/chat-list.styl'
</style> </style>

View File

@@ -120,11 +120,13 @@
<script setup> <script setup>
import ChatPrompt from '@/components/mobile/ChatPrompt.vue' import ChatPrompt from '@/components/mobile/ChatPrompt.vue'
import ChatReply from '@/components/mobile/ChatReply.vue' import ChatReply from '@/components/mobile/ChatReply.vue'
import { checkSession, getClientId } from '@/store/cache' import { checkSession } from '@/store/cache'
import { getUserToken } from '@/store/session'
import { useSharedStore } from '@/store/sharedata' import { useSharedStore } from '@/store/sharedata'
import { showMessageError } from '@/utils/dialog' import { showMessageError } from '@/utils/dialog'
import { httpGet } from '@/utils/http' import { httpGet } from '@/utils/http'
import { processContent, randString, renderInputText, UUID } from '@/utils/libs' import { processContent, randString, renderInputText, UUID } from '@/utils/libs'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import Clipboard from 'clipboard' import Clipboard from 'clipboard'
import hl from 'highlight.js' import hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css' import 'highlight.js/styles/a11y-dark.css'
@@ -134,6 +136,7 @@ import mathjaxPlugin from 'markdown-it-mathjax3'
import { showImagePreview, showNotify, showToast } from 'vant' import { showImagePreview, showNotify, showToast } from 'vant'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const winHeight = ref(0) const winHeight = ref(0)
const navBarRef = ref(null) const navBarRef = ref(null)
const bottomBarRef = ref(null) const bottomBarRef = ref(null)
@@ -267,65 +270,10 @@ onMounted(() => {
clipboard.on('error', () => { clipboard.on('error', () => {
showNotify({ type: 'danger', message: '复制失败', duration: 2000 }) showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
}) })
store.addMessageHandler('chat', (data) => {
if (data.channel !== 'chat' || data.clientId !== getClientId()) {
return
}
if (data.type === 'error') {
showMessageError(data.body)
return
}
if (isNewMsg.value) {
if (!title.value) {
title.value = previousText.value
}
lineBuffer.value = data.body
isNewMsg.value = false
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'] = lineBuffer.value
}
} else if (data.type === 'end') {
// 消息接收完毕
enableInput()
lineBuffer.value = '' // 清空缓冲
isNewMsg.value = true
} else {
lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1]
reply['orgContent'] = lineBuffer.value
reply['content'] = md.render(processContent(lineBuffer.value))
nextTick(() => {
hl.configure({ ignoreUnescapedHTML: true })
const lines = document.querySelectorAll('.message-line')
const blocks = lines[lines.length - 1].querySelectorAll('pre code')
blocks.forEach((block) => {
hl.highlightElement(block)
})
scrollListBox()
const items = document.querySelectorAll('.message-line')
const imgs = items[items.length - 1].querySelectorAll('img')
for (let i = 0; i < imgs.length; i++) {
if (!imgs[i].src) {
continue
}
imgs[i].addEventListener('click', (e) => {
e.stopPropagation()
showImagePreview([imgs[i].src])
})
}
})
}
})
}) })
onUnmounted(() => { onUnmounted(() => {
store.removeMessageHandler('chat') // Remove WebSocket handler cleanup
}) })
const newChat = (item) => { const newChat = (item) => {
@@ -360,7 +308,9 @@ const loadChatHistory = () => {
type: 'reply', type: 'reply',
id: randString(32), id: randString(32),
icon: role.icon, icon: role.icon,
content: role.hello_msg, content: {
text: role.hello_msg,
},
orgContent: role.hello_msg, orgContent: role.hello_msg,
}) })
return return
@@ -373,7 +323,7 @@ const loadChatHistory = () => {
} }
data[i].orgContent = data[i].content data[i].orgContent = data[i].content
data[i].content = md.render(processContent(data[i].content)) data[i].content.text = md.render(processContent(data[i].content.text))
chatData.value.push(data[i]) chatData.value.push(data[i])
} }
@@ -427,17 +377,106 @@ const scrollListBox = () => {
.scrollTo(0, document.getElementById('message-list-box').scrollHeight + 46) .scrollTo(0, document.getElementById('message-list-box').scrollHeight + 46)
} }
// 发送 SSE 请求
const sendSSERequest = async (message) => {
try {
await fetchEventSource('/api/chat/message', {
method: 'POST',
headers: {
Authorization: getUserToken(),
},
body: JSON.stringify(message),
openWhenHidden: true,
onopen(response) {
if (response.ok && response.status === 200) {
console.log('SSE connection opened')
} else {
throw new Error(`Failed to open SSE connection: ${response.status}`)
}
},
onmessage(msg) {
try {
const data = JSON.parse(msg.data)
if (data.type === 'error') {
showMessageError(data.body)
enableInput()
return
}
if (data.type === 'end') {
enableInput()
lineBuffer.value = '' // 清空缓冲
isNewMsg.value = true
return
}
if (data.type === 'text') {
if (isNewMsg.value) {
isNewMsg.value = false
lineBuffer.value = data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content']['text'] = lineBuffer.value
}
} else {
lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1]
reply['orgContent'] = lineBuffer.value
reply['content']['text'] = md.render(processContent(lineBuffer.value))
nextTick(() => {
hl.configure({ ignoreUnescapedHTML: true })
const lines = document.querySelectorAll('.message-line')
const blocks = lines[lines.length - 1].querySelectorAll('pre code')
blocks.forEach((block) => {
hl.highlightElement(block)
})
scrollListBox()
const items = document.querySelectorAll('.message-line')
const imgs = items[items.length - 1].querySelectorAll('img')
for (let i = 0; i < imgs.length; i++) {
if (!imgs[i].src) {
continue
}
imgs[i].addEventListener('click', (e) => {
e.stopPropagation()
showImagePreview([imgs[i].src])
})
}
})
}
}
} catch (error) {
console.error('Error processing message:', error)
enableInput()
showMessageError('消息处理出错,请重试')
}
},
onerror(err) {
console.error('SSE Error:', err)
enableInput()
showMessageError('连接已断开,请重试')
},
onclose() {
console.log('SSE connection closed')
enableInput()
},
})
} catch (error) {
console.error('Failed to send message:', error)
enableInput()
showMessageError('发送消息失败,请重试')
}
}
// 发送消息
const sendMessage = () => { const sendMessage = () => {
if (canSend.value === false) { if (canSend.value === false) {
showToast('AI 正在作答中,请稍后...') showToast('AI 正在作答中,请稍后...')
return return
} }
if (store.socket.conn.readyState !== WebSocket.OPEN) {
showToast('连接断开,正在重连...')
return
}
if (prompt.value.trim().length === 0) { if (prompt.value.trim().length === 0) {
showToast('请输入需要 AI 回答的问题') showToast('请输入需要 AI 回答的问题')
return false return false
@@ -448,7 +487,7 @@ const sendMessage = () => {
type: 'prompt', type: 'prompt',
id: randString(32), id: randString(32),
icon: loginUser.value.avatar, icon: loginUser.value.avatar,
content: renderInputText(prompt.value), content: { text: renderInputText(prompt.value) },
created_at: new Date().getTime(), created_at: new Date().getTime(),
}) })
// 添加空回复消息 // 添加空回复消息
@@ -459,7 +498,9 @@ const sendMessage = () => {
type: 'reply', type: 'reply',
id: randString(32), id: randString(32),
icon: _role['icon'], icon: _role['icon'],
content: '', content: {
text: '',
},
}) })
nextTick(() => { nextTick(() => {
@@ -467,31 +508,23 @@ const sendMessage = () => {
}) })
disableInput(false) disableInput(false)
store.socket.conn.send(
JSON.stringify({ // 发送 SSE 请求
channel: 'chat', sendSSERequest({
type: 'text', user_id: loginUser.value.id,
body: { role_id: roleId.value,
role_id: roleId.value, model_id: modelId.value,
model_id: modelId.value, chat_id: chatId.value,
chat_id: chatId.value, prompt: prompt.value,
content: prompt.value, stream: stream.value,
stream: stream.value, })
},
})
)
previousText.value = prompt.value previousText.value = prompt.value
prompt.value = '' prompt.value = ''
return true return true
} }
const stopGenerate = () => { // 重新生成
showStopGenerate.value = false
httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => {
enableInput()
})
}
const reGenerate = () => { const reGenerate = () => {
disableInput(false) disableInput(false)
const text = '重新生成上述问题的答案:' + previousText.value const text = '重新生成上述问题的答案:' + previousText.value
@@ -502,19 +535,16 @@ const reGenerate = () => {
icon: loginUser.value.avatar, icon: loginUser.value.avatar,
content: renderInputText(text), content: renderInputText(text),
}) })
store.socket.conn.send(
JSON.stringify({ // 发送 SSE 请求
channel: 'chat', sendSSERequest({
type: 'text', user_id: loginUser.value.id,
body: { role_id: roleId.value,
role_id: roleId.value, model_id: modelId.value,
model_id: modelId.value, chat_id: chatId.value,
chat_id: chatId.value, prompt: previousText.value,
content: previousText.value, stream: stream.value,
stream: stream.value, })
},
})
)
} }
const showShare = ref(false) const showShare = ref(false)
@@ -601,6 +631,6 @@ const onChange = (item) => {
} }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/mobile/chat-session.styl" @import "../../assets/css/mobile/chat-session.styl"
</style> </style>

View File

@@ -1,37 +1,36 @@
<template> <template>
<van-config-provider :theme="theme"> <van-config-provider :theme="theme">
<div class="mobile-home"> <div class="mobile-home">
<router-view/> <router-view />
<van-tabbar route v-model="active"> <van-tabbar route v-model="active">
<van-tabbar-item to="/mobile/index" name="home" icon="home-o">首页</van-tabbar-item> <van-tabbar-item to="/mobile/index" name="home" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item to="/mobile/chat" name="chat" icon="chat-o">对话</van-tabbar-item> <van-tabbar-item to="/mobile/chat" name="chat" icon="chat-o">对话</van-tabbar-item>
<van-tabbar-item to="/mobile/image" name="image" icon="photo-o">绘图</van-tabbar-item> <van-tabbar-item to="/mobile/image" name="image" icon="photo-o">绘图</van-tabbar-item>
<van-tabbar-item to="/mobile/profile" name="profile" icon="user-o">我的 <van-tabbar-item to="/mobile/profile" name="profile" icon="user-o">我的 </van-tabbar-item>
</van-tabbar-item>
</van-tabbar> </van-tabbar>
</div> </div>
</van-config-provider> </van-config-provider>
</template> </template>
<script setup> <script setup>
import {ref, watch} from "vue"; import { useSharedStore } from '@/store/sharedata'
import {useSharedStore} from "@/store/sharedata"; import { ref, watch } from 'vue'
const active = ref('home') const active = ref('home')
const store = useSharedStore() const store = useSharedStore()
const theme = ref(store.theme) const theme = ref(store.theme)
watch(() => store.theme, (val) => { watch(
theme.value = val () => store.theme,
}) (val) => {
theme.value = val
}
)
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import '@/assets/iconfont/iconfont.css'; @import '../../assets/iconfont/iconfont.css';
.mobile-home { .mobile-home {
.container { .container {
.van-nav-bar { .van-nav-bar {
@@ -53,4 +52,4 @@ watch(() => store.theme, (val) => {
position fixed position fixed
width 100% width 100%
} }
</style> </style>

View File

@@ -93,7 +93,7 @@ import { showNotify } from 'vant'
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
const title = ref(process.env.VUE_APP_TITLE) const title = ref(import.meta.env.VITE_TITLE)
const router = useRouter() const router = useRouter()
const isLogin = ref(false) const isLogin = ref(false)
const apps = ref([]) const apps = ref([])

View File

@@ -30,28 +30,36 @@
<div class="opt" v-if="isLogin"> <div class="opt" v-if="isLogin">
<van-row :gutter="10"> <van-row :gutter="10">
<van-col :span="8"> <van-col :span="8">
<van-button round block @click="showPasswordDialog = true" size="small">修改密码</van-button> <van-button round block @click="showPasswordDialog = true" size="small"
>修改密码</van-button
>
</van-col> </van-col>
<van-col :span="8"> <van-col :span="8">
<van-button round block @click="logout" size="small">退出登录</van-button> <van-button round block @click="logout" size="small">退出登录</van-button>
</van-col> </van-col>
<van-col :span="8"> <van-col :span="8">
<van-button round block @click="showSettings = true" icon="setting" size="small">设置</van-button> <van-button round block @click="showSettings = true" icon="setting" size="small"
>设置</van-button
>
</van-col> </van-col>
</van-row> </van-row>
</div> </div>
<div class="product-list"> <div class="product-list" v-if="menuList['/member']">
<h3>充值套餐</h3> <h3 class="py-3">充值套餐</h3>
<div class="item" v-for="item in products" :key="item.id"> <div class="item" v-for="item in products" :key="item.id">
<div class="title"> <div class="title">
<span class="name">{{ item.name }}</span> <span class="name">{{ item.name }}</span>
<div class="pay-btn"> <div class="pay-btn">
<div v-for="payWay in payWays" @click="pay(item, payWay)" :key="payWay"> <div v-for="payWay in payWays" @click="pay(item, payWay)" :key="payWay">
<span> <span>
<van-button type="primary" size="small" v-if="payWay.pay_type === 'alipay'"> <i class="iconfont icon-alipay"></i> 支付宝 </van-button> <van-button type="primary" size="small" v-if="payWay.pay_type === 'alipay'">
<van-button type="success" size="small" v-if="payWay.pay_type === 'wxpay'"> <i class="iconfont icon-wechat-pay"></i> 微信支付 </van-button> <i class="iconfont icon-alipay"></i> 支付宝
</van-button>
<van-button type="success" size="small" v-if="payWay.pay_type === 'wxpay'">
<i class="iconfont icon-wechat-pay"></i> 微信支付
</van-button>
</span> </span>
</div> </div>
</div> </div>
@@ -100,7 +108,10 @@
<van-cell-group inset> <van-cell-group inset>
<van-field name="switch" label="暗黑主题"> <van-field name="switch" label="暗黑主题">
<template #input> <template #input>
<van-switch v-model="dark" @change="(val) => store.setTheme(val ? 'dark' : 'light')" /> <van-switch
v-model="dark"
@change="(val) => store.setTheme(val ? 'dark' : 'light')"
/>
</template> </template>
</van-field> </van-field>
@@ -130,163 +141,151 @@
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import { showFailToast, showLoadingToast, showNotify, showSuccessToast } from "vant"; import { removeUserToken } from '@/store/session'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import { dateFormat, showLoginDialog } from "@/utils/libs"; import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from "element-plus"; import { dateFormat, showLoginDialog } from '@/utils/libs'
import { checkSession, getSystemInfo } from "@/store/cache"; import { ElMessage } from 'element-plus'
import { useRouter } from "vue-router"; import { showFailToast, showLoadingToast, showNotify, showSuccessToast } from 'vant'
import { removeUserToken } from "@/store/session"; import { onMounted, ref } from 'vue'
import { useSharedStore } from "@/store/sharedata"; import { useRouter } from 'vue-router'
const form = ref({ const form = ref({
username: "GeekMaster", username: 'GeekMaster',
nickname: "极客学长@001", nickname: '极客学长@001',
mobile: "1300000000", mobile: '1300000000',
avatar: "", avatar: '',
power: 0, power: 0,
}); })
const fileList = ref([ const fileList = ref([
{ {
url: "/images/user-info.png", url: '/images/user-info.png',
message: "上传中...", message: '上传中...',
}, },
]); ])
const products = ref([]); const products = ref([])
const vipMonthPower = ref(0); const vipMonthPower = ref(0)
const payWays = ref({}); const payWays = ref({})
const router = useRouter(); const router = useRouter()
const userId = ref(0); const userId = ref(0)
const isLogin = ref(false); const isLogin = ref(false)
const showSettings = ref(false); const showSettings = ref(false)
const store = useSharedStore(); const store = useSharedStore()
const stream = ref(store.chatStream); const stream = ref(store.chatStream)
const dark = ref(store.theme === "dark"); const dark = ref(store.theme === 'dark')
const menuList = ref({})
onMounted(() => { onMounted(() => {
checkSession() checkSession()
.then((user) => { .then((user) => {
userId.value = user.id; userId.value = user.id
isLogin.value = true; isLogin.value = true
httpGet("/api/user/profile") httpGet('/api/user/profile')
.then((res) => { .then((res) => {
form.value = res.data; form.value = res.data
fileList.value[0].url = form.value.avatar; fileList.value[0].url = form.value.avatar
}) })
.catch((e) => { .catch((e) => {
console.log(e.message); console.log(e.message)
showFailToast("获取用户信息失败"); showFailToast('获取用户信息失败')
}); })
}) })
.catch(() => {}); .catch(() => {})
// 获取产品列表 // 获取产品列表
httpGet("/api/product/list") httpGet('/api/product/list')
.then((res) => { .then((res) => {
products.value = res.data; products.value = res.data
}) })
.catch((e) => { .catch((e) => {
showFailToast("获取产品套餐失败:" + e.message); showFailToast('获取产品套餐失败:' + e.message)
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
vipMonthPower.value = res.data["vip_month_power"]; vipMonthPower.value = res.data['vip_month_power']
}) })
.catch((e) => { .catch((e) => {
showFailToast("获取系统配置失败:" + e.message); showFailToast('获取系统配置失败:' + e.message)
}); })
httpGet("/api/payment/payWays") httpGet('/api/payment/payWays')
.then((res) => { .then((res) => {
payWays.value = res.data; payWays.value = res.data
}) })
.catch((e) => { .catch((e) => {
ElMessage.error("获取支付方式失败:" + e.message); ElMessage.error('获取支付方式失败:' + e.message)
}); })
});
// const afterRead = (file) => { getMenuList()
// file.status = 'uploading'; })
// file.message = '上传中...';
// // 压缩图片并上传
// new Compressor(file.file, {
// quality: 0.6,
// success(result) {
// const formData = new FormData();
// formData.append('file', result, result.name);
// // 执行上传操作
// httpPost('/api/upload', formData).then((res) => {
// form.value.avatar = res.data.url
// file.status = 'success'
// httpPost('/api/user/profile/update', form.value).then(() => {
// showSuccessToast('上传成功')
// }).catch(() => {
// showFailToast('上传失败')
// })
// }).catch((e) => {
// showNotify({type: 'danger', message: '上传失败:' + e.message})
// })
// },
// error(err) {
// console.log(err.message);
// },
// });
// }
const showPasswordDialog = ref(false); // 获取菜单列表
const getMenuList = () => {
httpGet('/api/menu/list')
.then((res) => {
res.data.forEach((item) => {
menuList.value[item.url] = item
})
})
.catch((e) => {
showFailToast('获取菜单列表失败:' + e.message)
})
}
const showPasswordDialog = ref(false)
const pass = ref({ const pass = ref({
old: "", old: '',
new: "", new: '',
renew: "", renew: '',
}); })
const beforeClose = (action) => { const beforeClose = (action) => {
new Promise((resolve) => { new Promise((resolve) => {
resolve(action === "confirm"); resolve(action === 'confirm')
}); })
}; }
// 提交修改密码 // 提交修改密码
const updatePass = () => { const updatePass = () => {
if (pass.value.old === "") { if (pass.value.old === '') {
return showNotify({ type: "danger", message: "请输入旧密码" }); return showNotify({ type: 'danger', message: '请输入旧密码' })
} }
if (!pass.value.new || pass.value.new.length < 8) { if (!pass.value.new || pass.value.new.length < 8) {
return showNotify({ type: "danger", message: "密码的长度为8-16个字符" }); return showNotify({ type: 'danger', message: '密码的长度为8-16个字符' })
} }
if (pass.value.renew !== pass.value.new) { if (pass.value.renew !== pass.value.new) {
return showNotify({ type: "danger", message: "两次输入密码不一致" }); return showNotify({ type: 'danger', message: '两次输入密码不一致' })
} }
httpPost("/api/user/password", { httpPost('/api/user/password', {
old_pass: pass.value.old, old_pass: pass.value.old,
password: pass.value.new, password: pass.value.new,
repass: pass.value.renew, repass: pass.value.renew,
}) })
.then(() => { .then(() => {
showSuccessToast("更新成功!"); showSuccessToast('更新成功!')
showPasswordDialog.value = false; showPasswordDialog.value = false
}) })
.catch((e) => { .catch((e) => {
showFailToast("更新失败," + e.message); showFailToast('更新失败,' + e.message)
showPasswordDialog.value = false; showPasswordDialog.value = false
}); })
}; }
const pay = (product, payWay) => { const pay = (product, payWay) => {
if (!isLogin.value) { if (!isLogin.value) {
return showLoginDialog(router); return showLoginDialog(router)
} }
showLoadingToast({ showLoadingToast({
message: "正在创建订单", message: '正在创建订单',
forbidClick: true, forbidClick: true,
}); })
let host = process.env.VUE_APP_API_HOST; let host = process.env.VUE_APP_API_HOST
if (host === "") { if (host === '') {
host = `${location.protocol}//${location.host}`; host = `${location.protocol}//${location.host}`
} }
httpPost(`${process.env.VUE_APP_API_HOST}/api/payment/doPay`, { httpPost(`${process.env.VUE_APP_API_HOST}/api/payment/doPay`, {
product_id: product.id, product_id: product.id,
@@ -294,27 +293,27 @@ const pay = (product, payWay) => {
pay_type: payWay.pay_type, pay_type: payWay.pay_type,
user_id: userId.value, user_id: userId.value,
host: host, host: host,
device: "wechat", device: 'wechat',
}) })
.then((res) => { .then((res) => {
location.href = res.data; location.href = res.data
}) })
.catch((e) => { .catch((e) => {
showFailToast("生成支付订单失败:" + e.message); showFailToast('生成支付订单失败:' + e.message)
}); })
}; }
const logout = function () { const logout = function () {
httpGet("/api/user/logout") httpGet('/api/user/logout')
.then(() => { .then(() => {
removeUserToken(); removeUserToken()
store.setIsLogin(false); store.setIsLogin(false)
router.push("/"); router.push('/')
}) })
.catch(() => { .catch(() => {
showFailToast("注销失败!"); showFailToast('注销失败!')
}); })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus">

View File

@@ -3,29 +3,65 @@
<van-form @submit="generate"> <van-form @submit="generate">
<van-cell-group inset> <van-cell-group inset>
<div> <div>
<van-field v-model="selectedModel" is-link label="生图模型" placeholder="选择生图模型" @click="showModelPicker = true" /> <van-field
v-model="selectedModel"
is-link
label="生图模型"
placeholder="选择生图模型"
@click="showModelPicker = true"
/>
<van-popup v-model:show="showModelPicker" position="bottom" teleport="#app"> <van-popup v-model:show="showModelPicker" position="bottom" teleport="#app">
<van-picker :columns="models" @cancel="showModelPicker = false" @confirm="modelConfirm" /> <van-picker
:columns="models"
@cancel="showModelPicker = false"
@confirm="modelConfirm"
/>
</van-popup> </van-popup>
</div> </div>
<div> <div>
<van-field v-model="quality" is-link label="图片质量" placeholder="选择图片质量" @click="showQualityPicker = true" /> <van-field
v-model="quality"
is-link
label="图片质量"
placeholder="选择图片质量"
@click="showQualityPicker = true"
/>
<van-popup v-model:show="showQualityPicker" position="bottom" teleport="#app"> <van-popup v-model:show="showQualityPicker" position="bottom" teleport="#app">
<van-picker :columns="qualities" @cancel="showQualityPicker = false" @confirm="qualityConfirm" /> <van-picker
:columns="qualities"
@cancel="showQualityPicker = false"
@confirm="qualityConfirm"
/>
</van-popup> </van-popup>
</div> </div>
<div> <div>
<van-field v-model="size" is-link label="图片尺寸" placeholder="选择图片尺寸" @click="showSizePicker = true" /> <van-field
v-model="size"
is-link
label="图片尺寸"
placeholder="选择图片尺寸"
@click="showSizePicker = true"
/>
<van-popup v-model:show="showSizePicker" position="bottom" teleport="#app"> <van-popup v-model:show="showSizePicker" position="bottom" teleport="#app">
<van-picker :columns="sizes" @cancel="showSizePicker = false" @confirm="sizeConfirm" /> <van-picker :columns="sizes" @cancel="showSizePicker = false" @confirm="sizeConfirm" />
</van-popup> </van-popup>
</div> </div>
<div> <div>
<van-field v-model="style" is-link label="图片样式" placeholder="选择图片样式" @click="showStylePicker = true" /> <van-field
v-model="style"
is-link
label="图片样式"
placeholder="选择图片样式"
@click="showStylePicker = true"
/>
<van-popup v-model:show="showStylePicker" position="bottom" teleport="#app"> <van-popup v-model:show="showStylePicker" position="bottom" teleport="#app">
<van-picker :columns="styles" @cancel="showStylePicker = false" @confirm="styleConfirm" /> <van-picker
:columns="styles"
@cancel="showStylePicker = false"
@confirm="styleConfirm"
/>
</van-popup> </van-popup>
</div> </div>
@@ -61,7 +97,14 @@
<div v-if="item.progress > 0"> <div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image> <van-image src="/images/img-holder.png"></van-image>
<div class="progress"> <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-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div> </div>
</div> </div>
@@ -97,11 +140,19 @@
<div class="title">任务失败</div> <div class="title">任务失败</div>
<div class="opt"> <div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button> <van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small">删除</van-button> <van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
</div> </div>
</div> </div>
<div class="job-item" v-else> <div class="job-item" v-else>
<van-image :src="item['img_url']" :class="item['can_opt'] ? '' : 'upscale'" lazy-load @click="imageView(item)" fit="cover"> <van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading> <template v-slot:loading>
<van-loading type="spinner" size="20" /> <van-loading type="spinner" size="20" />
</template> </template>
@@ -109,7 +160,12 @@
<div class="remove"> <div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage($event, item)" circle /> <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> <el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i> <i class="iconfont icon-cancel-share"></i>
</el-button> </el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle> <el-button type="success" v-else @click="publishImage($event, item, true)" circle>
@@ -124,324 +180,343 @@
</van-grid> </van-grid>
</van-list> </van-list>
</div> </div>
<button style="display: none" class="copy-prompt-dall" :data-clipboard-text="prompt" id="copy-btn-dall">复制</button> <button
style="display: none"
class="copy-prompt-dall"
:data-clipboard-text="prompt"
id="copy-btn-dall"
>
复制
</button>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from "vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import { Delete } from "@element-plus/icons-vue"; import { getSessionId } from '@/store/session'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import Clipboard from "clipboard"; import { httpGet, httpPost } from '@/utils/http'
import { checkSession, getSystemInfo } from "@/store/cache"; import { showLoginDialog } from '@/utils/libs'
import { useRouter } from "vue-router"; import { Delete } from '@element-plus/icons-vue'
import { getSessionId } from "@/store/session"; import Clipboard from 'clipboard'
import { showConfirmDialog, showDialog, showFailToast, showImagePreview, showNotify, showSuccessToast, showToast } from "vant"; import {
import { showLoginDialog } from "@/utils/libs"; showConfirmDialog,
import { useSharedStore } from "@/store/sharedata"; showDialog,
showFailToast,
showImagePreview,
showNotify,
showSuccessToast,
showToast,
} from 'vant'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const listBoxHeight = ref(window.innerHeight - 40); const listBoxHeight = ref(window.innerHeight - 40)
const mjBoxHeight = ref(window.innerHeight - 150); const mjBoxHeight = ref(window.innerHeight - 150)
const isLogin = ref(false); const isLogin = ref(false)
window.onresize = () => { window.onresize = () => {
listBoxHeight.value = window.innerHeight - 40; listBoxHeight.value = window.innerHeight - 40
mjBoxHeight.value = window.innerHeight - 150; mjBoxHeight.value = window.innerHeight - 150
}; }
const qualities = [ const qualities = [
{ text: "标准", value: "standard" }, { text: '标准', value: 'standard' },
{ text: "高清", value: "hd" }, { text: '高清', value: 'hd' },
]; ]
const fluxSizes = [ const fluxSizes = [
{ text: "1024x1024", value: "1024x1024" }, { text: '1024x1024', value: '1024x1024' },
{ text: "1024x768", value: "1024x768" }, { text: '1024x768', value: '1024x768' },
{ text: "768x1024", value: "768x1024" }, { text: '768x1024', value: '768x1024' },
{ text: "1280x960", value: "1280x960" }, { text: '1280x960', value: '1280x960' },
{ text: "960x1280", value: "960x1280" }, { text: '960x1280', value: '960x1280' },
{ text: "1366x768", value: "1366x768" }, { text: '1366x768', value: '1366x768' },
{ text: "768x1366", value: "768x1366" }, { text: '768x1366', value: '768x1366' },
]; ]
const dalleSizes = [ const dalleSizes = [
{ text: "1024x1024", value: "1024x1024" }, { text: '1024x1024', value: '1024x1024' },
{ text: "1792x1024", value: "1792x1024" }, { text: '1792x1024', value: '1792x1024' },
{ text: "1024x1792", value: "1024x1792" }, { text: '1024x1792', value: '1024x1792' },
]; ]
let sizes = dalleSizes; let sizes = dalleSizes
const styles = [ const styles = [
{ text: "生动", value: "vivid" }, { text: '生动', value: 'vivid' },
{ text: "自然", value: "natural" }, { text: '自然', value: 'natural' },
]; ]
const params = ref({ const params = ref({
quality: qualities[0].value, quality: qualities[0].value,
size: sizes[0].value, size: sizes[0].value,
style: styles[0].value, style: styles[0].value,
prompt: "", prompt: '',
}); })
const quality = ref(qualities[0].text); const quality = ref(qualities[0].text)
const size = ref(sizes[0].text); const size = ref(sizes[0].text)
const style = ref(styles[0].text); const style = ref(styles[0].text)
const showQualityPicker = ref(false); const showQualityPicker = ref(false)
const showStylePicker = ref(false); const showStylePicker = ref(false)
const showSizePicker = ref(false); const showSizePicker = ref(false)
const showModelPicker = ref(false); const showModelPicker = ref(false)
const runningJobs = ref([]); const runningJobs = ref([])
const finishedJobs = ref([]); const finishedJobs = ref([])
const allowPulling = ref(true); // 是否允许轮询 const allowPulling = ref(true) // 是否允许轮询
const tastPullHandler = ref(null); const tastPullHandler = ref(null)
const router = useRouter(); const router = useRouter()
const power = ref(0); const power = ref(0)
const dallPower = ref(0); // 画一张 DALL 图片消耗算力 const dallPower = ref(0) // 画一张 DALL 图片消耗算力
const userId = ref(0); const userId = ref(0)
const store = useSharedStore(); const store = useSharedStore()
const clipboard = ref(null); const clipboard = ref(null)
const prompt = ref(""); const prompt = ref('')
const models = ref([]); const models = ref([])
const selectedModel = ref(null); const selectedModel = ref(null)
onMounted(() => { onMounted(() => {
initData(); initData()
clipboard.value = new Clipboard(".copy-prompt-dall"); clipboard.value = new Clipboard('.copy-prompt-dall')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
showNotify({ type: "success", message: "复制成功", duration: 1000 }); showNotify({ type: 'success', message: '复制成功', duration: 1000 })
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
showNotify({ type: "danger", message: "复制失败", duration: 2000 }); showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
dallPower.value = res.data.dall_power; dallPower.value = res.data.dall_power
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取系统配置失败:" + e.message }); showNotify({ type: 'danger', message: '获取系统配置失败:' + e.message })
}); })
// 获取模型列表 // 获取模型列表
httpGet("/api/dall/models") httpGet('/api/dall/models')
.then((res) => { .then((res) => {
for (let i = 0; i < res.data.length; i++) { for (let i = 0; i < res.data.length; i++) {
models.value.push({ text: res.data[i].name, value: res.data[i].id, name: res.data[i].value }); models.value.push({
text: res.data[i].name,
value: res.data[i].id,
name: res.data[i].value,
})
} }
selectedModel.value = models.value[0]?.text; selectedModel.value = models.value[0]?.text
params.value.model_id = models.value[0]?.value; params.value.model_id = models.value[0]?.value
}) })
.catch((e) => { .catch((e) => {
showMessageError("获取模型列表失败:" + e.message); showMessageError('获取模型列表失败:' + e.message)
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (tastPullHandler.value) { if (tastPullHandler.value) {
clearInterval(tastPullHandler.value); clearInterval(tastPullHandler.value)
} }
}); })
const initData = () => { const initData = () => {
checkSession() checkSession()
.then((user) => { .then((user) => {
power.value = user["power"]; power.value = user['power']
isLogin.value = true; isLogin.value = true
fetchRunningJobs(); fetchRunningJobs()
fetchFinishJobs(1); fetchFinishJobs(1)
tastPullHandler.value = setInterval(() => { tastPullHandler.value = setInterval(() => {
if (allowPulling.value) { if (allowPulling.value) {
fetchRunningJobs(); fetchRunningJobs()
} }
}, 5000); }, 5000)
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false
}); })
}; }
const fetchRunningJobs = () => { const fetchRunningJobs = () => {
// 获取运行中的任务 // 获取运行中的任务
httpGet(`/api/dall/jobs?finish=0`) httpGet(`/api/dall/jobs?finish=0`)
.then((res) => { .then((res) => {
if (runningJobs.value.length !== res.data.items.length) { if (runningJobs.value.length !== res.data.items.length) {
fetchFinishJobs(1); fetchFinishJobs(1)
} }
if (res.data.items.length === 0) { if (res.data.items.length === 0) {
allowPulling.value = false; allowPulling.value = false
} }
runningJobs.value = res.data.items; runningJobs.value = res.data.items
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取任务失败:" + e.message }); showNotify({ type: 'danger', message: '获取任务失败:' + e.message })
}); })
}; }
const loading = ref(false); const loading = ref(false)
const finished = ref(false); const finished = ref(false)
const error = ref(false); const error = ref(false)
const page = ref(0); const page = ref(0)
const pageSize = ref(10); const pageSize = ref(10)
// 获取已完成的任务 // 获取已完成的任务
const fetchFinishJobs = (page) => { const fetchFinishJobs = (page) => {
loading.value = true; loading.value = true
httpGet(`/api/dall/jobs?finish=1&page=${page}&page_size=${pageSize.value}`) httpGet(`/api/dall/jobs?finish=1&page=${page}&page_size=${pageSize.value}`)
.then((res) => { .then((res) => {
const jobs = res.data.items; const jobs = res.data.items
if (jobs.length < pageSize.value) { if (jobs.length < pageSize.value) {
finished.value = true; finished.value = true
} }
const _jobs = []; const _jobs = []
for (let i = 0; i < jobs.length; i++) { for (let i = 0; i < jobs.length; i++) {
if (jobs[i].progress === -1) { if (jobs[i].progress === -1) {
jobs[i]["thumb_url"] = jobs[i]["img_url"] + "?imageView2/1/w/480/h/600/q/75"; jobs[i]['thumb_url'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/600/q/75'
} }
_jobs.push(jobs[i]); _jobs.push(jobs[i])
} }
if (page === 1) { if (page === 1) {
finishedJobs.value = _jobs; finishedJobs.value = _jobs
} else { } else {
finishedJobs.value = finishedJobs.value.concat(_jobs); finishedJobs.value = finishedJobs.value.concat(_jobs)
} }
loading.value = false; loading.value = false
}) })
.catch((e) => { .catch((e) => {
loading.value = false; loading.value = false
showNotify({ type: "danger", message: "获取任务失败:" + e.message }); showNotify({ type: 'danger', message: '获取任务失败:' + e.message })
}); })
}; }
const onLoad = () => { const onLoad = () => {
page.value += 1; page.value += 1
fetchFinishJobs(page.value); fetchFinishJobs(page.value)
}; }
// 创建绘图任务 // 创建绘图任务
const promptRef = ref(null); const promptRef = ref(null)
const generate = () => { const generate = () => {
if (!isLogin.value) { if (!isLogin.value) {
return showLoginDialog(router); return showLoginDialog(router)
} }
if (params.value.prompt === "") { if (params.value.prompt === '') {
promptRef.value.focus(); promptRef.value.focus()
return showToast("请输入绘画提示词!"); return showToast('请输入绘画提示词!')
} }
if (!params.value.seed) { if (!params.value.seed) {
params.value.seed = -1; params.value.seed = -1
} }
params.value.session_id = getSessionId(); params.value.session_id = getSessionId()
httpPost("/api/dall/image", params.value) httpPost('/api/dall/image', params.value)
.then(() => { .then(() => {
showSuccessToast("绘画任务推送成功,请耐心等待任务执行..."); showSuccessToast('绘画任务推送成功,请耐心等待任务执行...')
power.value -= dallPower.value; power.value -= dallPower.value
allowPulling.value = true; allowPulling.value = true
runningJobs.value.push({ runningJobs.value.push({
progress: 0, progress: 0,
}); })
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务推送失败:" + e.message); showFailToast('任务推送失败:' + e.message)
}); })
}; }
const showPrompt = (item) => { const showPrompt = (item) => {
prompt.value = item.prompt; prompt.value = item.prompt
showConfirmDialog({ showConfirmDialog({
title: "绘画提示词", title: '绘画提示词',
message: item.prompt, message: item.prompt,
confirmButtonText: "复制", confirmButtonText: '复制',
cancelButtonText: "关闭", cancelButtonText: '关闭',
}) })
.then(() => { .then(() => {
document.querySelector("#copy-btn-dall").click(); document.querySelector('#copy-btn-dall').click()
}) })
.catch(() => {}); .catch(() => {})
}; }
const showErrMsg = (item) => { const showErrMsg = (item) => {
showDialog({ showDialog({
title: "错误详情", title: '错误详情',
message: item["err_msg"], message: item['err_msg'],
}).then(() => { }).then(() => {
// on close // on close
}); })
}; }
const removeImage = (event, item) => { const removeImage = (event, item) => {
event.stopPropagation(); event.stopPropagation()
showConfirmDialog({ showConfirmDialog({
title: "标题", title: '标题',
message: "此操作将会删除任务和图片,继续操作码?", message: '此操作将会删除任务和图片,继续操作码?',
}) })
.then(() => { .then(() => {
httpGet("/api/dall/remove", { id: item.id, user_id: item.user_id }) httpGet('/api/dall/remove', { id: item.id, user_id: item.user_id })
.then(() => { .then(() => {
showSuccessToast("任务删除成功"); showSuccessToast('任务删除成功')
fetchFinishJobs(1); fetchFinishJobs(1)
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务删除失败:" + e.message); showFailToast('任务删除失败:' + e.message)
}); })
}) })
.catch(() => { .catch(() => {
showToast("您取消了操作"); showToast('您取消了操作')
}); })
}; }
// 发布图片到作品墙 // 发布图片到作品墙
const publishImage = (event, item, action) => { const publishImage = (event, item, action) => {
event.stopPropagation(); event.stopPropagation()
let text = "图片发布"; let text = '图片发布'
if (action === false) { if (action === false) {
text = "取消发布"; text = '取消发布'
} }
httpGet("/api/dall/publish", { id: item.id, action: action, user_id: item.user_id }) httpGet('/api/dall/publish', { id: item.id, action: action, user_id: item.user_id })
.then(() => { .then(() => {
showSuccessToast(text + "成功"); showSuccessToast(text + '成功')
item.publish = action; item.publish = action
}) })
.catch((e) => { .catch((e) => {
showFailToast(text + "失败:" + e.message); showFailToast(text + '失败:' + e.message)
}); })
}; }
const imageView = (item) => { const imageView = (item) => {
showImagePreview([item["img_url"]]); showImagePreview([item['img_url']])
}; }
const qualityConfirm = (item) => { const qualityConfirm = (item) => {
params.value.quality = item.selectedOptions[0].value; params.value.quality = item.selectedOptions[0].value
quality.value = item.selectedOptions[0].text; quality.value = item.selectedOptions[0].text
showQualityPicker.value = false; showQualityPicker.value = false
}; }
const styleConfirm = (item) => { const styleConfirm = (item) => {
params.value.style = item.selectedOptions[0].value; params.value.style = item.selectedOptions[0].value
style.value = item.selectedOptions[0].text; style.value = item.selectedOptions[0].text
showStylePicker.value = false; showStylePicker.value = false
}; }
const sizeConfirm = (item) => { const sizeConfirm = (item) => {
params.value.size = item.selectedOptions[0].value; params.value.size = item.selectedOptions[0].value
size.value = item.selectedOptions[0].text; size.value = item.selectedOptions[0].text
showSizePicker.value = false; showSizePicker.value = false
}; }
const modelConfirm = (item) => { const modelConfirm = (item) => {
params.value.model_id = item.selectedOptions[0].value; params.value.model_id = item.selectedOptions[0].value
selectedModel.value = item.selectedOptions[0].text; selectedModel.value = item.selectedOptions[0].text
showModelPicker.value = false; showModelPicker.value = false
if (item.selectedOptions[0].name.startsWith("dall")) { if (item.selectedOptions[0].name.startsWith('dall')) {
sizes = dalleSizes; sizes = dalleSizes
} else { } else {
sizes = fluxSizes; sizes = fluxSizes
} }
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import "@/assets/css/mobile/image-sd.styl" @import '../../../assets/css/mobile/image-sd.styl'
</style> </style>

View File

@@ -5,7 +5,10 @@
<div class="text-line"> <div class="text-line">
<van-row :gutter="10"> <van-row :gutter="10">
<van-col :span="4" v-for="item in rates" :key="item.value"> <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="item.value === params.rate ? 'rate active' : 'rate'"
@click="changeRate(item)"
>
<div class="icon"> <div class="icon">
<van-image :src="item.img" fit="cover"></van-image> <van-image :src="item.img" fit="cover"></van-image>
</div> </div>
@@ -18,7 +21,10 @@
<div class="text-line"> <div class="text-line">
<van-row :gutter="10"> <van-row :gutter="10">
<van-col :span="8" v-for="item in models" :key="item.value"> <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="item.value === params.model ? 'model active' : 'model'"
@click="changeModel(item)"
>
<div class="icon"> <div class="icon">
<van-image :src="item.img" fit="cover"></van-image> <van-image :src="item.img" fit="cover"></van-image>
</div> </div>
@@ -32,7 +38,12 @@
<div class="text-line"> <div class="text-line">
<van-field label="创意度"> <van-field label="创意度">
<template #input> <template #input>
<van-slider v-model.number="params.chaos" :max="100" :step="1" @update:model-value="showToast('当前值' + params.chaos)" /> <van-slider
v-model.number="params.chaos"
:max="100"
:step="1"
@update:model-value="showToast('当前值' + params.chaos)"
/>
</template> </template>
</van-field> </van-field>
</div> </div>
@@ -40,7 +51,12 @@
<div class="text-line"> <div class="text-line">
<van-field label="风格化"> <van-field label="风格化">
<template #input> <template #input>
<van-slider v-model.number="params.stylize" :max="1000" :step="1" @update:model-value="showToast('当前值' + params.stylize)" /> <van-slider
v-model.number="params.stylize"
:max="1000"
:step="1"
@update:model-value="showToast('当前值' + params.stylize)"
/>
</template> </template>
</van-field> </van-field>
</div> </div>
@@ -85,14 +101,27 @@
<div class="text-line"> <div class="text-line">
<van-field label="垫图权重"> <van-field label="垫图权重">
<template #input> <template #input>
<van-slider v-model.number="params.iw" :max="1" :step="0.01" @update:model-value="showToast('当前值' + params.iw)" /> <van-slider
v-model.number="params.iw"
:max="1"
:step="0.01"
@update:model-value="showToast('当前值' + params.iw)"
/>
</template> </template>
</van-field> </van-field>
</div> </div>
<div class="tip-text">提示只有于 niji6 v6 模型支持一致性功能如果选择其他模型此功能将会生成失败</div> <div class="tip-text">
提示只有于 niji6 v6 模型支持一致性功能如果选择其他模型此功能将会生成失败
</div>
<van-cell-group> <van-cell-group>
<van-field v-model="params.cref" center clearable label="角色一致性" placeholder="请输入图片URL或者上传图片"> <van-field
v-model="params.cref"
center
clearable
label="角色一致性"
placeholder="请输入图片URL或者上传图片"
>
<template #button> <template #button>
<van-uploader @click="beforeUpload('cref')" :after-read="uploadImg"> <van-uploader @click="beforeUpload('cref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" /> <van-button size="mini" type="primary" icon="plus" />
@@ -102,7 +131,13 @@
</van-cell-group> </van-cell-group>
<van-cell-group> <van-cell-group>
<van-field v-model="params.sref" center clearable label="风格一致性" placeholder="请输入图片URL或者上传图片"> <van-field
v-model="params.sref"
center
clearable
label="风格一致性"
placeholder="请输入图片URL或者上传图片"
>
<template #button> <template #button>
<van-uploader @click="beforeUpload('sref')" :after-read="uploadImg"> <van-uploader @click="beforeUpload('sref')" :after-read="uploadImg">
<van-button size="mini" type="primary" icon="plus" /> <van-button size="mini" type="primary" icon="plus" />
@@ -114,13 +149,20 @@
<div class="text-line"> <div class="text-line">
<van-field label="一致性权重"> <van-field label="一致性权重">
<template #input> <template #input>
<van-slider v-model.number="params.cw" :max="100" :step="1" @update:model-value="showToast('当前值' + params.cw)" /> <van-slider
v-model.number="params.cw"
:max="100"
:step="1"
@update:model-value="showToast('当前值' + params.cw)"
/>
</template> </template>
</van-field> </van-field>
</div> </div>
</van-tab> </van-tab>
<van-tab title="融图" name="blend"> <van-tab title="融图" name="blend">
<div class="tip-text">请上传两张以上的图片最多不超过五张超过五张图片请使用图生图功能</div> <div class="tip-text">
请上传两张以上的图片最多不超过五张超过五张图片请使用图生图功能
</div>
<div class="text-line"> <div class="text-line">
<van-uploader v-model="imgList" :after-read="uploadImg" /> <van-uploader v-model="imgList" :after-read="uploadImg" />
</div> </div>
@@ -137,13 +179,24 @@
<div class="text-line"> <div class="text-line">
<van-collapse v-model="activeColspan"> <van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt"> <van-collapse-item title="反向提示词" name="neg_prompt">
<van-field v-model="params.neg_prompt" rows="3" maxlength="2000" autosize type="textarea" placeholder="不想出现在图片上的元素(例如:树,建筑)" /> <van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item> </van-collapse-item>
</van-collapse> </van-collapse>
</div> </div>
<div class="text-line pt-6"> <div class="text-line pt-6">
<el-tag>绘图消耗{{ mjPower }}算力U/V 操作消耗{{ mjActionPower }}算力当前算力{{ power }}</el-tag> <el-tag
>绘图消耗{{ mjPower }}算力U/V 操作消耗{{ mjActionPower }}算力当前算力{{
power
}}</el-tag
>
</div> </div>
<div class="text-line"> <div class="text-line">
@@ -164,7 +217,14 @@
<div v-if="item.progress > 0"> <div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image> <van-image src="/images/img-holder.png"></van-image>
<div class="progress"> <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-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div> </div>
</div> </div>
@@ -204,7 +264,13 @@
</div> </div>
</div> </div>
<div class="job-item" v-else> <div class="job-item" v-else>
<van-image :src="item['thumb_url']" :class="item['can_opt'] ? '' : 'upscale'" lazy-load @click="imageView(item)" fit="cover"> <van-image
:src="item['thumb_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading> <template v-slot:loading>
<van-loading type="spinner" size="20" /> <van-loading type="spinner" size="20" />
</template> </template>
@@ -220,16 +286,29 @@
<van-grid-item><a @click="upscale(2, item)" class="opt-btn">U2</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(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="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
<van-grid-item><a @click="variation(2, item)" class="opt-btn">V2</a></van-grid-item> ><a @click="variation(1, item)" class="opt-btn">V1</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-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-grid>
</div> </div>
<div class="remove"> <div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage(item)" circle /> <el-button type="danger" :icon="Delete" @click="removeImage(item)" circle />
<el-button type="warning" v-if="item.publish" @click="publishImage(item, false)" circle> <el-button
type="warning"
v-if="item.publish"
@click="publishImage(item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i> <i class="iconfont icon-cancel-share"></i>
</el-button> </el-button>
<el-button type="success" v-else @click="publishImage(item, true)" circle> <el-button type="success" v-else @click="publishImage(item, true)" circle>
@@ -245,44 +324,54 @@
</van-list> </van-list>
</div> </div>
<button style="display: none" class="copy-prompt" :data-clipboard-text="prompt" id="copy-btn">复制</button> <button style="display: none" class="copy-prompt" :data-clipboard-text="prompt" id="copy-btn">
复制
</button>
</div> </div>
</template> </template>
<script setup> <script setup>
import { nextTick, onMounted, onUnmounted, ref } from "vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import { showConfirmDialog, showFailToast, showImagePreview, showNotify, showSuccessToast, showToast, showDialog } from "vant"; import { getSessionId } from '@/store/session'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import Compressor from "compressorjs"; import { httpGet, httpPost } from '@/utils/http'
import { getSessionId } from "@/store/session"; import { showLoginDialog } from '@/utils/libs'
import { checkSession, getSystemInfo } from "@/store/cache"; import { Delete } from '@element-plus/icons-vue'
import { useRouter } from "vue-router"; import Clipboard from 'clipboard'
import { Delete } from "@element-plus/icons-vue"; import Compressor from 'compressorjs'
import { showLoginDialog } from "@/utils/libs"; import {
import Clipboard from "clipboard"; showConfirmDialog,
import { useSharedStore } from "@/store/sharedata"; showDialog,
showFailToast,
showImagePreview,
showNotify,
showSuccessToast,
showToast,
} from 'vant'
import { nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const activeColspan = ref([""]); const activeColspan = ref([''])
const rates = [ const rates = [
{ css: "square", value: "1:1", text: "1:1", img: "/images/mj/rate_1_1.png" }, { css: 'square', value: '1:1', text: '1:1', img: '/images/mj/rate_1_1.png' },
{ css: "size2-3", value: "2:3", text: "2:3", img: "/images/mj/rate_3_4.png" }, { css: 'size2-3', value: '2:3', text: '2:3', img: '/images/mj/rate_3_4.png' },
{ css: "size3-4", value: "3:4", text: "3:4", img: "/images/mj/rate_3_4.png" }, { css: 'size3-4', value: '3:4', text: '3:4', img: '/images/mj/rate_3_4.png' },
{ css: "size4-3", value: "4:3", text: "4:3", img: "/images/mj/rate_4_3.png" }, { css: 'size4-3', value: '4:3', text: '4:3', img: '/images/mj/rate_4_3.png' },
{ css: "size16-9", value: "16:9", text: "16:9", img: "/images/mj/rate_16_9.png" }, { css: 'size16-9', value: '16:9', text: '16:9', img: '/images/mj/rate_16_9.png' },
{ css: "size9-16", value: "9:16", text: "9:16", img: "/images/mj/rate_9_16.png" }, { css: 'size9-16', value: '9:16', text: '9:16', img: '/images/mj/rate_9_16.png' },
]; ]
const models = [ const models = [
{ text: "MJ-6.0", value: " --v 6", img: "/images/mj/mj-v6.png" }, { text: 'MJ-6.0', value: ' --v 6', img: '/images/mj/mj-v6.png' },
{ text: "MJ-5.2", value: " --v 5.2", img: "/images/mj/mj-v5.2.png" }, { text: 'MJ-5.2', value: ' --v 5.2', img: '/images/mj/mj-v5.2.png' },
{ text: "Niji5", value: " --niji 5", img: "/images/mj/mj-niji.png" }, { text: 'Niji5', value: ' --niji 5', img: '/images/mj/mj-niji.png' },
{ text: "Niji5 可爱", value: " --niji 5 --style cute", img: "/images/mj/nj1.jpg" }, { text: 'Niji5 可爱', value: ' --niji 5 --style cute', img: '/images/mj/nj1.jpg' },
{ text: "Niji5 风景", value: " --niji 5 --style scenic", img: "/images/mj/nj2.jpg" }, { text: 'Niji5 风景', value: ' --niji 5 --style scenic', img: '/images/mj/nj2.jpg' },
{ text: "Niji6", value: " --niji 6", img: "/images/mj/nj3.jpg" }, { text: 'Niji6', value: ' --niji 6', img: '/images/mj/nj3.jpg' },
]; ]
const imgList = ref([]); const imgList = ref([])
const params = ref({ const params = ref({
task_type: "image", task_type: 'image',
rate: rates[0].value, rate: rates[0].value,
model: models[0].value, model: models[0].value,
chaos: 0, chaos: 0,
@@ -291,229 +380,229 @@ const params = ref({
img_arr: [], img_arr: [],
raw: false, raw: false,
iw: 0, iw: 0,
prompt: "", prompt: '',
neg_prompt: "", neg_prompt: '',
tile: false, tile: false,
quality: 0, quality: 0,
cref: "", cref: '',
sref: "", sref: '',
cw: 0, cw: 0,
}); })
const userId = ref(0); const userId = ref(0)
const router = useRouter(); const router = useRouter()
const runningJobs = ref([]); const runningJobs = ref([])
const finishedJobs = ref([]); const finishedJobs = ref([])
const power = ref(0); const power = ref(0)
const activeName = ref("txt2img"); const activeName = ref('txt2img')
const isLogin = ref(false); const isLogin = ref(false)
const prompt = ref(""); const prompt = ref('')
const store = useSharedStore(); const store = useSharedStore()
const clipboard = ref(null); const clipboard = ref(null)
const taskPulling = ref(true); const taskPulling = ref(true)
const tastPullHandler = ref(null); const tastPullHandler = ref(null)
const downloadPulling = ref(false); const downloadPulling = ref(false)
const downloadPullHandler = ref(null); const downloadPullHandler = ref(null)
onMounted(() => { onMounted(() => {
clipboard.value = new Clipboard(".copy-prompt"); clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
showNotify({ type: "success", message: "复制成功", duration: 1000 }); showNotify({ type: 'success', message: '复制成功', duration: 1000 })
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
showNotify({ type: "danger", message: "复制失败", duration: 2000 }); showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
}); })
checkSession() checkSession()
.then((user) => { .then((user) => {
power.value = user["power"]; power.value = user['power']
userId.value = user.id; userId.value = user.id
isLogin.value = true; isLogin.value = true
fetchRunningJobs(); fetchRunningJobs()
fetchFinishJobs(1); fetchFinishJobs(1)
tastPullHandler.value = setInterval(() => { tastPullHandler.value = setInterval(() => {
if (taskPulling.value) { if (taskPulling.value) {
fetchRunningJobs(); fetchRunningJobs()
} }
}, 5000); }, 5000)
downloadPullHandler.value = setInterval(() => { downloadPullHandler.value = setInterval(() => {
if (downloadPulling.value) { if (downloadPulling.value) {
page.value = 1; page.value = 1
fetchFinishJobs(1); fetchFinishJobs(1)
} }
}, 5000); }, 5000)
}) })
.catch(() => { .catch(() => {
// router.push('/login') // router.push('/login')
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (tastPullHandler.value) { if (tastPullHandler.value) {
clearInterval(tastPullHandler.value); clearInterval(tastPullHandler.value)
} }
if (downloadPullHandler.value) { if (downloadPullHandler.value) {
clearInterval(downloadPullHandler.value); clearInterval(downloadPullHandler.value)
} }
}); })
const mjPower = ref(1); const mjPower = ref(1)
const mjActionPower = ref(1); const mjActionPower = ref(1)
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
mjPower.value = res.data["mj_power"]; mjPower.value = res.data['mj_power']
mjActionPower.value = res.data["mj_action_power"]; mjActionPower.value = res.data['mj_action_power']
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取系统配置失败:" + e.message }); showNotify({ type: 'danger', message: '获取系统配置失败:' + e.message })
}); })
// 获取运行中的任务 // 获取运行中的任务
const fetchRunningJobs = (userId) => { const fetchRunningJobs = (userId) => {
if (!isLogin.value) { if (!isLogin.value) {
return; return
} }
httpGet(`/api/mj/jobs?finish=0&user_id=${userId}`) httpGet(`/api/mj/jobs?finish=0&user_id=${userId}`)
.then((res) => { .then((res) => {
const jobs = res.data.items; const jobs = res.data.items
const _jobs = []; const _jobs = []
for (let i = 0; i < jobs.length; i++) { for (let i = 0; i < jobs.length; i++) {
if (jobs[i].progress === -1) { if (jobs[i].progress === -1) {
showNotify({ showNotify({
message: `任务执行失败:${jobs[i]["err_msg"]}`, message: `任务执行失败:${jobs[i]['err_msg']}`,
type: "danger", type: 'danger',
}); })
if (jobs[i].type === "image") { if (jobs[i].type === 'image') {
power.value += mjPower.value; power.value += mjPower.value
} else { } else {
power.value += mjActionPower.value; power.value += mjActionPower.value
} }
continue; continue
} }
_jobs.push(jobs[i]); _jobs.push(jobs[i])
} }
if (runningJobs.value.length !== _jobs.length) { if (runningJobs.value.length !== _jobs.length) {
page.value = 1; page.value = 1
downloadPulling.value = true; downloadPulling.value = true
fetchFinishJobs(1); fetchFinishJobs(1)
} }
if (_jobs.length === 0) { if (_jobs.length === 0) {
taskPulling.value = false; taskPulling.value = false
} }
runningJobs.value = _jobs; runningJobs.value = _jobs
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取任务失败:" + e.message }); showNotify({ type: 'danger', message: '获取任务失败:' + e.message })
}); })
}; }
const loading = ref(false); const loading = ref(false)
const finished = ref(false); const finished = ref(false)
const error = ref(false); const error = ref(false)
const page = ref(0); const page = ref(0)
const pageSize = ref(10); const pageSize = ref(10)
const fetchFinishJobs = (page) => { const fetchFinishJobs = (page) => {
if (!isLogin.value) { if (!isLogin.value) {
return; return
} }
loading.value = true; loading.value = true
// 获取已完成的任务 // 获取已完成的任务
httpGet(`/api/mj/jobs?finish=1&page=${page}&page_size=${pageSize.value}`) httpGet(`/api/mj/jobs?finish=1&page=${page}&page_size=${pageSize.value}`)
.then((res) => { .then((res) => {
const jobs = res.data.items; const jobs = res.data.items
let hasDownload = false; let hasDownload = false
for (let i = 0; i < jobs.length; i++) { for (let i = 0; i < jobs.length; i++) {
if (jobs[i].type === "upscale" || jobs[i].type === "swapFace") { if (jobs[i].type === 'upscale' || jobs[i].type === 'swapFace') {
jobs[i]["thumb_url"] = jobs[i]["img_url"] + "?imageView2/1/w/480/h/600/q/75"; jobs[i]['thumb_url'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/600/q/75'
} else { } else {
jobs[i]["thumb_url"] = jobs[i]["img_url"] + "?imageView2/1/w/480/h/480/q/75"; jobs[i]['thumb_url'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/480/q/75'
} }
if (jobs[i]["img_url"] === "" && jobs[i].progress === 100) { if (jobs[i]['img_url'] === '' && jobs[i].progress === 100) {
hasDownload = true; hasDownload = true
} }
if (jobs[i].type !== "upscale" && jobs[i].progress === 100) { if (jobs[i].type !== 'upscale' && jobs[i].progress === 100) {
jobs[i]["can_opt"] = true; jobs[i]['can_opt'] = true
} }
} }
if (page === 1) { if (page === 1) {
downloadPulling.value = hasDownload; downloadPulling.value = hasDownload
} }
if (jobs.length < pageSize.value) { if (jobs.length < pageSize.value) {
finished.value = true; finished.value = true
} }
if (page === 1) { if (page === 1) {
finishedJobs.value = jobs; finishedJobs.value = jobs
} else { } else {
finishedJobs.value = finishedJobs.value.concat(jobs); finishedJobs.value = finishedJobs.value.concat(jobs)
} }
nextTick(() => (loading.value = false)); nextTick(() => (loading.value = false))
}) })
.catch((e) => { .catch((e) => {
loading.value = false; loading.value = false
error.value = true; error.value = true
showFailToast("获取任务失败:" + e.message); showFailToast('获取任务失败:' + e.message)
}); })
}; }
const onLoad = () => { const onLoad = () => {
page.value += 1; page.value += 1
fetchFinishJobs(page.value); fetchFinishJobs(page.value)
}; }
// 切换图片比例 // 切换图片比例
const changeRate = (item) => { const changeRate = (item) => {
params.value.rate = item.value; params.value.rate = item.value
}; }
// 切换模型 // 切换模型
const changeModel = (item) => { const changeModel = (item) => {
params.value.model = item.value; params.value.model = item.value
}; }
const imgKey = ref(""); const imgKey = ref('')
const beforeUpload = (key) => { const beforeUpload = (key) => {
imgKey.value = key; imgKey.value = key
}; }
// 图片上传 // 图片上传
const uploadImg = (file) => { const uploadImg = (file) => {
file.status = "uploading"; file.status = 'uploading'
// 压缩图片并上传 // 压缩图片并上传
new Compressor(file.file, { new Compressor(file.file, {
quality: 0.6, quality: 0.6,
success(result) { success(result) {
const formData = new FormData(); const formData = new FormData()
formData.append("file", result, result.name); formData.append('file', result, result.name)
// 执行上传操作 // 执行上传操作
httpPost("/api/upload", formData) httpPost('/api/upload', formData)
.then((res) => { .then((res) => {
file.url = res.data.url; file.url = res.data.url
if (imgKey.value !== "") { if (imgKey.value !== '') {
// 单张图片上传 // 单张图片上传
params.value[imgKey.value] = res.data.url; params.value[imgKey.value] = res.data.url
imgKey.value = ""; imgKey.value = ''
} }
file.status = "done"; file.status = 'done'
}) })
.catch((e) => { .catch((e) => {
file.status = "failed"; file.status = 'failed'
file.message = "上传失败"; file.message = '上传失败'
showFailToast("图片上传失败:" + e.message); showFailToast('图片上传失败:' + e.message)
}); })
}, },
error(err) { error(err) {
console.log(err.message); console.log(err.message)
}, },
}); })
}; }
const send = (url, index, item) => { const send = (url, index, item) => {
httpPost(url, { httpPost(url, {
@@ -525,126 +614,126 @@ const send = (url, index, item) => {
prompt: item.prompt, prompt: item.prompt,
}) })
.then(() => { .then(() => {
showSuccessToast("任务推送成功,请耐心等待任务执行..."); showSuccessToast('任务推送成功,请耐心等待任务执行...')
power.value -= mjActionPower.value; power.value -= mjActionPower.value
runningJobs.value.push({ runningJobs.value.push({
progress: 0, progress: 0,
}); })
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务推送失败:" + e.message); showFailToast('任务推送失败:' + e.message)
}); })
}; }
// 图片放大任务 // 图片放大任务
const upscale = (index, item) => { const upscale = (index, item) => {
send("/api/mj/upscale", index, item); send('/api/mj/upscale', index, item)
}; }
// 图片变换任务 // 图片变换任务
const variation = (index, item) => { const variation = (index, item) => {
send("/api/mj/variation", index, item); send('/api/mj/variation', index, item)
}; }
const generate = () => { const generate = () => {
if (!isLogin.value) { if (!isLogin.value) {
return showLoginDialog(router); return showLoginDialog(router)
} }
if (params.value.prompt === "" && params.value.task_type === "image") { if (params.value.prompt === '' && params.value.task_type === 'image') {
return showFailToast("请输入绘画提示词!"); return showFailToast('请输入绘画提示词!')
} }
if (params.value.model.indexOf("niji") !== -1 && params.value.raw) { if (params.value.model.indexOf('niji') !== -1 && params.value.raw) {
return showFailToast("动漫模型不允许启用原始模式"); return showFailToast('动漫模型不允许启用原始模式')
} }
params.value.session_id = getSessionId(); params.value.session_id = getSessionId()
params.value.img_arr = imgList.value.map((img) => img.url); params.value.img_arr = imgList.value.map((img) => img.url)
httpPost("/api/mj/image", params.value) httpPost('/api/mj/image', params.value)
.then(() => { .then(() => {
showToast("绘画任务推送成功,请耐心等待任务执行"); showToast('绘画任务推送成功,请耐心等待任务执行')
power.value -= mjPower.value; power.value -= mjPower.value
taskPulling.value = true; taskPulling.value = true
runningJobs.value.push({ runningJobs.value.push({
progress: 0, progress: 0,
}); })
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务推送失败:" + e.message); showFailToast('任务推送失败:' + e.message)
}); })
}; }
const removeImage = (item) => { const removeImage = (item) => {
showConfirmDialog({ showConfirmDialog({
title: "删除提示", title: '删除提示',
message: "此操作将会删除任务和图片,继续操作码?", message: '此操作将会删除任务和图片,继续操作码?',
}) })
.then(() => { .then(() => {
httpGet("/api/mj/remove", { id: item.id, user_id: item.user_id }) httpGet('/api/mj/remove', { id: item.id, user_id: item.user_id })
.then(() => { .then(() => {
showSuccessToast("任务删除成功"); showSuccessToast('任务删除成功')
fetchFinishJobs(1); fetchFinishJobs(1)
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务删除失败:" + e.message); showFailToast('任务删除失败:' + e.message)
}); })
}) })
.catch(() => { .catch(() => {
showToast("您取消了操作"); showToast('您取消了操作')
}); })
}; }
// 发布图片到作品墙 // 发布图片到作品墙
const publishImage = (item, action) => { const publishImage = (item, action) => {
let text = "图片发布"; let text = '图片发布'
if (action === false) { if (action === false) {
text = "取消发布"; text = '取消发布'
} }
httpGet("/api/mj/publish", { id: item.id, action: action, user_id: item.user_id }) httpGet('/api/mj/publish', { id: item.id, action: action, user_id: item.user_id })
.then(() => { .then(() => {
showSuccessToast(text + "成功"); showSuccessToast(text + '成功')
item.publish = action; item.publish = action
}) })
.catch((e) => { .catch((e) => {
showFailToast(text + "失败:" + e.message); showFailToast(text + '失败:' + e.message)
}); })
}; }
const showPrompt = (item) => { const showPrompt = (item) => {
prompt.value = item.prompt; prompt.value = item.prompt
showConfirmDialog({ showConfirmDialog({
title: "绘画提示词", title: '绘画提示词',
message: item.prompt, message: item.prompt,
confirmButtonText: "复制", confirmButtonText: '复制',
cancelButtonText: "关闭", cancelButtonText: '关闭',
}) })
.then(() => { .then(() => {
document.querySelector("#copy-btn").click(); document.querySelector('#copy-btn').click()
}) })
.catch(() => {}); .catch(() => {})
}; }
const showErrMsg = (item) => { const showErrMsg = (item) => {
showDialog({ showDialog({
title: "错误详情", title: '错误详情',
message: item["err_msg"], message: item['err_msg'],
}).then(() => { }).then(() => {
// on close // on close
}); })
}; }
const imageView = (item) => { const imageView = (item) => {
showImagePreview([item["img_url"]]); showImagePreview([item['img_url']])
}; }
// 切换菜单 // 切换菜单
const tabChange = (tab) => { const tabChange = (tab) => {
if (tab === "txt2img" || tab === "img2img") { if (tab === 'txt2img' || tab === 'img2img') {
params.value.task_type = "image"; params.value.task_type = 'image'
} else { } else {
params.value.task_type = tab; params.value.task_type = tab
} }
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus">
@import "@/assets/css/mobile/image-mj.styl" @import "../../../assets/css/mobile/image-mj.styl"
</style> </style>

View File

@@ -3,9 +3,20 @@
<van-form @submit="generate"> <van-form @submit="generate">
<van-cell-group inset> <van-cell-group inset>
<div> <div>
<van-field v-model="params.sampler" is-link readonly label="采样方法" placeholder="选择采样方法" @click="showSamplerPicker = true" /> <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-popup v-model:show="showSamplerPicker" position="bottom" teleport="#app">
<van-picker :columns="samplers" @cancel="showSamplerPicker = false" @confirm="samplerConfirm" /> <van-picker
:columns="samplers"
@cancel="showSamplerPicker = false"
@confirm="samplerConfirm"
/>
</van-popup> </van-popup>
</div> </div>
@@ -24,17 +35,30 @@
<van-field v-model.number="params.steps" label="迭代步数" placeholder=""> <van-field v-model.number="params.steps" label="迭代步数" placeholder="">
<template #right-icon> <template #right-icon>
<van-icon name="info-o" @click="showInfo('值越大则代表细节越多同时也意味着出图速度越慢一般推荐20-30')" /> <van-icon
name="info-o"
@click="showInfo('值越大则代表细节越多同时也意味着出图速度越慢一般推荐20-30')"
/>
</template> </template>
</van-field> </van-field>
<van-field v-model.number="params.cfg_scale" label="引导系数" placeholder=""> <van-field v-model.number="params.cfg_scale" label="引导系数" placeholder="">
<template #right-icon> <template #right-icon>
<van-icon name="info-o" @click="showInfo('提示词引导系数,图像在多大程度上服从提示词,较低值会产生更有创意的结果')" /> <van-icon
name="info-o"
@click="
showInfo('提示词引导系数,图像在多大程度上服从提示词,较低值会产生更有创意的结果')
"
/>
</template> </template>
</van-field> </van-field>
<van-field v-model.number="params.seed" label="随机因子" placeholder=""> <van-field v-model.number="params.seed" label="随机因子" placeholder="">
<template #right-icon> <template #right-icon>
<van-icon name="info-o" @click="showInfo('随机数种子,相同的种子会得到相同的结果,设置为 -1 则每次随机生成种子')" /> <van-icon
name="info-o"
@click="
showInfo('随机数种子,相同的种子会得到相同的结果,设置为 -1 则每次随机生成种子')
"
/>
</template> </template>
</van-field> </van-field>
@@ -46,9 +70,20 @@
<div v-if="params.hd_fix"> <div v-if="params.hd_fix">
<div> <div>
<van-field v-model="params.hd_scale_alg" is-link readonly label="放大算法" placeholder="选择放大算法" @click="showUpscalePicker = true" /> <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-popup v-model:show="showUpscalePicker" position="bottom" teleport="#app">
<van-picker :columns="upscaleAlgArr" @cancel="showUpscalePicker = false" @confirm="upscaleConfirm" /> <van-picker
:columns="upscaleAlgArr"
@cancel="showUpscalePicker = false"
@confirm="upscaleConfirm"
/>
</van-popup> </van-popup>
</div> </div>
@@ -57,10 +92,18 @@
<van-field label="重绘幅度"> <van-field label="重绘幅度">
<template #input> <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-slider
v-model.number="params.hd_redraw_rate"
:max="1"
:step="0.1"
@update:model-value="showToast('当前值' + params.hd_redraw_rate)"
/>
</template> </template>
<template #right-icon> <template #right-icon>
<van-icon name="info-o" @click="showInfo('决定算法对图像内容的影响程度,较大的值将得到越有创意的图像')" /> <van-icon
name="info-o"
@click="showInfo('决定算法对图像内容的影响程度,较大的值将得到越有创意的图像')"
/>
</template> </template>
</van-field> </van-field>
</div> </div>
@@ -76,7 +119,14 @@
<van-collapse v-model="activeColspan"> <van-collapse v-model="activeColspan">
<van-collapse-item title="反向提示词" name="neg_prompt"> <van-collapse-item title="反向提示词" name="neg_prompt">
<van-field v-model="params.neg_prompt" rows="3" maxlength="2000" autosize type="textarea" placeholder="不想出现在图片上的元素(例如:树,建筑)" /> <van-field
v-model="params.neg_prompt"
rows="3"
maxlength="2000"
autosize
type="textarea"
placeholder="不想出现在图片上的元素(例如:树,建筑)"
/>
</van-collapse-item> </van-collapse-item>
</van-collapse> </van-collapse>
@@ -103,7 +153,14 @@
<div v-if="item.progress > 0"> <div v-if="item.progress > 0">
<van-image src="/images/img-holder.png"></van-image> <van-image src="/images/img-holder.png"></van-image>
<div class="progress"> <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-circle
v-model:current-rate="item.progress"
:rate="item.progress"
:speed="100"
:text="item.progress + '%'"
:stroke-width="60"
size="90px"
/>
</div> </div>
</div> </div>
@@ -139,11 +196,19 @@
<div class="title">任务失败</div> <div class="title">任务失败</div>
<div class="opt"> <div class="opt">
<van-button size="small" @click="showErrMsg(item)">详情</van-button> <van-button size="small" @click="showErrMsg(item)">详情</van-button>
<van-button type="danger" @click="removeImage($event, item)" size="small">删除</van-button> <van-button type="danger" @click="removeImage($event, item)" size="small"
>删除</van-button
>
</div> </div>
</div> </div>
<div class="job-item" v-else> <div class="job-item" v-else>
<van-image :src="item['img_url']" :class="item['can_opt'] ? '' : 'upscale'" lazy-load @click="imageView(item)" fit="cover"> <van-image
:src="item['img_url']"
:class="item['can_opt'] ? '' : 'upscale'"
lazy-load
@click="imageView(item)"
fit="cover"
>
<template v-slot:loading> <template v-slot:loading>
<van-loading type="spinner" size="20" /> <van-loading type="spinner" size="20" />
</template> </template>
@@ -151,7 +216,12 @@
<div class="remove"> <div class="remove">
<el-button type="danger" :icon="Delete" @click="removeImage($event, item)" circle /> <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> <el-button
type="warning"
v-if="item.publish"
@click="publishImage($event, item, false)"
circle
>
<i class="iconfont icon-cancel-share"></i> <i class="iconfont icon-cancel-share"></i>
</el-button> </el-button>
<el-button type="success" v-else @click="publishImage($event, item, true)" circle> <el-button type="success" v-else @click="publishImage($event, item, true)" circle>
@@ -166,49 +236,64 @@
</van-grid> </van-grid>
</van-list> </van-list>
</div> </div>
<button style="display: none" class="copy-prompt-sd" :data-clipboard-text="prompt" id="copy-btn-sd">复制</button> <button
style="display: none"
class="copy-prompt-sd"
:data-clipboard-text="prompt"
id="copy-btn-sd"
>
复制
</button>
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from "vue"; import { checkSession, getSystemInfo } from '@/store/cache'
import { Delete } from "@element-plus/icons-vue"; import { getSessionId } from '@/store/session'
import { httpGet, httpPost } from "@/utils/http"; import { useSharedStore } from '@/store/sharedata'
import Clipboard from "clipboard"; import { httpGet, httpPost } from '@/utils/http'
import { checkSession, getSystemInfo } from "@/store/cache"; import { showLoginDialog } from '@/utils/libs'
import { useRouter } from "vue-router"; import { Delete } from '@element-plus/icons-vue'
import { getSessionId } from "@/store/session"; import Clipboard from 'clipboard'
import { showConfirmDialog, showDialog, showFailToast, showImagePreview, showNotify, showSuccessToast, showToast } from "vant"; import {
import { showLoginDialog } from "@/utils/libs"; showConfirmDialog,
import { useSharedStore } from "@/store/sharedata"; showDialog,
showFailToast,
showImagePreview,
showNotify,
showSuccessToast,
showToast,
} from 'vant'
import { onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const listBoxHeight = ref(window.innerHeight - 40); const listBoxHeight = ref(window.innerHeight - 40)
const mjBoxHeight = ref(window.innerHeight - 150); const mjBoxHeight = ref(window.innerHeight - 150)
const isLogin = ref(false); const isLogin = ref(false)
const activeColspan = ref([""]); const activeColspan = ref([''])
window.onresize = () => { window.onresize = () => {
listBoxHeight.value = window.innerHeight - 40; listBoxHeight.value = window.innerHeight - 40
mjBoxHeight.value = window.innerHeight - 150; mjBoxHeight.value = window.innerHeight - 150
}; }
const samplers = ref([ const samplers = ref([
{ text: "Euler a", value: "Euler a" }, { text: 'Euler a', value: 'Euler a' },
{ text: "DPM++ 2S a Karras", value: "DPM++ 2S a Karras" }, { text: 'DPM++ 2S a Karras', value: 'DPM++ 2S a Karras' },
{ text: "DPM++ 2M Karras", value: "DPM++ 2M Karras" }, { text: 'DPM++ 2M Karras', value: 'DPM++ 2M Karras' },
{ text: "DPM++ 2M SDE Karras", value: "DPM++ 2M SDE Karras" }, { text: 'DPM++ 2M SDE Karras', value: 'DPM++ 2M SDE Karras' },
{ text: "DPM++ 2M Karras", value: "DPM++ 2M Karras" }, { text: 'DPM++ 2M Karras', value: 'DPM++ 2M Karras' },
{ text: "DPM++ 3M SDE Karras", value: "DPM++ 3M SDE Karras" }, { text: 'DPM++ 3M SDE Karras', value: 'DPM++ 3M SDE Karras' },
]); ])
const showSamplerPicker = ref(false); const showSamplerPicker = ref(false)
const upscaleAlgArr = ref([ const upscaleAlgArr = ref([
{ text: "Latent", value: "Latent" }, { text: 'Latent', value: 'Latent' },
{ text: "ESRGAN_4x", value: "ESRGAN_4x" }, { text: 'ESRGAN_4x', value: 'ESRGAN_4x' },
{ text: "ESRGAN 4x+", value: "ESRGAN 4x+" }, { text: 'ESRGAN 4x+', value: 'ESRGAN 4x+' },
{ text: "SwinIR_4x", value: "SwinIR_4x" }, { text: 'SwinIR_4x', value: 'SwinIR_4x' },
{ text: "LDSR", value: "LDSR" }, { text: 'LDSR', value: 'LDSR' },
]); ])
const showUpscalePicker = ref(false); const showUpscalePicker = ref(false)
const params = ref({ const params = ref({
width: 1024, width: 1024,
@@ -222,260 +307,261 @@ const params = ref({
hd_scale: 2, hd_scale: 2,
hd_scale_alg: upscaleAlgArr.value[0].value, hd_scale_alg: upscaleAlgArr.value[0].value,
hd_steps: 0, hd_steps: 0,
prompt: "", prompt: '',
neg_prompt: "nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet", neg_prompt:
}); 'nsfw, paintings,low quality,easynegative,ng_deepnegative ,lowres,bad anatomy,bad hands,bad feet',
})
const runningJobs = ref([]); const runningJobs = ref([])
const finishedJobs = ref([]); const finishedJobs = ref([])
const allowPulling = ref(true); // 是否允许轮询 const allowPulling = ref(true) // 是否允许轮询
const tastPullHandler = ref(null); const tastPullHandler = ref(null)
const router = useRouter(); const router = useRouter()
// 检查是否有画同款的参数 // 检查是否有画同款的参数
const _params = router.currentRoute.value.params["copyParams"]; const _params = router.currentRoute.value.params['copyParams']
if (_params) { if (_params) {
params.value = JSON.parse(_params); params.value = JSON.parse(_params)
} }
const power = ref(0); const power = ref(0)
const sdPower = ref(0); // 画一张 SD 图片消耗算力 const sdPower = ref(0) // 画一张 SD 图片消耗算力
const userId = ref(0); const userId = ref(0)
const store = useSharedStore(); const store = useSharedStore()
const clipboard = ref(null); const clipboard = ref(null)
const prompt = ref(""); const prompt = ref('')
onMounted(() => { onMounted(() => {
initData(); initData()
clipboard.value = new Clipboard(".copy-prompt-sd"); clipboard.value = new Clipboard('.copy-prompt-sd')
clipboard.value.on("success", () => { clipboard.value.on('success', () => {
showNotify({ type: "success", message: "复制成功", duration: 1000 }); showNotify({ type: 'success', message: '复制成功', duration: 1000 })
}); })
clipboard.value.on("error", () => { clipboard.value.on('error', () => {
showNotify({ type: "danger", message: "复制失败", duration: 2000 }); showNotify({ type: 'danger', message: '复制失败', duration: 2000 })
}); })
getSystemInfo() getSystemInfo()
.then((res) => { .then((res) => {
sdPower.value = res.data.sd_power; sdPower.value = res.data.sd_power
params.value.neg_prompt = res.data.sd_neg_prompt; params.value.neg_prompt = res.data.sd_neg_prompt
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取系统配置失败:" + e.message }); showNotify({ type: 'danger', message: '获取系统配置失败:' + e.message })
}); })
}); })
onUnmounted(() => { onUnmounted(() => {
clipboard.value.destroy(); clipboard.value.destroy()
if (tastPullHandler.value) { if (tastPullHandler.value) {
clearInterval(tastPullHandler.value); clearInterval(tastPullHandler.value)
} }
}); })
const initData = () => { const initData = () => {
checkSession() checkSession()
.then((user) => { .then((user) => {
power.value = user["power"]; power.value = user['power']
userId.value = user.id; userId.value = user.id
isLogin.value = true; isLogin.value = true
fetchRunningJobs(); fetchRunningJobs()
fetchFinishJobs(1); fetchFinishJobs(1)
tastPullHandler.value = setInterval(() => { tastPullHandler.value = setInterval(() => {
if (allowPulling.value) { if (allowPulling.value) {
fetchRunningJobs(); fetchRunningJobs()
} }
}, 5000); }, 5000)
}) })
.catch(() => { .catch(() => {
loading.value = false; loading.value = false
}); })
}; }
const fetchRunningJobs = () => { const fetchRunningJobs = () => {
// 获取运行中的任务 // 获取运行中的任务
httpGet(`/api/sd/jobs?finish=0`) httpGet(`/api/sd/jobs?finish=0`)
.then((res) => { .then((res) => {
const jobs = res.data.items; const jobs = res.data.items
const _jobs = []; const _jobs = []
for (let i = 0; i < jobs.length; i++) { for (let i = 0; i < jobs.length; i++) {
if (jobs[i].progress === -1) { if (jobs[i].progress === -1) {
showNotify({ showNotify({
message: `任务ID${jobs[i]["task_id"]} 原因:${jobs[i]["err_msg"]}`, message: `任务ID${jobs[i]['task_id']} 原因:${jobs[i]['err_msg']}`,
type: "danger", type: 'danger',
}); })
power.value += sdPower.value; power.value += sdPower.value
continue; continue
} }
_jobs.push(jobs[i]); _jobs.push(jobs[i])
} }
if (runningJobs.value.length !== _jobs.length) { if (runningJobs.value.length !== _jobs.length) {
fetchFinishJobs(1); fetchFinishJobs(1)
} }
if (runningJobs.value.length === 0) { if (runningJobs.value.length === 0) {
allowPulling.value = false; allowPulling.value = false
} }
runningJobs.value = _jobs; runningJobs.value = _jobs
}) })
.catch((e) => { .catch((e) => {
showNotify({ type: "danger", message: "获取任务失败:" + e.message }); showNotify({ type: 'danger', message: '获取任务失败:' + e.message })
}); })
}; }
const loading = ref(false); const loading = ref(false)
const finished = ref(false); const finished = ref(false)
const error = ref(false); const error = ref(false)
const page = ref(0); const page = ref(0)
const pageSize = ref(10); const pageSize = ref(10)
// 获取已完成的任务 // 获取已完成的任务
const fetchFinishJobs = (page) => { const fetchFinishJobs = (page) => {
loading.value = true; loading.value = true
httpGet(`/api/sd/jobs?finish=1&page=${page}&page_size=${pageSize.value}`) httpGet(`/api/sd/jobs?finish=1&page=${page}&page_size=${pageSize.value}`)
.then((res) => { .then((res) => {
const jobs = res.data.items; const jobs = res.data.items
if (jobs.length < pageSize.value) { if (jobs.length < pageSize.value) {
finished.value = true; finished.value = true
} }
const _jobs = []; const _jobs = []
for (let i = 0; i < jobs.length; i++) { for (let i = 0; i < jobs.length; i++) {
if (jobs[i].progress === -1) { if (jobs[i].progress === -1) {
jobs[i]["thumb_url"] = jobs[i]["img_url"] + "?imageView2/1/w/480/h/600/q/75"; jobs[i]['thumb_url'] = jobs[i]['img_url'] + '?imageView2/1/w/480/h/600/q/75'
} }
_jobs.push(jobs[i]); _jobs.push(jobs[i])
} }
if (page === 1) { if (page === 1) {
finishedJobs.value = _jobs; finishedJobs.value = _jobs
} else { } else {
finishedJobs.value = finishedJobs.value.concat(_jobs); finishedJobs.value = finishedJobs.value.concat(_jobs)
} }
loading.value = false; loading.value = false
}) })
.catch((e) => { .catch((e) => {
loading.value = false; loading.value = false
showNotify({ type: "danger", message: "获取任务失败:" + e.message }); showNotify({ type: 'danger', message: '获取任务失败:' + e.message })
}); })
}; }
const onLoad = () => { const onLoad = () => {
page.value += 1; page.value += 1
fetchFinishJobs(page.value); fetchFinishJobs(page.value)
}; }
// 创建绘图任务 // 创建绘图任务
const promptRef = ref(null); const promptRef = ref(null)
const generate = () => { const generate = () => {
if (!isLogin.value) { if (!isLogin.value) {
return showLoginDialog(router); return showLoginDialog(router)
} }
if (params.value.prompt === "") { if (params.value.prompt === '') {
promptRef.value.focus(); promptRef.value.focus()
return showToast("请输入绘画提示词!"); return showToast('请输入绘画提示词!')
} }
if (!params.value.seed) { if (!params.value.seed) {
params.value.seed = -1; params.value.seed = -1
} }
params.value.session_id = getSessionId(); params.value.session_id = getSessionId()
httpPost("/api/sd/image", params.value) httpPost('/api/sd/image', params.value)
.then(() => { .then(() => {
showSuccessToast("绘画任务推送成功,请耐心等待任务执行..."); showSuccessToast('绘画任务推送成功,请耐心等待任务执行...')
power.value -= sdPower.value; power.value -= sdPower.value
allowPulling.value = true; allowPulling.value = true
runningJobs.value.push({ runningJobs.value.push({
progress: 0, progress: 0,
}); })
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务推送失败:" + e.message); showFailToast('任务推送失败:' + e.message)
}); })
}; }
const showPrompt = (item) => { const showPrompt = (item) => {
prompt.value = item.prompt; prompt.value = item.prompt
showConfirmDialog({ showConfirmDialog({
title: "绘画提示词", title: '绘画提示词',
message: item.prompt, message: item.prompt,
confirmButtonText: "复制", confirmButtonText: '复制',
cancelButtonText: "关闭", cancelButtonText: '关闭',
}) })
.then(() => { .then(() => {
document.querySelector("#copy-btn-sd").click(); document.querySelector('#copy-btn-sd').click()
}) })
.catch(() => {}); .catch(() => {})
}; }
const showErrMsg = (item) => { const showErrMsg = (item) => {
showDialog({ showDialog({
title: "错误详情", title: '错误详情',
message: item["err_msg"], message: item['err_msg'],
}).then(() => { }).then(() => {
// on close // on close
}); })
}; }
const removeImage = (event, item) => { const removeImage = (event, item) => {
event.stopPropagation(); event.stopPropagation()
showConfirmDialog({ showConfirmDialog({
title: "标题", title: '标题',
message: "此操作将会删除任务和图片,继续操作码?", message: '此操作将会删除任务和图片,继续操作码?',
}) })
.then(() => { .then(() => {
httpGet("/api/sd/remove", { id: item.id, user_id: item.user }) httpGet('/api/sd/remove', { id: item.id, user_id: item.user })
.then(() => { .then(() => {
showSuccessToast("任务删除成功"); showSuccessToast('任务删除成功')
fetchFinishJobs(1); fetchFinishJobs(1)
}) })
.catch((e) => { .catch((e) => {
showFailToast("任务删除失败:" + e.message); showFailToast('任务删除失败:' + e.message)
}); })
}) })
.catch(() => { .catch(() => {
showToast("您取消了操作"); showToast('您取消了操作')
}); })
}; }
// 发布图片到作品墙 // 发布图片到作品墙
const publishImage = (event, item, action) => { const publishImage = (event, item, action) => {
event.stopPropagation(); event.stopPropagation()
let text = "图片发布"; let text = '图片发布'
if (action === false) { if (action === false) {
text = "取消发布"; text = '取消发布'
} }
httpGet("/api/sd/publish", { id: item.id, action: action, user_id: item.user }) httpGet('/api/sd/publish', { id: item.id, action: action, user_id: item.user })
.then(() => { .then(() => {
showSuccessToast(text + "成功"); showSuccessToast(text + '成功')
item.publish = action; item.publish = action
}) })
.catch((e) => { .catch((e) => {
showFailToast(text + "失败:" + e.message); showFailToast(text + '失败:' + e.message)
}); })
}; }
const imageView = (item) => { const imageView = (item) => {
showImagePreview([item["img_url"]]); showImagePreview([item['img_url']])
}; }
const samplerConfirm = (item) => { const samplerConfirm = (item) => {
params.value.sampler = item.selectedOptions[0].text; params.value.sampler = item.selectedOptions[0].text
showSamplerPicker.value = false; showSamplerPicker.value = false
}; }
const upscaleConfirm = (item) => { const upscaleConfirm = (item) => {
params.value.hd_scale_alg = item.selectedOptions[0].text; params.value.hd_scale_alg = item.selectedOptions[0].text
showUpscalePicker.value = false; showUpscalePicker.value = false
}; }
const showInfo = (message) => { const showInfo = (message) => {
showDialog({ showDialog({
title: "参数说明", title: '参数说明',
message: message, message: message,
}).then(() => { }).then(() => {
// on close // on close
}); })
}; }
</script> </script>
<style lang="stylus"> <style lang="stylus" scoped>
@import "@/assets/css/mobile/image-sd.styl" @import "../../../assets/css/mobile/image-sd.styl"
</style> </style>

View File

@@ -1,7 +1,7 @@
module.exports = { export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: { theme: {
extend: {}, extend: {},
}, },
plugins: [], plugins: [],
}; }

57
web/vite.config.js Normal file
View File

@@ -0,0 +1,57 @@
import vue from '@vitejs/plugin-vue'
import path from 'path'
import AutoImport from 'unplugin-auto-import/vite'
import { defineConfig, loadEnv } from 'vite'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
const apiHost = env.VITE_API_HOST || 'http://localhost:5678'
return {
plugins: [
vue(),
AutoImport({
imports: [
'vue',
'vue-router',
{
'@vueuse/core': ['useMouse', 'useFetch'],
},
],
dts: true, // 生成 TypeScript 声明文件
}),
],
base: env.VITE_BASE_URL,
build: {
outDir: 'dist', // 构建输出目录
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'~@': path.resolve(__dirname, './src'),
},
},
server: {
port: 8888, // 设置你想要的端口号
open: false, // 可选:启动服务器时自动打开浏览器
...(process.env.NODE_ENV === 'development'
? {
proxy: {
'/api': {
target: apiHost,
changeOrigin: true,
ws: true,
},
'/static/upload/': {
target: apiHost,
changeOrigin: true,
},
},
}
: {}),
},
}
})

View File

@@ -1,39 +0,0 @@
const { defineConfig } = require("@vue/cli-service");
const path = require("path");
let webpack = require("webpack");
module.exports = defineConfig({
transpileDependencies: true,
lintOnSave: false, //关闭eslint校验
productionSourceMap: false, //在生产模式中禁用 Source Map既可以减少包大小也可以加密源码
configureWebpack: {
// disable performance hints
performance: {
hints: false,
},
plugins: [new webpack.optimize.MinChunkSizePlugin({ minChunkSize: 10000 })],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
},
publicPath: "/",
outputDir: "dist",
crossorigin: "anonymous",
devServer: {
client: {
overlay: false // 关闭错误覆盖层
},
allowedHosts: "all",
port: 8888,
proxy: {
"/static/upload/": {
target: process.env.VUE_APP_API_HOST,
changeOrigin: true,
},
},
},
});