Compare commits

...

17 Commits

Author SHA1 Message Date
RockYang
4e65a5b1a1 opt: optimize the layout for regiseter page. add function to disable registration 2023-08-02 17:44:13 +08:00
RockYang
b09d23f97f feat: the dashboard page is ready for admin console 2023-08-02 16:37:47 +08:00
RockYang
3529649ba9 refactor: refactor admin console page layout 2023-08-02 15:00:18 +08:00
RockYang
fdd659f393 fix: fixed bug for chat request's token reach the max token limit for OpenAI 2023-08-01 17:58:03 +08:00
RockYang
9eb8da2789 feat: new function for add user in manger console user list page 2023-08-01 16:02:49 +08:00
RockYang
ffb1ef0470 doc: add video tutorial for deploying system 2023-08-01 10:15:43 +08:00
RockYang
862c6aea43 docs: remove docs for deploy manually 2023-07-31 20:04:22 +08:00
RockYang
54fe4b7588 opt: optimize the build script and docker-compose yaml file 2023-07-31 17:31:02 +08:00
RockYang
c6062ee70e feat: add env var to set log level 2023-07-31 08:34:11 +08:00
RockYang
bed184dc1f feat: update system config cache for AppServer when updating system config in admin console 2023-07-31 08:13:20 +08:00
RockYang
29094ba3b3 add system config cache for AppServer object 2023-07-31 06:56:28 +08:00
RockYang
a18188876c feat: allow user to login with username and mobile no 2023-07-27 15:37:50 +08:00
RockYang
4faee3e48e fix: deauthorize some apis 2023-07-27 10:53:14 +08:00
RockYang
1a6afcd266 fix: fix bug for mobile verify code function not available in register page 2023-07-27 10:29:59 +08:00
RockYang
f567831d92 style: adjust reward image styles 2023-07-26 17:57:19 +08:00
RockYang
cf36ca4285 chore: optimize build script, compress reward img 2023-07-26 17:48:16 +08:00
RockYang
0e4ae01498 feat: add reset password function to user list page 2023-07-26 15:22:11 +08:00
50 changed files with 947 additions and 492 deletions

View File

@@ -1,5 +1,16 @@
# 更新日志
## v3.0.6
1. 管理后台:新增用户名和手机号码搜索功能
2. 管理后台:新增重置用户密码功能
3. 管理后台:支持关闭注册功能,新增添加用户功能,适用于内部使用场景
4. 管理后台:新增仪表盘页面,统计当天的新增用户,新增会话数据,以及 Token 消耗
5. Bug修复修复注册页面验证码不显示 Bug
6. Bug修复优化上下文 Token 计算算法,修复聊天上下文超出限制时循环发送消息的 Bug
7. 功能修正:允许用户使用手机号码登录
8. 功能优化:更新系统配置后同步更新服务端内存变量数据
9. 功能优化:优化打包脚本,减少容器镜像大小
## v3.0.5
重磅功能更新!!! 新增函数插件支持可以轻松地接入你的第三方插件服务ChatGPT 自动帮您调用对应的函数完成任务。

104
README.md
View File

@@ -100,6 +100,9 @@ ChatGPT 的服务。
## Docker 快速部署
> 鉴于最新不少网友反馈在部署的时候遇到一些问题,大部分问题都是相同的,所以我这边做了一个视频教程 [五分钟部署自己的 ChatGPT 服务](https://www.bilibili.com/video/BV1H14y1B7Qw/)。
> 习惯看视频教程的朋友可以去看视频教程,视频的语速比较慢,建议 2 倍速观看。
V3.0.0 版本以后已经支持使用容器部署了,跳过所有的繁琐的环境准备,一条命令就可以轻松部署上线。
### 1. 导入数据库
@@ -195,104 +198,9 @@ docker-compose up -d
> 输入你前面配置文档中设置的管理员用户名和密码登录。
> 然后进入 `API KEY 管理` 菜单,添加一个 OpenAI 的 API KEY 才可以正常开启 AI 对话。
## 手动安装部署
由于本项目采用的是前后端分离的开发方式,所以部署也需要前后端分开部署。我这里以 linux 系统为例,演示一下部署过程:
### 1. 导入数据库
请参考容器部署的[导入数据](#1-导入数据库)。
### 2. 修改配置文档
先拷贝项目中的 `api/config.sample.toml` 配置文档,修改代理地址和管理员密码:
如何修改请参考[修改配置文档](#2-修改配置文档)
### 3. 运行后端程序
你可以自己编译或者直接下载我打包好的后端程序运行。
```shell
# 1. 下载程序,你也可以自己编译
wget https://github.com/yangjian102621/chatgpt-plus/releases/download/v3.0.0/chatgpt-v3-amd64-linux
# 2. 添加执行权限
chmod +x chatgpt-v3-amd64-linux
# 3. 运行程序,如果配置文档不在当前目录,注意指定配置文档
./chatgpt-v3-amd64-linux
```
### 4. 前端部署
前端是 Vue 项目编译好静态资源文件,同样你也可以直接下载我编译好的文件解压。
```shell
# 1. 下载程序
wget https://github.com/yangjian102621/chatgpt-plus/releases/download/v3.0.0/dist.tar.gz
# 2. 解压
tar -xf dist.tar.gz
```
### 5. 配置 Nginx 服务
前端程序需要搭载 Web 服务器才可以运行,这里我们选择 Nginx先安装
```shell
sudo apt install nginx -y
```
建立 Nginx 配置文件:
```conf
server {
listen 443 ssl;
server_name www.chatgpt.com; #替换成你自己的域名
ssl_certificate xxx.pem; # 替换成自己的 SSL 证书
ssl_certificate_key xxx.key;
ssl_session_timeout 5m;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
ssl_prefer_server_ciphers on;
# 日志地址
access_log /var/log/chatgpt/access.log;
error_log /var/log/chatgpt/error.log;
index index.html;
root /var/www/chatgpt/dist; # 这里改成前端静态页面的地址
location / {
try_files $uri $uri/ /index.html;
# 后端 API 的转发
location /api/ {
proxy_http_version 1.1;
proxy_connect_timeout 300s;
proxy_read_timeout 300s;
proxy_send_timeout 12s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://172.28.173.76:6789; # 这里改成后端服务的内网 IP 地址
}
# 静态资源转发
location /static/ {
proxy_pass http://172.28.173.76:6789; # 这里改成后端服务的内网 IP 地址
}
}
}
```
配置好之后重启 Nginx然后 []
![add API Key](docs/imgs/apikey_add.png)
最后登录前端聊天页面 [http://www.chatgpt.com/admin](http://www.chatgpt.com/admin)
最后登录前端聊天页面 [http://localhost:8080/chat](http://localhost:8080/chat)
你可以注册新用户,也可以使用系统默认有个账号:`geekmaster/12345678` 登录聊天。
祝你使用愉快!!!
@@ -363,9 +271,9 @@ npm run build
```shell
cd api
# for all platforms
make all
make clean all
# for linux only
make linux
make clean linux
```
打包后的可执行文件在 `bin` 目录下。

View File

@@ -24,7 +24,9 @@ type AppServer struct {
Config *types.AppConfig
Engine *gin.Engine
ChatContexts *types.LMap[string, []interface{}] // 聊天上下文 Map [chatId] => []Message
ChatConfig *types.ChatConfig // 聊天配置
ChatConfig *types.ChatConfig // chat config cache
SysConfig *types.SystemConfig // system config cache
// 保存 Websocket 会话 UserId, 每个 UserId 只能连接一次
// 防止第三方直接连接 socket 调用 OpenAI API
@@ -61,9 +63,8 @@ func (s *AppServer) Init(debug bool) {
if debug { // 调试模式允许跨域请求 API
s.Debug = debug
logger.Info("Enabled debug mode")
s.Engine.Use(corsMiddleware())
}
s.Engine.Use(corsMiddleware())
s.Engine.Use(sessionMiddleware(s.Config))
s.Engine.Use(authorizeMiddleware(s))
s.Engine.Use(errorHandler)
@@ -73,12 +74,22 @@ func (s *AppServer) Init(debug bool) {
func (s *AppServer) Run(db *gorm.DB) error {
// load chat config from database
var config model.Config
res := db.Where("marker", "chat").First(&config)
var chatConfig model.Config
res := db.Where("marker", "chat").First(&chatConfig)
if res.Error != nil {
return res.Error
}
err := utils.JsonDecode(config.Config, &s.ChatConfig)
err := utils.JsonDecode(chatConfig.Config, &s.ChatConfig)
if err != nil {
return err
}
// load system configs
var sysConfig model.Config
res = db.Where("marker", "system").First(&sysConfig)
if res.Error != nil {
return res.Error
}
err = utils.JsonDecode(sysConfig.Config, &s.SysConfig)
if err != nil {
return err
}
@@ -175,7 +186,8 @@ func authorizeMiddleware(s *AppServer) gin.HandlerFunc {
if c.Request.URL.Path == "/api/user/login" ||
c.Request.URL.Path == "/api/admin/login" ||
c.Request.URL.Path == "/api/user/register" ||
strings.HasPrefix(c.Request.URL.Path, "/api/verify/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/sms/") ||
strings.HasPrefix(c.Request.URL.Path, "/api/captcha/") ||
strings.HasPrefix(c.Request.URL.Path, "/static/") ||
c.Request.URL.Path == "/api/admin/config/get" {
c.Next()

View File

@@ -53,3 +53,10 @@ type ApiError struct {
const PromptMsg = "prompt" // prompt message
const ReplyMsg = "reply" // reply message
var ModelToTokens = map[string]int{
"gpt-3.5-turbo": 4096,
"gpt-3.5-turbo-16k": 16384,
"gpt-4": 8192,
"gpt-4-32k": 32768,
}

View File

@@ -15,13 +15,19 @@ type AppConfig struct {
StaticDir string // 静态资源目录
StaticUrl string // 静态资源 URL
Redis RedisConfig // redis 连接信息
ApiConfig ChatPlusApiConfig // chatplus api configs
ApiConfig ChatPlusApiConfig // ChatPlus API authorization configs
AesEncryptKey string
SmsConfig AliYunSmsConfig // 短信发送配置
SmsConfig AliYunSmsConfig // AliYun send message service config
StartWechatBot bool // 是否启动微信机器人
EnabledMsgService bool // 是否启用短信服务
}
type ChatPlusApiConfig struct {
ApiURL string
AppId string
Token string
}
type AliYunSmsConfig struct {
AccessKey string
AccessSecret string
@@ -76,20 +82,12 @@ type ChatConfig struct {
EnableHistory bool `json:"enable_history"` // 是否允许保存聊天记录
ApiKey string `json:"api_key"`
ContextDeep int `json:"context_deep"` // 上下文深度
}
type SystemConfig struct {
Title string `json:"title"`
AdminTitle string `json:"admin_title"`
Models []string `json:"models"`
UserInitCalls int `json:"user_init_calls"` // 新用户注册默认总送多少次调用
Title string `json:"title"`
AdminTitle string `json:"admin_title"`
Models []string `json:"models"`
UserInitCalls int `json:"user_init_calls"` // 新用户注册默认总送多少次调用
EnabledRegister bool `json:"enabled_register"`
}
type ChatPlusApiConfig struct {
ApiURL string
AppId string
Token string
}
const UserInitCalls = 1000

View File

@@ -48,6 +48,21 @@ func (h *ConfigHandler) Update(c *gin.Context) {
resp.ERROR(c, res.Error.Error())
return
}
// update config cache for AppServer
var cfg model.Config
h.db.Where("marker", data.Key).First(&cfg)
var err error
if data.Key == "system" {
err = utils.JsonDecode(cfg.Config, &h.App.SysConfig)
} else if data.Key == "chat" {
err = utils.JsonDecode(cfg.Config, &h.App.ChatConfig)
}
if err != nil {
resp.ERROR(c, "Failed to update config cache: "+err.Error())
return
}
logger.Infof("Update AppServer's config successfully: %v", config.Config)
}
resp.SUCCESS(c, config)

View File

@@ -0,0 +1,55 @@
package admin
import (
"chatplus/core"
"chatplus/handler"
"chatplus/store/model"
"chatplus/utils/resp"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"time"
)
type DashboardHandler struct {
handler.BaseHandler
db *gorm.DB
}
func NewDashboardHandler(app *core.AppServer, db *gorm.DB) *DashboardHandler {
h := DashboardHandler{db: db}
h.App = app
return &h
}
type statsVo struct {
Users int64 `json:"users"`
Chats int64 `json:"chats"`
Tokens int64 `json:"tokens"`
}
func (h *DashboardHandler) Stats(c *gin.Context) {
stats := statsVo{}
// new users statistic
var userCount int64
now := time.Now()
zeroTime := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
res := h.db.Model(&model.User{}).Where("created_at > ?", zeroTime).Count(&userCount)
if res.Error == nil {
stats.Users = userCount
}
// new chats statistic
var chatCount int64
res = h.db.Model(&model.ChatItem{}).Where("created_at > ?", zeroTime).Count(&chatCount)
if res.Error == nil {
stats.Chats = chatCount
}
// tokens took stats
var tokenCount int64
res = h.db.Model(&model.HistoryMessage{}).Select("sum(tokens) as tokens_total").Where("created_at > ?", zeroTime).Scan(&tokenCount)
if res.Error == nil {
stats.Tokens = tokenCount
}
resp.SUCCESS(c, stats)
}

View File

@@ -8,6 +8,7 @@ import (
"chatplus/store/vo"
"chatplus/utils"
"chatplus/utils/resp"
"fmt"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -28,12 +29,24 @@ func NewUserHandler(app *core.AppServer, db *gorm.DB) *UserHandler {
func (h *UserHandler) List(c *gin.Context) {
page := h.GetInt(c, "page", 1)
pageSize := h.GetInt(c, "page_size", 20)
mobile := h.GetTrim(c, "mobile")
username := h.GetTrim(c, "username")
offset := (page - 1) * pageSize
var items []model.User
var users = make([]vo.User, 0)
var total int64
h.db.Model(&model.User{}).Count(&total)
res := h.db.Offset(offset).Limit(pageSize).Find(&items)
session := h.db.Session(&gorm.Session{})
if mobile != "" {
session = session.Where("mobile LIKE ?", "%"+mobile+"%")
}
if username != "" {
session = session.Where("username LIKE ?", "%"+username+"%")
}
session.Model(&model.User{}).Count(&total)
res := session.Offset(offset).Limit(pageSize).Find(&items)
if res.Error == nil {
for _, item := range items {
var user vo.User
@@ -52,9 +65,12 @@ func (h *UserHandler) List(c *gin.Context) {
resp.SUCCESS(c, pageVo)
}
func (h *UserHandler) Update(c *gin.Context) {
func (h *UserHandler) Save(c *gin.Context) {
var data struct {
Id uint `json:"id"`
Username string `json:"username"`
Password string `json:"password"`
Mobile string `json:"mobile"`
Nickname string `json:"nickname"`
Calls int `json:"calls"`
ChatRoles []string `json:"chat_roles"`
@@ -66,21 +82,82 @@ func (h *UserHandler) Update(c *gin.Context) {
return
}
var user = model.User{}
user.Id = data.Id
// 此处需要用 map 更新,用结构体无法更新 0 值
res := h.db.Model(&user).Updates(map[string]interface{}{
"nickname": data.Nickname,
"calls": data.Calls,
"status": data.Status,
"chat_roles_json": utils.JsonEncode(data.ChatRoles),
"expired_time": utils.Str2stamp(data.ExpiredTime),
})
var res *gorm.DB
var userVo vo.User
if data.Id > 0 { // 更新
user.Id = data.Id
// 此处需要用 map 更新,用结构体无法更新 0 值
res = h.db.Model(&user).Updates(map[string]interface{}{
"nickname": data.Nickname,
"mobile": data.Mobile,
"calls": data.Calls,
"status": data.Status,
"chat_roles_json": utils.JsonEncode(data.ChatRoles),
"expired_time": utils.Str2stamp(data.ExpiredTime),
})
} else {
salt := utils.RandString(8)
u := model.User{
Username: data.Username,
Password: utils.GenPassword(data.Password, salt),
Nickname: fmt.Sprintf("极客学长@%d", utils.RandomNumber(5)),
Avatar: "/images/avatar/user.png",
Salt: salt,
Status: true,
Mobile: data.Mobile,
ChatRoles: utils.JsonEncode(data.ChatRoles),
ExpiredTime: utils.Str2stamp(data.ExpiredTime),
ChatConfig: utils.JsonEncode(types.ChatConfig{
Temperature: h.App.ChatConfig.Temperature,
MaxTokens: h.App.ChatConfig.MaxTokens,
EnableContext: h.App.ChatConfig.EnableContext,
EnableHistory: true,
Model: h.App.ChatConfig.Model,
ApiKey: "",
}),
Calls: h.App.SysConfig.UserInitCalls,
}
res = h.db.Create(&u)
_ = utils.CopyObject(u, &userVo)
userVo.Id = u.Id
userVo.CreatedAt = u.CreatedAt.Unix()
userVo.UpdatedAt = u.UpdatedAt.Unix()
}
if res.Error != nil {
resp.ERROR(c, "更新数据库失败")
return
}
resp.SUCCESS(c)
resp.SUCCESS(c, userVo)
}
// ResetPass 重置密码
func (h *UserHandler) ResetPass(c *gin.Context) {
var data struct {
Id uint
Password string
}
if err := c.ShouldBindJSON(&data); err != nil {
resp.ERROR(c, types.InvalidArgs)
return
}
var user model.User
res := h.db.First(&user, data.Id)
if res.Error != nil {
resp.ERROR(c, "No user found")
return
}
password := utils.GenPassword(data.Password, user.Salt)
user.Password = password
res = h.db.Updates(&user)
if res.Error != nil {
resp.ERROR(c)
} else {
resp.SUCCESS(c)
}
}
func (h *UserHandler) Remove(c *gin.Context) {

View File

@@ -180,21 +180,37 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session types.ChatSession
if h.App.ChatContexts.Has(session.ChatId) {
chatCtx = h.App.ChatContexts.Get(session.ChatId)
} else {
// 加载角色信息
// calculate the tokens of current request, to prevent to exceeding the max tokens num
tokens := req.MaxTokens
for _, f := range types.InnerFunctions {
tks, _ := utils.CalcTokens(utils.JsonEncode(f), req.Model)
tokens += tks
}
// loading the role context
var messages []types.Message
err := utils.JsonDecode(role.Context, &messages)
if err == nil {
for _, v := range messages {
tks, _ := utils.CalcTokens(v.Content, req.Model)
if tokens+tks >= types.ModelToTokens[req.Model] {
break
}
tokens += tks
chatCtx = append(chatCtx, v)
}
}
// 加载最近的聊天记录作为聊天上下文
// loading recent chat history as chat context
if chatConfig.ContextDeep > 0 {
var historyMessages []model.HistoryMessage
res := h.db.Where("chat_id = ? and use_context = 1", session.ChatId).Limit(2).Order("created_at desc").Find(&historyMessages)
res := h.db.Where("chat_id = ? and use_context = 1", session.ChatId).Limit(chatConfig.ContextDeep).Order("created_at desc").Find(&historyMessages)
if res.Error == nil {
for _, msg := range historyMessages {
if tokens+msg.Tokens >= types.ModelToTokens[session.Model] {
break
}
tokens += msg.Tokens
ms := types.Message{Role: "user", Content: msg.Content}
if msg.Type == types.ReplyMsg {
ms.Role = "assistant"
@@ -204,10 +220,7 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session types.ChatSession
}
}
}
if h.App.Debug { // 调试打印聊天上下文
logger.Info("聊天上下文:", chatCtx)
}
logger.Debugf("聊天上下文:%+v", chatCtx)
}
reqMgs := make([]interface{}, 0)
for _, m := range chatCtx {
@@ -312,7 +325,6 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session types.ChatSession
logger.Info(functionName)
logger.Info(arguments)
f := h.App.Functions[functionName]
// TODO 调用函数完成任务
data, err := f.Invoke(arguments)
if err != nil {
msg := "调用函数出错:" + err.Error()
@@ -462,13 +474,9 @@ func (h *ChatHandler) sendMessage(ctx context.Context, session types.ChatSession
} else if strings.Contains(res.Error.Message, "You exceeded your current quota") {
replyMessage(ws, "请求 OpenAI API 失败API KEY 触发并发限制,请稍后再试。")
} else if strings.Contains(res.Error.Message, "This model's maximum context length") {
replyMessage(ws, "当前会话上下文长度超出限制,已为您删减会话上下文!")
// 只保留最近的三条记录
chatContext := h.App.ChatContexts.Get(session.ChatId)
if len(chatContext) > 3 {
chatContext = chatContext[len(chatContext)-3:]
}
h.App.ChatContexts.Put(session.ChatId, chatContext)
logger.Error(res.Error.Message)
replyMessage(ws, "当前会话上下文长度超出限制,已为您清空会话上下文!")
h.App.ChatContexts.Delete(session.ChatId)
return h.sendMessage(ctx, session, role, prompt, ws)
} else {
replyMessage(ws, "请求 OpenAI API 失败:"+res.Error.Message)

View File

@@ -25,8 +25,8 @@ func NewSmsHandler(app *core.AppServer, db *store.LevelDB, sms *service.AliYunSm
return handler
}
// VerifyCode 发送验证码短信
func (h *SmsHandler) VerifyCode(c *gin.Context) {
// SendCode 发送验证码短信
func (h *SmsHandler) SendCode(c *gin.Context) {
var data struct {
Mobile string `json:"mobile"`
Key string `json:"key"`
@@ -59,7 +59,12 @@ func (h *SmsHandler) VerifyCode(c *gin.Context) {
resp.SUCCESS(c)
}
type statusVo struct {
EnabledMsgService bool `json:"enabled_msg_service"`
EnabledRegister bool `json:"enabled_register"`
}
// Status check if the message service is enabled
func (h *SmsHandler) Status(c *gin.Context) {
resp.SUCCESS(c, h.App.Config.EnabledMsgService)
resp.SUCCESS(c, statusVo{EnabledMsgService: h.App.Config.EnabledMsgService, EnabledRegister: h.App.SysConfig.EnabledRegister})
}

View File

@@ -108,16 +108,7 @@ func (h *UserHandler) Register(c *gin.Context) {
Model: h.App.ChatConfig.Model,
ApiKey: "",
}),
}
// 初始化调用次数
var cfg model.Config
h.db.Where("marker = ?", "system").First(&cfg)
var config types.SystemConfig
err := utils.JsonDecode(cfg.Config, &config)
if err != nil || config.UserInitCalls <= 0 {
user.Calls = types.UserInitCalls
} else {
user.Calls = config.UserInitCalls
Calls: h.App.SysConfig.UserInitCalls,
}
res = h.db.Create(&user)
if res.Error != nil {
@@ -143,7 +134,7 @@ func (h *UserHandler) Login(c *gin.Context) {
return
}
var user model.User
res := h.db.Where("username = ?", data.Username).First(&user)
res := h.db.Where("username = ? OR mobile = ?", data.Username, data.Username).First(&user)
if res.Error != nil {
resp.ERROR(c, "用户名不存在")
return
@@ -155,6 +146,11 @@ func (h *UserHandler) Login(c *gin.Context) {
return
}
if user.Status == false {
resp.ERROR(c, "该用户已被禁止登录,请联系管理员")
return
}
// 更新最后登录时间和IP
user.LastLoginIp = c.ClientIP()
user.LastLoginAt = time.Now().Unix()

View File

@@ -5,6 +5,7 @@ import (
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
"os"
"strings"
)
var logger *zap.Logger
@@ -15,7 +16,7 @@ func GetLogger() *zap.SugaredLogger {
return sugarLogger
}
logLevel := zap.NewAtomicLevelAt(zapcore.InfoLevel)
logLevel := zap.NewAtomicLevelAt(getLogLevel(os.Getenv("LOG_LEVEL")))
encoder := getEncoder()
writerSyncer := getLogWriter()
fileCore := zapcore.NewCore(encoder, writerSyncer, logLevel)
@@ -58,3 +59,16 @@ func getLogWriter() zapcore.WriteSyncer {
}
return zapcore.AddSync(lumberJackLogger)
}
func getLogLevel(level string) zapcore.Level {
switch strings.ToUpper(level) {
case "DEBUG":
return zapcore.DebugLevel
case "WARN":
return zapcore.WarnLevel
case "ERROR":
return zapcore.ErrorLevel
default:
return zapcore.InfoLevel
}
}

View File

@@ -141,6 +141,7 @@ func main() {
fx.Provide(admin.NewUserHandler),
fx.Provide(admin.NewChatRoleHandler),
fx.Provide(admin.NewRewardHandler),
fx.Provide(admin.NewDashboardHandler),
// 创建服务
fx.Provide(service.NewAliYunSmsService),
@@ -181,7 +182,7 @@ func main() {
fx.Invoke(func(s *core.AppServer, h *handler.SmsHandler) {
group := s.Engine.Group("/api/sms/")
group.GET("status", h.Status)
group.POST("code", h.VerifyCode)
group.POST("code", h.SendCode)
}),
fx.Invoke(func(s *core.AppServer, h *handler.CaptchaHandler) {
group := s.Engine.Group("/api/captcha/")
@@ -215,9 +216,10 @@ func main() {
fx.Invoke(func(s *core.AppServer, h *admin.UserHandler) {
group := s.Engine.Group("/api/admin/user/")
group.GET("list", h.List)
group.POST("update", h.Update)
group.POST("save", h.Save)
group.GET("remove", h.Remove)
group.GET("loginLog", h.LoginLog)
group.POST("resetPass", h.ResetPass)
}),
fx.Invoke(func(s *core.AppServer, h *admin.ChatRoleHandler) {
group := s.Engine.Group("/api/admin/role/")
@@ -230,6 +232,10 @@ func main() {
group := s.Engine.Group("/api/admin/reward/")
group.GET("list", h.List)
}),
fx.Invoke(func(s *core.AppServer, h *admin.DashboardHandler) {
group := s.Engine.Group("/api/admin/dashboard/")
group.GET("stats", h.Stats)
}),
fx.Invoke(func(s *core.AppServer, db *gorm.DB) {
err := s.Run(db)

View File

@@ -35,9 +35,12 @@ func (f FuncHeadlines) Invoke(...interface{}) (string, error) {
SetHeader("AppId", f.config.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", f.config.Token)).
SetSuccessResult(&res).Get(url)
if err != nil || r.IsErrorState() {
if err != nil {
return "", err
}
if r.IsErrorState() {
return "", r.Err
}
if res.Code != types.Success {
return "", errors.New(res.Message)

View File

@@ -35,9 +35,12 @@ func (f FuncWeiboHot) Invoke(...interface{}) (string, error) {
SetHeader("AppId", f.config.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", f.config.Token)).
SetSuccessResult(&res).Get(url)
if err != nil || r.IsErrorState() {
if err != nil {
return "", err
}
if r.IsErrorState() {
return "", r.Err
}
if res.Code != types.Success {
return "", errors.New(res.Message)

View File

@@ -35,9 +35,12 @@ func (f FuncZaoBao) Invoke(...interface{}) (string, error) {
SetHeader("AppId", f.config.AppId).
SetHeader("Authorization", fmt.Sprintf("Bearer %s", f.config.Token)).
SetSuccessResult(&res).Get(url)
if err != nil || r.IsErrorState() {
if err != nil {
return "", err
}
if r.IsErrorState() {
return "", r.Err
}
if res.Code != types.Success {
return "", errors.New(res.Message)

View File

@@ -56,6 +56,10 @@ func Stamp2str(timestamp int64) string {
// Str2stamp 字符串转时间戳
func Str2stamp(str string) int64 {
if len(str) == 0 {
return 0
}
layout := "2006-01-02 15:04:05"
t, err := time.Parse(layout, str)
if err != nil {

View File

@@ -13,18 +13,10 @@ cd ../docker
# remove docker image if exists
docker rmi -f registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-go:$version
docker rmi -f chatgpt-plus-go:$version
# build docker image for chatgpt-plus-go
docker build -t chatgpt-plus-go:$version -f dockerfile-api-go ../
docker build -t registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-go:$version -f dockerfile-api-go ../
# build docker image for chatgpt-plus-vue
docker rmi -f registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-vue:$version
docker rmi -f chatgpt-plus-vue:$version
docker build --platform linux/amd64 -t chatgpt-plus-vue:$version -f dockerfile-vue ../
# add tag for aliyum docker registry
goImageId=`docker images |grep chatgpt-plus-go |grep $version |awk '{print $3}'`
docker tag $goImageId registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-go:$version
vueImageId=`docker images |grep chatgpt-plus-vue |grep $version |awk '{print $3}'`
docker tag $vueImageId registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-vue:$version
docker build --platform linux/amd64 -t registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-vue:$version -f dockerfile-vue ../

View File

@@ -35,12 +35,12 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://172.28.173.76:6789; # 这里改成后端服务的内网 IP 地址
proxy_pass http://172.22.11.47:5678; # 这里改成后端服务的内网 IP 地址
}
# 静态资源转发
location /static/ {
proxy_pass http://172.28.173.76:6789; # 这里改成后端服务的内网 IP 地址
proxy_pass http://172.22.11.47:5678; # 这里改成后端服务的内网 IP 地址
}
}
}
}

View File

@@ -2,23 +2,22 @@ version: '3'
services:
# 后端 API 程序
chatgpt-plus-go:
image: registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-go:v3.0.4
# image: chatgpt-plus-go:v3.0.2
image: registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-go:v3.0.5.2
container_name: chatgpt-plus-go
restart: always
environment:
- DEBUG=false
- CONFIG_FILE=config.toml
ports:
- "6789:5678"
- "5678:5678"
volumes:
- /usr/share/zoneinfo/Asia/Shanghai:/etc/localtime
- ./conf/config.toml:/var/www/app/config.toml
- ./static:/var/www/app/static
# 前端应用
chatgpt-vue:
image: registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-vue:v3.0.4
# image: chatgpt-plus-vue:v3.0.2
image: registry.cn-hangzhou.aliyuncs.com/geekmaster/chatgpt-plus-vue:v3.0.5.2
container_name: chatgpt-plus-vue
restart: always
ports:

View File

@@ -1,5 +1,5 @@
# GO api docker 镜像创建
FROM registry.cn-hangzhou.aliyuncs.com/geekmaster/ubuntu-ca:22.04
FROM alpine:3.18.2
MAINTAINER yangjian<yangjian102621@163.com>

View File

@@ -1,5 +1,5 @@
# 前端 Vue 项目构建
FROM nginx:1.20.2
FROM nginx:1.20
MAINTAINER yangjian<yangjian102621@163.com>

29
web/package-lock.json generated
View File

@@ -25,7 +25,8 @@
"sortablejs": "^1.15.0",
"vant": "^4.5.0",
"vue": "^3.2.13",
"vue-router": "^4.0.15"
"vue-router": "^4.0.15",
"vue-schart": "^2.0.0"
},
"devDependencies": {
"@babel/core": "7.18.6",
@@ -9366,6 +9367,11 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true
},
"node_modules/schart.js": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/schart.js/-/schart.js-3.0.4.tgz",
"integrity": "sha512-uylb2u9rrHX1jyAuSAJUQON8XTfyDKI9kWj1J3fUlCQCkLVZ4HG4+IiV8qm//Z71dqvLI78QZ/fCBw0reB22Zw=="
},
"node_modules/schema-utils": {
"version": "2.7.1",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-2.7.1.tgz",
@@ -10694,6 +10700,14 @@
"vue": "^3.2.0"
}
},
"node_modules/vue-schart": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-schart/-/vue-schart-2.0.0.tgz",
"integrity": "sha512-qAu3e5wfMcq26wK1xeHExEWfGpnjfoN1R/9QXblNi+AsU/p52X7tTwhi+Fw7H/otfEufhEY2X7z7emaoF4QO+g==",
"dependencies": {
"schart.js": "^3.0.0"
}
},
"node_modules/vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/vue-style-loader/-/vue-style-loader-4.1.3.tgz",
@@ -18621,6 +18635,11 @@
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==",
"dev": true
},
"schart.js": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/schart.js/-/schart.js-3.0.4.tgz",
"integrity": "sha512-uylb2u9rrHX1jyAuSAJUQON8XTfyDKI9kWj1J3fUlCQCkLVZ4HG4+IiV8qm//Z71dqvLI78QZ/fCBw0reB22Zw=="
},
"schema-utils": {
"version": "2.7.1",
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-2.7.1.tgz",
@@ -19686,6 +19705,14 @@
"@vue/devtools-api": "^6.0.0"
}
},
"vue-schart": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/vue-schart/-/vue-schart-2.0.0.tgz",
"integrity": "sha512-qAu3e5wfMcq26wK1xeHExEWfGpnjfoN1R/9QXblNi+AsU/p52X7tTwhi+Fw7H/otfEufhEY2X7z7emaoF4QO+g==",
"requires": {
"schart.js": "^3.0.0"
}
},
"vue-style-loader": {
"version": "4.1.3",
"resolved": "https://registry.npmmirror.com/vue-style-loader/-/vue-style-loader-4.1.3.tgz",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -20,6 +20,8 @@ html, body {
#app {
margin: 0 !important;
padding: 0 !important;
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', '微软雅黑', Arial, sans-serif;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
</style>

View File

@@ -1,6 +1,3 @@
.admin-home .header {
background-color: #242f42;
}
.admin-home .login-wrap {
background: #324157;
}

View File

@@ -1,6 +1,6 @@
.admin-home {
.header {
background-color: #242f42;
}
.login-wrap {

View File

@@ -11,7 +11,9 @@ body,
overflow: hidden;
}
body {
font-family: 'PingFang SC', "Helvetica Neue", Helvetica, "microsoft yahei", arial, STHeiTi, sans-serif;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.admin-home a {
text-decoration: none;
@@ -20,7 +22,7 @@ body {
position: absolute;
left: 250px;
right: 0;
top: 70px;
top: 0;
bottom: 0;
padding-bottom: 30px;
-webkit-transition: left 0.3s ease-in-out;

View File

@@ -13,7 +13,9 @@ body,
}
body {
font-family: 'PingFang SC', "Helvetica Neue", Helvetica, "microsoft yahei", arial, STHeiTi, sans-serif;
font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.admin-home {
@@ -25,7 +27,7 @@ body {
position: absolute;
left: 250px;
right: 0;
top: 70px;
top: 0;
bottom: 0;
padding-bottom: 30px;
-webkit-transition: left .3s ease-in-out;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 824 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,9 +1,5 @@
<template>
<div class="header admin-header">
<div class="logo">
<el-image :src="logo"/>
<span class="text">{{ title }}</span>
</div>
<!-- 折叠按钮 -->
<div class="collapse-btn" @click="collapseChange">
<el-icon v-if="sidebar.collapse">
@@ -13,6 +9,12 @@
<Fold/>
</el-icon>
</div>
<div class="breadcrumb">
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item v-for="item in breadcrumb">{{ item.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<div class="header-user-con">
<!-- 消息中心 -->
@@ -26,12 +28,10 @@
</el-tooltip>
<span class="btn-bell-badge" v-if="message"></span>
</div>
<!-- 用户头像 -->
<el-avatar class="user-avatar" :size="30" :src="avatar"/>
<!-- 用户名下拉菜单 -->
<el-dropdown class="user-name" :hide-on-click="true" trigger="click">
<span class="el-dropdown-link">
{{ username }}
<el-avatar class="user-avatar" :size="30" :src="avatar"/>
<el-icon class="el-icon--right">
<arrow-down/>
</el-icon>
@@ -77,28 +77,68 @@
</template>
<script setup>
import {onMounted, ref} from 'vue';
import {useSidebarStore} from '@/store/sidebar';
import {getMenuItems, useSidebarStore} from '@/store/sidebar';
import {useRouter} from 'vue-router';
import {ArrowDown, Expand, Fold} from "@element-plus/icons-vue";
import {ArrowDown, ArrowRight, Expand, Fold} from "@element-plus/icons-vue";
import {httpGet} from "@/utils/http";
import {ElMessage} from "element-plus";
const message = ref(5);
const username = ref('极客学长')
const avatar = ref('/images/user-info.jpg')
const donateImg = ref('/images/wechat-pay.png')
const showDialog = ref(false)
const sidebar = useSidebarStore();
const title = ref('Chat-Plus 控制台')
const logo = ref('/images/logo.png')
const router = useRouter();
const breadcrumb = ref([])
// 加载系统配置
httpGet('/api/admin/config/get?key=system').then(res => {
title.value = res.data['admin_title'];
}).catch(e => {
ElMessage.error("加载系统配置失败: " + e.message)
router.afterEach((to, from) => {
initBreadCrumb(to.path)
});
onMounted(() => {
initBreadCrumb(router.currentRoute.value.path)
})
// 初始化面包屑导航
const initBreadCrumb = (path) => {
breadcrumb.value = [{title: "首页"}]
const items = getMenuItems()
if (items) {
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
}
if (bk) {
break
}
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
}
}
}
}
}
}
// 侧边栏折叠
const collapseChange = () => {
sidebar.handleCollapse();
@@ -110,7 +150,6 @@ onMounted(() => {
}
});
const router = useRouter();
const logout = function () {
httpGet("/api/admin/logout").then(() => {
router.replace('/admin/login')
@@ -123,10 +162,12 @@ const logout = function () {
.header {
position: relative;
box-sizing: border-box;
width: 100%;
height: 70px;
overflow hidden
height: 50px;
font-size: 22px;
color: #fff;
color: #303133;
background-color #ffffff
border-bottom 1px solid #eaecef
.collapse-btn {
display: flex;
@@ -134,19 +175,19 @@ const logout = function () {
align-items: center;
height: 100%;
float: left;
padding: 0 21px;
padding: 0 10px;
cursor: pointer;
&:hover {
background-color #eaecef
}
}
.logo {
float: left;
padding-left 10px;
.breadcrumb {
float left
display flex
.text {
line-height: 66px;
margin-left 10px;
}
align-items center
height 50px
}
.header-right {
@@ -155,7 +196,7 @@ const logout = function () {
.header-user-con {
display: flex;
height: 70px;
height: 50px;
align-items: center;
.btn-bell {
@@ -176,7 +217,7 @@ const logout = function () {
height: 8px;
border-radius: 4px;
background: #f56c6c;
color: #fff;
color: #303133;
}
.icon-bell {
@@ -186,10 +227,14 @@ const logout = function () {
.user-name {
margin-left: 10px;
.el-icon {
color: #303133;
}
}
.user-avatar {
margin-left: 20px;
}
}
}
@@ -227,15 +272,7 @@ const logout = function () {
}
.admin-header {
.logo {
.el-image {
padding-top 10px
.el-image__inner {
height 40px
}
}
}
}
</style>

View File

@@ -1,5 +1,10 @@
<template>
<div class="sidebar">
<div class="logo">
<el-image :src="logo"/>
<span class="text" v-show="!sidebar.collapse">{{ title }}</span>
</div>
<el-menu
class="sidebar-el-menu"
:default-active="onRoutes"
@@ -47,15 +52,27 @@
</template>
<script setup>
import {computed} from 'vue';
import {useSidebarStore} from '@/store/sidebar';
import {computed, ref} from 'vue';
import {setMenuItems, useSidebarStore} from '@/store/sidebar';
import {useRoute} from 'vue-router';
import {httpGet} from "@/utils/http";
import {ElMessage} from "element-plus";
const title = ref('Chat-Plus-Admin')
const logo = ref('/images/logo.png')
// 加载系统配置
httpGet('/api/admin/config/get?key=system').then(res => {
title.value = res.data['admin_title'];
}).catch(e => {
ElMessage.error("加载系统配置失败: " + e.message)
})
const items = [
{
icon: 'home',
index: '/admin/welcome',
title: '系统首页',
index: '/admin/dashboard',
title: '仪表盘',
},
{
icon: 'config',
@@ -120,6 +137,7 @@ const onRoutes = computed(() => {
});
const sidebar = useSidebarStore();
setMenuItems(items)
</script>
<style scoped lang="stylus">
@@ -127,10 +145,35 @@ const sidebar = useSidebarStore();
display: block;
position: absolute;
left: 0;
top: 70px;
top: 0;
bottom: 0;
overflow-y: scroll;
.logo {
display flex
width 219px
background-color #324157
padding 6px 15px;
.el-image {
width 30px;
height 30px;
padding-top 5px;
border-radius 100%
.el-image__inner {
height 40px
}
}
.text {
color #ffffff
font-weight bold
padding 12px 0 12px 10px;
transition: width 2s ease;
}
}
ul {
height: 100%;
@@ -140,6 +183,10 @@ const sidebar = useSidebarStore();
margin-right 5px;
}
}
.el-menu-item.is-active {
background-color rgb(40, 52, 70)
}
}
.sidebar-el-menu:not(.el-menu--collapse) {

View File

@@ -15,7 +15,7 @@
</ul>
<div class="tags-close-box">
<el-dropdown @command="handleTags">
<el-button size="small" type="primary">
<el-button size="small" type="info">
标签选项
<el-icon class="el-icon--right">
<arrow-down/>
@@ -115,7 +115,8 @@ const handleTags = (command) => {
overflow: hidden;
background: #fff;
padding-right: 120px;
box-shadow: 0 5px 10px #ddd;
-webkit-box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
box-shadow: 0 1px 4px rgba(0, 21, 41, .08);
}
.tags ul {
@@ -168,14 +169,13 @@ const handleTags = (command) => {
.tags-close-box {
position: absolute;
right: 0;
top: 0;
top: 2px;
box-sizing: border-box;
padding-top: 1px;
text-align: center;
width: 110px;
height: 30px;
background: #fff;
box-shadow: -3px 0 15px 3px rgba(0, 0, 0, 0.1);
z-index: 10;
//box-shadow: -3px 0 15px 3px rgba(0, 0, 0, 0.1); z-index: 10;
}
</style>

View File

@@ -35,15 +35,15 @@ const routes = [
{
name: 'admin',
path: '/admin',
redirect: '/admin/welcome',
redirect: '/admin/dashboard',
component: () => import("@/views/admin/Home.vue"),
meta: {title: 'ChatGPT-Plus 管理后台'},
children: [
{
path: '/admin/welcome',
name: 'admin-home',
meta: {title: '系统首页'},
component: () => import('@/views/admin/Welcome.vue'),
path: '/admin/dashboard',
name: 'admin-dashboard',
meta: {title: '仪表盘'},
component: () => import('@/views/admin/Dashboard.vue'),
},
{
path: '/admin/system',

View File

@@ -1,4 +1,5 @@
import {defineStore} from 'pinia';
import Storage from "good-storage";
export const useSidebarStore = defineStore('sidebar', {
state: () => {
@@ -13,3 +14,13 @@ export const useSidebarStore = defineStore('sidebar', {
}
}
});
const MENU_STORE_KEY = "admin_menu_items"
export function getMenuItems() {
return Storage.get(MENU_STORE_KEY)
}
export function setMenuItems(items) {
return Storage.set(MENU_STORE_KEY, items)
}

View File

@@ -228,7 +228,7 @@
API KEY 也全部用完了因此我们准备开启众筹模式只需要打赏9.9就可以兑换 100 次对话以此来覆盖我们的 OpenAI
账单和服务器的费用</p>
</el-alert>
<p>
<p style="text-align: center">
<el-image :src="rewardImg"/>
</p>
</el-dialog>
@@ -274,7 +274,7 @@ const title = ref('ChatGPT-智能助手');
const logo = 'images/logo.png';
const rewardImg = ref('images/reward.png')
const models = ref([])
const model = ref('gpt-3.5-turbo')
const model = ref('gpt-3.5-turbo-16k')
const chatData = ref([]);
const allChats = ref([]); // 会话列表
const chatList = ref(allChats.value);

View File

@@ -9,7 +9,7 @@
<div class="header">{{ title }}</div>
<div class="content">
<div class="block">
<el-input placeholder="手机号/邮箱" size="large" v-model="username" autocomplete="off">
<el-input placeholder="手机号/用户名" size="large" v-model="username" autocomplete="off">
<template #prefix>
<el-icon>
<UserFilled/>

View File

@@ -1,101 +1,111 @@
<template>
<div>
<div class="bg"></div>
<div class="main">
<div class="contain">
<div class="logo">
<el-image src="images/logo.png" fit="cover"/>
</div>
<div class="register-page">
<div class="page-inner">
<div class="contain" v-if="enableRegister">
<div class="logo">
<el-image src="images/logo.png" fit="cover"/>
</div>
<div class="header">{{ title }}</div>
<div class="content">
<el-form :model="formData" label-width="120px" ref="formRef">
<div class="block">
<el-input placeholder="请输入用户名(4-30位)"
size="large" maxlength="30"
v-model="formData.username"
autocomplete="off">
<template #prefix>
<el-icon>
<UserFilled/>
</el-icon>
</template>
</el-input>
</div>
<div class="header">{{ title }}</div>
<div class="content">
<el-form :model="formData" label-width="120px" ref="formRef">
<div class="block">
<el-input placeholder="请输入用户名(4-30位)"
size="large" maxlength="30"
v-model="formData.username"
autocomplete="off">
<template #prefix>
<el-icon>
<UserFilled/>
</el-icon>
</template>
</el-input>
</div>
<div class="block">
<el-input placeholder="请输入密码(8-16位)"
maxlength="16" size="large"
v-model="formData.password" show-password
autocomplete="off">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</div>
<div class="block">
<el-input placeholder="请输入密码(8-16位)"
maxlength="16" size="large"
v-model="formData.password" show-password
autocomplete="off">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</div>
<div class="block">
<el-input placeholder="重复密码(8-16位)"
size="large" maxlength="16" v-model="formData.repass" show-password
autocomplete="off">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</div>
<div class="block">
<el-input placeholder="重复密码(8-16位)"
size="large" maxlength="16" v-model="formData.repass" show-password
autocomplete="off">
<template #prefix>
<el-icon>
<Lock/>
</el-icon>
</template>
</el-input>
</div>
<div class="block" v-if="enableMsg">
<el-input placeholder="手机号码"
size="large" maxlength="11"
v-model="formData.mobile"
autocomplete="off">
<template #prefix>
<el-icon>
<Iphone/>
</el-icon>
</template>
</el-input>
</div>
<div class="block" v-if="enableMsg">
<el-input placeholder="手机号码"
size="large" maxlength="11"
v-model="formData.mobile"
autocomplete="off">
<template #prefix>
<el-icon>
<Iphone/>
</el-icon>
</template>
</el-input>
</div>
<div class="block" v-if="enableMsg">
<el-row :gutter="10">
<el-col :span="12">
<el-input placeholder="手机验证码"
size="large" maxlength="30"
v-model.number="formData.code"
autocomplete="off">
<template #prefix>
<el-icon>
<Checked/>
</el-icon>
</template>
</el-input>
</el-col>
<el-col :span="12">
<send-msg size="large" :mobile="formData.mobile"/>
</el-col>
<div class="block" v-if="enableMsg">
<el-row :gutter="10">
<el-col :span="12">
<el-input placeholder="手机验证码"
size="large" maxlength="30"
v-model.number="formData.code"
autocomplete="off">
<template #prefix>
<el-icon>
<Checked/>
</el-icon>
</template>
</el-input>
</el-col>
<el-col :span="12">
<send-msg size="large" :mobile="formData.mobile"/>
</el-col>
</el-row>
</div>
<el-row class="btn-row">
<el-button class="login-btn" size="large" type="primary" @click="register">注册</el-button>
</el-row>
</div>
<el-row class="btn-row">
<el-button class="login-btn" size="large" type="primary" @click="register">注册</el-button>
</el-row>
<el-row class="text-line">
已经有账号
<el-link type="primary" @click="router.push('login')">登录</el-link>
</el-row>
</el-form>
<el-row class="text-line">
已经有账号
<el-link type="primary" @click="router.push('login')">登录</el-link>
</el-row>
</el-form>
</div>
</div>
</div>
<footer class="footer">
<footer-bar/>
</footer>
<div class="tip-result" v-else>
<el-result icon="error" title="注册功能已关闭">
<template #sub-title>
<p>抱歉系统已关闭注册功能请联系管理员添加账号</p>
</template>
</el-result>
</div>
<footer class="footer">
<footer-bar/>
</footer>
</div>
</div>
</div>
</template>
@@ -121,10 +131,12 @@ const formData = ref({
})
const formRef = ref(null)
const enableMsg = ref(false)
const enableRegister = ref(true)
httpGet('/api/sms/status').then(res => {
if (res.data === true) {
enableMsg.value = true
if (res.data) {
enableMsg.value = res.data['enabled_msg_service']
enableRegister.value = res.data['enabled_register']
}
})
@@ -139,6 +151,9 @@ const register = function () {
return ElMessage.error('两次输入密码不一致');
}
if (formData.value.code === '') {
formData.value.code = 0
}
httpPost('/api/user/register', formData.value).then(() => {
ElMessage.success({"message": "注册成功,即将跳转到登录页...", onClose: () => router.push("login")})
}).catch((e) => {
@@ -163,76 +178,108 @@ const register = function () {
//filter: blur(10px); /* 调整模糊程度,可以根据需要修改值 */
}
.main {
.contain {
position fixed
left 50%
top 40%
width 90%
max-width 400px
transform translate(-50%, -50%)
padding 20px 40px;
color #ffffff
border-radius 10px;
background rgba(255, 255, 255, 0.3)
.register-page {
display flex
justify-content center
.logo {
text-align center
.page-inner {
max-width 450px
height 100vh
display flex
justify-content center
align-items center
.el-image {
width 120px;
.contain {
padding 0 40px 20px 40px;
color #ffffff
border-radius 10px;
z-index 10
background-color rgba(255, 255, 255, 0.3)
.logo {
text-align center
.el-image {
width 120px;
}
}
}
.header {
width 100%
margin-bottom 24px
font-size 24px
color $white_v1
letter-space 2px
text-align center
}
.header {
width 100%
margin-bottom 24px
font-size 24px
color $white_v1
letter-space 2px
text-align center
}
.content {
width 100%
height: auto
border-radius 3px
.content {
width 100%
height: auto
border-radius 3px
.block {
margin-bottom 16px
.block {
margin-bottom 16px
.el-input__inner {
border 1px solid $gray-v6 !important
.el-input__inner {
border 1px solid $gray-v6 !important
.el-icon-user, .el-icon-lock {
font-size 20px
.el-icon-user, .el-icon-lock {
font-size 20px
}
}
}
}
.btn-row {
padding-top 10px;
.btn-row {
padding-top 10px;
.login-btn {
width 100%
font-size 16px
letter-spacing 2px
.login-btn {
width 100%
font-size 16px
letter-spacing 2px
}
}
.text-line {
justify-content center
padding-top 10px;
font-size 14px;
}
}
}
.text-line {
justify-content center
padding-top 10px;
font-size 14px;
.tip-result {
z-index 10
}
.footer {
color #ffffff;
.container {
padding 20px;
}
}
}
.footer {
color #ffffff;
}
</style>
.container {
padding 20px;
<style lang="stylus">
.register-page {
.el-result {
border-radius 10px;
background-color rgba(14, 25, 30, 0.6)
border 1px solid #666
.el-result__title p {
color #ffffff
}
.el-result__subtitle p {
color #c1c1c1
}
}
}

View File

@@ -132,7 +132,7 @@ const save = function () {
const remove = function (row) {
httpGet('/api/admin/apikey/remove?id=' + row.id).then(() => {
ElMessage.success("删除成功")
item.value = removeArrayItem(items.value, row, (v1, v2) => {
items.value = removeArrayItem(items.value, row, (v1, v2) => {
return v1.id === v2.id
})
}).catch((e) => {

View File

@@ -0,0 +1,120 @@
<template>
<div class="dashboard">
<el-row class="mgb20" :gutter="20">
<el-col :span="8">
<el-card shadow="hover" :body-style="{ padding: '0px' }">
<div class="grid-content grid-con-1">
<el-icon class="grid-con-icon">
<User/>
</el-icon>
<div class="grid-cont-right">
<div class="grid-num">{{ stats.users }}</div>
<div>今日新增用户</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" :body-style="{ padding: '0px' }">
<div class="grid-content grid-con-2">
<el-icon class="grid-con-icon">
<ChatDotRound/>
</el-icon>
<div class="grid-cont-right">
<div class="grid-num">{{ stats.chats }}</div>
<div>今日新增对话</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="8">
<el-card shadow="hover" :body-style="{ padding: '0px' }">
<div class="grid-content grid-con-3">
<el-icon class="grid-con-icon">
<TrendCharts/>
</el-icon>
<div class="grid-cont-right">
<div class="grid-num">{{ stats.tokens }}</div>
<div>今日消耗 Tokens</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import {ref} from 'vue';
import {ChatDotRound, TrendCharts, User} from "@element-plus/icons-vue";
import {httpGet} from "@/utils/http";
import {ElMessage} from "element-plus";
const stats = ref({users: 0, chats: 0, tokens: 0})
httpGet('/api/admin/dashboard/stats').then((res) => {
stats.value.users = res.data.users
stats.value.chats = res.data.chats
stats.value.tokens = res.data.tokens
}).catch((e) => {
ElMessage.error("获取统计数据失败:" + e.message)
})
</script>
<style scoped lang="stylus">
.dashboard {
.grid-content {
display: flex;
align-items: center;
height: 100px;
}
.grid-cont-right {
flex: 1;
text-align: center;
font-size: 14px;
color: #999;
}
.grid-num {
font-size: 30px;
font-weight: bold;
}
.grid-con-icon {
font-size: 50px;
width: 100px;
height: 100px;
text-align: center;
line-height: 100px;
color: #fff;
}
.grid-con-1 .grid-con-icon {
background: rgb(45, 140, 240);
}
.grid-con-1 .grid-num {
color: rgb(45, 140, 240);
}
.grid-con-2 .grid-con-icon {
background: rgb(100, 213, 114);
}
.grid-con-2 .grid-num {
color: rgb(100, 213, 114);
}
.grid-con-3 .grid-con-icon {
background: rgb(242, 94, 67);
}
.grid-con-3 .grid-num {
color: rgb(242, 94, 67);
}
}
</style>

View File

@@ -1,8 +1,8 @@
<template>
<div class="admin-home" v-if="isLogin">
<admin-header/>
<admin-sidebar/>
<div class="content-box" :class="{ 'content-collapse': sidebar.collapse }">
<admin-header/>
<admin-tags/>
<div class="content">
<router-view v-slot="{ Component }">

View File

@@ -1,11 +1,7 @@
<template>
<div class="admin-login">
<div class="bg"></div>
<div class="main">
<div class="contain">
<div class="logo">
<el-image src="../images/logo.png" fit="cover"/>
</div>
<div class="header">{{ title }}</div>
<div class="content">
<div class="block">
@@ -53,7 +49,7 @@ import {useRouter} from "vue-router";
import FooterBar from "@/components/FooterBar.vue";
const router = useRouter();
const title = ref('ChatGPT-PLUS 控制台登录');
const title = ref('ChatGPT Plus Admin');
const username = ref(process.env.VUE_APP_ADMIN_USER);
const password = ref(process.env.VUE_APP_ADMIN_PASS);
@@ -86,51 +82,29 @@ const login = function () {
<style lang="stylus" scoped>
.admin-login {
.bg {
position fixed
left 0
right 0
top 0
bottom 0
background-color #313237
background-image url("~@/assets/img/admin-login-bg.jpg")
background-size cover
background-position center
background-repeat no-repeat
filter: blur(10px); /* 调整模糊程度,可以根据需要修改值 */
}
display flex
justify-content center
width: 100%
background #2D3A4B
.main {
width 400px;
display flex
justify-content center
align-items center
height 100vh
.contain {
position fixed
left 50%
top 40%
width 90%
max-width 400px;
transform translate(-50%, -50%)
width 100%
padding 20px 40px;
color #ffffff
border-radius 10px;
background rgba(255, 255, 255, 0.3)
.logo {
text-align center
.el-image {
width 120px;
.el-image__inner {
height 100%
}
}
}
.header {
width 100%
margin-bottom 24px
margin-bottom 20px
font-size 24px
color $white_v1
letter-space 2px
text-align center
}
@@ -169,6 +143,7 @@ const login = function () {
}
}
.footer {
color #ffffff;

View File

@@ -46,7 +46,6 @@ onMounted(() => {
// 获取数据
const fetchList = function (_page, _pageSize) {
console.log(_page, _pageSize)
httpGet(`/api/admin/user/loginLog?page=${_page}&page_size=${_pageSize}`).then((res) => {
if (res.data) {
items.value = res.data.items

View File

@@ -36,7 +36,7 @@
</template>
</el-table-column>
<el-table-column label="打招呼信息" prop="hello_msg"/>
<el-table-column label="操作" width="180" align="right">
<el-table-column label="操作" width="150" align="right">
<template #default="scope">
<el-button size="small" type="primary" @click="rowEdit(scope.$index, scope.row)">编辑</el-button>
<el-popconfirm title="确定要删除当前角色吗?" @confirm="removeRole(scope.row)">

View File

@@ -12,6 +12,9 @@
<el-form-item label="注册赠送次数" prop="init_calls">
<el-input v-model.number="system['user_init_calls']" placeholder="新用户注册赠送对话次数"/>
</el-form-item>
<el-form-item label="开放用户注册" prop="init_calls">
<el-switch v-model="system['enabled_register']"/>
</el-form-item>
<el-alert type="info" show-icon :closable="false">
<p>在这里维护前端聊天页面可用的 GPT 模型列表</p>
</el-alert>

View File

@@ -1,5 +1,13 @@
<template>
<div class="container user-list" v-loading="loading">
<div class="handle-box">
<el-input v-model="query.username" placeholder="用户名" class="handle-input mr10"></el-input>
<el-input v-model="query.mobile" placeholder="手机号码" class="handle-input mr10"></el-input>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="success" :icon="Plus" @click="addUser">新增用户</el-button>
</div>
<el-row>
<el-table :data="users.items" border class="table" :row-key="row => row.id"
@selection-change="handleSelectionChange">
@@ -27,10 +35,13 @@
</template>
</el-table-column>
<el-table-column label="操作" width="180">
<el-table-column fixed="right" label="操作" width="200">
<template #default="scope">
<el-button size="small" type="primary" @click="userEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeUser(scope.row)">删除</el-button>
<el-button-group class="ml-4">
<el-button size="small" type="primary" @click="userEdit(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="removeUser(scope.row)">删除</el-button>
<el-button size="small" type="success" @click="resetPass(scope.row)">重置密码</el-button>
</el-button-group>
</template>
</el-table-column>
</el-table>
@@ -50,14 +61,24 @@
<el-dialog
v-model="showUserEditDialog"
title="编辑用户"
:title="title"
width="50%"
>
<el-form :model="user" label-width="100px" ref="userEditFormRef" :rules="rules">
<el-form-item label="昵称" prop="nickname">
<el-form-item v-if="add" label="用户名" prop="username">
<el-input v-model="user.username" autocomplete="off"/>
</el-form-item>
<el-form-item v-else label="昵称" prop="nickname">
<el-input v-model="user.nickname" autocomplete="off"/>
</el-form-item>
<el-form-item v-if="add" label="密码" prop="password">
<el-input v-model="user.password" autocomplete="off"/>
</el-form-item>
<el-form-item label="手机号" prop="mobile">
<el-input v-model="user.mobile" autocomplete="off"/>
</el-form-item>
<el-form-item label="提问次数" prop="calls">
<el-input v-model.number="user.calls" autocomplete="off" placeholder="0"/>
</el-form-item>
@@ -97,10 +118,33 @@
<template #footer>
<span class="dialog-footer">
<el-button @click="showUserEditDialog = false">取消</el-button>
<el-button type="primary" @click="updateUser">提交</el-button>
<el-button type="primary" @click="saveUser">提交</el-button>
</span>
</template>
</el-dialog>
<el-dialog
v-model="showResetPassDialog"
title="重置密码"
width="50%"
>
<el-form label-width="100px" ref="userEditFormRef">
<el-form-item label="用户名">
<el-input v-model="pass.username" autocomplete="off" readonly disabled/>
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pass.password" autocomplete="off"/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button type="primary" @click="doResetPass">提交</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
@@ -109,15 +153,24 @@ import {nextTick, onMounted, reactive, ref} from "vue";
import {httpGet, httpPost} from "@/utils/http";
import {ElMessage, ElMessageBox} from "element-plus";
import {dateFormat, disabledDate, removeArrayItem} from "@/utils/libs";
import {Plus, Search} from "@element-plus/icons-vue";
// 变量定义
const users = ref({page: 1, page_size: 15})
const users = ref({page: 1, page_size: 15, items: []})
const query = ref({username: '', mobile: '', page: 1, page_size: 15})
const title = ref('添加用户')
const add = ref(true)
const user = ref({chat_roles: []})
const pass = ref({username: '', password: '', id: 0})
const roles = ref([])
const showUserEditDialog = ref(false)
const showResetPassDialog = ref(false)
const rules = reactive({
username: [{required: true, message: '请输入用户名', trigger: 'change',}],
nickname: [{required: true, message: '请输入昵称', trigger: 'change',}],
password: [{required: true, message: '请输入密码', trigger: 'change',}],
mobile: [{required: true, message: '请输入手机号码', trigger: 'change',}],
calls: [
{required: true, message: '请输入提问次数'},
{type: 'number', message: '请输入有效数字'},
@@ -143,7 +196,9 @@ onMounted(() => {
})
const fetchUserList = function (page, pageSize) {
httpGet('/api/admin/user/list', {page: page, page_size: pageSize}).then((res) => {
query.value.page = page
query.value.page_size = pageSize
httpGet('/api/admin/user/list', query.value).then((res) => {
if (res.data) {
// 初始化数据
const arr = res.data.items;
@@ -160,6 +215,10 @@ const fetchUserList = function (page, pageSize) {
})
}
const handleSearch = () => {
fetchUserList(users.value.page, users.value.page_size)
}
// 删除用户
const removeUser = function (user) {
ElMessageBox.confirm(
@@ -170,35 +229,44 @@ const removeUser = function (user) {
cancelButtonText: '取消',
type: 'warning',
}
)
.then(() => {
httpGet('/api/admin/user/remove', {id: user.id}).then(() => {
ElMessage.success('操作成功!')
users.value.items = removeArrayItem(users.value.items, user, function (v1, v2) {
return v1.id === v2.id
})
}).catch((e) => {
ElMessage.error('操作失败,' + e.message)
})
})
.catch(() => {
ElMessage.info('操作被取消')
).then(() => {
httpGet('/api/admin/user/remove', {id: user.id}).then(() => {
ElMessage.success('操作成功!')
users.value.items = removeArrayItem(users.value.items, user, function (v1, v2) {
return v1.id === v2.id
})
}).catch((e) => {
ElMessage.error('操作失败,' + e.message)
})
}).catch(() => {
ElMessage.info('操作被取消')
})
}
const userEdit = function (_user) {
user.value = _user
const userEdit = function (row) {
user.value = row
title.value = '编辑用户'
showUserEditDialog.value = true
add.value = false
}
// 更新口令
const updateUser = function () {
const addUser = () => {
user.value = {}
title.value = '添加用户'
showUserEditDialog.value = true
add.value = true
}
const saveUser = function () {
userEditFormRef.value.validate((valid) => {
if (valid) {
showUserEditDialog.value = false
httpPost('/api/admin/user/update', user.value).then(() => {
httpPost('/api/admin/user/save', user.value).then((res) => {
ElMessage.success('操作成功!')
if (add.value) {
users.value.items.push(res.data)
}
}).catch((e) => {
ElMessage.error('操作失败,' + e.message)
})
@@ -212,11 +280,38 @@ const handleSelectionChange = function (rows) {
// TODO: 批量删除操作
console.log(rows)
}
const resetPass = (row) => {
showResetPassDialog.value = true
pass.value.id = row.id
pass.value.username = row.username
}
const doResetPass = () => {
httpPost('/api/admin/user/resetPass', pass.value).then(() => {
ElMessage.success('操作成功!')
showResetPassDialog.value = false
}).catch((e) => {
ElMessage.error('操作失败,' + e.message)
})
}
</script>
<style lang="stylus" scoped>
.user-list {
.handle-box {
.handle-input {
max-width 150px;
margin-right 10px;
}
}
.table {
width 100%
}
.opt-box {
padding-bottom: 10px;

View File

@@ -1,25 +0,0 @@
<template>
<div class="welcome">
<h1>ChatGPT-PLUS 控制台</h1>
</div>
</template>
<script setup></script>
<style lang="stylus" scoped>
.welcome {
display: flex;
justify-content: center;
align-items: center;
color: #202020;
background-color: #282c34;
height 100%;
h1 {
font-size: 300%;
font-weight: bold;
letter-spacing: 0.1em;
text-shadow: -1px -1px 1px #111111, 2px 2px 1px #363636;
}
}
</style>

View File

@@ -11,8 +11,8 @@
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button type="primary" :icon="Plus">新增</el-button>
</div>
<el-table :data="tableData" border class="table" ref="multipleTable" header-cell-class-name="table-header">
<el-table-column prop="id" label="ID" width="55" align="center"></el-table-column>
<el-table :data="tableData" border class="table" style="width: 100%" header-cell-class-name="table-header">
<el-table-column prop="id" fixed label="ID" width="55" align="center"></el-table-column>
<el-table-column prop="name" label="姓名"></el-table-column>
<el-table-column label="头像(查看大图)" align="center">
<template #default="scope">
@@ -38,7 +38,7 @@
</el-table-column>
<el-table-column prop="date" label="注册时间"></el-table-column>
<el-table-column label="操作" width="220" align="center">
<el-table-column label="操作" fixed="right" align="center">
<template #default="scope">
<el-button text :icon="Edit" @click="handleEdit(scope.$index, scope.row)">
编辑