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
- 功能优化:增加模型分组与模型描述,采用卡片展示模式改进模型选择功能体验

View File

@@ -100,6 +100,26 @@ func (s *AppServer) Run(db *gorm.DB) error {
&model.UserLoginLog{},
&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")
// 统计安装信息

View File

@@ -52,17 +52,6 @@ type Delta struct {
} `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 {
Id uint `json:"id"`
Name string `json:"name"`

View File

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

View File

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

View File

@@ -209,20 +209,28 @@ func (h *ChatHandler) Messages(c *gin.Context) {
func (h *ChatHandler) History(c *gin.Context) {
chatId := c.Query("chat_id") // 会话 ID
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)
if res.Error != nil {
resp.ERROR(c, "No history message")
return
} else {
for _, item := range items {
var v vo.HistoryMessage
var v vo.ChatMessage
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.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"`
MaxTokens int `json:"max_tokens"` // 最大响应长度
MaxContext int `json:"max_context"` // 最大上下文长度
Description string `json:"description"` //模型描述
Category string `json:"category"` //模型类别
Desc string `json:"desc"` //模型描述
Tag string `json:"tag"` //模型标签
Temperature float32 `json:"temperature"` // 模型温度
KeyId int `json:"key_id,omitempty"`
CreatedAt int64 `json:"created_at"`
@@ -66,8 +66,8 @@ func (h *ChatModelHandler) Save(c *gin.Context) {
item.Power = data.Power
item.MaxTokens = data.MaxTokens
item.MaxContext = data.MaxContext
item.Description = data.Description
item.Category = data.Category
item.Desc = data.Desc
item.Tag = data.Tag
item.Temperature = data.Temperature
item.KeyId = uint(data.KeyId)
item.Type = data.Type
@@ -100,12 +100,16 @@ func (h *ChatModelHandler) List(c *gin.Context) {
session := h.DB.Session(&gorm.Session{})
enable := h.GetBool(c, "enable")
name := h.GetTrim(c, "name")
modelType := h.GetTrim(c, "type")
if enable {
session = session.Where("enabled", enable)
}
if name != "" {
session = session.Where("name LIKE ?", name+"%")
}
if modelType != "" {
session = session.Where("type", modelType)
}
var items []model.ChatModel
var cms = make([]vo.ChatModel, 0)
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("Content-Type", "text/csv")
c.Header("Prompt-Disposition", "attachment; filename=output.csv")
c.Header("Prompt-Type", "text/csv")
// 创建一个 CSV writer
writer := csv.NewWriter(c.Writer)

View File

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

View File

@@ -21,11 +21,11 @@ import (
"geekai/store/vo"
"geekai/utils"
"geekai/utils/resp"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"unicode/utf8"
@@ -36,13 +36,34 @@ import (
"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 {
BaseHandler
redis *redis.Client
uploadManager *oss.UploaderManager
licenseService *service.LicenseService
ReqCancelFunc *types.LMap[string, context.CancelFunc] // HttpClient 请求取消 handle function
ChatContexts *types.LMap[string, []any] // 聊天上下文 Map [chatId] => []Message
userService *service.UserService
}
@@ -53,14 +74,74 @@ func NewChatHandler(app *core.AppServer, db *gorm.DB, redis *redis.Client, manag
uploadManager: manager,
licenseService: licenseService,
ReqCancelFunc: types.NewLMap[string, context.CancelFunc](),
ChatContexts: types.NewLMap[string, []any](),
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
res := h.DB.Model(&model.User{}).First(&user, session.UserId)
res := h.DB.Model(&model.User{}).First(&user, input.UserId)
if res.Error != nil {
return errors.New("未授权用户,您正在进行非法操作!")
}
@@ -71,12 +152,12 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
return errors.New("User 对象转换失败," + err.Error())
}
if userVo.Status == false {
if !userVo.Status {
return errors.New("您的账号已经被禁用,如果疑问,请联系管理员!")
}
if userVo.Power < session.Model.Power {
return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, session.Model.Power)
if userVo.Power < input.ChatModel.Power {
return fmt.Errorf("您当前剩余算力 %d 已不足以支付当前模型的单次对话需要消耗的算力 %d[立即购买](/member)。", userVo.Power, input.ChatModel.Power)
}
if userVo.ExpiredTime > 0 && userVo.ExpiredTime <= time.Now().Unix() {
@@ -84,30 +165,29 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
}
// 检查 prompt 长度是否超过了当前模型允许的最大上下文长度
promptTokens, _ := utils.CalcTokens(prompt, session.Model.Value)
if promptTokens > session.Model.MaxContext {
promptTokens, _ := utils.CalcTokens(input.Prompt, input.ChatModel.Value)
if promptTokens > input.ChatModel.MaxContext {
return errors.New("对话内容超出了当前模型允许的最大上下文长度!")
}
var req = types.ApiRequest{
Model: session.Model.Value,
Stream: session.Stream,
Temperature: session.Model.Temperature,
Model: input.ChatModel.Value,
Stream: input.Stream,
Temperature: input.ChatModel.Temperature,
}
// 兼容 OpenAI 模型
if strings.HasPrefix(session.Model.Value, "o1-") ||
strings.HasPrefix(session.Model.Value, "o3-") ||
strings.HasPrefix(session.Model.Value, "gpt") {
req.MaxCompletionTokens = session.Model.MaxTokens
session.Start = time.Now().Unix()
if strings.HasPrefix(input.ChatModel.Value, "o1-") ||
strings.HasPrefix(input.ChatModel.Value, "o3-") ||
strings.HasPrefix(input.ChatModel.Value, "gpt") {
req.MaxCompletionTokens = input.ChatModel.MaxTokens
} 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
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 {
var tools = make([]types.Tool, 0)
for _, v := range items {
@@ -138,25 +218,27 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
}
// 加载聊天上下文
chatCtx := make([]interface{}, 0)
messages := make([]interface{}, 0)
chatCtx := make([]any, 0)
messages := make([]any, 0)
if h.App.SysConfig.EnableContext {
if h.ChatContexts.Has(session.ChatId) {
messages = h.ChatContexts.Get(session.ChatId)
} else {
_ = utils.JsonDecode(role.Context, &messages)
if h.App.SysConfig.ContextDeep > 0 {
var historyMessages []model.ChatMessage
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 {
for i := len(historyMessages) - 1; i >= 0; i-- {
msg := historyMessages[i]
ms := types.Message{Role: "user", Content: msg.Content}
if msg.Type == types.ReplyMsg {
ms.Role = "assistant"
}
chatCtx = append(chatCtx, ms)
_ = utils.JsonDecode(input.ChatRole.Context, &messages)
if h.App.SysConfig.ContextDeep > 0 {
var historyMessages []model.ChatMessage
dbSession := h.DB.Session(&gorm.Session{}).Where("chat_id", input.ChatId)
if input.LastMsgId > 0 { // 重新生成逻辑
dbSession = dbSession.Where("id < ?", input.LastMsgId)
// 删除对应的聊天记录
h.DB.Debug().Where("chat_id", input.ChatId).Where("id >= ?", input.LastMsgId).Delete(&model.ChatMessage{})
}
err = dbSession.Limit(h.App.SysConfig.ContextDeep).Order("id DESC").Find(&historyMessages).Error
if err == nil {
for i := len(historyMessages) - 1; i >= 0; i-- {
msg := historyMessages[i]
ms := types.Message{Role: "user", Content: msg.Content}
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]
tks, _ = utils.CalcTokens(utils.JsonEncode(v), req.Model)
// 上下文 token 超出了模型的最大上下文长度
if tokens+tks >= session.Model.MaxContext {
if tokens+tks >= input.ChatModel.MaxContext {
break
}
@@ -183,78 +265,106 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session *types.ChatSessio
tokens += tks
chatCtx = append(chatCtx, v)
}
logger.Debugf("聊天上下文:%+v", chatCtx)
}
reqMgs := make([]interface{}, 0)
reqMgs := make([]any, 0)
for i := len(chatCtx) - 1; i >= 0; i-- {
reqMgs = append(reqMgs, chatCtx[i])
}
fullPrompt := prompt
text := prompt
// extract files in prompt
files := utils.ExtractFileURLs(prompt)
logger.Debugf("detected FILES: %+v", files)
// 如果不是逆向模型,则提取文件内容
if len(files) > 0 && !(session.Model.Value == "gpt-4-all" ||
strings.HasPrefix(session.Model.Value, "gpt-4-gizmo") ||
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{
fileContents := make([]string, 0) // 文件内容
var finalPrompt = input.Prompt
imgList := make([]any, 0)
for _, file := range input.Files {
logger.Debugf("detected file: %+v", file.URL)
// 处理图片
if isImageURL(file.URL) {
imgList = append(imgList, gin.H{
"type": "image_url",
"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 数量
@@ -323,15 +433,14 @@ func (h *ChatHandler) StopGenerate(c *gin.Context) {
// 发送请求到 OpenAI 服务器
// 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 session.Model.KeyId > 0 {
h.DB.Where("id", session.Model.KeyId).Find(apiKey)
}
// use the last unused key
if apiKey.Id == 0 {
if input.ChatModel.KeyId > 0 {
h.DB.Where("id", input.ChatModel.KeyId).Find(apiKey)
} else { // use the last unused key
h.DB.Where("type", "chat").Where("enabled", true).Order("last_used_at ASC").First(apiKey)
}
if apiKey.Id == 0 {
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
if session.Model.Power > 0 {
power = session.Model.Power
if input.ChatModel.Power > 0 {
power = input.ChatModel.Power
}
err := h.userService.DecreasePower(userVo.Id, power, model.PowerLog{
Type: types.PowerConsume,
Model: session.Model.Value,
Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", session.Model.Name, promptTokens, replyTokens),
Model: input.ChatModel.Value,
Remark: fmt.Sprintf("模型名称:%s, 提问长度:%d回复长度%d", input.ChatModel.Name, promptTokens, replyTokens),
})
if err != nil {
logger.Error(err)
@@ -401,19 +510,11 @@ func (h *ChatHandler) saveChatHistory(
req types.ApiRequest,
usage Usage,
message types.Message,
session *types.ChatSession,
role model.ChatRole,
input ChatInput,
userVo vo.User,
promptCreatedAt 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
var promptTokens, replyTokens, totalTokens int
@@ -424,12 +525,15 @@ func (h *ChatHandler) saveChatHistory(
}
historyUserMsg := model.ChatMessage{
UserId: userVo.Id,
ChatId: session.ChatId,
RoleId: role.Id,
Type: types.PromptMsg,
Icon: userVo.Avatar,
Content: template.HTMLEscapeString(usage.Prompt),
UserId: userVo.Id,
ChatId: input.ChatId,
RoleId: input.RoleId,
Type: types.PromptMsg,
Icon: userVo.Avatar,
Content: utils.JsonEncode(vo.MsgContent{
Text: usage.Prompt,
Files: input.Files,
}),
Tokens: promptTokens,
TotalTokens: promptTokens,
UseContext: true,
@@ -452,12 +556,15 @@ func (h *ChatHandler) saveChatHistory(
totalTokens = replyTokens + getTotalTokens(req)
}
historyReplyMsg := model.ChatMessage{
UserId: userVo.Id,
ChatId: session.ChatId,
RoleId: role.Id,
Type: types.ReplyMsg,
Icon: role.Icon,
Content: usage.Content,
UserId: userVo.Id,
ChatId: input.ChatId,
RoleId: input.RoleId,
Type: types.ReplyMsg,
Icon: input.ChatRole.Icon,
Content: utils.JsonEncode(vo.MsgContent{
Text: message.Content,
Files: input.Files,
}),
Tokens: replyTokens,
TotalTokens: totalTokens,
UseContext: true,
@@ -471,17 +578,17 @@ func (h *ChatHandler) saveChatHistory(
}
// 更新用户算力
if session.Model.Power > 0 {
h.subUserPower(userVo, session, promptTokens, replyTokens)
if input.ChatModel.Power > 0 {
h.subUserPower(userVo, input, promptTokens, replyTokens)
}
// 保存当前会话
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 {
chatItem.ChatId = session.ChatId
chatItem.ChatId = input.ChatId
chatItem.UserId = userVo.Id
chatItem.RoleId = role.Id
chatItem.ModelId = session.Model.Id
chatItem.RoleId = input.RoleId
chatItem.ModelId = input.ModelId
if utf8.RuneCountInString(usage.Prompt) > 30 {
chatItem.Title = string([]rune(usage.Prompt)[:30]) + "..."
} else {
@@ -495,7 +602,7 @@ func (h *ChatHandler) saveChatHistory(
}
}
// 文本生成语音
// TextToSpeech 文本生成语音
func (h *ChatHandler) TextToSpeech(c *gin.Context) {
var data struct {
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))
audioFile := fmt.Sprintf("%s/audio", h.App.Config.StaticDir)
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)
if _, err := os.Stat(audioFile); err == nil {
// 设置响应头
c.Header("Content-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3")
c.Header("Prompt-Type", "audio/mpeg")
c.Header("Prompt-Disposition", "attachment; filename=speech.mp3")
c.File(audioFile)
return
}
@@ -579,9 +692,230 @@ func (h *ChatHandler) TextToSpeech(c *gin.Context) {
}
// 设置响应头
c.Header("Content-Type", "audio/mpeg")
c.Header("Content-Disposition", "attachment; filename=speech.mp3")
c.Header("Prompt-Type", "audio/mpeg")
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)
for _, chat := range chats {
chatIds = append(chatIds, chat.ChatId)
// 清空会话上下文
h.ChatContexts.Delete(chat.ChatId)
}
err = h.DB.Transaction(func(tx *gorm.DB) error {
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) {
chatId := c.Query("chat_id") // 会话 ID
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)
if res.Error != nil {
resp.ERROR(c, "No history message")
return
} else {
for _, item := range items {
var v vo.HistoryMessage
var v vo.ChatMessage
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.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
}
// TODO: 是否要删除 MidJourney 绘画记录和图片文件?
// 清空会话上下文
h.ChatContexts.Delete(chatId)
resp.SUCCESS(c, types.OkMsg)
}

View File

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

View File

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

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) {
group := s.Engine.Group("/api/chat/")
group.Any("message", h.Chat)
group.GET("list", h.List)
group.GET("detail", h.Detail)
group.POST("update", h.Update)
@@ -519,11 +520,6 @@ func main() {
group := s.Engine.Group("/api/test")
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.Invoke(func(s *core.AppServer, h *handler.PromptHandler) {
group := s.Engine.Group("/api/prompt")

View File

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

View File

@@ -16,7 +16,6 @@ import (
"geekai/store"
"geekai/store/model"
"geekai/utils"
"io"
"time"
"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)
}
all, _ := io.ReadAll(r.Body)
logger.Debugf("response: %+v", string(all))
// update the api key last use time
s.db.Model(&apiKey).UpdateColumn("last_used_at", time.Now().Unix())
var imgURL string

View File

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

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 {
Id int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserId uint `gorm:"column:user_id;type:int;not null;comment:用户 ID" json:"user_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"`
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"`
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"`
Model string `gorm:"column:model;type:varchar(30);comment:模型名称" json:"model"`
RoleId uint `gorm:"column:role_id;type:int;not null;comment:角色 ID" json:"role_id"`
Model string `gorm:"column:model;type:varchar(255);comment:模型名称" json:"model"`
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"`
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"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
}

View File

@@ -5,20 +5,20 @@ import (
)
type ChatModel struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Description string `gorm:"column:description;type:varchar(1024);not null;default:'';comment:模型类型描述" json:"description"`
Category string `gorm:"column:category;type:varchar(1024);not null;default:'';comment:模型类别" json:"category"`
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
Desc string `gorm:"column:desc;type:varchar(1024);not null;default:'';comment:模型类型描述" json:"desc"`
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"`
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"`
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"`
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"`
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"`
KeyId uint `gorm:"column:key_id;type:int;not null;comment:绑定API KEY ID" json:"key_id"`
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"`
Options string `gorm:"column:options;type:text;not null;comment:模型自定义选项" json:"options"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime" json:"updated_at"`

View File

@@ -5,27 +5,27 @@ import (
)
type User struct {
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
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"`
Email string `gorm:"column:email;type:varchar(50);comment:邮箱地址" json:"email"`
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"`
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"`
Power int `gorm:"column:power;type:int;not null;default:0;comment:剩余算力" json:"power"`
ExpiredTime int64 `gorm:"column:expired_time;type:int;not null;comment:用户过期时间" json:"expired_time"`
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
ChatConfig string `gorm:"column:chat_config;type:text;not null;comment:聊天配置json" json:"chat_config"`
ChatRoles string `gorm:"column:chat_roles_json;type:text;not null;comment:聊天角色 json" json:"chat_roles_json"`
ChatModels string `gorm:"column:chat_models_json;type:text;not null;comment:AI模型 json" json:"chat_models_json"`
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"`
Vip bool `gorm:"column:vip;type:tinyint(1);not null;default:0;comment:是否会员" json:"vip"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(16);not null;comment:最后登录 IP" json:"last_login_ip"`
OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"`
Platform string `gorm:"column:platform;type:varchar(30);comment:登录平台" json:"platform"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
Id uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
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"`
Email string `gorm:"column:email;type:varchar(50);comment:邮箱地址" json:"email"`
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"`
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"`
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"`
Status bool `gorm:"column:status;type:tinyint(1);not null;comment:当前状态" json:"status"`
ChatConfig string `gorm:"column:chat_config_json;type:text;default:null;comment:聊天配置json" json:"chat_config"`
ChatRoles string `gorm:"column:chat_roles_json;type:text;default:null;comment:聊天角色 json" json:"chat_roles"`
ChatModels string `gorm:"column:chat_models_json;type:text;default:null;comment:AI模型 json" json:"chat_models"`
LastLoginAt int64 `gorm:"column:last_login_at;type:int;not null;comment:最后登录时间" json:"last_login_at"`
Vip bool `gorm:"column:vip;type:tinyint(1);not null;default:0;comment:是否会员" json:"vip"`
LastLoginIp string `gorm:"column:last_login_ip;type:char(16);not null;comment:最后登录 IP" json:"last_login_ip"`
OpenId string `gorm:"column:openid;type:varchar(100);comment:第三方登录账号ID" json:"openid"`
Platform string `gorm:"column:platform;type:varchar(30);comment:登录平台" json:"platform"`
CreatedAt time.Time `gorm:"column:created_at;type:datetime;not null" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;type:datetime;not null" json:"updated_at"`
}
func (m *User) TableName() string {

View File

@@ -9,16 +9,17 @@ package store
import (
"geekai/core/types"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"time"
)
func NewGormConfig() *gorm.Config {
return &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
Logger: logger.Default.LogMode(logger.Warn),
NamingStrategy: schema.NamingStrategy{
TablePrefix: "chatgpt_", // 设置表前缀
SingularTable: false, // 使用单数表名形式
@@ -36,9 +37,9 @@ func NewMysql(config *gorm.Config, appConfig *types.AppConfig) (*gorm.DB, error)
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(32)
sqlDB.SetMaxOpenConns(512)
sqlDB.SetConnMaxLifetime(time.Hour)
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"`
MaxTokens int `json:"max_tokens"` // 最大响应长度
MaxContext int `json:"max_context"` // 最大上下文长度
Description string `json:"description"` // 模型描述
Category string `json:"category"` //模型类别
Desc string `json:"desc"` // 模型描述
Tag string `json:"tag"` //模型标签
Temperature float32 `json:"temperature"` // 模型温度
KeyId uint `json:"key_id,omitempty"`
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',
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '角色图标',
`role_id` bigint NOT NULL COMMENT '角色 ID',
`model` varchar(30) DEFAULT NULL COMMENT '模型名称',
`model` varchar(255) DEFAULT NULL COMMENT '模型名称',
`content` text NOT NULL COMMENT '聊天内容',
`tokens` smallint 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',
`title` varchar(100) NOT NULL COMMENT '会话标题',
`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 '创建时间',
`updated_at` datetime NOT NULL COMMENT '更新时间',
`deleted_at` datetime DEFAULT NULL
@@ -465,7 +465,7 @@ CREATE TABLE `chatgpt_power_logs` (
`type` tinyint(1) NOT NULL COMMENT '类型1充值2消费3退费',
`amount` smallint 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 '备注',
`mark` tinyint(1) NOT NULL COMMENT '资金类型0支出1收入',
`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_orders` 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 程序
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
restart: always
depends_on:
@@ -69,7 +69,7 @@ services:
# 前端应用
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
restart: always
depends_on:

View File

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

View File

@@ -1,9 +1,9 @@
VUE_APP_API_HOST=
VUE_APP_WS_HOST=
VUE_APP_KEY_PREFIX=GeekAI_
VUE_APP_VERSION=v4.2.2
VITE_API_HOST=
VITE_WS_HOST=
VITE_KEY_PREFIX=GeekAI_
VITE_VERSION=v4.2.4
VUE_APP_TITLE="Geek-AI 创作系统"
VUE_APP_DOCS_URL=https://docs.geekai.me
VUE_APP_GITHUB_URL=https://github.com/yangjian102621/geekai
VUE_APP_GITEE_URL=https://gitee.com/blackfox/geekai
VUE_APP_GITCODE_URL=https://gitcode.com/yangjian102621/geekai
VITE_DOCS_URL=https://docs.geekai.me
VITE_GITHUB_URL=https://github.com/yangjian102621/geekai
VITE_GITEE_URL=https://gitee.com/blackfox/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"
/>
<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>
<body>
@@ -15,5 +17,6 @@
<strong>请开启JavaScript支持</strong>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</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",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
"dev": "vite --host",
"build": "vite build",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
},
"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",
"animate.css": "^4.1.1",
"axios": "^0.27.2",
@@ -23,6 +29,7 @@
"markdown-it": "^13.0.1",
"markdown-it-emoji": "^2.0.0",
"markdown-it-mathjax3": "^4.3.2",
"marked": "^15.0.11",
"markmap-common": "^0.16.0",
"markmap-lib": "^0.16.1",
"markmap-toolbar": "^0.17.0",
@@ -32,51 +39,22 @@
"pinia": "^2.1.4",
"qrcode": "^1.5.3",
"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",
"three": "^0.128.0",
"vant": "^4.5.0",
"vue": "^3.2.13",
"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"
},
"devDependencies": {
"@babel/core": "7.18.6",
"@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",
"@vitejs/plugin-vue": "^5.2.4",
"autoprefixer": "^10.4.20",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"postcss": "^8.4.49",
"stylus": "^0.58.1",
"stylus-loader": "^7.0.0",
"tailwindcss": "^3.4.17",
"webpack": "^5.90.3"
},
"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"
]
"vite": "^5.4.10"
}
}

View File

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

View File

@@ -5,97 +5,58 @@
</template>
<script setup>
import { ElConfigProvider } from "element-plus";
import { onMounted, ref, watch } from "vue";
import { checkSession, getClientId, getSystemInfo } from "@/store/cache";
import { isChrome, isMobile } from "@/utils/libs";
import { showMessageInfo } from "@/utils/dialog";
import { useSharedStore } from "@/store/sharedata";
import { getUserToken } from "@/store/session";
import { checkSession, getSystemInfo } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { showMessageInfo } from '@/utils/dialog'
import { isChrome, isMobile } from '@/utils/libs'
import { ElConfigProvider } from 'element-plus'
import { onMounted } from 'vue'
const debounce = (fn, delay) => {
let timer;
let timer
return (...args) => {
if (timer) {
clearTimeout(timer);
clearTimeout(timer)
}
timer = setTimeout(() => {
fn(...args);
}, delay);
};
};
fn(...args)
}, delay)
}
}
const _ResizeObserver = window.ResizeObserver;
const _ResizeObserver = window.ResizeObserver
window.ResizeObserver = class ResizeObserver extends _ResizeObserver {
constructor(callback) {
callback = debounce(callback, 200);
super(callback);
callback = debounce(callback, 200)
super(callback)
}
};
}
const store = useSharedStore();
const store = useSharedStore()
onMounted(() => {
// 获取系统参数
getSystemInfo().then((res) => {
const link = document.createElement("link");
link.rel = "shortcut icon";
link.href = res.data.logo;
document.head.appendChild(link);
});
const link = document.createElement('link')
link.rel = 'shortcut icon'
link.href = res.data.logo
document.head.appendChild(link)
})
if (!isChrome() && !isMobile()) {
showMessageInfo("建议使用 Chrome 浏览器以获得最佳体验。");
showMessageInfo('建议使用 Chrome 浏览器以获得最佳体验。')
}
checkSession()
.then(() => {
store.setIsLogin(true);
store.setIsLogin(true)
})
.catch(() => {});
.catch(() => {})
// 设置主题
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);
};
document.documentElement.setAttribute('data-theme', store.theme)
})
// 打印 banner
const banner = `
.oooooo. oooo .o. ooooo
d8P' 'Y8b 888 .888. 888
888 .ooooo. .ooooo. 888 oooo .8"888. 888
@@ -103,18 +64,18 @@ const banner = `
888 ooooo 888ooo888 888ooo888 888888. .88ooo8888. 888
'88. .88' 888 .o 888 .o 888 88b. .8' 888. 888
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(
"%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: red;font-size: 20px;font-family: '微软雅黑';"
);
console.log("%c 愿你出走半生,归来仍是少年!大奉武夫许七安,前来凿阵!", "color: #7c39ed;font-size: 18px;font-family: '微软雅黑';");
)
</script>
<style lang="stylus">
@@ -173,6 +134,4 @@ html, body {
background #D6FBCC
color #07C160
}
@import '@/assets/iconfont/iconfont.css'
</style>

View File

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

View File

@@ -6,7 +6,7 @@
</div>
<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 class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
@@ -29,7 +29,9 @@
</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">
<span class="bar-item"
><el-icon><Clock /></el-icon> {{ dateFormat(data.created_at) }}</span
@@ -47,7 +49,7 @@
</div>
<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 class="image" v-if="isImage(file.ext)">
<el-image :src="file.url" fit="cover" />
@@ -71,7 +73,9 @@
</div>
</div>
<div class="content-wrapper">
<div class="content" v-html="content"></div>
<div class="content position-relative">
<div v-html="content"></div>
</div>
</div>
<div class="bar" v-if="data.created_at > 0">
<span class="bar-item"
@@ -86,7 +90,6 @@
<script setup>
import { FormatFileSize, GetFileIcon, GetFileType } from '@/store/system'
import { httpPost } from '@/utils/http'
import { dateFormat, isImage, processPrompt } from '@/utils/libs'
import { Clock } from '@element-plus/icons-vue'
import hl from 'highlight.js'
@@ -111,7 +114,7 @@ const md = new MarkdownIt({
if (lang && hl.getLanguage(lang)) {
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 中
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(emoji)
const props = defineProps({
data: {
type: Object,
default: {
content: '',
content: {
text: '',
files: [],
},
created_at: '',
tokens: 0,
model: '',
@@ -141,8 +148,11 @@ const props = defineProps({
},
})
const finalTokens = ref(props.data.tokens)
const content = ref(processPrompt(props.data.content))
const files = ref([])
const content = ref(processPrompt(props.data.content.text))
const files = ref(props.data.content.files)
// 定义emit事件
const emit = defineEmits(['edit'])
onMounted(() => {
processFiles()
@@ -152,38 +162,6 @@ const processFiles = () => {
if (!props.data.content) {
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())
}
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>

View File

@@ -9,8 +9,8 @@
<div class="chat-item">
<div
class="content-wrapper"
v-html="md.render(processContent(data.content))"
v-if="data.content"
v-html="md.render(processContent(data.content.text))"
v-if="data.content.text"
></div>
<div class="content-wrapper flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -26,7 +26,7 @@
</el-tooltip>
</span>
<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-icon><Refresh /></el-icon>
</el-tooltip>
@@ -48,18 +48,6 @@
</el-tooltip>
</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>
@@ -74,8 +62,8 @@
<div class="content-wrapper">
<div
class="content"
v-html="md.render(processContent(data.content))"
v-if="data.content"
v-html="md.render(processContent(data.content.text))"
v-if="data.content.text"
></div>
<div class="content flex justify-start items-center" v-else>
<span class="mr-2">AI 思考中</span> <Thinking :duration="1.5" />
@@ -83,16 +71,15 @@
</div>
<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">tokens: {{ data.tokens }}</span>-->
<span class="bar-item bg">
<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 />
</el-icon>
</el-tooltip>
</span>
<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-icon><Refresh /></el-icon>
</el-tooltip>
@@ -106,7 +93,7 @@
placement="bottom"
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
class="box-item"
@@ -145,7 +132,10 @@ const props = defineProps({
type: Object,
default: {
icon: '',
content: '',
content: {
text: '',
files: [],
},
created_at: '',
tokens: 0,
},
@@ -233,9 +223,8 @@ const stopSynthesis = () => {
}
// 重新生成
const reGenerate = (prompt) => {
console.log(prompt)
emits('regen', prompt)
const reGenerate = (messageId) => {
emits('regen', messageId)
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,11 @@
import {defineStore} from "pinia";
import Storage from "good-storage";
import errorIcon from "@/assets/img/failed.png";
import loadingIcon from "@/assets/img/loading.gif";
import errorIcon from '@/assets/img/failed.png'
import loadingIcon from '@/assets/img/loading.gif'
import Storage from 'good-storage'
import { defineStore } from 'pinia'
let waterfallOptions = {
// 唯一key值
rowKey: "id",
rowKey: 'id',
// 卡片之间的间隙
gutter: 10,
// 是否有周围的gutter
@@ -44,16 +44,16 @@ let waterfallOptions = {
},
},
// 动画效果
animationEffect: "animate__fadeInUp",
animationEffect: 'animate__fadeInUp',
// 动画时间
animationDuration: 1000,
// 动画延迟
animationDelay: 300,
animationCancel: false,
// 背景色
backgroundColor: "",
backgroundColor: '',
// imgSelector
imgSelector: "img_thumb",
imgSelector: 'img_thumb',
// 是否跨域
crossOrigin: true,
// 加载配置
@@ -61,102 +61,62 @@ let waterfallOptions = {
loading: loadingIcon,
error: errorIcon,
ratioCalculator: (width, height) => {
const minRatio = 3 / 4;
const maxRatio = 4 / 3;
const curRatio = height / width;
const minRatio = 3 / 4
const maxRatio = 4 / 3
const curRatio = height / width
if (curRatio < minRatio) {
return minRatio;
return minRatio
} else if (curRatio > maxRatio) {
return maxRatio;
return maxRatio
} else {
return curRatio;
return curRatio
}
},
},
// 是否懒加载
lazyload: true,
align: "center",
align: 'center',
}
export const useSharedStore = defineStore("shared", {
export const useSharedStore = defineStore('shared', {
state: () => ({
showLoginDialog: false,
chatListStyle: Storage.get("chat_list_style", "chat"),
chatStream: Storage.get("chat_stream", true),
socket: { conn: null, handlers: {} },
theme: Storage.get("theme", "light"),
chatListStyle: Storage.get('chat_list_style', 'chat'),
chatStream: Storage.get('chat_stream', true),
theme: Storage.get('theme', 'light'),
isLogin: false,
chatListExtend: Storage.get("chat_list_extend", true),
ttsModel: Storage.get("tts_model", ""),
chatListExtend: Storage.get('chat_list_extend', true),
ttsModel: Storage.get('tts_model', ''),
waterfallOptions,
}),
getters: {},
actions: {
setShowLoginDialog(value) {
this.showLoginDialog = value;
this.showLoginDialog = value
},
setChatListStyle(value) {
this.chatListStyle = value;
Storage.set("chat_list_style", value);
this.chatListStyle = value
Storage.set('chat_list_style', value)
},
setChatStream(value) {
this.chatStream = 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;
this.chatStream = value
Storage.set('chat_stream', value)
},
setChatListExtend(value) {
this.chatListExtend = 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];
this.chatListExtend = value
Storage.set('chat_list_extend', value)
},
setTheme(theme) {
this.theme = theme;
document.documentElement.setAttribute("data-theme", theme); // 设置 HTML 的 data-theme 属性
Storage.set("theme", theme);
this.theme = theme
document.documentElement.setAttribute('data-theme', theme) // 设置 HTML 的 data-theme 属性
Storage.set('theme', theme)
},
setIsLogin(value) {
this.isLogin = value;
this.isLogin = value
},
setTtsModel(value) {
this.ttsModel = value;
Storage.set("tts_model", value);
this.ttsModel = value
Storage.set('tts_model', value)
},
},
});
})

View File

@@ -5,50 +5,50 @@
// * @Author yangjian102621@163.com
// * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
import Storage from "good-storage";
import Storage from 'good-storage'
export function GetFileIcon(ext) {
const files = {
".docx": "doc.png",
".doc": "doc.png",
".xls": "xls.png",
".xlsx": "xls.png",
".csv": "xls.png",
".ppt": "ppt.png",
".pptx": "ppt.png",
".md": "md.png",
".pdf": "pdf.png",
".sql": "sql.png",
".mp3": "mp3.png",
".wav": "mp3.png",
".mp4": "mp4.png",
".avi": "mp4.png",
}
if (files[ext]) {
return '/images/ext/' + files[ext]
}
const files = {
'.docx': 'doc.png',
'.doc': 'doc.png',
'.xls': 'xls.png',
'.xlsx': 'xls.png',
'.csv': 'xls.png',
'.ppt': 'ppt.png',
'.pptx': 'ppt.png',
'.md': 'md.png',
'.pdf': 'pdf.png',
'.sql': 'sql.png',
'.mp3': 'mp3.png',
'.wav': 'mp3.png',
'.mp4': 'mp4.png',
'.avi': 'mp4.png',
}
if (files[ext]) {
return '/images/ext/' + files[ext]
}
return '/images/ext/file.png'
return '/images/ext/file.png'
}
// 获取文件类型
export function GetFileType (ext) {
return ext.replace(".", "").toUpperCase()
export function GetFileType(ext) {
return ext.replace('.', '').toUpperCase()
}
// 将文件大小转成字符
export function FormatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
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() {
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 {getAdminToken, getUserToken, removeAdminToken, removeUserToken} from "@/store/session";
import { getAdminToken, getUserToken, removeAdminToken, removeUserToken } from '@/store/session'
axios.defaults.timeout = 180000
axios.defaults.baseURL = process.env.VUE_APP_API_HOST
axios.defaults.withCredentials = true;
// axios.defaults.baseURL = process.env.VUE_APP_API_HOST
axios.defaults.withCredentials = true
//axios.defaults.headers.post['Content-Type'] = 'application/json'
// HTTP拦截器
axios.interceptors.request.use(
config => {
// set token
config.headers['Authorization'] = getUserToken();
config.headers['Admin-Authorization'] = getAdminToken();
return config
}, error => {
return Promise.reject(error)
})
(config) => {
// set token
config.headers['Authorization'] = getUserToken()
config.headers['Admin-Authorization'] = getAdminToken()
return config
},
(error) => {
return Promise.reject(error)
}
)
axios.interceptors.response.use(
response => {
return response
}, error => {
if (error.response.status === 401) {
if (error.response.request.responseURL.indexOf("/api/admin") !== -1) {
removeAdminToken()
} else {
console.log("FUCK")
removeUserToken()
}
error.response.data.message = "请先登录"
return Promise.reject(error.response.data)
}
if (error.response.status === 400) {
return Promise.reject(new Error(error.response.data.message))
} else {
return Promise.reject(error)
}
})
(response) => {
return response
},
(error) => {
if (error.response.status === 401) {
if (error.response.request.responseURL.indexOf('/api/admin') !== -1) {
removeAdminToken()
} else {
removeUserToken()
}
error.response.data.message = '请先登录'
return Promise.reject(error.response.data)
}
if (error.response.status === 400) {
return Promise.reject(new Error(error.response.data.message))
} else {
return Promise.reject(error)
}
}
)
// send a http get request
export function httpGet(url, params = {}) {
return new Promise((resolve, reject) => {
axios.get(url, {
params: params
}).then(response => {
resolve(response.data)
}).catch(err => {
reject(err)
})
})
return new Promise((resolve, reject) => {
axios
.get(url, {
params: params,
})
.then((response) => {
resolve(response.data)
})
.catch((err) => {
reject(err)
})
})
}
// send a http post request
export function httpPost(url, data = {}, options = {}) {
return new Promise((resolve, reject) => {
axios.post(url, data, options).then(response => {
resolve(response.data)
}).catch(err => {
reject(err)
})
})
return new Promise((resolve, reject) => {
axios
.post(url, data, options)
.then((response) => {
resolve(response.data)
})
.catch((err) => {
reject(err)
})
})
}
export function httpDownload(url) {
return new Promise((resolve, reject) => {
axios({
method: 'GET',
url: url,
responseType: 'blob' // 将响应类型设置为 `blob`
}).then(response => {
resolve(response)
}).catch(err => {
reject(err)
})
return new Promise((resolve, reject) => {
axios({
method: 'GET',
url: url,
responseType: 'blob', // 将响应类型设置为 `blob`
})
.then((response) => {
resolve(response)
})
.catch((err) => {
reject(err)
})
})
}
export function httpPostDownload(url, data) {
return new Promise((resolve, reject) => {
axios({
method: 'POST',
url: url,
data: data,
responseType: 'blob' // 将响应类型设置为 `blob`
}).then(response => {
resolve(response)
}).catch(err => {
reject(err)
})
return new Promise((resolve, reject) => {
axios({
method: 'POST',
url: url,
data: data,
responseType: 'blob', // 将响应类型设置为 `blob`
})
}
.then((response) => {
resolve(response)
})
.catch((err) => {
reject(err)
})
})
}

View File

@@ -8,252 +8,250 @@
/**
* Util lib functions
*/
import { showConfirmDialog } from "vant";
import { showConfirmDialog } from 'vant'
// generate a random string
export function randString(length) {
const str = "0123456789abcdefghijklmnopqrstuvwxyz";
const size = str.length;
let buf = [];
const str = '0123456789abcdefghijklmnopqrstuvwxyz'
const size = str.length
let buf = []
for (let i = 0; i < length; i++) {
const rand = Math.random() * size;
buf.push(str.charAt(rand));
const rand = Math.random() * size
buf.push(str.charAt(rand))
}
return buf.join("");
return buf.join('')
}
export function UUID() {
let d = new Date().getTime();
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === "x" ? r : (r & 0x3) | 0x8).toString(16);
});
let d = new Date().getTime()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (d + Math.random() * 16) % 16 | 0
d = Math.floor(d / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
// 判断是否是移动设备
export function isMobile() {
const userAgent = navigator.userAgent;
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i;
return mobileRegex.test(userAgent);
const userAgent = navigator.userAgent
const mobileRegex =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i
return mobileRegex.test(userAgent)
}
// 格式化日期
export function dateFormat(timestamp, format) {
if (!timestamp) {
return "";
return ''
} else if (timestamp < 9680917502) {
timestamp = timestamp * 1000;
timestamp = timestamp * 1000
}
let year, month, day, HH, mm, ss;
let time = new Date(timestamp);
let timeDate;
year = time.getFullYear(); // 年
month = time.getMonth() + 1; // 月
day = time.getDate(); // 日
HH = time.getHours(); // 时
mm = time.getMinutes(); // 分
ss = time.getSeconds(); // 秒
let year, month, day, HH, mm, ss
let time = new Date(timestamp)
let timeDate
year = time.getFullYear() // 年
month = time.getMonth() + 1 // 月
day = time.getDate() // 日
HH = time.getHours() // 时
mm = time.getMinutes() // 分
ss = time.getSeconds() // 秒
month = month < 10 ? "0" + month : month;
day = day < 10 ? "0" + day : day;
HH = HH < 10 ? "0" + HH : HH; // 时
mm = mm < 10 ? "0" + mm : mm; // 分
ss = ss < 10 ? "0" + ss : ss; // 秒
month = month < 10 ? '0' + month : month
day = day < 10 ? '0' + day : day
HH = HH < 10 ? '0' + HH : HH // 时
mm = mm < 10 ? '0' + mm : mm // 分
ss = ss < 10 ? '0' + ss : ss // 秒
switch (format) {
case "yyyy":
timeDate = String(year);
break;
case "yyyy-MM":
timeDate = year + "-" + month;
break;
case "yyyy-MM-dd":
timeDate = year + "-" + month + "-" + day;
break;
case "yyyy/MM/dd":
timeDate = year + "/" + month + "/" + day;
break;
case "yyyy-MM-dd HH:mm:ss":
timeDate = year + "-" + month + "-" + day + " " + HH + ":" + mm + ":" + ss;
break;
case "HH:mm:ss":
timeDate = HH + ":" + mm + ":" + ss;
break;
case "MM":
timeDate = String(month);
break;
case 'yyyy':
timeDate = String(year)
break
case 'yyyy-MM':
timeDate = year + '-' + month
break
case 'yyyy-MM-dd':
timeDate = year + '-' + month + '-' + day
break
case 'yyyy/MM/dd':
timeDate = year + '/' + month + '/' + day
break
case 'yyyy-MM-dd HH:mm:ss':
timeDate = year + '-' + month + '-' + day + ' ' + HH + ':' + mm + ':' + ss
break
case 'HH:mm:ss':
timeDate = HH + ':' + mm + ':' + ss
break
case 'MM':
timeDate = String(month)
break
default:
timeDate = year + "-" + month + "-" + day + " " + HH + ":" + mm + ":" + ss;
break;
timeDate = year + '-' + month + '-' + day + ' ' + HH + ':' + mm + ':' + ss
break
}
return timeDate;
return timeDate
}
export function formatTime(time) {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
}
// 判断数组中是否包含某个元素
export function arrayContains(array, value, compare) {
if (!array) {
return false;
return false
}
if (typeof compare !== "function") {
if (typeof compare !== 'function') {
compare = function (v1, v2) {
return v1 === v2;
};
return v1 === v2
}
}
for (let i = 0; i < array.length; i++) {
if (compare(array[i], value)) {
return true;
return true
}
}
return false;
return false
}
// 删除数组中指定的元素
export function removeArrayItem(array, value, compare) {
if (typeof compare !== "function") {
if (typeof compare !== 'function') {
compare = function (v1, v2) {
return v1 === v2;
};
return v1 === v2
}
}
for (let i = 0; i < array.length; i++) {
if (compare(array[i], value)) {
array.splice(i, 1);
break;
array.splice(i, 1)
break
}
}
return array;
return array
}
// 渲染输入的换行符
export function renderInputText(text) {
const replaceRegex = /(\n\r|\r\n|\r|\n)/g;
text = text || "";
return text.replace(replaceRegex, "<br/>");
const replaceRegex = /(\n\r|\r\n|\r|\n)/g
text = text || ''
return text.replace(replaceRegex, '<br/>')
}
// 拷贝对象
export function copyObj(origin) {
return JSON.parse(JSON.stringify(origin));
return JSON.parse(JSON.stringify(origin))
}
export function disabledDate(time) {
return time.getTime() < Date.now();
return time.getTime() < Date.now()
}
// 字符串截取
export function substr(str, length) {
let result = "";
let count = 0;
let result = ''
let count = 0
for (let i = 0; i < str.length; i++) {
const char = str.charAt(i);
const charCode = str.charCodeAt(i);
const char = str.charAt(i)
const charCode = str.charCodeAt(i)
// 判断字符是否为中文字符
if (charCode >= 0x4e00 && charCode <= 0x9fff) {
// 中文字符算两个字符
count += 2;
count += 2
} else {
count++;
count++
}
if (count <= length) {
result += char;
result += char
} else {
result += " ...";
break;
result += ' ...'
break
}
}
return result;
return result
}
export function isImage(url) {
const expr = /\.(jpg|jpeg|png|gif|bmp|svg)$/i;
return expr.test(url);
const expr = /\.(jpg|jpeg|png|gif|bmp|svg)$/i
return expr.test(url)
}
export function processContent(content) {
if (!content) {
return "";
return ''
}
// 如果是图片链接地址,则直接替换成图片标签
const linkRegex = /(https?:\/\/\S+)/g;
const links = content.match(linkRegex);
const linkRegex = /(https?:\/\/\S+)/g
const links = content.match(linkRegex)
if (links) {
for (let link of links) {
if (isImage(link)) {
const index = content.indexOf(link);
if (content.substring(index - 1, 2) !== "]") {
content = content.replace(link, "\n![](" + link + ")\n");
const index = content.indexOf(link)
if (content.substring(index - 1, 2) !== ']') {
content = content.replace(link, '\n![](' + link + ')\n')
}
}
}
}
// 处理推理标签
if (content.includes("<think>")) {
if (content.includes('<think>')) {
content = content.replace(/<think>(.*?)<\/think>/gs, (match, content) => {
if (content.length > 10) {
return `<blockquote>${content}</blockquote>`;
return `<blockquote>${content}</blockquote>`
}
return "";
});
return ''
})
content = content.replace(/<think>(.*?)$/gs, (match, content) => {
if (content.length > 10) {
return `<blockquote>${content}</blockquote>`;
return `<blockquote>${content}</blockquote>`
}
return "";
});
return ''
})
}
// 支持 \[ 公式标签
content = content.replace(/\\\[/g, "$$").replace(/\\\]/g, "$$");
content = content.replace(/\\\(\\boxed\{(\d+)\}\\\)/g, '<span class="boxed">$1</span>');
return content;
content = content.replace(/\\\[/g, '$$').replace(/\\\]/g, '$$')
content = content.replace(/\\\(\\boxed\{(\d+)\}\\\)/g, '<span class="boxed">$1</span>')
return content
}
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() {
return /MicroMessenger/i.test(navigator.userAgent);
return /MicroMessenger/i.test(navigator.userAgent)
}
export function showLoginDialog(router) {
showConfirmDialog({
title: "登录",
message: "此操作需要登录才能进行,前往登录?",
title: '登录',
message: '此操作需要登录才能进行,前往登录?',
})
.then(() => {
router.push("/mobile/login");
router.push('/mobile/login')
})
.catch(() => {
// on cancel
});
})
}
export const replaceImg = (img) => {
if (!img.startsWith("http")) {
img = `${location.protocol}//${location.host}${img}`;
if (img.startsWith('http')) {
return img
}
const devHost = process.env.VUE_APP_API_HOST;
const localhost = "http://localhost:5678";
if (img.includes(localhost)) {
return img?.replace(localhost, devHost);
}
return img;
};
export function isChrome() {
const userAgent = navigator.userAgent.toLowerCase();
return /chrome/.test(userAgent) && !/edg/.test(userAgent);
return `${location.protocol}//${location.host}${img}`
}
// 判断是否 google 浏览器
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">
<ul class="scrollbar-type-nav">
<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">
<el-image :src="item.icon" fit="cover" />
</div>
@@ -27,37 +32,25 @@
<div class="info-text">{{ scope.item.hello_msg }}</div>
</div>
<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-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 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>
</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>
</ItemList>
<div v-else style="width: 100%">
@@ -69,112 +62,112 @@
</template>
<script setup>
import nodata from "@/assets/img/no-data.png";
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { httpGet, httpPost } from "@/utils/http";
import { checkSession } from "@/store/cache";
import { arrayContains, removeArrayItem, substr } from "@/utils/libs";
import { useRouter } from "vue-router";
import { useSharedStore } from "@/store/sharedata";
import ItemList from "@/components/ItemList.vue";
import nodata from '@/assets/img/no-data.png'
import ItemList from '@/components/ItemList.vue'
import { checkSession } from '@/store/cache'
import { useSharedStore } from '@/store/sharedata'
import { httpGet, httpPost } from '@/utils/http'
import { arrayContains, removeArrayItem, substr } from '@/utils/libs'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
const listBoxHeight = window.innerHeight - 133;
const listBoxHeight = window.innerHeight - 133
const typeId = ref("");
const appTypes = ref([]);
const list = ref([]);
const roles = ref([]);
const store = useSharedStore();
const typeId = ref('')
const appTypes = ref([])
const list = ref([])
const roles = ref([])
const store = useSharedStore()
onMounted(() => {
getAppType();
getAppList();
getRoles();
});
getAppType()
getAppList()
getRoles()
})
const getRoles = () => {
checkSession()
.then((user) => {
roles.value = user.chat_roles;
roles.value = user.chat_roles
})
.catch((e) => {
console.log(e.message);
});
};
console.log(e.message)
})
}
const getAppType = () => {
httpGet("/api/app/type/list")
httpGet('/api/app/type/list')
.then((res) => {
appTypes.value = res.data;
appTypes.value = res.data
})
.catch((e) => {
ElMessage.error("获取分类失败:" + e.message);
});
};
ElMessage.error('获取分类失败:' + e.message)
})
}
const getAppList = (tid = "") => {
typeId.value = tid;
httpGet("/api/app/list", { tid })
const getAppList = (tid = '') => {
typeId.value = tid
httpGet('/api/app/list', { tid })
.then((res) => {
const items = res.data;
const items = res.data
// 处理 hello message
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) => {
ElMessage.error("获取应用失败:" + e.message);
});
};
ElMessage.error('获取应用失败:' + e.message)
})
}
const updateRole = (row, opt) => {
checkSession()
.then(() => {
const title = ref("");
if (opt === "add") {
title.value = "添加应用";
const exists = arrayContains(roles.value, row.key);
const title = ref('')
if (opt === 'add') {
title.value = '添加应用'
const exists = arrayContains(roles.value, row.key)
if (exists) {
return;
return
}
roles.value.push(row.key);
roles.value.push(row.key)
} else {
title.value = "移除应用";
const exists = arrayContains(roles.value, row.key);
title.value = '移除应用'
const exists = arrayContains(roles.value, row.key)
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(() => {
ElMessage.success({
message: title.value + "成功!",
message: title.value + '成功!',
duration: 1000,
});
})
})
.catch((e) => {
ElMessage.error(title.value + "失败:" + e.message);
});
ElMessage.error(title.value + '失败:' + e.message)
})
})
.catch(() => {
store.setShowLoginDialog(true);
});
};
store.setShowLoginDialog(true)
})
}
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) => {
router.push(`/chat?role_id=${role.id}`);
};
router.push(`/chat?role_id=${role.id}`)
}
</script>
<style lang="stylus">
@import "@/assets/css/chat-app.styl"
@import "@/assets/css/custom-scroll.styl"
<style lang="stylus" scoped>
@import "../assets/css/chat-app.styl"
@import "../assets/css/custom-scroll.styl"
</style>

View File

@@ -147,7 +147,7 @@
type="info"
style="margin-left: 8px; flex-shrink: 0"
>
{{ getSelectedModel()?.power }}算力
{{ getSelectedModel() && getSelectedModel().power }}算力
</el-tag>
</div>
</el-button>
@@ -264,7 +264,12 @@
<welcome @send="autofillPrompt" />
</div>
<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
v-else-if="item.type === 'reply'"
:data="item"
@@ -315,7 +320,10 @@
<span class="tool-item-btn">
<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>
</span>
</div>
@@ -414,13 +422,15 @@ import {
Share,
VideoPause,
} from '@element-plus/icons-vue'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
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 { getUserToken } from '../store/session'
const title = ref('GeekAI-智能助手')
const logo = ref('')
@@ -694,153 +704,216 @@ onMounted(() => {
})
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)
return
// 获取模型列表
const modelRes = await httpGet('/api/model/list')
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 prePrompt = chatData.value[chatData.value.length - 1]?.content
isNewMsg.value = false
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 // 只追加一次
}
// 获取用户信息
const user = await checkSession()
loginUser.value = user
isLogin.value = true
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(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
localStorage.setItem('chat_id', chatId.value)
})
})
.catch(() => {})
isNewMsg.value = true
} else if (data.type === 'text') {
lineBuffer.value += data.body
const reply = chatData.value[chatData.value.length - 1]
if (reply) {
reply['content'] = lineBuffer.value
}
}
// 将聊天框的滚动条滑动到最底部
nextTick(() => {
document
.getElementById('chat-box')
.scrollTo(0, document.getElementById('chat-box').scrollHeight)
localStorage.setItem('chat_id', chatId.value)
} catch (error) {
console.error('Error processing message:', error)
enableInput()
ElMessage.error('消息处理出错,请重试')
}
},
onerror(err) {
console.error('SSE Error:', err)
enableInput()
ElMessage.error('连接已断开,请重试')
},
onclose() {
console.log('SSE connection closed')
enableInput()
},
})
} 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()
updateGroupedModels()
})
onUnmounted(() => {
store.removeMessageHandler('chat')
})
// 初始化数据
const initData = () => {
// 加载模型
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
}
}
// 添加空回复消息
const _role = getRoleById(roleId.value)
chatData.value.push({
chat_id: chatId,
role_id: roleId.value,
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: {
text: '',
files: [],
},
})
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) {
@@ -854,7 +927,6 @@ const getRoleById = function (rid) {
const resizeElement = function () {
chatListHeight.value = window.innerHeight - 240
// chatBoxHeight.value = window.innerHeight;
mainWinHeight.value = window.innerHeight - 50
chatBoxHeight.value = window.innerHeight - 101 - 82 - 38
}
@@ -1036,85 +1108,6 @@ const autofillPrompt = (text) => {
inputRef.value.focus()
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 () {
ElMessageBox.confirm('清除所有对话?此操作不可撤销!', '警告', {
@@ -1157,7 +1150,10 @@ const loadChatHistory = function (chatId) {
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: _role['hello_msg'],
content: {
text: _role['hello_msg'],
files: [],
},
})
return
}
@@ -1181,6 +1177,7 @@ const loadChatHistory = function (chatId) {
})
}
// 停止生成
const stopGenerate = function () {
showStopGenerate.value = false
httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => {
@@ -1189,30 +1186,100 @@ const stopGenerate = function () {
}
// 重新生成
const reGenerate = function (prompt) {
disableInput(false)
const text = '重新回答下述问题:' + prompt
// 追加消息
chatData.value.push({
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: text,
const reGenerate = function (messageId) {
// 恢复发送按钮状态
canSend.value = true
showStopGenerate.value = false
console.log(messageId)
chatData.value = chatData.value.filter((item) => item.id < messageId)
// 保存用户消息内容,填入输入框
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',
body: {
}
// 编辑用户消息
const editUserPrompt = function (messageId) {
// 找到要编辑的消息及其索引
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,
model_id: modelID.value,
chat_id: chatId.value,
content: text,
tools: toolSelected.value,
stream: stream.value,
},
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
})
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('')
@@ -1296,11 +1363,11 @@ const realtimeChat = () => {
</script>
<style scoped lang="stylus">
@import "@/assets/css/chat-plus.styl"
@import '../assets/css/chat-plus.styl'
</style>
<style lang="stylus">
@import '@/assets/css/markdown/vue.css';
@import '../assets/css/markdown/vue.css';
.notice-dialog {
.el-dialog__header {
padding-bottom 0

View File

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

View File

@@ -168,7 +168,7 @@ const routerViewKey = ref(0)
const showConfigDialog = ref(false)
const license = ref({ de_copy: true })
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>
<style lang="stylus" scoped>
@import "@/assets/css/custom-scroll.styl"
@import "@/assets/css/home.styl"
@import "../assets/css/custom-scroll.styl"
@import "../assets/css/home.styl"
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,18 @@
@keyup="handleKeyup"
/>
</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-button class="login-btn" size="large" type="primary" @click="login"
>登录</el-button
@@ -63,6 +75,8 @@ import { useSharedStore } from '@/store/sharedata'
import { setRoute } from '@/store/system'
import { showMessageError } from '@/utils/dialog'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -71,7 +85,6 @@ import Captcha from '@/components/Captcha.vue'
const router = useRouter()
const title = ref('')
const logo = ref('')
const licenseConfig = ref({})
const wechatLoginURL = ref('')
@@ -79,13 +92,24 @@ const enableVerify = ref(false)
const captchaRef = ref(null)
const ruleFormRef = ref(null)
const ruleForm = reactive({
username: process.env.VUE_APP_USER,
password: process.env.VUE_APP_PASS,
username: import.meta.env.VITE_USER,
password: import.meta.env.VITE_PASS,
agreement: false,
})
const rules = {
username: [{ 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(() => {
// 检查URL中是否存在token参数
@@ -110,6 +134,36 @@ onMounted(() => {
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()
.then((res) => {
licenseConfig.value = res.data
@@ -141,6 +195,16 @@ const handleKeyup = (e) => {
}
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) => {
if (valid) {
if (enableVerify.value) {
@@ -170,8 +234,127 @@ const doLogin = (verifyData) => {
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>
<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>

View File

@@ -15,11 +15,25 @@
<div class="prompt-container">
<div class="input-container">
<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>
</el-upload>
</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">
<i class="iconfont icon-send"></i>
</div>
@@ -44,7 +58,11 @@
</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>
<div class="list-box" v-if="!noData">
@@ -53,7 +71,15 @@
<div class="left">
<div class="container">
<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)">
<img src="/images/play.svg" alt="" />
</button>
@@ -63,7 +89,9 @@
</div>
</div>
<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>
<div class="right" v-if="item.progress === 100">
@@ -100,7 +128,12 @@
</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">
<el-pagination
@@ -116,8 +149,22 @@
/>
</div>
</el-container>
<black-dialog 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">
<black-dialog
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>
</black-dialog>
@@ -125,217 +172,216 @@
</template>
<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 { CircleCloseFilled } from "@element-plus/icons-vue";
import { httpDownload, httpPost, httpGet } from "@/utils/http";
import { checkSession } from "@/store/cache";
import { closeLoading, showLoading, showMessageError, showMessageOK } from "@/utils/dialog";
import { replaceImg } from "@/utils/libs";
import { ElMessage, ElMessageBox } from "element-plus";
import BlackSwitch from "@/components/ui/BlackSwitch.vue";
import Generating from "@/components/ui/Generating.vue";
import BlackDialog from "@/components/ui/BlackDialog.vue";
import Clipboard from "clipboard";
const showDialog = ref(false);
const currentVideoUrl = ref("");
const row = ref(1);
const images = ref([]);
import BlackDialog from '@/components/ui/BlackDialog.vue'
import Generating from '@/components/ui/Generating.vue'
import { checkSession } from '@/store/cache'
import { closeLoading, showLoading, showMessageError, showMessageOK } from '@/utils/dialog'
import { httpDownload, httpGet, httpPost } from '@/utils/http'
import { replaceImg } from '@/utils/libs'
import { CircleCloseFilled } from '@element-plus/icons-vue'
import Clipboard from 'clipboard'
import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, onUnmounted, reactive, ref } from 'vue'
const showDialog = ref(false)
const currentVideoUrl = ref('')
const row = ref(1)
const images = ref([])
const formData = reactive({
prompt: "",
prompt: '',
expand_prompt: false,
loop: false,
first_frame_img: "",
end_frame_img: "",
});
first_frame_img: '',
end_frame_img: '',
})
const loading = ref(false);
const list = ref([]);
const noData = ref(true);
const page = ref(1);
const pageSize = ref(10);
const total = ref(0);
const taskPulling = ref(true);
const clipboard = ref(null);
const pullHandler = ref(null);
const loading = ref(false)
const list = ref([])
const noData = ref(true)
const page = ref(1)
const pageSize = ref(10)
const total = ref(0)
const taskPulling = ref(true)
const clipboard = ref(null)
const pullHandler = ref(null)
onMounted(() => {
checkSession().then(() => {
fetchData(1);
fetchData(1)
// 设置轮询
pullHandler.value = setInterval(() => {
if (taskPulling.value) {
fetchData(1);
fetchData(1)
}
}, 5000);
});
}, 5000)
})
clipboard.value = new Clipboard(".copy-prompt");
clipboard.value.on("success", () => {
ElMessage.success("复制成功!");
});
});
clipboard.value = new Clipboard('.copy-prompt')
clipboard.value.on('success', () => {
ElMessage.success('复制成功!')
})
})
onUnmounted(() => {
clipboard.value.destroy();
clipboard.value.destroy()
if (pullHandler.value) {
clearInterval(pullHandler.value);
clearInterval(pullHandler.value)
}
});
})
const download = (item) => {
const url = replaceImg(item.video_url);
const downloadURL = `${process.env.VUE_APP_API_HOST}/api/download?url=${url}`;
const urlObj = new URL(url);
const fileName = urlObj.pathname.split("/").pop();
item.downloading = true;
const url = replaceImg(item.video_url)
const downloadURL = `${import.meta.env.VITE_API_HOST}/api/download?url=${url}`
const urlObj = new URL(url)
const fileName = urlObj.pathname.split('/').pop()
item.downloading = true
httpDownload(downloadURL)
.then((response) => {
const blob = new Blob([response.data]);
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
item.downloading = false;
const blob = new Blob([response.data])
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(link.href)
item.downloading = false
})
.catch(() => {
showMessageError("下载失败");
item.downloading = false;
});
};
showMessageError('下载失败')
item.downloading = false
})
}
const play = (item) => {
currentVideoUrl.value = replaceImg(item.video_url);
showDialog.value = true;
};
currentVideoUrl.value = replaceImg(item.video_url)
showDialog.value = true
}
const removeJob = (item) => {
ElMessageBox.confirm("此操作将会删除任务相关文件,继续操作码?", "删除提示", {
confirmButtonText: "确认",
cancelButtonText: "取消",
type: "warning",
ElMessageBox.confirm('此操作将会删除任务相关文件,继续操作码?', '删除提示', {
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => {
httpGet("/api/video/remove", { id: item.id })
httpGet('/api/video/remove', { id: item.id })
.then(() => {
ElMessage.success("任务删除成功");
fetchData();
ElMessage.success('任务删除成功')
fetchData()
})
.catch((e) => {
ElMessage.error("任务删除失败:" + e.message);
});
ElMessage.error('任务删除失败:' + e.message)
})
})
.catch(() => {});
};
.catch(() => {})
}
const publishJob = (item) => {
httpGet("/api/video/publish", { id: item.id, publish: item.publish })
httpGet('/api/video/publish', { id: item.id, publish: item.publish })
.then(() => {
ElMessage.success("操作成功");
ElMessage.success('操作成功')
})
.catch((e) => {
ElMessage.error("操作失败:" + e.message);
});
};
ElMessage.error('操作失败:' + e.message)
})
}
const upload = (file) => {
const formData = new FormData();
formData.append("file", file.file, file.name);
showLoading("正在上传文件...");
httpPost("/api/upload", formData)
const formData = new FormData()
formData.append('file', file.file, file.name)
showLoading('正在上传文件...')
httpPost('/api/upload', formData)
.then((res) => {
images.value.push(res.data.url);
ElMessage.success({ message: "上传成功", duration: 500 });
closeLoading();
images.value.push(res.data.url)
ElMessage.success({ message: '上传成功', duration: 500 })
closeLoading()
})
.catch((e) => {
ElMessage.error("图片上传失败:" + e.message);
closeLoading();
});
};
ElMessage.error('图片上传失败:' + e.message)
closeLoading()
})
}
const remove = (img) => {
images.value = images.value.filter((item) => item !== img);
};
images.value = images.value.filter((item) => item !== img)
}
const switchReverse = () => {
images.value = images.value.reverse();
};
images.value = images.value.reverse()
}
const fetchData = (_page) => {
if (_page) {
page.value = _page;
page.value = _page
}
httpGet("/api/video/list", {
httpGet('/api/video/list', {
page: page.value,
page_size: pageSize.value,
type: "luma",
type: 'luma',
})
.then((res) => {
total.value = res.data.total;
let needPull = false;
const items = [];
total.value = res.data.total
let needPull = false
const items = []
for (let v of res.data.items) {
if (v.progress === 0 || v.progress === 102) {
needPull = true;
needPull = true
}
items.push(v);
items.push(v)
}
loading.value = false;
taskPulling.value = needPull;
loading.value = false
taskPulling.value = needPull
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(() => {
loading.value = false;
noData.value = true;
});
};
loading.value = false
noData.value = true
})
}
const create = () => {
const len = images.value.length;
const len = images.value.length
if (len) {
formData.first_frame_img = replaceImg(images.value[0]);
formData.first_frame_img = replaceImg(images.value[0])
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(() => {
fetchData(1);
taskPulling.value = true;
showMessageOK("创建任务成功");
fetchData(1)
taskPulling.value = true
showMessageOK('创建任务成功')
})
.catch((e) => {
showMessageError("创建任务失败:" + e.message);
});
};
showMessageError('创建任务失败:' + e.message)
})
}
const generatePrompt = () => {
if (formData.prompt === "") {
return showMessageError("请输入原始提示词");
if (formData.prompt === '') {
return showMessageError('请输入原始提示词')
}
showLoading("正在生成视频脚本...");
httpPost("/api/prompt/video", { prompt: formData.prompt })
showLoading('正在生成视频脚本...')
httpPost('/api/prompt/video', { prompt: formData.prompt })
.then((res) => {
formData.prompt = res.data;
closeLoading();
formData.prompt = res.data
closeLoading()
})
.catch((e) => {
showMessageError("生成提示词失败:" + e.message);
closeLoading();
});
};
showMessageError('生成提示词失败:' + e.message)
closeLoading()
})
}
</script>
<style lang="stylus" scoped>
@import "@/assets/css/luma.styl"
@import "../assets/css/luma.styl"
</style>

View File

@@ -109,15 +109,15 @@
</template>
<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 { 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 { 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 rightBoxHeight = ref(window.innerHeight - 115);
@@ -263,9 +263,9 @@ const getModelById = (modelId) => {
}
// 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 serializer = new XMLSerializer()
@@ -275,7 +275,7 @@ const downloadImage = async() => {
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(source)
// 分辨率倍数,越高图片越清晰,但文件越大
const scale = 4
const scale = 4
const canvas = document.createElement('canvas')
canvas.width = svgElement.offsetWidth * scale
canvas.height = svgElement.offsetHeight * scale
@@ -298,6 +298,6 @@ const downloadImage = async() => {
</script>
<style lang="stylus">
@import "@/assets/css/mark-map.styl"
@import "@/assets/css/custom-scroll.styl"
@import '../assets/css/mark-map.styl'
@import '../assets/css/custom-scroll.styl'
</style>

View File

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

View File

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

View File

@@ -10,12 +10,27 @@
<el-tab-pane label="手机注册" name="mobile" v-if="enableMobile">
<el-form-item>
<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>
<div class="form-title">验证码</div>
<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" />
</div>
@@ -24,12 +39,26 @@
<el-tab-pane label="邮箱注册" name="email" v-if="enableEmail">
<el-form-item class="block">
<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 class="block">
<div class="form-title">验证码</div>
<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" />
</div>
@@ -39,7 +68,13 @@
<el-form-item class="block">
<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-tab-pane>
</el-tabs>
@@ -47,24 +82,67 @@
<el-form-item class="block">
<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 class="block">
<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 class="block">
<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-row class="btn-row" :gutter="20">
<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-row>
</el-form>
@@ -80,7 +158,9 @@
</div>
<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>
</template>
</el-result>
@@ -92,146 +172,232 @@
</template>
<script setup>
import { ref } from "vue";
import AccountTop from "@/components/AccountTop.vue";
import AccountBg from "@/components/AccountBg.vue";
import AccountBg from '@/components/AccountBg.vue'
import AccountTop from '@/components/AccountTop.vue'
import { ref } from 'vue'
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage } from "element-plus";
import { useRouter } from "vue-router";
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage, ElMessageBox } from 'element-plus'
import MarkdownIt from 'markdown-it'
import { useRouter } from 'vue-router'
import SendMsg from "@/components/SendMsg.vue";
import { arrayContains, isMobile } from "@/utils/libs";
import { setUserToken } from "@/store/session";
import { validateEmail, validateMobile } from "@/utils/validate";
import { showMessageError, showMessageOK } from "@/utils/dialog";
import { getLicenseInfo, getSystemInfo } from "@/store/cache";
import Captcha from "@/components/Captcha.vue";
import Captcha from '@/components/Captcha.vue'
import SendMsg from '@/components/SendMsg.vue'
import { getLicenseInfo, getSystemInfo } from '@/store/cache'
import { setUserToken } from '@/store/session'
import { showMessageError, showMessageOK } from '@/utils/dialog'
import { arrayContains, isMobile } from '@/utils/libs'
import { validateEmail, validateMobile } from '@/utils/validate'
const router = useRouter();
const title = ref("");
const logo = ref("");
const router = useRouter()
const title = ref('')
const logo = ref('')
const data = ref({
username: "",
mobile: "",
email: "",
password: "",
code: "",
repass: "",
invite_code: router.currentRoute.value.query["invite_code"],
});
username: '',
mobile: '',
email: '',
password: '',
code: '',
repass: '',
invite_code: router.currentRoute.value.query['invite_code'],
agreement: false,
})
const enableMobile = ref(false);
const enableEmail = ref(false);
const enableUser = ref(false);
const enableRegister = ref(true);
const activeName = ref("mobile");
const wxImg = ref("/images/wx.png");
const licenseConfig = ref({});
const enableVerify = ref(false);
const captchaRef = ref(null);
const enableMobile = ref(false)
const enableEmail = ref(false)
const enableUser = ref(false)
const enableRegister = ref(true)
const activeName = ref('mobile')
const wxImg = ref('/images/wx.png')
const licenseConfig = ref({})
const enableVerify = ref(false)
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) {
httpGet("/api/invite/hits", { code: data.value.invite_code });
httpGet('/api/invite/hits', { code: data.value.invite_code })
}
getSystemInfo()
.then((res) => {
if (res.data) {
title.value = res.data.title;
logo.value = res.data.logo;
const registerWays = res.data["register_ways"];
title.value = res.data.title
logo.value = res.data.logo
const registerWays = res.data['register_ways']
if (arrayContains(registerWays, "username")) {
enableUser.value = true;
activeName.value = "username";
if (arrayContains(registerWays, 'username')) {
enableUser.value = true
activeName.value = 'username'
}
if (arrayContains(registerWays, "email")) {
enableEmail.value = true;
activeName.value = "email";
if (arrayContains(registerWays, 'email')) {
enableEmail.value = true
activeName.value = 'email'
}
if (arrayContains(registerWays, "mobile")) {
enableMobile.value = true;
activeName.value = "mobile";
if (arrayContains(registerWays, 'mobile')) {
enableMobile.value = true
activeName.value = 'mobile'
}
// 是否启用注册
enableRegister.value = res.data["enabled_register"];
enableRegister.value = res.data['enabled_register']
// 使用后台上传的客服微信二维码
if (res.data["wechat_card_url"] !== "") {
wxImg.value = res.data["wechat_card_url"];
if (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) => {
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()
.then((res) => {
licenseConfig.value = res.data;
licenseConfig.value = res.data
})
.catch((e) => {
showMessageError("获取 License 配置:" + e.message);
});
showMessageError('获取 License 配置:' + e.message)
})
// 注册操作
const submitRegister = () => {
if (activeName.value === "username" && data.value.username === "") {
return showMessageError("请输入用户名");
if (activeName.value === 'username' && data.value.username === '') {
return showMessageError('请输入用户名')
}
if (activeName.value === "mobile" && !validateMobile(data.value.mobile)) {
return showMessageError("请输入合法的手机号");
if (activeName.value === 'mobile' && !validateMobile(data.value.mobile)) {
return showMessageError('请输入合法的手机号')
}
if (activeName.value === "email" && !validateEmail(data.value.email)) {
return showMessageError("请输入合法的邮箱地址");
if (activeName.value === 'email' && !validateEmail(data.value.email)) {
return showMessageError('请输入合法的邮箱地址')
}
if (data.value.password.length < 8) {
return showMessageError("密码的长度为8-16个字符");
return showMessageError('密码的长度为8-16个字符')
}
if (data.value.repass !== data.value.password) {
return showMessageError("两次输入密码不一致");
return showMessageError('两次输入密码不一致')
}
if ((activeName.value === "mobile" || activeName.value === "email") && data.value.code === "") {
return showMessageError("请输入验证码");
if ((activeName.value === 'mobile' || activeName.value === 'email') && data.value.code === '') {
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") {
captchaRef.value.loadCaptcha();
if (enableVerify.value && activeName.value === 'username') {
captchaRef.value.loadCaptcha()
} else {
doSubmitRegister({});
doSubmitRegister({})
}
};
}
const doSubmitRegister = (verifyData) => {
data.value.key = verifyData.key;
data.value.dots = verifyData.dots;
data.value.x = verifyData.x;
data.value.reg_way = activeName.value;
httpPost("/api/user/register", data.value)
data.value.key = verifyData.key
data.value.dots = verifyData.dots
data.value.x = verifyData.x
data.value.reg_way = activeName.value
httpPost('/api/user/register', data.value)
.then((res) => {
setUserToken(res.data.token);
showMessageOK("注册成功,即将跳转到对话主界面...");
setUserToken(res.data.token)
showMessageOK('注册成功,即将跳转到对话主界面...')
if (isMobile()) {
router.push("/mobile/index");
router.push('/mobile/index')
} else {
router.push("/chat");
router.push('/chat')
}
})
.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>
<style lang="stylus" scoped>
@import "@/assets/css/login.styl"
@import '../assets/css/login.styl'
:deep(.back){
margin-bottom: 10px;
}
@@ -243,4 +409,84 @@ const doSubmitRegister = (verifyData) => {
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -186,7 +186,7 @@
class="chat-dialog"
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">
<chat-prompt v-if="item.type === 'prompt'" :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 hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import MarkdownIt from 'markdown-it'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { onMounted, ref } from 'vue'
// 变量定义
@@ -316,8 +318,7 @@ const removeMessage = function (row) {
})
}
const mathjaxPlugin = require('markdown-it-mathjax3')
const md = require('markdown-it')({
const md = MarkdownIt({
breaks: true,
html: true,
linkify: true,
@@ -325,7 +326,7 @@ const md = require('markdown-it')({
highlight: function (str, lang) {
if (lang && hl.getLanguage(lang)) {
// 处理代码高亮
const preCode = hl.highlight(lang, str, true).value
const preCode = hl.highlight(str, { language: lang }).value
// 将代码包裹在 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-tab-pane label="Midjourney" name="mj" v-loading="data.mj.loading">
<div class="handle-box">
<el-input 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-input
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
v-model="data.mj.query.created_at"
type="daterange"
@@ -23,7 +35,9 @@
<el-table-column prop="user_id" label="用户ID" />
<el-table-column label="任务类型">
<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>
</el-table-column>
<el-table-column prop="progress" label="任务进度">
@@ -35,12 +49,25 @@
<el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片">
<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>
</el-table-column>
<el-table-column label="提示词">
<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>
<span>{{ substr(scope.row.prompt, 20) }}</span>
</template>
@@ -49,7 +76,7 @@
</el-table-column>
<el-table-column label="创建时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="失败原因">
@@ -98,8 +125,20 @@
</el-tab-pane>
<el-tab-pane label="Stable-Diffusion" name="sd" v-loading="data.sd.loading">
<div class="handle-box">
<el-input 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-input
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
v-model="data.sd.query.created_at"
type="daterange"
@@ -125,12 +164,25 @@
<el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片">
<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>
</el-table-column>
<el-table-column label="提示词">
<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>
<span>{{ substr(scope.row.prompt, 20) }}</span>
</template>
@@ -139,7 +191,7 @@
</el-table-column>
<el-table-column label="创建时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="失败原因">
@@ -188,8 +240,20 @@
</el-tab-pane>
<el-tab-pane label="DALL-E" name="dall">
<div class="handle-box">
<el-input 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-input
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
v-model="data.dall.query.created_at"
type="daterange"
@@ -215,12 +279,25 @@
<el-table-column prop="power" label="消耗算力" />
<el-table-column label="结果图片">
<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>
</el-table-column>
<el-table-column label="提示词">
<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>
<span>{{ substr(scope.row.prompt, 20) }}</span>
</template>
@@ -229,7 +306,7 @@
</el-table-column>
<el-table-column label="创建时间">
<template #default="scope">
<span>{{ dateFormat(scope.row["created_at"]) }}</span>
<span>{{ dateFormat(scope.row['created_at']) }}</span>
</template>
</el-table-column>
<el-table-column label="失败原因">
@@ -278,24 +355,32 @@
</el-tab-pane>
</el-tabs>
<el-dialog v-model="showImageDialog" title="图片预览">
<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 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-dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { httpGet, httpPost } from "@/utils/http";
import { ElMessage } from "element-plus";
import { dateFormat, substr } from "@/utils/libs";
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref } from 'vue'
import { httpGet, httpPost } from '@/utils/http'
import { ElMessage } from 'element-plus'
import { dateFormat, substr } from '@/utils/libs'
import { Search } from '@element-plus/icons-vue'
// 变量定义
const data = ref({
mj: {
items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 },
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0,
page: 1,
pageSize: 15,
@@ -303,7 +388,7 @@ const data = ref({
},
sd: {
items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 },
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0,
page: 1,
pageSize: 15,
@@ -311,122 +396,122 @@ const data = ref({
},
dall: {
items: [],
query: { prompt: "", username: "", created_at: [], page: 1, page_size: 15 },
query: { prompt: '', username: '', created_at: [], page: 1, page_size: 15 },
total: 0,
page: 1,
pageSize: 15,
loading: true,
},
});
const activeName = ref("mj");
})
const activeName = ref('mj')
const taskTypeTheme = {
image: { text: "绘图", color: "#2185d0" },
upscale: { text: "放大", color: "#f2711c" },
variation: { text: "变换", color: "#00b5ad" },
blend: { text: "融图", color: "#21ba45" },
swapFace: { text: "换脸", color: "#a333c8" },
};
image: { text: '绘图', color: '#2185d0' },
upscale: { text: '放大', color: '#f2711c' },
variation: { text: '变换', color: '#00b5ad' },
blend: { text: '融图', color: '#21ba45' },
swapFace: { text: '换脸', color: '#a333c8' },
}
onMounted(() => {
fetchMjData();
});
fetchMjData()
})
const handleChange = (tab) => {
switch (tab) {
case "mj":
fetchMjData();
break;
case "sd":
fetchSdData();
break;
case "dall":
fetchDallData();
break;
case 'mj':
fetchMjData()
break
case 'sd':
fetchSdData()
break
case 'dall':
fetchDallData()
break
}
};
}
// 搜索对话
const search = (evt, tab) => {
if (evt.keyCode === 13) {
handleChange(tab);
handleChange(tab)
}
};
}
// 获取数据
const fetchMjData = () => {
const d = data.value.mj;
d.query.page = d.page;
d.query.page_size = d.pageSize;
httpPost("/api/admin/image/list/mj", d.query)
const d = data.value.mj
d.query.page = d.page
d.query.page_size = d.pageSize
httpPost('/api/admin/image/list/mj', d.query)
.then((res) => {
if (res.data) {
d.items = res.data.items;
d.total = res.data.total;
d.page = res.data.page;
d.pageSize = res.data.page_size;
d.items = res.data.items
d.total = res.data.total
d.page = res.data.page
d.pageSize = res.data.page_size
}
d.loading = false;
d.loading = false
})
.catch((e) => {
ElMessage.error("获取数据失败" + e.message);
});
};
ElMessage.error('获取数据失败' + e.message)
})
}
const fetchSdData = () => {
const d = data.value.sd;
d.query.page = d.page;
d.query.page_size = d.pageSize;
httpPost("/api/admin/image/list/sd", d.query)
const d = data.value.sd
d.query.page = d.page
d.query.page_size = d.pageSize
httpPost('/api/admin/image/list/sd', d.query)
.then((res) => {
if (res.data) {
d.items = res.data.items;
d.total = res.data.total;
d.page = res.data.page;
d.pageSize = res.data.page_size;
d.items = res.data.items
d.total = res.data.total
d.page = res.data.page
d.pageSize = res.data.page_size
}
d.loading = false;
d.loading = false
})
.catch((e) => {
ElMessage.error("获取数据失败" + e.message);
});
};
ElMessage.error('获取数据失败' + e.message)
})
}
const fetchDallData = () => {
const d = data.value.dall;
d.query.page = d.page;
d.query.page_size = d.pageSize;
httpPost("/api/admin/image/list/dall", d.query)
const d = data.value.dall
d.query.page = d.page
d.query.page_size = d.pageSize
httpPost('/api/admin/image/list/dall', d.query)
.then((res) => {
if (res.data) {
d.items = res.data.items;
d.total = res.data.total;
d.page = res.data.page;
d.pageSize = res.data.page_size;
d.items = res.data.items
d.total = res.data.total
d.page = res.data.page
d.pageSize = res.data.page_size
}
d.loading = false;
d.loading = false
})
.catch((e) => {
ElMessage.error("获取数据失败" + e.message);
});
};
ElMessage.error('获取数据失败' + e.message)
})
}
const remove = function (row, tab) {
httpGet(`/api/admin/image/remove?id=${row.id}&tab=${tab}`)
.then(() => {
ElMessage.success("删除成功");
handleChange(tab);
ElMessage.success('删除成功')
handleChange(tab)
})
.catch((e) => {
ElMessage.error("删除失败" + e.message);
});
};
ElMessage.error('删除失败' + e.message)
})
}
const showImageDialog = ref(false);
const imgURL = ref("");
const showImageDialog = ref(false)
const imgURL = ref('')
const showImage = (url) => {
showImageDialog.value = true;
imgURL.value = url;
};
showImageDialog.value = true
imgURL.value = url
}
</script>
<style lang="stylus" scoped>

View File

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

View File

@@ -15,43 +15,47 @@
<div class="chat-list-wrapper">
<div id="message-list-box" class="message-list-box">
<van-list
v-model:error="error"
:finished="finished"
error-text="请求失败点击重新加载"
@load="onLoad"
v-model:error="error"
:finished="finished"
error-text="请求失败点击重新加载"
@load="onLoad"
>
<van-cell v-for="item in chatData" :key="item" :border="false" class="message-line">
<chat-prompt
v-if="item.type==='prompt'"
:content="item.content"
:created-at="dateFormat(item['created_at'])"
:icon="item.icon"
:tokens="item['tokens']"/>
<chat-reply v-else-if="item.type==='reply'"
:content="item.content"
:created-at="dateFormat(item['created_at'])"
:icon="item.icon"
:org-content="item.orgContent"
:tokens="item['tokens']"/>
v-if="item.type === 'prompt'"
:content="item.content"
:created-at="dateFormat(item['created_at'])"
:icon="item.icon"
:tokens="item['tokens']"
/>
<chat-reply
v-else-if="item.type === 'reply'"
:content="item.content"
:created-at="dateFormat(item['created_at'])"
:icon="item.icon"
:org-content="item.orgContent"
:tokens="item['tokens']"
/>
</van-cell>
</van-list>
</div>
</div>
</div><!-- end chat box -->
</div>
<!-- end chat box -->
</div>
</template>
<script setup>
import {dateFormat, processContent} from "@/utils/libs";
import ChatReply from "@/components/mobile/ChatReply.vue";
import ChatPrompt from "@/components/mobile/ChatPrompt.vue";
import {nextTick, ref} from "vue";
import {useRouter} from "vue-router";
import {httpGet} from "@/utils/http";
import ChatPrompt from '@/components/mobile/ChatPrompt.vue'
import ChatReply from '@/components/mobile/ChatReply.vue'
import { httpGet } from '@/utils/http'
import { dateFormat, processContent } from '@/utils/libs'
import hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
import hl from "highlight.js";
import {showFailToast} from "vant";
import MarkdownIt from 'markdown-it'
import mathjaxPlugin from 'markdown-it-mathjax3'
import { showFailToast } from 'vant'
import { nextTick, ref } from 'vue'
import { useRouter } from 'vue-router'
const chatData = ref([])
const router = useRouter()
@@ -62,8 +66,7 @@ const model = ref('')
const finished = ref(false)
const error = ref(false)
const mathjaxPlugin = require('markdown-it-mathjax3')
const md = require('markdown-it')({
const md = new MarkdownIt({
breaks: true,
html: true,
linkify: true,
@@ -72,11 +75,14 @@ const md = require('markdown-it')({
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>
<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)) {
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 中
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)
// 将代码包裹在 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)
const onLoad = () => {
httpGet('/api/chat/history?chat_id=' + chatId).then(res => {
// 加载状态结束
finished.value = true;
const data = res.data
if (data && data.length > 0) {
for (let i = 0; i < data.length; i++) {
if (data[i].type === "prompt") {
chatData.value.push(data[i]);
continue;
httpGet('/api/chat/history?chat_id=' + chatId)
.then((res) => {
// 加载状态结束
finished.value = true
const data = res.data
if (data && data.length > 0) {
for (let i = 0; i < data.length; i++) {
if (data[i].type === 'prompt') {
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;
data[i].content = md.render(processContent(data[i].content))
chatData.value.push(data[i]);
}
nextTick(() => {
hl.configure({ignoreUnescapedHTML: true})
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(() => {
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)
})
};
}
})
.catch(() => {
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)
})
}
</script>
<style scoped lang="stylus">
.chat-export-mobile {
@@ -171,4 +179,4 @@ const onLoad = () => {
}
}
}
</style>
</style>

View File

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

View File

@@ -120,11 +120,13 @@
<script setup>
import ChatPrompt from '@/components/mobile/ChatPrompt.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 { showMessageError } from '@/utils/dialog'
import { httpGet } from '@/utils/http'
import { processContent, randString, renderInputText, UUID } from '@/utils/libs'
import { fetchEventSource } from '@microsoft/fetch-event-source'
import Clipboard from 'clipboard'
import hl from 'highlight.js'
import 'highlight.js/styles/a11y-dark.css'
@@ -134,6 +136,7 @@ import mathjaxPlugin from 'markdown-it-mathjax3'
import { showImagePreview, showNotify, showToast } from 'vant'
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const winHeight = ref(0)
const navBarRef = ref(null)
const bottomBarRef = ref(null)
@@ -267,65 +270,10 @@ onMounted(() => {
clipboard.on('error', () => {
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(() => {
store.removeMessageHandler('chat')
// Remove WebSocket handler cleanup
})
const newChat = (item) => {
@@ -360,7 +308,9 @@ const loadChatHistory = () => {
type: 'reply',
id: randString(32),
icon: role.icon,
content: role.hello_msg,
content: {
text: role.hello_msg,
},
orgContent: role.hello_msg,
})
return
@@ -373,7 +323,7 @@ const loadChatHistory = () => {
}
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])
}
@@ -427,17 +377,106 @@ const scrollListBox = () => {
.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 = () => {
if (canSend.value === false) {
showToast('AI 正在作答中,请稍后...')
return
}
if (store.socket.conn.readyState !== WebSocket.OPEN) {
showToast('连接断开,正在重连...')
return
}
if (prompt.value.trim().length === 0) {
showToast('请输入需要 AI 回答的问题')
return false
@@ -448,7 +487,7 @@ const sendMessage = () => {
type: 'prompt',
id: randString(32),
icon: loginUser.value.avatar,
content: renderInputText(prompt.value),
content: { text: renderInputText(prompt.value) },
created_at: new Date().getTime(),
})
// 添加空回复消息
@@ -459,7 +498,9 @@ const sendMessage = () => {
type: 'reply',
id: randString(32),
icon: _role['icon'],
content: '',
content: {
text: '',
},
})
nextTick(() => {
@@ -467,31 +508,23 @@ const sendMessage = () => {
})
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: prompt.value,
stream: stream.value,
},
})
)
// 发送 SSE 请求
sendSSERequest({
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelId.value,
chat_id: chatId.value,
prompt: prompt.value,
stream: stream.value,
})
previousText.value = prompt.value
prompt.value = ''
return true
}
const stopGenerate = () => {
showStopGenerate.value = false
httpGet('/api/chat/stop?session_id=' + getClientId()).then(() => {
enableInput()
})
}
// 重新生成
const reGenerate = () => {
disableInput(false)
const text = '重新生成上述问题的答案:' + previousText.value
@@ -502,19 +535,16 @@ const reGenerate = () => {
icon: loginUser.value.avatar,
content: renderInputText(text),
})
store.socket.conn.send(
JSON.stringify({
channel: 'chat',
type: 'text',
body: {
role_id: roleId.value,
model_id: modelId.value,
chat_id: chatId.value,
content: previousText.value,
stream: stream.value,
},
})
)
// 发送 SSE 请求
sendSSERequest({
user_id: loginUser.value.id,
role_id: roleId.value,
model_id: modelId.value,
chat_id: chatId.value,
prompt: previousText.value,
stream: stream.value,
})
}
const showShare = ref(false)
@@ -601,6 +631,6 @@ const onChange = (item) => {
}
</script>
<style lang="stylus">
@import "@/assets/css/mobile/chat-session.styl"
<style lang="stylus" scoped>
@import "../../assets/css/mobile/chat-session.styl"
</style>

View File

@@ -1,37 +1,36 @@
<template>
<van-config-provider :theme="theme">
<div class="mobile-home">
<router-view/>
<router-view />
<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/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/profile" name="profile" icon="user-o">我的
</van-tabbar-item>
<van-tabbar-item to="/mobile/profile" name="profile" icon="user-o">我的 </van-tabbar-item>
</van-tabbar>
</div>
</van-config-provider>
</template>
<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 store = useSharedStore()
const theme = ref(store.theme)
watch(() => store.theme, (val) => {
theme.value = val
})
watch(
() => store.theme,
(val) => {
theme.value = val
}
)
</script>
<style lang="stylus">
@import '@/assets/iconfont/iconfont.css';
@import '../../assets/iconfont/iconfont.css';
.mobile-home {
.container {
.van-nav-bar {
@@ -53,4 +52,4 @@ watch(() => store.theme, (val) => {
position fixed
width 100%
}
</style>
</style>

View File

@@ -93,7 +93,7 @@ import { showNotify } from 'vant'
import { computed, onMounted, ref } from 'vue'
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 isLogin = ref(false)
const apps = ref([])

View File

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

View File

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

View File

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

View File

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

View File

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