mirror of
https://github.com/yangjian102621/geekai.git
synced 2025-10-28 21:03:42 +08:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e65a5b1a1 | ||
|
|
b09d23f97f | ||
|
|
3529649ba9 | ||
|
|
fdd659f393 | ||
|
|
9eb8da2789 | ||
|
|
ffb1ef0470 | ||
|
|
862c6aea43 | ||
|
|
54fe4b7588 | ||
|
|
c6062ee70e | ||
|
|
bed184dc1f | ||
|
|
29094ba3b3 | ||
|
|
a18188876c | ||
|
|
4faee3e48e | ||
|
|
1a6afcd266 | ||
|
|
f567831d92 | ||
|
|
cf36ca4285 | ||
|
|
0e4ae01498 |
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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
104
README.md
@@ -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,然后 []
|
||||
|
||||

|
||||
|
||||
最后登录前端聊天页面 [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` 目录下。
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
55
api/handler/admin/dashboard_handler.go
Normal file
55
api/handler/admin/dashboard_handler.go
Normal 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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
10
api/main.go
10
api/main.go
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ../
|
||||
|
||||
|
||||
@@ -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 地址
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
29
web/package-lock.json
generated
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
.admin-home .header {
|
||||
background-color: #242f42;
|
||||
}
|
||||
.admin-home .login-wrap {
|
||||
background: #324157;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
.admin-home {
|
||||
.header {
|
||||
background-color: #242f42;
|
||||
|
||||
}
|
||||
|
||||
.login-wrap {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
120
web/src/views/admin/Dashboard.vue
Normal file
120
web/src/views/admin/Dashboard.vue
Normal 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>
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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)">
|
||||
编辑
|
||||
|
||||
Reference in New Issue
Block a user