Просмотр исходного кода

Merge pull request #8 from Calcium-Ion/main

11
luxl 2 лет назад
Родитель
Сommit
d5aca0ae78

+ 2 - 0
README.md

@@ -34,6 +34,8 @@
 <img src="https://github.com/Calcium-Ion/new-api/assets/61247483/de536a8a-0161-47a7-a0a2-66ef6de81266" width="500">
 
 ## 界面截图
+![image](https://github.com/Calcium-Ion/new-api/assets/61247483/d1ac216e-0804-4105-9fdc-66b35022d861)
+
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/3ca0b282-00ff-4c96-bf9d-e29ef615c605)  
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/f4f40ed4-8ccb-43d7-a580-90677827646d)  
 ![image](https://github.com/Calcium-Ion/new-api/assets/61247483/90d7d763-6a77-4b36-9f76-2bb30f18583d)

+ 64 - 0
common/image.go

@@ -0,0 +1,64 @@
+package common
+
+import (
+	"bytes"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"github.com/chai2010/webp"
+	"image"
+	"io"
+	"net/http"
+	"strings"
+)
+
+func DecodeBase64ImageData(base64String string) (image.Config, error) {
+	// 去除base64数据的URL前缀(如果有)
+	if idx := strings.Index(base64String, ","); idx != -1 {
+		base64String = base64String[idx+1:]
+	}
+
+	// 将base64字符串解码为字节切片
+	decodedData, err := base64.StdEncoding.DecodeString(base64String)
+	if err != nil {
+		fmt.Println("Error: Failed to decode base64 string")
+		return image.Config{}, err
+	}
+
+	// 创建一个bytes.Buffer用于存储解码后的数据
+	reader := bytes.NewReader(decodedData)
+	config, err := getImageConfig(reader)
+	return config, err
+}
+
+func DecodeUrlImageData(imageUrl string) (image.Config, error) {
+	response, err := http.Get(imageUrl)
+	if err != nil {
+		SysLog(fmt.Sprintf("fail to get image from url: %s", err.Error()))
+		return image.Config{}, err
+	}
+
+	// 限制读取的字节数,防止下载整个图片
+	limitReader := io.LimitReader(response.Body, 8192)
+	config, err := getImageConfig(limitReader)
+	response.Body.Close()
+	return config, err
+}
+
+func getImageConfig(reader io.Reader) (image.Config, error) {
+	// 读取图片的头部信息来获取图片尺寸
+	config, _, err := image.DecodeConfig(reader)
+	if err != nil {
+		err = errors.New(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
+		SysLog(err.Error())
+		config, err = webp.DecodeConfig(reader)
+		if err != nil {
+			err = errors.New(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
+			SysLog(err.Error())
+		}
+	}
+	if err != nil {
+		return image.Config{}, err
+	}
+	return config, nil
+}

+ 44 - 0
common/pprof.go

@@ -0,0 +1,44 @@
+package common
+
+import (
+	"fmt"
+	"github.com/shirou/gopsutil/cpu"
+	"os"
+	"runtime/pprof"
+	"time"
+)
+
+// Monitor 定时监控cpu使用率,超过阈值输出pprof文件
+func Monitor() {
+	for {
+		percent, err := cpu.Percent(time.Second, false)
+		if err != nil {
+			panic(err)
+		}
+		if percent[0] > 80 {
+			fmt.Println("cpu usage too high")
+			// write pprof file
+			if _, err := os.Stat("./pprof"); os.IsNotExist(err) {
+				err := os.Mkdir("./pprof", os.ModePerm)
+				if err != nil {
+					SysLog("创建pprof文件夹失败 " + err.Error())
+					continue
+				}
+			}
+			f, err := os.Create("./pprof/" + fmt.Sprintf("cpu-%s.pprof", time.Now().Format("20060102150405")))
+			if err != nil {
+				SysLog("创建pprof文件失败 " + err.Error())
+				continue
+			}
+			err = pprof.StartCPUProfile(f)
+			if err != nil {
+				SysLog("启动pprof失败 " + err.Error())
+				continue
+			}
+			time.Sleep(10 * time.Second) // profile for 30 seconds
+			pprof.StopCPUProfile()
+			f.Close()
+		}
+		time.Sleep(30 * time.Second)
+	}
+}

+ 9 - 9
controller/midjourney.go

@@ -19,12 +19,12 @@ import (
 func UpdateMidjourneyTask() {
 	//revocer
 	imageModel := "midjourney"
+	defer func() {
+		if err := recover(); err != nil {
+			log.Printf("UpdateMidjourneyTask panic: %v", err)
+		}
+	}()
 	for {
-		defer func() {
-			if err := recover(); err != nil {
-				log.Printf("UpdateMidjourneyTask panic: %v", err)
-			}
-		}()
 		time.Sleep(time.Duration(15) * time.Second)
 		tasks := model.GetAllUnFinishTasks()
 		if len(tasks) != 0 {
@@ -55,7 +55,6 @@ func UpdateMidjourneyTask() {
 				// 设置超时时间
 				timeout := time.Second * 5
 				ctx, cancel := context.WithTimeout(context.Background(), timeout)
-				defer cancel()
 
 				// 使用带有超时的 context 创建新的请求
 				req = req.WithContext(ctx)
@@ -68,8 +67,8 @@ func UpdateMidjourneyTask() {
 					log.Printf("UpdateMidjourneyTask error: %v", err)
 					continue
 				}
-				defer resp.Body.Close()
 				responseBody, err := io.ReadAll(resp.Body)
+				resp.Body.Close()
 				log.Printf("responseBody: %s", string(responseBody))
 				var responseItem Midjourney
 				// err = json.NewDecoder(resp.Body).Decode(&responseItem)
@@ -83,12 +82,12 @@ func UpdateMidjourneyTask() {
 						if err1 == nil && err2 == nil {
 							jsonData, err3 := json.Marshal(responseWithoutStatus)
 							if err3 != nil {
-								log.Fatalf("UpdateMidjourneyTask error1: %v", err3)
+								log.Printf("UpdateMidjourneyTask error1: %v", err3)
 								continue
 							}
 							err4 := json.Unmarshal(jsonData, &responseStatus)
 							if err4 != nil {
-								log.Fatalf("UpdateMidjourneyTask error2: %v", err4)
+								log.Printf("UpdateMidjourneyTask error2: %v", err4)
 								continue
 							}
 							responseItem.Status = strconv.Itoa(responseStatus.Status)
@@ -138,6 +137,7 @@ func UpdateMidjourneyTask() {
 					log.Printf("UpdateMidjourneyTask error5: %v", err)
 				}
 				log.Printf("UpdateMidjourneyTask success: %v", task)
+				cancel()
 			}
 		}
 	}

+ 1 - 1
controller/relay-image.go

@@ -147,7 +147,7 @@ func relayImageHelper(c *gin.Context, relayMode int) *OpenAIErrorWithStatusCode
 	var textResponse ImageResponse
 	defer func(ctx context.Context) {
 		if consumeQuota {
-			err := model.PostConsumeTokenQuota(tokenId, userId, quota, 0, true)
+			err := model.PostConsumeTokenQuota(tokenId, userQuota, quota, 0, true)
 			if err != nil {
 				common.SysError("error consuming token remain quota: " + err.Error())
 			}

+ 10 - 19
controller/relay-utils.go

@@ -4,7 +4,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/chai2010/webp"
 	"github.com/gin-gonic/gin"
 	"github.com/pkoukk/tiktoken-go"
 	"image"
@@ -75,29 +74,21 @@ func getImageToken(imageUrl MessageImageUrl) (int, error) {
 	if imageUrl.Detail == "low" {
 		return 85, nil
 	}
-
-	response, err := http.Get(imageUrl.Url)
+	var config image.Config
+	var err error
+	if strings.HasPrefix(imageUrl.Url, "http") {
+		common.SysLog(fmt.Sprintf("downloading image: %s", imageUrl.Url))
+		config, err = common.DecodeUrlImageData(imageUrl.Url)
+	} else {
+		common.SysLog(fmt.Sprintf("decoding image"))
+		config, err = common.DecodeBase64ImageData(imageUrl.Url)
+	}
 	if err != nil {
-		fmt.Println("Error: Failed to get the URL")
 		return 0, err
 	}
 
-	// 限制读取的字节数,防止下载整个图片
-	limitReader := io.LimitReader(response.Body, 8192)
-
-	response.Body.Close()
-
-	// 读取图片的头部信息来获取图片尺寸
-	config, _, err := image.DecodeConfig(limitReader)
-	if err != nil {
-		common.SysLog(fmt.Sprintf("fail to decode image config(gif, jpg, png): %s", err.Error()))
-		config, err = webp.DecodeConfig(limitReader)
-		if err != nil {
-			common.SysLog(fmt.Sprintf("fail to decode image config(webp): %s", err.Error()))
-		}
-	}
 	if config.Width == 0 || config.Height == 0 {
-		return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", err.Error()))
+		return 0, errors.New(fmt.Sprintf("fail to decode image config: %s", imageUrl.Url))
 	}
 	if config.Width < 512 && config.Height < 512 {
 		if imageUrl.Detail == "auto" || imageUrl.Detail == "" {

+ 59 - 0
controller/user.go

@@ -79,6 +79,7 @@ func setupLogin(user *model.User, c *gin.Context) {
 		DisplayName: user.DisplayName,
 		Role:        user.Role,
 		Status:      user.Status,
+		Group:       user.Group,
 	}
 	c.JSON(http.StatusOK, gin.H{
 		"message": "",
@@ -284,6 +285,42 @@ func GenerateAccessToken(c *gin.Context) {
 	return
 }
 
+type TransferAffQuotaRequest struct {
+	Quota int `json:"quota" binding:"required"`
+}
+
+func TransferAffQuota(c *gin.Context) {
+	id := c.GetInt("id")
+	user, err := model.GetUserById(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	tran := TransferAffQuotaRequest{}
+	if err := c.ShouldBindJSON(&tran); err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	err = user.TransferAffQuotaToQuota(tran.Quota)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "划转失败 " + err.Error(),
+		})
+		return
+	}
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "划转成功",
+	})
+}
+
 func GetAffCode(c *gin.Context) {
 	id := c.GetInt("id")
 	user, err := model.GetUserById(id, true)
@@ -330,6 +367,28 @@ func GetSelf(c *gin.Context) {
 	return
 }
 
+func GetUserModels(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		id = c.GetInt("id")
+	}
+	user, err := model.GetUserById(id, true)
+	if err != nil {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": err.Error(),
+		})
+		return
+	}
+	models := model.GetGroupModels(user.Group)
+	c.JSON(http.StatusOK, gin.H{
+		"success": true,
+		"message": "",
+		"data":    models,
+	})
+	return
+}
+
 func UpdateUser(c *gin.Context) {
 	var updatedUser model.User
 	err := json.NewDecoder(c.Request.Body).Decode(&updatedUser)

+ 5 - 0
go.mod

@@ -17,6 +17,7 @@ require (
 	github.com/gorilla/websocket v1.5.0
 	github.com/pkoukk/tiktoken-go v0.1.1
 	github.com/samber/lo v1.38.1
+	github.com/shirou/gopsutil v3.21.11+incompatible
 	github.com/star-horizon/go-epay v0.0.0-20230204124159-fa2e2293fdc2
 	golang.org/x/crypto v0.14.0
 	gorm.io/driver/mysql v1.4.3
@@ -33,6 +34,7 @@ require (
 	github.com/dlclark/regexp2 v1.8.1 // indirect
 	github.com/gabriel-vasile/mimetype v1.4.2 // indirect
 	github.com/gin-contrib/sse v0.1.0 // indirect
+	github.com/go-ole/go-ole v1.2.6 // indirect
 	github.com/go-playground/locales v0.14.1 // indirect
 	github.com/go-playground/universal-translator v0.18.1 // indirect
 	github.com/go-sql-driver/mysql v1.6.0 // indirect
@@ -54,8 +56,11 @@ require (
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/pelletier/go-toml/v2 v2.0.8 // indirect
+	github.com/tklauser/go-sysconf v0.3.12 // indirect
+	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.2.11 // indirect
+	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	golang.org/x/arch v0.3.0 // indirect
 	golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
 	golang.org/x/net v0.17.0 // indirect

+ 13 - 0
go.sum

@@ -33,6 +33,8 @@ github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwv
 github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
 github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
 github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
+github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
+github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
 github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
 github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
@@ -131,6 +133,8 @@ github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUA
 github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
 github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM=
 github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
+github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
+github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
 github.com/star-horizon/go-epay v0.0.0-20230204124159-fa2e2293fdc2 h1:avbt5a8F/zbYwFzTugrqWOBJe/K1cJj6+xpr+x1oVAI=
 github.com/star-horizon/go-epay v0.0.0-20230204124159-fa2e2293fdc2/go.mod h1:SiffGCWGGMVwujne2dUQbJ5zUVD1V1Yj0hDuTfqFNEo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -146,6 +150,10 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY=
 github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
+github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
+github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
+github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
@@ -154,6 +162,8 @@ github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLY
 github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
 github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
 github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
+github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
 golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
 golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
 golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
@@ -165,6 +175,7 @@ golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -172,6 +183,8 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

+ 6 - 0
main.go

@@ -83,6 +83,12 @@ func main() {
 		common.SysLog("batch update enabled with interval " + strconv.Itoa(common.BatchUpdateInterval) + "s")
 		model.InitBatchUpdater()
 	}
+
+	if os.Getenv("ENABLE_PPROF") == "true" {
+		go common.Monitor()
+		common.SysLog("pprof enabled")
+	}
+
 	controller.InitTokenEncoders()
 
 	// Initialize HTTP server

+ 11 - 0
model/ability.go

@@ -13,6 +13,17 @@ type Ability struct {
 	Priority  *int64 `json:"priority" gorm:"bigint;default:0;index"`
 }
 
+func GetGroupModels(group string) []string {
+	var abilities []Ability
+	//去重
+	DB.Where("`group` = ?", group).Distinct("model").Find(&abilities)
+	models := make([]string, 0, len(abilities))
+	for _, ability := range abilities {
+		models = append(models, ability.Model)
+	}
+	return models
+}
+
 func GetRandomSatisfiedChannel(group string, model string) (*Channel, error) {
 	ability := Ability{}
 	groupCol := "`group`"

+ 22 - 20
model/token.go

@@ -220,28 +220,30 @@ func PostConsumeTokenQuota(tokenId int, userQuota int, quota int, preConsumedQuo
 	}
 
 	if sendEmail {
-		quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-(quota+preConsumedQuota) < common.QuotaRemindThreshold
-		noMoreQuota := userQuota-(quota+preConsumedQuota) <= 0
-		if quotaTooLow || noMoreQuota {
-			go func() {
-				email, err := GetUserEmail(token.UserId)
-				if err != nil {
-					common.SysError("failed to fetch user email: " + err.Error())
-				}
-				prompt := "您的额度即将用尽"
-				if noMoreQuota {
-					prompt = "您的额度已用尽"
-				}
-				if email != "" {
-					topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
-					err = common.SendEmail(prompt, email,
-						fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
+		if (quota + preConsumedQuota) != 0 {
+			quotaTooLow := userQuota >= common.QuotaRemindThreshold && userQuota-(quota+preConsumedQuota) < common.QuotaRemindThreshold
+			noMoreQuota := userQuota-(quota+preConsumedQuota) <= 0
+			if quotaTooLow || noMoreQuota {
+				go func() {
+					email, err := GetUserEmail(token.UserId)
 					if err != nil {
-						common.SysError("failed to send email" + err.Error())
+						common.SysError("failed to fetch user email: " + err.Error())
 					}
-					common.SysLog("user quota is low, consumed quota: " + strconv.Itoa(quota) + ", user quota: " + strconv.Itoa(userQuota))
-				}
-			}()
+					prompt := "您的额度即将用尽"
+					if noMoreQuota {
+						prompt = "您的额度已用尽"
+					}
+					if email != "" {
+						topUpLink := fmt.Sprintf("%s/topup", common.ServerAddress)
+						err = common.SendEmail(prompt, email,
+							fmt.Sprintf("%s,当前剩余额度为 %d,为了不影响您的使用,请及时充值。<br/>充值链接:<a href='%s'>%s</a>", prompt, userQuota, topUpLink, topUpLink))
+						if err != nil {
+							common.SysError("failed to send email" + err.Error())
+						}
+						common.SysLog("user quota is low, consumed quota: " + strconv.Itoa(quota) + ", user quota: " + strconv.Itoa(userQuota))
+					}
+				}()
+			}
 		}
 	}
 

+ 53 - 1
model/user.go

@@ -27,6 +27,9 @@ type User struct {
 	RequestCount     int    `json:"request_count" gorm:"type:int;default:0;"`               // request number
 	Group            string `json:"group" gorm:"type:varchar(32);default:'default'"`
 	AffCode          string `json:"aff_code" gorm:"type:varchar(32);column:aff_code;uniqueIndex"`
+	AffCount         int    `json:"aff_count" gorm:"type:int;default:0;column:aff_count"`
+	AffQuota         int    `json:"aff_quota" gorm:"type:int;default:0;column:aff_quota"`           // 邀请剩余额度
+	AffHistoryQuota  int    `json:"aff_history_quota" gorm:"type:int;default:0;column:aff_history"` // 邀请历史额度
 	InviterId        int    `json:"inviter_id" gorm:"type:int;column:inviter_id;index"`
 }
 
@@ -77,6 +80,54 @@ func DeleteUserById(id int) (err error) {
 	return user.Delete()
 }
 
+func inviteUser(inviterId int) (err error) {
+	user, err := GetUserById(inviterId, true)
+	if err != nil {
+		return err
+	}
+	user.AffCount++
+	user.AffQuota += common.QuotaForInviter
+	user.AffHistoryQuota += common.QuotaForInviter
+	return DB.Save(user).Error
+}
+
+func (user *User) TransferAffQuotaToQuota(quota int) error {
+	// 检查quota是否小于最小额度
+	if float64(quota) < common.QuotaPerUnit {
+		return fmt.Errorf("转移额度最小为%s!", common.LogQuota(int(common.QuotaPerUnit)))
+	}
+
+	// 开始数据库事务
+	tx := DB.Begin()
+	if tx.Error != nil {
+		return tx.Error
+	}
+	defer tx.Rollback() // 确保在函数退出时事务能回滚
+
+	// 加锁查询用户以确保数据一致性
+	err := tx.Set("gorm:query_option", "FOR UPDATE").First(&user, user.Id).Error
+	if err != nil {
+		return err
+	}
+
+	// 再次检查用户的AffQuota是否足够
+	if user.AffQuota < quota {
+		return errors.New("邀请额度不足!")
+	}
+
+	// 更新用户额度
+	user.AffQuota -= quota
+	user.Quota += quota
+
+	// 保存用户状态
+	if err := tx.Save(user).Error; err != nil {
+		return err
+	}
+
+	// 提交事务
+	return tx.Commit().Error
+}
+
 func (user *User) Insert(inviterId int) error {
 	var err error
 	if user.Password != "" {
@@ -101,8 +152,9 @@ func (user *User) Insert(inviterId int) error {
 			RecordLog(user.Id, LogTypeSystem, fmt.Sprintf("使用邀请码赠送 %s", common.LogQuota(common.QuotaForInvitee)))
 		}
 		if common.QuotaForInviter > 0 {
-			_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
+			//_ = IncreaseUserQuota(inviterId, common.QuotaForInviter)
 			RecordLog(inviterId, LogTypeSystem, fmt.Sprintf("邀请用户赠送 %s", common.LogQuota(common.QuotaForInviter)))
+			_ = inviteUser(inviterId)
 		}
 	}
 	return nil

+ 2 - 0
router/api-router.go

@@ -39,6 +39,7 @@ func SetApiRouter(router *gin.Engine) {
 			selfRoute.Use(middleware.UserAuth())
 			{
 				selfRoute.GET("/self", controller.GetSelf)
+				selfRoute.GET("/models", controller.GetUserModels)
 				selfRoute.PUT("/self", controller.UpdateSelf)
 				selfRoute.DELETE("/self", controller.DeleteSelf)
 				selfRoute.GET("/token", controller.GenerateAccessToken)
@@ -46,6 +47,7 @@ func SetApiRouter(router *gin.Engine) {
 				selfRoute.POST("/topup", controller.TopUp)
 				selfRoute.POST("/pay", controller.RequestEpay)
 				selfRoute.POST("/amount", controller.RequestAmount)
+				selfRoute.POST("/aff_transfer", controller.TransferAffQuota)
 			}
 
 			adminRoute := userRoute.Group("/")

+ 7 - 4
web/src/components/LogsTable.js

@@ -106,7 +106,7 @@ const LogsTable = () => {
                 return (
                     record.type === 0 || record.type === 2 ?
                         <div>
-                            <Tag color='grey' size='large' onClick={()=>{
+                            <Tag color='grey' size='large' onClick={() => {
                                 copyText(text)
                             }}> {text} </Tag>
                         </div>
@@ -133,7 +133,7 @@ const LogsTable = () => {
                 return (
                     record.type === 0 || record.type === 2 ?
                         <div>
-                            <Tag color={stringToColor(text)} size='large' onClick={()=>{
+                            <Tag color={stringToColor(text)} size='large' onClick={() => {
                                 copyText(text)
                             }}> {text} </Tag>
                         </div>
@@ -202,11 +202,12 @@ const LogsTable = () => {
     const [logType, setLogType] = useState(0);
     const isAdminUser = isAdmin();
     let now = new Date();
+    // 初始化start_timestamp为前一天
     const [inputs, setInputs] = useState({
         username: '',
         token_name: '',
         model_name: '',
-        start_timestamp: timestamp2string(0),
+        start_timestamp: timestamp2string(now.getTime() / 1000 - 86400),
         end_timestamp: timestamp2string(now.getTime() / 1000 + 3600),
         channel: ''
     });
@@ -338,7 +339,7 @@ const LogsTable = () => {
             showSuccess('已复制:' + text);
         } else {
             // setSearchKeyword(text);
-            Modal.error({ title: '无法复制到剪贴板,请手动复制', content: text });
+            Modal.error({title: '无法复制到剪贴板,请手动复制', content: text});
         }
     }
 
@@ -412,10 +413,12 @@ const LogsTable = () => {
                                     name='model_name'
                                     onChange={value => handleInputChange(value, 'model_name')}/>
                         <Form.DatePicker field="start_timestamp" label='起始时间' style={{width: 272}}
+                                         initValue={start_timestamp}
                                          value={start_timestamp} type='dateTime'
                                          name='start_timestamp'
                                          onChange={value => handleInputChange(value, 'start_timestamp')}/>
                         <Form.DatePicker field="end_timestamp" fluid label='结束时间' style={{width: 272}}
+                                         initValue={end_timestamp}
                                          value={end_timestamp} type='dateTime'
                                          name='end_timestamp'
                                          onChange={value => handleInputChange(value, 'end_timestamp')}/>

+ 523 - 351
web/src/components/PersonalSetting.js

@@ -1,376 +1,548 @@
-import React, { useContext, useEffect, useState } from 'react';
-import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
-import { Link, useNavigate } from 'react-router-dom';
-import { API, copy, showError, showInfo, showNotice, showSuccess } from '../helpers';
+import React, {useContext, useEffect, useState} from 'react';
+import {Form, Image, Message} from 'semantic-ui-react';
+import {Link, useNavigate} from 'react-router-dom';
+import {API, copy, isRoot, showError, showInfo, showNotice, showSuccess} from '../helpers';
 import Turnstile from 'react-turnstile';
-import { UserContext } from '../context/User';
-import { onGitHubOAuthClicked } from './utils';
+import {UserContext} from '../context/User';
+import {onGitHubOAuthClicked} from './utils';
+import {
+    Avatar, Banner,
+    Button,
+    Card,
+    Descriptions,
+    Divider,
+    Input, InputNumber,
+    Layout,
+    Modal,
+    Space,
+    Tag,
+    Typography
+} from "@douyinfe/semi-ui";
+import {getQuotaPerUnit, renderQuota, renderQuotaWithPrompt, stringToColor} from "../helpers/render";
+import EditToken from "../pages/Token/EditToken";
+import EditUser from "../pages/User/EditUser";
 
 const PersonalSetting = () => {
-  const [userState, userDispatch] = useContext(UserContext);
-  let navigate = useNavigate();
+    const [userState, userDispatch] = useContext(UserContext);
+    let navigate = useNavigate();
 
-  const [inputs, setInputs] = useState({
-    wechat_verification_code: '',
-    email_verification_code: '',
-    email: '',
-    self_account_deletion_confirmation: ''
-  });
-  const [status, setStatus] = useState({});
-  const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
-  const [showEmailBindModal, setShowEmailBindModal] = useState(false);
-  const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
-  const [turnstileEnabled, setTurnstileEnabled] = useState(false);
-  const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
-  const [turnstileToken, setTurnstileToken] = useState('');
-  const [loading, setLoading] = useState(false);
-  const [disableButton, setDisableButton] = useState(false);
-  const [countdown, setCountdown] = useState(30);
-  const [affLink, setAffLink] = useState("");
-  const [systemToken, setSystemToken] = useState("");
+    const [inputs, setInputs] = useState({
+        wechat_verification_code: '',
+        email_verification_code: '',
+        email: '',
+        self_account_deletion_confirmation: ''
+    });
+    const [status, setStatus] = useState({});
+    const [showWeChatBindModal, setShowWeChatBindModal] = useState(false);
+    const [showEmailBindModal, setShowEmailBindModal] = useState(false);
+    const [showAccountDeleteModal, setShowAccountDeleteModal] = useState(false);
+    const [turnstileEnabled, setTurnstileEnabled] = useState(false);
+    const [turnstileSiteKey, setTurnstileSiteKey] = useState('');
+    const [turnstileToken, setTurnstileToken] = useState('');
+    const [loading, setLoading] = useState(false);
+    const [disableButton, setDisableButton] = useState(false);
+    const [countdown, setCountdown] = useState(30);
+    const [affLink, setAffLink] = useState("");
+    const [systemToken, setSystemToken] = useState("");
+    const [models, setModels] = useState([]);
+    const [openTransfer, setOpenTransfer] = useState(false);
+    const [transferAmount, setTransferAmount] = useState(0);
 
-  useEffect(() => {
-    let status = localStorage.getItem('status');
-    if (status) {
-      status = JSON.parse(status);
-      setStatus(status);
-      if (status.turnstile_check) {
-        setTurnstileEnabled(true);
-        setTurnstileSiteKey(status.turnstile_site_key);
-      }
-    }
-  }, []);
+    useEffect(() => {
+        // let user = localStorage.getItem('user');
+        // if (user) {
+        //   userDispatch({ type: 'login', payload: user });
+        // }
+        // console.log(localStorage.getItem('user'))
 
-  useEffect(() => {
-    let countdownInterval = null;
-    if (disableButton && countdown > 0) {
-      countdownInterval = setInterval(() => {
-        setCountdown(countdown - 1);
-      }, 1000);
-    } else if (countdown === 0) {
-      setDisableButton(false);
-      setCountdown(30);
-    }
-    return () => clearInterval(countdownInterval); // Clean up on unmount
-  }, [disableButton, countdown]);
+        let status = localStorage.getItem('status');
+        if (status) {
+            status = JSON.parse(status);
+            setStatus(status);
+            if (status.turnstile_check) {
+                setTurnstileEnabled(true);
+                setTurnstileSiteKey(status.turnstile_site_key);
+            }
+        }
+        getUserData().then(
+            (res) => {
+                console.log(userState)
+            }
+        );
+        loadModels().then();
+        getAffLink().then();
+        setTransferAmount(getQuotaPerUnit())
+    }, []);
+
+    useEffect(() => {
+        let countdownInterval = null;
+        if (disableButton && countdown > 0) {
+            countdownInterval = setInterval(() => {
+                setCountdown(countdown - 1);
+            }, 1000);
+        } else if (countdown === 0) {
+            setDisableButton(false);
+            setCountdown(30);
+        }
+        return () => clearInterval(countdownInterval); // Clean up on unmount
+    }, [disableButton, countdown]);
+
+    const handleInputChange = (name, value) => {
+        setInputs((inputs) => ({...inputs, [name]: value}));
+    };
+
+    const generateAccessToken = async () => {
+        const res = await API.get('/api/user/token');
+        const {success, message, data} = res.data;
+        if (success) {
+            setSystemToken(data);
+            await copy(data);
+            showSuccess(`令牌已重置并已复制到剪贴板`);
+        } else {
+            showError(message);
+        }
+    };
 
-  const handleInputChange = (e, { name, value }) => {
-    setInputs((inputs) => ({ ...inputs, [name]: value }));
-  };
+    const getAffLink = async () => {
+        const res = await API.get('/api/user/aff');
+        const {success, message, data} = res.data;
+        if (success) {
+            let link = `${window.location.origin}/register?aff=${data}`;
+            setAffLink(link);
+        } else {
+            showError(message);
+        }
+    };
 
-  const generateAccessToken = async () => {
-    const res = await API.get('/api/user/token');
-    const { success, message, data } = res.data;
-    if (success) {
-      setSystemToken(data);
-      setAffLink(""); 
-      await copy(data);
-      showSuccess(`令牌已重置并已复制到剪贴板`);
-    } else {
-      showError(message);
+    const getUserData = async () => {
+        let res = await API.get(`/api/user/self`);
+        const {success, message, data} = res.data;
+        if (success) {
+            userDispatch({type: 'login', payload: data});
+        } else {
+            showError(message);
+        }
     }
-  };
 
-  const getAffLink = async () => {
-    const res = await API.get('/api/user/aff');
-    const { success, message, data } = res.data;
-    if (success) {
-      let link = `${window.location.origin}/register?aff=${data}`;
-      setAffLink(link);
-      setSystemToken("");
-      await copy(link);
-      showSuccess(`邀请链接已复制到剪切板`);
-    } else {
-      showError(message);
+    const loadModels = async () => {
+        let res = await API.get(`/api/user/models`);
+        const {success, message, data} = res.data;
+        if (success) {
+            setModels(data);
+            console.log(data)
+        } else {
+            showError(message);
+        }
     }
-  };
 
-  const handleAffLinkClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(`邀请链接已复制到剪切板`);
-  };
+    const handleAffLinkClick = async (e) => {
+        e.target.select();
+        await copy(e.target.value);
+        showSuccess(`邀请链接已复制到剪切板`);
+    };
 
-  const handleSystemTokenClick = async (e) => {
-    e.target.select();
-    await copy(e.target.value);
-    showSuccess(`系统令牌已复制到剪切板`);
-  };
+    const handleSystemTokenClick = async (e) => {
+        e.target.select();
+        await copy(e.target.value);
+        showSuccess(`系统令牌已复制到剪切板`);
+    };
 
-  const deleteAccount = async () => {
-    if (inputs.self_account_deletion_confirmation !== userState.user.username) {
-      showError('请输入你的账户名以确认删除!');
-      return;
-    }
+    const deleteAccount = async () => {
+        if (inputs.self_account_deletion_confirmation !== userState.user.username) {
+            showError('请输入你的账户名以确认删除!');
+            return;
+        }
 
-    const res = await API.delete('/api/user/self');
-    const { success, message } = res.data;
+        const res = await API.delete('/api/user/self');
+        const {success, message} = res.data;
 
-    if (success) {
-      showSuccess('账户已删除!');
-      await API.get('/api/user/logout');
-      userDispatch({ type: 'logout' });
-      localStorage.removeItem('user');
-      navigate('/login');
-    } else {
-      showError(message);
-    }
-  };
+        if (success) {
+            showSuccess('账户已删除!');
+            await API.get('/api/user/logout');
+            userDispatch({type: 'logout'});
+            localStorage.removeItem('user');
+            navigate('/login');
+        } else {
+            showError(message);
+        }
+    };
 
-  const bindWeChat = async () => {
-    if (inputs.wechat_verification_code === '') return;
-    const res = await API.get(
-      `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('微信账户绑定成功!');
-      setShowWeChatBindModal(false);
-    } else {
-      showError(message);
-    }
-  };
+    const bindWeChat = async () => {
+        if (inputs.wechat_verification_code === '') return;
+        const res = await API.get(
+            `/api/oauth/wechat/bind?code=${inputs.wechat_verification_code}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('微信账户绑定成功!');
+            setShowWeChatBindModal(false);
+        } else {
+            showError(message);
+        }
+    };
 
-  const sendVerificationCode = async () => {
-    setDisableButton(true);
-    if (inputs.email === '') return;
-    if (turnstileEnabled && turnstileToken === '') {
-      showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
-      return;
-    }
-    setLoading(true);
-    const res = await API.get(
-      `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('验证码发送成功,请检查邮箱!');
-    } else {
-      showError(message);
+    const transfer = async () => {
+        if (transferAmount < getQuotaPerUnit()) {
+            showError('划转金额最低为' + renderQuota(getQuotaPerUnit()));
+            return;
+        }
+        const res = await API.post(
+            `/api/user/aff_transfer`,
+            {
+                quota: transferAmount
+            }
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess(message);
+            setOpenTransfer(false);
+            getUserData().then();
+        } else {
+            showError(message);
+        }
+    };
+
+    const sendVerificationCode = async () => {
+        if (inputs.email === '') {
+            showError('请输入邮箱!');
+            return;
+        }
+        setDisableButton(true);
+        if (turnstileEnabled && turnstileToken === '') {
+            showInfo('请稍后几秒重试,Turnstile 正在检查用户环境!');
+            return;
+        }
+        setLoading(true);
+        const res = await API.get(
+            `/api/verification?email=${inputs.email}&turnstile=${turnstileToken}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('验证码发送成功,请检查邮箱!');
+        } else {
+            showError(message);
+        }
+        setLoading(false);
+    };
+
+    const bindEmail = async () => {
+        if (inputs.email_verification_code === '') {
+            showError('请输入邮箱验证码!');
+            return;
+        }
+        setLoading(true);
+        const res = await API.get(
+            `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
+        );
+        const {success, message} = res.data;
+        if (success) {
+            showSuccess('邮箱账户绑定成功!');
+            setShowEmailBindModal(false);
+            userState.user.email = inputs.email;
+        } else {
+            showError(message);
+        }
+        setLoading(false);
+    };
+
+    const getUsername = () => {
+        if (userState.user) {
+            return userState.user.username;
+        } else {
+            return 'null';
+        }
     }
-    setLoading(false);
-  };
 
-  const bindEmail = async () => {
-    if (inputs.email_verification_code === '') return;
-    setLoading(true);
-    const res = await API.get(
-      `/api/oauth/email/bind?email=${inputs.email}&code=${inputs.email_verification_code}`
-    );
-    const { success, message } = res.data;
-    if (success) {
-      showSuccess('邮箱账户绑定成功!');
-      setShowEmailBindModal(false);
-    } else {
-      showError(message);
+    const handleCancel = () => {
+        setOpenTransfer(false);
     }
-    setLoading(false);
-  };
 
-  return (
-    <div style={{ lineHeight: '40px' }}>
-      <Header as='h3'>通用设置</Header>
-      <Message>
-        注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。
-      </Message>
-      <Button as={Link} to={`/user/edit/`}>
-        更新个人信息
-      </Button>
-      <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
-      <Button onClick={getAffLink}>复制邀请链接</Button>
-      <Button onClick={() => {
-        setShowAccountDeleteModal(true);
-      }}>删除个人账户</Button>
-      
-      {systemToken && (
-        <Form.Input 
-          fluid 
-          readOnly 
-          value={systemToken} 
-          onClick={handleSystemTokenClick}
-          style={{ marginTop: '10px' }}
-        />
-      )}
-      {affLink && (
-        <Form.Input 
-          fluid 
-          readOnly 
-          value={affLink} 
-          onClick={handleAffLinkClick}
-          style={{ marginTop: '10px' }}
-        />
-      )}
-      <Divider />
-      <Header as='h3'>账号绑定</Header>
-      {
-        status.wechat_login && (
-          <Button
-            onClick={() => {
-              setShowWeChatBindModal(true);
-            }}
-          >
-            绑定微信账号
-          </Button>
-        )
-      }
-      <Modal
-        onClose={() => setShowWeChatBindModal(false)}
-        onOpen={() => setShowWeChatBindModal(true)}
-        open={showWeChatBindModal}
-        size={'mini'}
-      >
-        <Modal.Content>
-          <Modal.Description>
-            <Image src={status.wechat_qrcode} fluid />
-            <div style={{ textAlign: 'center' }}>
-              <p>
-                微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
-              </p>
-            </div>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder='验证码'
-                name='wechat_verification_code'
-                value={inputs.wechat_verification_code}
-                onChange={handleInputChange}
-              />
-              <Button color='' fluid size='large' onClick={bindWeChat}>
-                绑定
-              </Button>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-      {
-        status.github_oauth && (
-          <Button onClick={()=>{onGitHubOAuthClicked(status.github_client_id)}}>绑定 GitHub 账号</Button>
-        )
-      }
-      <Button
-        onClick={() => {
-          setShowEmailBindModal(true);
-        }}
-      >
-        绑定邮箱地址
-      </Button>
-      <Modal
-        onClose={() => setShowEmailBindModal(false)}
-        onOpen={() => setShowEmailBindModal(true)}
-        open={showEmailBindModal}
-        size={'tiny'}
-        style={{ maxWidth: '450px' }}
-      >
-        <Modal.Header>绑定邮箱地址</Modal.Header>
-        <Modal.Content>
-          <Modal.Description>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder='输入邮箱地址'
-                onChange={handleInputChange}
-                name='email'
-                type='email'
-                action={
-                  <Button onClick={sendVerificationCode} disabled={disableButton || loading}>
-                    {disableButton ? `重新发送(${countdown})` : '获取验证码'}
-                  </Button>
-                }
-              />
-              <Form.Input
-                fluid
-                placeholder='验证码'
-                name='email_verification_code'
-                value={inputs.email_verification_code}
-                onChange={handleInputChange}
-              />
-              {turnstileEnabled ? (
-                <Turnstile
-                  sitekey={turnstileSiteKey}
-                  onVerify={(token) => {
-                    setTurnstileToken(token);
-                  }}
-                />
-              ) : (
-                <></>
-              )}
-              <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
-              <Button
-                color=''
-                fluid
-                size='large'
-                onClick={bindEmail}
-                loading={loading}
-              >
-                确认绑定
-              </Button>
-              <div style={{ width: '1rem' }}></div> 
-              <Button
-                fluid
-                size='large'
-                onClick={() => setShowEmailBindModal(false)}
-              >
-                取消
-              </Button>
-              </div>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-      <Modal
-        onClose={() => setShowAccountDeleteModal(false)}
-        onOpen={() => setShowAccountDeleteModal(true)}
-        open={showAccountDeleteModal}
-        size={'tiny'}
-        style={{ maxWidth: '450px' }}
-      >
-        <Modal.Header>危险操作</Modal.Header>
-        <Modal.Content>
-        <Message>您正在删除自己的帐户,将清空所有数据且不可恢复</Message>
-          <Modal.Description>
-            <Form size='large'>
-              <Form.Input
-                fluid
-                placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
-                name='self_account_deletion_confirmation'
-                value={inputs.self_account_deletion_confirmation}
-                onChange={handleInputChange}
-              />
-              {turnstileEnabled ? (
-                <Turnstile
-                  sitekey={turnstileSiteKey}
-                  onVerify={(token) => {
-                    setTurnstileToken(token);
-                  }}
-                />
-              ) : (
-                <></>
-              )}
-              <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
-                <Button
-                  color='red'
-                  fluid
-                  size='large'
-                  onClick={deleteAccount}
-                  loading={loading}
-                >
-                  确认删除
-                </Button>
-                <div style={{ width: '1rem' }}></div>
-                <Button
-                  fluid
-                  size='large'
-                  onClick={() => setShowAccountDeleteModal(false)}
-                >
-                  取消
-                </Button>
-              </div>
-            </Form>
-          </Modal.Description>
-        </Modal.Content>
-      </Modal>
-    </div>
-  );
+    return (
+        <div style={{lineHeight: '40px'}}>
+            <Layout>
+                <Layout.Content>
+                    <Modal
+                        title="请输入要划转的数量"
+                        visible={openTransfer}
+                        onOk={transfer}
+                        onCancel={handleCancel}
+                        maskClosable={false}
+                        size={'small'}
+                        centered={true}
+                    >
+                        <div style={{marginTop: 20}}>
+                            <Typography.Text>{`可用额度${renderQuotaWithPrompt(userState?.user?.aff_quota)}`}</Typography.Text>
+                            <Input style={{marginTop: 5}} value={userState?.user?.aff_quota} disabled={true}></Input>
+                        </div>
+                        <div style={{marginTop: 20}}>
+                            <Typography.Text>{`划转额度${renderQuotaWithPrompt(transferAmount)} 最低` + renderQuota(getQuotaPerUnit())}</Typography.Text>
+                            <div>
+                                <InputNumber min={0} style={{marginTop: 5}} value={transferAmount} onChange={(value)=>setTransferAmount(value)} disabled={false}></InputNumber>
+                            </div>
+                        </div>
+                    </Modal>
+                    <div style={{marginTop: 20}}>
+                        <Card
+                            title={
+                                <Card.Meta
+                                    avatar={<Avatar size="default" color={stringToColor(getUsername())}
+                                                    style={{marginRight: 4}}>
+                                        {typeof getUsername() === 'string' && getUsername().slice(0, 1)}
+                                    </Avatar>}
+                                    title={<Typography.Text>{getUsername()}</Typography.Text>}
+                                    description={isRoot()?<Tag color="red">管理员</Tag>:<Tag color="blue">普通用户</Tag>}
+                                ></Card.Meta>
+                            }
+                            headerExtraContent={
+                                <>
+                                    <Space vertical align="start">
+                                        <Tag color="green">{'ID: ' + userState?.user?.id}</Tag>
+                                        <Tag color="blue">{userState?.user?.group}</Tag>
+                                    </Space>
+                                </>
+                            }
+                            footer={
+                                <Descriptions row>
+                                    <Descriptions.Item itemKey="当前余额">{renderQuota(userState?.user?.quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="历史消耗">{renderQuota(userState?.user?.used_quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="请求次数">{userState.user?.request_count}</Descriptions.Item>
+                                </Descriptions>
+                            }
+                        >
+                            <Typography.Title heading={6}>可用模型</Typography.Title>
+                            <div style={{marginTop: 10}}>
+                                <Space wrap>
+                                    {models.map((model) => (
+                                        <Tag key={model} color="cyan">
+                                            {model}
+                                        </Tag>
+                                    ))}
+                                </Space>
+                            </div>
+
+                        </Card>
+                        <Card
+                            footer={
+                                <div>
+                                    <Typography.Text>邀请链接</Typography.Text>
+                                    <Input
+                                        style={{marginTop: 10}}
+                                        value={affLink}
+                                        onClick={handleAffLinkClick}
+                                        readOnly
+                                    />
+                                </div>
+                            }
+                        >
+                            <Typography.Title heading={6}>邀请信息</Typography.Title>
+                            <div style={{marginTop: 10}}>
+                                <Descriptions row>
+                                    <Descriptions.Item itemKey="待使用收益">
+                                        <span style={{color: 'rgba(var(--semi-red-5), 1)'}}>
+                                            {
+                                                renderQuota(userState?.user?.aff_quota)
+                                            }
+                                        </span>
+                                        <Button type={'secondary'} onClick={()=>setOpenTransfer(true)} size={'small'} style={{marginLeft: 10}}>划转</Button>
+                                    </Descriptions.Item>
+                                    <Descriptions.Item itemKey="总收益">{renderQuota(userState?.user?.aff_history_quota)}</Descriptions.Item>
+                                    <Descriptions.Item itemKey="邀请人数">{userState?.user?.aff_count}</Descriptions.Item>
+                                </Descriptions>
+                            </div>
+                        </Card>
+                        <Card>
+                            <Typography.Title heading={6}>个人信息</Typography.Title>
+                            <div style={{marginTop: 20}}>
+                                <Typography.Text strong>邮箱</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.email !== ''?userState.user.email:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button onClick={()=>{setShowEmailBindModal(true)}} disabled={userState.user && userState.user.email !== ''}>绑定邮箱</Button>
+                                    </div>
+                                </div>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>微信</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.wechat_id !== ''?'已绑定':'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button disabled={(userState.user && userState.user.wechat_id !== '') || !status.wechat_login}>
+                                            {
+                                                status.wechat_login?'绑定':'未启用'
+                                            }
+                                        </Button>
+                                    </div>
+                                </div>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Typography.Text strong>GitHub</Typography.Text>
+                                <div style={{display: 'flex', justifyContent: 'space-between'}}>
+                                    <div>
+                                        <Input
+                                            value={userState.user && userState.user.github_id !== ''?userState.user.github_id:'未绑定'}
+                                            readonly={true}
+                                        ></Input>
+                                    </div>
+                                    <div>
+                                        <Button
+                                            onClick={() => {onGitHubOAuthClicked(status.github_client_id)}}
+                                            disabled={(userState.user && userState.user.github_id !== '') || !status.github_oauth}
+                                        >
+                                            {
+                                                status.github_oauth?'绑定':'未启用'
+                                            }
+                                        </Button>
+                                    </div>
+                                </div>
+                            </div>
+
+                            <div style={{marginTop: 10}}>
+                                <Space>
+                                    <Button onClick={generateAccessToken}>生成系统访问令牌</Button>
+                                    <Button onClick={() => {
+                                        setShowAccountDeleteModal(true);
+                                    }}>删除个人账户</Button>
+                                </Space>
+
+                                {systemToken && (
+                                    <Form.Input
+                                        fluid
+                                        readOnly
+                                        value={systemToken}
+                                        onClick={handleSystemTokenClick}
+                                        style={{marginTop: '10px'}}
+                                    />
+                                )}
+                                {
+                                    status.wechat_login && (
+                                        <Button
+                                            onClick={() => {
+                                                setShowWeChatBindModal(true);
+                                            }}
+                                        >
+                                            绑定微信账号
+                                        </Button>
+                                    )
+                                }
+                                <Modal
+                                    onCancel={() => setShowWeChatBindModal(false)}
+                                    // onOpen={() => setShowWeChatBindModal(true)}
+                                    visible={showWeChatBindModal}
+                                    size={'mini'}
+                                >
+                                    <Image src={status.wechat_qrcode} fluid/>
+                                    <div style={{textAlign: 'center'}}>
+                                        <p>
+                                            微信扫码关注公众号,输入「验证码」获取验证码(三分钟内有效)
+                                        </p>
+                                    </div>
+                                    <Form size='large'>
+                                        <Form.Input
+                                            fluid
+                                            placeholder='验证码'
+                                            name='wechat_verification_code'
+                                            value={inputs.wechat_verification_code}
+                                            onChange={handleInputChange}
+                                        />
+                                        <Button color='' fluid size='large' onClick={bindWeChat}>
+                                            绑定
+                                        </Button>
+                                    </Form>
+                                </Modal>
+                            </div>
+                        </Card>
+                        <Modal
+                            onCancel={() => setShowEmailBindModal(false)}
+                            // onOpen={() => setShowEmailBindModal(true)}
+                            onOk={bindEmail}
+                            visible={showEmailBindModal}
+                            size={'small'}
+                            centered={true}
+                            maskClosable={false}
+                        >
+                            <Typography.Title heading={6}>绑定邮箱地址</Typography.Title>
+                            <div style={{marginTop: 20, display: 'flex', justifyContent: 'space-between'}}>
+                                <Input
+                                    fluid
+                                    placeholder='输入邮箱地址'
+                                    onChange={(value)=>handleInputChange('email', value)}
+                                    name='email'
+                                    type='email'
+                                />
+                                <Button onClick={sendVerificationCode}
+                                        disabled={disableButton || loading}>
+                                    {disableButton ? `重新发送(${countdown})` : '获取验证码'}
+                                </Button>
+                            </div>
+                            <div style={{marginTop: 10}}>
+                                <Input
+                                    fluid
+                                    placeholder='验证码'
+                                    name='email_verification_code'
+                                    value={inputs.email_verification_code}
+                                    onChange={(value)=>handleInputChange('email_verification_code', value)}
+                                />
+                            </div>
+                            {turnstileEnabled ? (
+                                <Turnstile
+                                    sitekey={turnstileSiteKey}
+                                    onVerify={(token) => {
+                                        setTurnstileToken(token);
+                                    }}
+                                />
+                            ) : (
+                                <></>
+                            )}
+                        </Modal>
+                        <Modal
+                            onCancel={() => setShowAccountDeleteModal(false)}
+                            visible={showAccountDeleteModal}
+                            size={'small'}
+                            centered={true}
+                            onOk={deleteAccount}
+                        >
+                            <div style={{marginTop: 20}}>
+                                <Banner
+                                    type="danger"
+                                    description="您正在删除自己的帐户,将清空所有数据且不可恢复"
+                                    closeIcon={null}
+                                />
+                            </div>
+                            <div style={{marginTop: 20}}>
+                                <Input
+                                    placeholder={`输入你的账户名 ${userState?.user?.username} 以确认删除`}
+                                    name='self_account_deletion_confirmation'
+                                    value={inputs.self_account_deletion_confirmation}
+                                    onChange={(value)=>handleInputChange('self_account_deletion_confirmation', value)}
+                                />
+                                {turnstileEnabled ? (
+                                    <Turnstile
+                                        sitekey={turnstileSiteKey}
+                                        onVerify={(token) => {
+                                            setTurnstileToken(token);
+                                        }}
+                                    />
+                                ) : (
+                                    <></>
+                                )}
+                            </div>
+                        </Modal>
+                    </div>
+
+                </Layout.Content>
+            </Layout>
+        </div>
+    );
 };
 
 export default PersonalSetting;

+ 6 - 0
web/src/helpers/render.js

@@ -37,6 +37,12 @@ export function renderNumber(num) {
   }
 }
 
+export function getQuotaPerUnit() {
+  let quotaPerUnit = localStorage.getItem('quota_per_unit');
+  quotaPerUnit = parseFloat(quotaPerUnit);
+  return quotaPerUnit;
+}
+
 export function renderQuota(quota, digits = 2) {
   let quotaPerUnit = localStorage.getItem('quota_per_unit');
   let displayInCurrency = localStorage.getItem('display_in_currency');

+ 41 - 43
web/src/pages/Setting/index.js

@@ -1,55 +1,53 @@
 import React from 'react';
-import { Segment, Tab } from 'semantic-ui-react';
 import SystemSetting from '../../components/SystemSetting';
-import { isRoot } from '../../helpers';
+import {isRoot} from '../../helpers';
 import OtherSetting from '../../components/OtherSetting';
 import PersonalSetting from '../../components/PersonalSetting';
 import OperationSetting from '../../components/OperationSetting';
+import {Layout, TabPane, Tabs} from "@douyinfe/semi-ui";
 
 const Setting = () => {
-  let panes = [
-    {
-      menuItem: '个人设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <PersonalSetting />
-        </Tab.Pane>
-      )
-    }
-  ];
+    let panes = [
+        {
+            tab: '个人设置',
+            content: <PersonalSetting/>,
+            itemKey: '1'
+        }
+    ];
 
-  if (isRoot()) {
-    panes.push({
-      menuItem: '运营设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <OperationSetting />
-        </Tab.Pane>
-      )
-    });
-    panes.push({
-      menuItem: '系统设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <SystemSetting />
-        </Tab.Pane>
-      )
-    });
-    panes.push({
-      menuItem: '其他设置',
-      render: () => (
-        <Tab.Pane attached={false}>
-          <OtherSetting />
-        </Tab.Pane>
-      )
-    });
-  }
+    if (isRoot()) {
+        panes.push({
+            tab: '运营设置',
+            content: <OperationSetting/>,
+            itemKey: '2'
+        });
+        panes.push({
+            tab: '系统设置',
+            content: <SystemSetting/>,
+            itemKey: '3'
+        });
+        panes.push({
+            tab: '其他设置',
+            content: <OtherSetting/>,
+            itemKey: '4'
+        });
+    }
 
-  return (
-    <Segment>
-      <Tab menu={{ secondary: true, pointing: true }} panes={panes} />
-    </Segment>
-  );
+    return (
+        <div>
+            <Layout>
+                <Layout.Content>
+                    <Tabs type="line" defaultActiveKey="1">
+                        {panes.map(pane => (
+                            <TabPane itemKey={pane.itemKey} tab={pane.tab}>
+                                {pane.content}
+                            </TabPane>
+                        ))}
+                    </Tabs>
+                </Layout.Content>
+            </Layout>
+        </div>
+    );
 };
 
 export default Setting;

+ 98 - 98
web/src/pages/User/EditUser.js

@@ -20,7 +20,7 @@ const EditUser = () => {
   });
   const [groupOptions, setGroupOptions] = useState([]);
   const { username, display_name, password, github_id, wechat_id, email, quota, group } =
-    inputs;
+      inputs;
   const handleInputChange = (e, { name, value }) => {
     setInputs((inputs) => ({ ...inputs, [name]: value }));
   };
@@ -83,107 +83,107 @@ const EditUser = () => {
   };
 
   return (
-    <>
-      <Segment loading={loading}>
-        <Header as='h3'>更新用户信息</Header>
-        <Form autoComplete='new-password'>
-          <Form.Field>
-            <Form.Input
-              label='用户名'
-              name='username'
-              placeholder={'请输入新的用户名'}
-              onChange={handleInputChange}
-              value={username}
-              autoComplete='new-password'
-            />
-          </Form.Field>
-          <Form.Field>
-            <Form.Input
-              label='密码'
-              name='password'
-              type={'password'}
-              placeholder={'请输入新的密码,最短 8 位'}
-              onChange={handleInputChange}
-              value={password}
-              autoComplete='new-password'
-            />
-          </Form.Field>
-          <Form.Field>
-            <Form.Input
-              label='显示名称'
-              name='display_name'
-              placeholder={'请输入新的显示名称'}
-              onChange={handleInputChange}
-              value={display_name}
-              autoComplete='new-password'
-            />
-          </Form.Field>
-          {
-            userId && <>
-              <Form.Field>
-                <Form.Dropdown
-                  label='分组'
-                  placeholder={'请选择分组'}
-                  name='group'
-                  fluid
-                  search
-                  selection
-                  allowAdditions
-                  additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+      <>
+        <Segment loading={loading}>
+          <Header as='h3'>更新用户信息</Header>
+          <Form autoComplete='new-password'>
+            <Form.Field>
+              <Form.Input
+                  label='用户名'
+                  name='username'
+                  placeholder={'请输入新的用户名'}
                   onChange={handleInputChange}
-                  value={inputs.group}
+                  value={username}
                   autoComplete='new-password'
-                  options={groupOptions}
-                />
-              </Form.Field>
-              <Form.Field>
-                <Form.Input
-                  label={`剩余额度${renderQuotaWithPrompt(quota)}`}
-                  name='quota'
-                  placeholder={'请输入新的剩余额度'}
+              />
+            </Form.Field>
+            <Form.Field>
+              <Form.Input
+                  label='密码'
+                  name='password'
+                  type={'password'}
+                  placeholder={'请输入新的密码,最短 8 位'}
                   onChange={handleInputChange}
-                  value={quota}
-                  type={'number'}
+                  value={password}
                   autoComplete='new-password'
-                />
-              </Form.Field>
-            </>
-          }
-          <Form.Field>
-            <Form.Input
-              label='已绑定的 GitHub 账户'
-              name='github_id'
-              value={github_id}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
-            />
-          </Form.Field>
-          <Form.Field>
-            <Form.Input
-              label='已绑定的微信账户'
-              name='wechat_id'
-              value={wechat_id}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
-            />
-          </Form.Field>
-          <Form.Field>
-            <Form.Input
-              label='已绑定的邮箱账户'
-              name='email'
-              value={email}
-              autoComplete='new-password'
-              placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
-              readOnly
-            />
-          </Form.Field>
-          <Button onClick={handleCancel}>取消</Button>
-          <Button positive onClick={submit}>提交</Button>
-        </Form>
-      </Segment>
-    </>
+              />
+            </Form.Field>
+            <Form.Field>
+              <Form.Input
+                  label='显示名称'
+                  name='display_name'
+                  placeholder={'请输入新的显示名称'}
+                  onChange={handleInputChange}
+                  value={display_name}
+                  autoComplete='new-password'
+              />
+            </Form.Field>
+            {
+                userId && <>
+                  <Form.Field>
+                    <Form.Dropdown
+                        label='分组'
+                        placeholder={'请选择分组'}
+                        name='group'
+                        fluid
+                        search
+                        selection
+                        allowAdditions
+                        additionLabel={'请在系统设置页面编辑分组倍率以添加新的分组:'}
+                        onChange={handleInputChange}
+                        value={inputs.group}
+                        autoComplete='new-password'
+                        options={groupOptions}
+                    />
+                  </Form.Field>
+                  <Form.Field>
+                    <Form.Input
+                        label={`剩余额度${renderQuotaWithPrompt(quota)}`}
+                        name='quota'
+                        placeholder={'请输入新的剩余额度'}
+                        onChange={handleInputChange}
+                        value={quota}
+                        type={'number'}
+                        autoComplete='new-password'
+                    />
+                  </Form.Field>
+                </>
+            }
+            <Form.Field>
+              <Form.Input
+                  label='已绑定的 GitHub 账户'
+                  name='github_id'
+                  value={github_id}
+                  autoComplete='new-password'
+                  placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                  readOnly
+              />
+            </Form.Field>
+            <Form.Field>
+              <Form.Input
+                  label='已绑定的微信账户'
+                  name='wechat_id'
+                  value={wechat_id}
+                  autoComplete='new-password'
+                  placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                  readOnly
+              />
+            </Form.Field>
+            <Form.Field>
+              <Form.Input
+                  label='已绑定的邮箱账户'
+                  name='email'
+                  value={email}
+                  autoComplete='new-password'
+                  placeholder='此项只读,需要用户通过个人设置页面的相关绑定按钮进行绑定,不可直接修改'
+                  readOnly
+              />
+            </Form.Field>
+            <Button onClick={handleCancel}>取消</Button>
+            <Button positive onClick={submit}>提交</Button>
+          </Form>
+        </Segment>
+      </>
   );
 };