mirror of
https://github.com/songquanpeng/one-api.git
synced 2025-10-23 01:43:42 +08:00
Compare commits
14 Commits
v0.1.3
...
v0.1.6-alp
Author | SHA1 | Date | |
---|---|---|---|
|
5bfc224669 | ||
|
fd149c242f | ||
|
b9cc5dfa3f | ||
|
8c305dc1bc | ||
|
f62a671fbe | ||
|
9e2f2383b9 | ||
|
e7a809b082 | ||
|
4f8cbd643d | ||
|
1dd92a3f92 | ||
|
34a3329f5f | ||
|
4fb07b6d6d | ||
|
8be7c9ae80 | ||
|
4e8dc8d0cf | ||
|
1e46b9d135 |
29
.github/workflows/github-pages.yml
vendored
29
.github/workflows/github-pages.yml
vendored
@@ -1,29 +0,0 @@
|
||||
name: Build GitHub Pages
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
name:
|
||||
description: 'Reason'
|
||||
required: false
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout 🛎️
|
||||
uses: actions/checkout@v2 # If you're using actions/checkout@v2 you must set persist-credentials to false in most cases for the deployment to work correctly.
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
|
||||
env:
|
||||
CI: ""
|
||||
run: |
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Deploy 🚀
|
||||
uses: JamesIves/github-pages-deploy-action@releases/v3
|
||||
with:
|
||||
ACCESS_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
BRANCH: gh-pages # The branch the action should deploy to.
|
||||
FOLDER: web/build # The folder the action should deploy.
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,4 +3,5 @@
|
||||
upload
|
||||
*.exe
|
||||
*.db
|
||||
build
|
||||
build
|
||||
*.db-journal
|
@@ -24,7 +24,7 @@ RUN apk update \
|
||||
&& apk upgrade \
|
||||
&& apk add --no-cache ca-certificates tzdata \
|
||||
&& update-ca-certificates 2>/dev/null || true
|
||||
ENV PORT=3000
|
||||
|
||||
COPY --from=builder2 /build/one-api /
|
||||
EXPOSE 3000
|
||||
WORKDIR /data
|
||||
|
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 JustSong
|
||||
Copyright (c) 2023 JustSong
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@@ -87,7 +87,7 @@ server{
|
||||
}
|
||||
```
|
||||
|
||||
注意,为了 SSE 正常工作,需要关闭 Nginx 的代理缓存和缓冲。
|
||||
注意,为了 SSE 正常工作,需要关闭 Nginx 的代理缓冲。
|
||||
|
||||
之后使用 Let's Encrypt 的 certbot 配置 HTTPS:
|
||||
```bash
|
||||
@@ -138,6 +138,7 @@ sudo service nginx restart
|
||||
之后就可以使用你的令牌访问 One API 了,使用方式与 [OpenAI API](https://platform.openai.com/docs/api-reference/introduction) 一致。
|
||||
|
||||
可以通过在令牌后面添加渠道 ID 的方式指定使用哪一个渠道处理本次请求,例如:`Authorization: Bearer ONE_API_KEY-CHANNEL_ID`。
|
||||
注意,需要是管理员用户创建的令牌才能指定渠道 ID。
|
||||
|
||||
不加的话将会使用负载均衡的方式使用多个渠道。
|
||||
|
||||
|
@@ -46,6 +46,8 @@ var WeChatAccountQRCodeImageURL = ""
|
||||
var TurnstileSiteKey = ""
|
||||
var TurnstileSecretKey = ""
|
||||
|
||||
var QuotaForNewUser = 100
|
||||
|
||||
const (
|
||||
RoleGuestUser = 0
|
||||
RoleCommonUser = 1
|
||||
@@ -63,7 +65,7 @@ var (
|
||||
// All duration's unit is seconds
|
||||
// Shouldn't larger then RateLimitKeyExpirationDuration
|
||||
var (
|
||||
GlobalApiRateLimitNum = 60000 // TODO: temporary set to 60000
|
||||
GlobalApiRateLimitNum = 180
|
||||
GlobalApiRateLimitDuration int64 = 3 * 60
|
||||
|
||||
GlobalWebRateLimitNum = 60
|
||||
@@ -93,6 +95,12 @@ const (
|
||||
TokenStatusExhausted = 4
|
||||
)
|
||||
|
||||
const (
|
||||
RedemptionCodeStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
RedemptionCodeStatusDisabled = 2 // also don't use 0
|
||||
RedemptionCodeStatusUsed = 3 // also don't use 0
|
||||
)
|
||||
|
||||
const (
|
||||
ChannelStatusUnknown = 0
|
||||
ChannelStatusEnabled = 1 // don't use 0, 0 is the default value!
|
||||
|
192
controller/redemption.go
Normal file
192
controller/redemption.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"net/http"
|
||||
"one-api/common"
|
||||
"one-api/model"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func GetAllRedemptions(c *gin.Context) {
|
||||
p, _ := strconv.Atoi(c.Query("p"))
|
||||
if p < 0 {
|
||||
p = 0
|
||||
}
|
||||
redemptions, err := model.GetAllRedemptions(p*common.ItemsPerPage, common.ItemsPerPage)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": redemptions,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func SearchRedemptions(c *gin.Context) {
|
||||
keyword := c.Query("keyword")
|
||||
redemptions, err := model.SearchRedemptions(keyword)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": redemptions,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetRedemption(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
redemption, err := model.GetRedemptionById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": redemption,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func AddRedemption(c *gin.Context) {
|
||||
redemption := model.Redemption{}
|
||||
err := c.ShouldBindJSON(&redemption)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(redemption.Name) == 0 || len(redemption.Name) > 20 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "兑换码名称长度必须在1-20之间",
|
||||
})
|
||||
return
|
||||
}
|
||||
if redemption.Count <= 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "兑换码个数必须大于0",
|
||||
})
|
||||
return
|
||||
}
|
||||
if redemption.Count > 100 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "一次兑换码批量生成的个数不能大于 100",
|
||||
})
|
||||
return
|
||||
}
|
||||
var keys []string
|
||||
for i := 0; i < redemption.Count; i++ {
|
||||
key := common.GetUUID()
|
||||
cleanRedemption := model.Redemption{
|
||||
UserId: c.GetInt("id"),
|
||||
Name: redemption.Name,
|
||||
Key: key,
|
||||
CreatedTime: common.GetTimestamp(),
|
||||
Quota: redemption.Quota,
|
||||
}
|
||||
err = cleanRedemption.Insert()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
"data": keys,
|
||||
})
|
||||
return
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": keys,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func DeleteRedemption(c *gin.Context) {
|
||||
id, _ := strconv.Atoi(c.Param("id"))
|
||||
err := model.DeleteRedemptionById(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func UpdateRedemption(c *gin.Context) {
|
||||
statusOnly := c.Query("status_only")
|
||||
redemption := model.Redemption{}
|
||||
err := c.ShouldBindJSON(&redemption)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
cleanRedemption, err := model.GetRedemptionById(redemption.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if statusOnly != "" {
|
||||
cleanRedemption.Status = redemption.Status
|
||||
} else {
|
||||
// If you add more fields, please also update redemption.Update()
|
||||
cleanRedemption.Name = redemption.Name
|
||||
cleanRedemption.Quota = redemption.Quota
|
||||
}
|
||||
err = cleanRedemption.Update()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": cleanRedemption,
|
||||
})
|
||||
return
|
||||
}
|
@@ -104,6 +104,19 @@ func AddToken(c *gin.Context) {
|
||||
if isAdmin {
|
||||
cleanToken.RemainTimes = token.RemainTimes
|
||||
cleanToken.UnlimitedTimes = token.UnlimitedTimes
|
||||
} else {
|
||||
userId := c.GetInt("id")
|
||||
quota, err := model.GetUserQuota(userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
if quota > 0 {
|
||||
cleanToken.RemainTimes = quota
|
||||
}
|
||||
}
|
||||
err = cleanToken.Insert()
|
||||
if err != nil {
|
||||
@@ -113,6 +126,10 @@ func AddToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if !isAdmin {
|
||||
// update user quota
|
||||
err = model.DecreaseUserQuota(c.GetInt("id"), cleanToken.RemainTimes)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -201,3 +218,34 @@ func UpdateToken(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
type topUpRequest struct {
|
||||
Id int `json:"id"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
func TopUp(c *gin.Context) {
|
||||
req := topUpRequest{}
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
quota, err := model.Redeem(req.Key, req.Id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": quota,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
@@ -243,6 +243,42 @@ func GetUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
func GenerateAccessToken(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
|
||||
}
|
||||
user.AccessToken = common.GetUUID()
|
||||
|
||||
if model.DB.Where("token = ?", user.AccessToken).First(user).RowsAffected != 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "请重试,系统生成的 UUID 竟然重复了!",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := user.Update(false); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
"data": user.AccessToken,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
func GetSelf(c *gin.Context) {
|
||||
id := c.GetInt("id")
|
||||
user, err := model.GetUserById(id, false)
|
||||
|
@@ -16,12 +16,31 @@ func authHelper(c *gin.Context, minRole int) {
|
||||
id := session.Get("id")
|
||||
status := session.Get("status")
|
||||
if username == nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
// Check access token
|
||||
accessToken := c.Request.Header.Get("Authorization")
|
||||
if accessToken == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,未登录且未提供 access token",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
user := model.ValidateAccessToken(accessToken)
|
||||
if user != nil && user.Username != "" {
|
||||
// Token is valid
|
||||
username = user.Username
|
||||
role = user.Role
|
||||
id = user.Id
|
||||
status = user.Status
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": false,
|
||||
"message": "无权进行此操作,access token 无效",
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
if status.(int) == common.UserStatusDisabled {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -83,7 +102,18 @@ func TokenAuth() func(c *gin.Context) {
|
||||
c.Set("token_id", token.Id)
|
||||
c.Set("unlimited_times", token.UnlimitedTimes)
|
||||
if len(parts) > 1 {
|
||||
c.Set("channelId", parts[1])
|
||||
if model.IsAdmin(token.UserId) {
|
||||
c.Set("channelId", parts[1])
|
||||
} else {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error": gin.H{
|
||||
"message": "普通用户不支持指定渠道",
|
||||
"type": "one_api_error",
|
||||
},
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
@@ -7,6 +7,9 @@ import (
|
||||
|
||||
func CORS() gin.HandlerFunc {
|
||||
config := cors.DefaultConfig()
|
||||
config.AllowOrigins = []string{"https://one-api.vercel.app", "http://localhost:3000/"}
|
||||
config.AllowAllOrigins = true
|
||||
config.AllowCredentials = true
|
||||
config.AllowMethods = []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}
|
||||
config.AllowHeaders = []string{"Origin", "Content-Length", "Content-Type", "Authorization", "Accept", "Connection"}
|
||||
return cors.New(config)
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ func createRootAccountIfNeed() error {
|
||||
Role: common.RoleRootUser,
|
||||
Status: common.UserStatusEnabled,
|
||||
DisplayName: "Root User",
|
||||
AccessToken: common.GetUUID(),
|
||||
}
|
||||
DB.Create(&rootUser)
|
||||
}
|
||||
@@ -69,6 +70,10 @@ func InitDB() (err error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&Redemption{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = createRootAccountIfNeed()
|
||||
return err
|
||||
} else {
|
||||
|
@@ -46,6 +46,7 @@ func InitOptionMap() {
|
||||
common.OptionMap["WeChatAccountQRCodeImageURL"] = ""
|
||||
common.OptionMap["TurnstileSiteKey"] = ""
|
||||
common.OptionMap["TurnstileSecretKey"] = ""
|
||||
common.OptionMap["QuotaForNewUser"] = strconv.Itoa(common.QuotaForNewUser)
|
||||
common.OptionMapRWMutex.Unlock()
|
||||
options, _ := AllOption()
|
||||
for _, option := range options {
|
||||
@@ -131,5 +132,7 @@ func updateOptionMap(key string, value string) {
|
||||
common.TurnstileSiteKey = value
|
||||
case "TurnstileSecretKey":
|
||||
common.TurnstileSecretKey = value
|
||||
case "QuotaForNewUser":
|
||||
common.QuotaForNewUser, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
107
model/redemption.go
Normal file
107
model/redemption.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"one-api/common"
|
||||
)
|
||||
|
||||
type Redemption struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Quota int `json:"quota" gorm:"default:100"`
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
RedeemedTime int64 `json:"redeemed_time" gorm:"bigint"`
|
||||
Count int `json:"count" gorm:"-:all"` // only for api request
|
||||
}
|
||||
|
||||
func GetAllRedemptions(startIdx int, num int) ([]*Redemption, error) {
|
||||
var redemptions []*Redemption
|
||||
var err error
|
||||
err = DB.Order("id desc").Limit(num).Offset(startIdx).Find(&redemptions).Error
|
||||
return redemptions, err
|
||||
}
|
||||
|
||||
func SearchRedemptions(keyword string) (redemptions []*Redemption, err error) {
|
||||
err = DB.Where("id = ? or name LIKE ?", keyword, keyword+"%").Find(&redemptions).Error
|
||||
return redemptions, err
|
||||
}
|
||||
|
||||
func GetRedemptionById(id int) (*Redemption, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New("id 为空!")
|
||||
}
|
||||
redemption := Redemption{Id: id}
|
||||
var err error = nil
|
||||
err = DB.First(&redemption, "id = ?", id).Error
|
||||
return &redemption, err
|
||||
}
|
||||
|
||||
func Redeem(key string, tokenId int) (quota int, err error) {
|
||||
if key == "" {
|
||||
return 0, errors.New("未提供兑换码")
|
||||
}
|
||||
if tokenId == 0 {
|
||||
return 0, errors.New("未提供 token id")
|
||||
}
|
||||
redemption := &Redemption{}
|
||||
err = DB.Where("key = ?", key).First(redemption).Error
|
||||
if err != nil {
|
||||
return 0, errors.New("无效的兑换码")
|
||||
}
|
||||
if redemption.Status != common.RedemptionCodeStatusEnabled {
|
||||
return 0, errors.New("该兑换码已被使用")
|
||||
}
|
||||
err = TopUpToken(tokenId, redemption.Quota)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
go func() {
|
||||
redemption.RedeemedTime = common.GetTimestamp()
|
||||
redemption.Status = common.RedemptionCodeStatusUsed
|
||||
err := redemption.SelectUpdate()
|
||||
if err != nil {
|
||||
common.SysError("更新兑换码状态失败:" + err.Error())
|
||||
}
|
||||
}()
|
||||
return redemption.Quota, nil
|
||||
}
|
||||
|
||||
func (redemption *Redemption) Insert() error {
|
||||
var err error
|
||||
err = DB.Create(redemption).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (redemption *Redemption) SelectUpdate() error {
|
||||
// This can update zero values
|
||||
return DB.Model(redemption).Select("redeemed_time", "status").Updates(redemption).Error
|
||||
}
|
||||
|
||||
// Update Make sure your token's fields is completed, because this will update non-zero values
|
||||
func (redemption *Redemption) Update() error {
|
||||
var err error
|
||||
err = DB.Model(redemption).Select("name", "status", "redeemed_time").Updates(redemption).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (redemption *Redemption) Delete() error {
|
||||
var err error
|
||||
err = DB.Delete(redemption).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteRedemptionById(id int) (err error) {
|
||||
if id == 0 {
|
||||
return errors.New("id 为空!")
|
||||
}
|
||||
redemption := Redemption{Id: id}
|
||||
err = DB.Where(redemption).First(&redemption).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return redemption.Delete()
|
||||
}
|
@@ -11,7 +11,7 @@ import (
|
||||
type Token struct {
|
||||
Id int `json:"id"`
|
||||
UserId int `json:"user_id"`
|
||||
Key string `json:"key" gorm:"uniqueIndex"`
|
||||
Key string `json:"key" gorm:"type:char(32);uniqueIndex"`
|
||||
Status int `json:"status" gorm:"default:1"`
|
||||
Name string `json:"name" gorm:"index" `
|
||||
CreatedTime int64 `json:"created_time" gorm:"bigint"`
|
||||
@@ -123,3 +123,8 @@ func DecreaseTokenRemainTimesById(id int) (err error) {
|
||||
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times - ?", 1)).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func TopUpToken(id int, times int) (err error) {
|
||||
err = DB.Model(&Token{}).Where("id = ?", id).Update("remain_times", gorm.Expr("remain_times + ?", times)).Error
|
||||
return err
|
||||
}
|
||||
|
@@ -2,7 +2,9 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"one-api/common"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// User if you add sensitive fields, don't forget to clean them in setupLogin function.
|
||||
@@ -19,6 +21,8 @@ type User struct {
|
||||
WeChatId string `json:"wechat_id" gorm:"column:wechat_id;index"`
|
||||
VerificationCode string `json:"verification_code" gorm:"-:all"` // this field is only for Email verification, don't save it to database!
|
||||
Balance int `json:"balance" gorm:"type:int;default:0"`
|
||||
AccessToken string `json:"access_token" gorm:"type:char(32);column:access_token;uniqueIndex"` // this token is for system management
|
||||
Quota int `json:"quota" gorm:"type:int;default:0"`
|
||||
}
|
||||
|
||||
func GetMaxUserId() int {
|
||||
@@ -67,6 +71,8 @@ func (user *User) Insert() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
user.Quota = common.QuotaForNewUser
|
||||
user.AccessToken = common.GetUUID()
|
||||
err = DB.Create(user).Error
|
||||
return err
|
||||
}
|
||||
@@ -175,3 +181,38 @@ func ResetUserPasswordByEmail(email string, password string) error {
|
||||
err = DB.Model(&User{}).Where("email = ?", email).Update("password", hashedPassword).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func IsAdmin(userId int) bool {
|
||||
if userId == 0 {
|
||||
return false
|
||||
}
|
||||
var user User
|
||||
err := DB.Where("id = ?", userId).Select("role").Find(&user).Error
|
||||
if err != nil {
|
||||
common.SysError("No such user " + err.Error())
|
||||
return false
|
||||
}
|
||||
return user.Role >= common.RoleAdminUser
|
||||
}
|
||||
|
||||
func ValidateAccessToken(token string) (user *User) {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
token = strings.Replace(token, "Bearer ", "", 1)
|
||||
user = &User{}
|
||||
if DB.Where("access_token = ?", token).First(user).RowsAffected == 1 {
|
||||
return user
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetUserQuota(id int) (quota int, err error) {
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Select("quota").Find("a).Error
|
||||
return quota, err
|
||||
}
|
||||
|
||||
func DecreaseUserQuota(id int, quota int) (err error) {
|
||||
err = DB.Model(&User{}).Where("id = ?", id).Update("quota", gorm.Expr("quota - ?", quota)).Error
|
||||
return err
|
||||
}
|
||||
|
@@ -35,6 +35,7 @@ func SetApiRouter(router *gin.Engine) {
|
||||
selfRoute.GET("/self", controller.GetSelf)
|
||||
selfRoute.PUT("/self", controller.UpdateSelf)
|
||||
selfRoute.DELETE("/self", controller.DeleteSelf)
|
||||
selfRoute.GET("/token", controller.GenerateAccessToken)
|
||||
}
|
||||
|
||||
adminRoute := userRoute.Group("/")
|
||||
@@ -70,10 +71,21 @@ func SetApiRouter(router *gin.Engine) {
|
||||
{
|
||||
tokenRoute.GET("/", controller.GetAllTokens)
|
||||
tokenRoute.GET("/search", controller.SearchTokens)
|
||||
tokenRoute.POST("/topup", controller.TopUp)
|
||||
tokenRoute.GET("/:id", controller.GetToken)
|
||||
tokenRoute.POST("/", controller.AddToken)
|
||||
tokenRoute.PUT("/", controller.UpdateToken)
|
||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||
}
|
||||
redemptionRoute := apiRouter.Group("/redemption")
|
||||
redemptionRoute.Use(middleware.AdminAuth())
|
||||
{
|
||||
redemptionRoute.GET("/", controller.GetAllRedemptions)
|
||||
redemptionRoute.GET("/search", controller.SearchRedemptions)
|
||||
redemptionRoute.GET("/:id", controller.GetRedemption)
|
||||
redemptionRoute.POST("/", controller.AddRedemption)
|
||||
redemptionRoute.PUT("/", controller.UpdateRedemption)
|
||||
redemptionRoute.DELETE("/:id", controller.DeleteRedemption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -8,12 +8,12 @@ import (
|
||||
|
||||
func SetRelayRouter(router *gin.Engine) {
|
||||
relayV1Router := router.Group("/v1")
|
||||
relayV1Router.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
|
||||
relayV1Router.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
relayV1Router.Any("/*path", controller.Relay)
|
||||
}
|
||||
relayDashboardRouter := router.Group("/dashboard")
|
||||
relayDashboardRouter.Use(middleware.GlobalAPIRateLimit(), middleware.TokenAuth(), middleware.Distribute())
|
||||
relayDashboardRouter.Use(middleware.TokenAuth(), middleware.Distribute())
|
||||
{
|
||||
relayDashboardRouter.Any("/*path", controller.Relay)
|
||||
}
|
||||
|
@@ -20,6 +20,8 @@ import Token from './pages/Token';
|
||||
import EditToken from './pages/Token/EditToken';
|
||||
import EditChannel from './pages/Channel/EditChannel';
|
||||
import AddChannel from './pages/Channel/AddChannel';
|
||||
import Redemption from './pages/Redemption';
|
||||
import EditRedemption from './pages/Redemption/EditRedemption';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const About = lazy(() => import('./pages/About'));
|
||||
@@ -119,6 +121,30 @@ function App() {
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption'
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Redemption />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption/edit/:id'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditRedemption />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/redemption/add'
|
||||
element={
|
||||
<Suspense fallback={<Loading></Loading>}>
|
||||
<EditRedemption />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path='/user'
|
||||
element={
|
||||
|
@@ -24,6 +24,12 @@ const headerButtons = [
|
||||
to: '/token',
|
||||
icon: 'key',
|
||||
},
|
||||
{
|
||||
name: '兑换',
|
||||
to: '/redemption',
|
||||
icon: 'dollar sign',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
name: '用户',
|
||||
to: '/user',
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Divider, Form, Header, Image, Modal } from 'semantic-ui-react';
|
||||
import { Button, Divider, Form, Header, Image, Message, Modal } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess } from '../helpers';
|
||||
import Turnstile from 'react-turnstile';
|
||||
@@ -34,6 +34,17 @@ const PersonalSetting = () => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const generateAccessToken = async () => {
|
||||
const res = await API.get('/api/user/token');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
await copy(data);
|
||||
showSuccess(`令牌已重置并已复制到剪贴板:${data}`);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const bindWeChat = async () => {
|
||||
if (inputs.wechat_verification_code === '') return;
|
||||
const res = await API.get(
|
||||
@@ -92,9 +103,13 @@ const PersonalSetting = () => {
|
||||
return (
|
||||
<div style={{ lineHeight: '40px' }}>
|
||||
<Header as='h3'>通用设置</Header>
|
||||
<Message>
|
||||
注意,此处生成的令牌用于系统管理,而非用于请求 OpenAI 相关的服务,请知悉。
|
||||
</Message>
|
||||
<Button as={Link} to={`/user/edit/`}>
|
||||
更新个人信息
|
||||
</Button>
|
||||
<Button onClick={generateAccessToken}>生成系统访问令牌</Button>
|
||||
<Divider />
|
||||
<Header as='h3'>账号绑定</Header>
|
||||
<Button
|
||||
|
303
web/src/components/RedemptionsTable.js
Normal file
303
web/src/components/RedemptionsTable.js
Normal file
@@ -0,0 +1,303 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
|
||||
function renderTimestamp(timestamp) {
|
||||
return (
|
||||
<>
|
||||
{timestamp2string(timestamp)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatus(status) {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return <Label basic color='green'>未使用</Label>;
|
||||
case 2:
|
||||
return <Label basic color='red'> 已禁用 </Label>;
|
||||
case 3:
|
||||
return <Label basic color='grey'> 已使用 </Label>;
|
||||
default:
|
||||
return <Label basic color='black'> 未知状态 </Label>;
|
||||
}
|
||||
}
|
||||
|
||||
const RedemptionsTable = () => {
|
||||
const [redemptions, setRedemptions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
|
||||
const loadRedemptions = async (startIdx) => {
|
||||
const res = await API.get(`/api/redemption/?p=${startIdx}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (startIdx === 0) {
|
||||
setRedemptions(data);
|
||||
} else {
|
||||
let newRedemptions = redemptions;
|
||||
newRedemptions.push(...data);
|
||||
setRedemptions(newRedemptions);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const onPaginationChange = (e, { activePage }) => {
|
||||
(async () => {
|
||||
if (activePage === Math.ceil(redemptions.length / ITEMS_PER_PAGE) + 1) {
|
||||
// In this case we have to load more data and then append them.
|
||||
await loadRedemptions(activePage - 1);
|
||||
}
|
||||
setActivePage(activePage);
|
||||
})();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRedemptions(0)
|
||||
.then()
|
||||
.catch((reason) => {
|
||||
showError(reason);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const manageRedemption = async (id, action, idx) => {
|
||||
let data = { id };
|
||||
let res;
|
||||
switch (action) {
|
||||
case 'delete':
|
||||
res = await API.delete(`/api/redemption/${id}/`);
|
||||
break;
|
||||
case 'enable':
|
||||
data.status = 1;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
case 'disable':
|
||||
data.status = 2;
|
||||
res = await API.put('/api/redemption/?status_only=true', data);
|
||||
break;
|
||||
}
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess('操作成功完成!');
|
||||
let redemption = res.data.data;
|
||||
let newRedemptions = [...redemptions];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + idx;
|
||||
if (action === 'delete') {
|
||||
newRedemptions[realIdx].deleted = true;
|
||||
} else {
|
||||
newRedemptions[realIdx].status = redemption.status;
|
||||
}
|
||||
setRedemptions(newRedemptions);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
};
|
||||
|
||||
const searchRedemptions = async () => {
|
||||
if (searchKeyword === '') {
|
||||
// if keyword is blank, load files instead.
|
||||
await loadRedemptions(0);
|
||||
setActivePage(1);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
const res = await API.get(`/api/redemption/search?keyword=${searchKeyword}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setRedemptions(data);
|
||||
setActivePage(1);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setSearching(false);
|
||||
};
|
||||
|
||||
const handleKeywordChange = async (e, { value }) => {
|
||||
setSearchKeyword(value.trim());
|
||||
};
|
||||
|
||||
const sortRedemption = (key) => {
|
||||
if (redemptions.length === 0) return;
|
||||
setLoading(true);
|
||||
let sortedRedemptions = [...redemptions];
|
||||
sortedRedemptions.sort((a, b) => {
|
||||
return ('' + a[key]).localeCompare(b[key]);
|
||||
});
|
||||
if (sortedRedemptions[0].id === redemptions[0].id) {
|
||||
sortedRedemptions.reverse();
|
||||
}
|
||||
setRedemptions(sortedRedemptions);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchRedemptions}>
|
||||
<Form.Input
|
||||
icon='search'
|
||||
fluid
|
||||
iconPosition='left'
|
||||
placeholder='搜索兑换码的 ID 和名称 ...'
|
||||
value={searchKeyword}
|
||||
loading={searching}
|
||||
onChange={handleKeywordChange}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Table basic>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('id');
|
||||
}}
|
||||
>
|
||||
ID
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('name');
|
||||
}}
|
||||
>
|
||||
名称
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('status');
|
||||
}}
|
||||
>
|
||||
状态
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('quota');
|
||||
}}
|
||||
>
|
||||
额度
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('created_time');
|
||||
}}
|
||||
>
|
||||
创建时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortRedemption('redeemed_time');
|
||||
}}
|
||||
>
|
||||
兑换时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell>操作</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{redemptions
|
||||
.slice(
|
||||
(activePage - 1) * ITEMS_PER_PAGE,
|
||||
activePage * ITEMS_PER_PAGE
|
||||
)
|
||||
.map((redemption, idx) => {
|
||||
if (redemption.deleted) return <></>;
|
||||
return (
|
||||
<Table.Row key={redemption.id}>
|
||||
<Table.Cell>{redemption.id}</Table.Cell>
|
||||
<Table.Cell>{redemption.name ? redemption.name : '无'}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(redemption.status)}</Table.Cell>
|
||||
<Table.Cell>{redemption.quota}</Table.Cell>
|
||||
<Table.Cell>{renderTimestamp(redemption.created_time)}</Table.Cell>
|
||||
<Table.Cell>{redemption.redeemed_time ? renderTimestamp(redemption.redeemed_time) : "尚未兑换"} </Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button
|
||||
size={'small'}
|
||||
positive
|
||||
onClick={async () => {
|
||||
if (await copy(redemption.key)) {
|
||||
showSuccess('已复制到剪贴板!');
|
||||
} else {
|
||||
showWarning('无法复制到剪贴板,请手动复制,已将兑换码填入搜索框。')
|
||||
setSearchKeyword(redemption.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
negative
|
||||
onClick={() => {
|
||||
manageRedemption(redemption.id, 'delete', idx);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
disabled={redemption.status === 3} // used
|
||||
onClick={() => {
|
||||
manageRedemption(
|
||||
redemption.id,
|
||||
redemption.status === 1 ? 'disable' : 'enable',
|
||||
idx
|
||||
);
|
||||
}}
|
||||
>
|
||||
{redemption.status === 1 ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
as={Link}
|
||||
to={'/redemption/edit/' + redemption.id}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
})}
|
||||
</Table.Body>
|
||||
|
||||
<Table.Footer>
|
||||
<Table.Row>
|
||||
<Table.HeaderCell colSpan='8'>
|
||||
<Button size='small' as={Link} to='/redemption/add' loading={loading}>
|
||||
添加新的兑换码
|
||||
</Button>
|
||||
<Pagination
|
||||
floated='right'
|
||||
activePage={activePage}
|
||||
onPageChange={onPaginationChange}
|
||||
size='small'
|
||||
siblingRange={1}
|
||||
totalPages={
|
||||
Math.ceil(redemptions.length / ITEMS_PER_PAGE) +
|
||||
(redemptions.length % ITEMS_PER_PAGE === 0 ? 1 : 0)
|
||||
}
|
||||
/>
|
||||
</Table.HeaderCell>
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default RedemptionsTable;
|
@@ -24,6 +24,7 @@ const SystemSetting = () => {
|
||||
TurnstileSiteKey: '',
|
||||
TurnstileSecretKey: '',
|
||||
RegisterEnabled: '',
|
||||
QuotaForNewUser: 0,
|
||||
});
|
||||
let originInputs = {};
|
||||
let [loading, setLoading] = useState(false);
|
||||
@@ -86,7 +87,8 @@ const SystemSetting = () => {
|
||||
name === 'WeChatServerToken' ||
|
||||
name === 'WeChatAccountQRCodeImageURL' ||
|
||||
name === 'TurnstileSiteKey' ||
|
||||
name === 'TurnstileSecretKey'
|
||||
name === 'TurnstileSecretKey' ||
|
||||
name === 'QuotaForNewUser'
|
||||
) {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
} else {
|
||||
@@ -228,6 +230,25 @@ const SystemSetting = () => {
|
||||
/>
|
||||
</Form.Group>
|
||||
<Divider />
|
||||
<Header as='h3'>
|
||||
运营设置
|
||||
</Header>
|
||||
<Form.Group widths={3}>
|
||||
<Form.Input
|
||||
label='新用户初始配额'
|
||||
name='QuotaForNewUser'
|
||||
onChange={handleInputChange}
|
||||
autoComplete='off'
|
||||
value={inputs.QuotaForNewUser}
|
||||
type='number'
|
||||
min='0'
|
||||
placeholder='例如:100'
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Button onClick={()=>{
|
||||
updateOption('QuotaForNewUser', inputs.QuotaForNewUser).then();
|
||||
}}>保存运营设置</Form.Button>
|
||||
<Divider />
|
||||
<Header as='h3'>
|
||||
配置 SMTP
|
||||
<Header.Subheader>用以支持系统的邮件发送</Header.Subheader>
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Label, Message, Pagination, Table } from 'semantic-ui-react';
|
||||
import { Button, Form, Label, Modal, Pagination, Table } from 'semantic-ui-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { API, copy, showError, showInfo, showSuccess, showWarning, timestamp2string } from '../helpers';
|
||||
import { API, copy, showError, showSuccess, showWarning, timestamp2string } from '../helpers';
|
||||
|
||||
import { ITEMS_PER_PAGE } from '../constants';
|
||||
|
||||
@@ -34,6 +34,9 @@ const TokensTable = () => {
|
||||
const [activePage, setActivePage] = useState(1);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showTopUpModal, setShowTopUpModal] = useState(false);
|
||||
const [targetTokenIdx, setTargetTokenIdx] = useState(0);
|
||||
const [redemptionCode, setRedemptionCode] = useState('');
|
||||
|
||||
const loadTokens = async (startIdx) => {
|
||||
const res = await API.get(`/api/token/?p=${startIdx}`);
|
||||
@@ -140,6 +143,28 @@ const TokensTable = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const topUp = async () => {
|
||||
if (redemptionCode === '') {
|
||||
return;
|
||||
}
|
||||
const res = await API.post('/api/token/topup/', {
|
||||
id: tokens[targetTokenIdx].id,
|
||||
key: redemptionCode
|
||||
});
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
showSuccess('充值成功!');
|
||||
let newTokens = [...tokens];
|
||||
let realIdx = (activePage - 1) * ITEMS_PER_PAGE + targetTokenIdx;
|
||||
newTokens[realIdx].remain_times += data;
|
||||
setTokens(newTokens);
|
||||
setRedemptionCode('');
|
||||
setShowTopUpModal(false);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form onSubmit={searchTokens}>
|
||||
@@ -197,14 +222,6 @@ const TokensTable = () => {
|
||||
>
|
||||
创建时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
sortToken('accessed_time');
|
||||
}}
|
||||
>
|
||||
访问时间
|
||||
</Table.HeaderCell>
|
||||
<Table.HeaderCell
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
@@ -230,10 +247,9 @@ const TokensTable = () => {
|
||||
<Table.Cell>{token.id}</Table.Cell>
|
||||
<Table.Cell>{token.name ? token.name : '无'}</Table.Cell>
|
||||
<Table.Cell>{renderStatus(token.status)}</Table.Cell>
|
||||
<Table.Cell>{token.unlimited_times ? "无限制" : token.remain_times}</Table.Cell>
|
||||
<Table.Cell>{token.unlimited_times ? '无限制' : token.remain_times}</Table.Cell>
|
||||
<Table.Cell>{renderTimestamp(token.created_time)}</Table.Cell>
|
||||
<Table.Cell>{renderTimestamp(token.accessed_time)}</Table.Cell>
|
||||
<Table.Cell>{token.expired_time === -1 ? "永不过期" : renderTimestamp(token.expired_time)}</Table.Cell>
|
||||
<Table.Cell>{token.expired_time === -1 ? '永不过期' : renderTimestamp(token.expired_time)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<div>
|
||||
<Button
|
||||
@@ -243,13 +259,22 @@ const TokensTable = () => {
|
||||
if (await copy(token.key)) {
|
||||
showSuccess('已复制到剪贴板!');
|
||||
} else {
|
||||
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。')
|
||||
showWarning('无法复制到剪贴板,请手动复制,已将令牌填入搜索框。');
|
||||
setSearchKeyword(token.key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
color={'yellow'}
|
||||
onClick={() => {
|
||||
setTargetTokenIdx(idx);
|
||||
setShowTopUpModal(true);
|
||||
}}>
|
||||
充值
|
||||
</Button>
|
||||
<Button
|
||||
size={'small'}
|
||||
negative
|
||||
@@ -306,6 +331,34 @@ const TokensTable = () => {
|
||||
</Table.Row>
|
||||
</Table.Footer>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
onClose={() => setShowTopUpModal(false)}
|
||||
onOpen={() => setShowTopUpModal(true)}
|
||||
open={showTopUpModal}
|
||||
size={'mini'}
|
||||
>
|
||||
<Modal.Header>通过兑换码为令牌「{tokens[targetTokenIdx]?.name}」充值</Modal.Header>
|
||||
<Modal.Content>
|
||||
<Modal.Description>
|
||||
{/*<Image src={status.wechat_qrcode} fluid />*/}
|
||||
<Form size='large'>
|
||||
<Form.Input
|
||||
fluid
|
||||
placeholder='兑换码'
|
||||
name='redemptionCode'
|
||||
value={redemptionCode}
|
||||
onChange={(e) => {
|
||||
setRedemptionCode(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<Button color='' fluid size='large' onClick={topUp}>
|
||||
充值
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Description>
|
||||
</Modal.Content>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@@ -143,4 +143,13 @@ export function timestamp2string(timestamp) {
|
||||
':' +
|
||||
second
|
||||
);
|
||||
}
|
||||
|
||||
export function downloadTextAsFile(text, filename) {
|
||||
let blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
|
||||
let url = URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
121
web/src/pages/Redemption/EditRedemption.js
Normal file
121
web/src/pages/Redemption/EditRedemption.js
Normal file
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Header, Segment } from 'semantic-ui-react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { API, downloadTextAsFile, showError, showSuccess } from '../../helpers';
|
||||
|
||||
const EditRedemption = () => {
|
||||
const params = useParams();
|
||||
const redemptionId = params.id;
|
||||
const isEdit = redemptionId !== undefined;
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
const originInputs = {
|
||||
name: '',
|
||||
quota: 100,
|
||||
count: 1
|
||||
};
|
||||
const [inputs, setInputs] = useState(originInputs);
|
||||
const { name, quota, count } = inputs;
|
||||
|
||||
const handleInputChange = (e, { name, value }) => {
|
||||
setInputs((inputs) => ({ ...inputs, [name]: value }));
|
||||
};
|
||||
|
||||
const loadRedemption = async () => {
|
||||
let res = await API.get(`/api/redemption/${redemptionId}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setInputs(data);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isEdit) {
|
||||
loadRedemption().then();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const submit = async () => {
|
||||
if (!isEdit && inputs.name === '') return;
|
||||
let localInputs = inputs;
|
||||
localInputs.count = parseInt(localInputs.count);
|
||||
localInputs.quota = parseInt(localInputs.quota);
|
||||
let res;
|
||||
if (isEdit) {
|
||||
res = await API.put(`/api/redemption/`, { ...localInputs, id: parseInt(redemptionId) });
|
||||
} else {
|
||||
res = await API.post(`/api/redemption/`, {
|
||||
...localInputs
|
||||
});
|
||||
}
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
if (isEdit) {
|
||||
showSuccess('兑换码更新成功!');
|
||||
} else {
|
||||
showSuccess('兑换码创建成功!');
|
||||
setInputs(originInputs);
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
if (!isEdit && data) {
|
||||
let text = "";
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
text += data[i] + "\n";
|
||||
}
|
||||
downloadTextAsFile(text, `${inputs.name}.txt`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Segment loading={loading}>
|
||||
<Header as='h3'>{isEdit ? '更新兑换码信息' : '创建新的兑换码'}</Header>
|
||||
<Form autoComplete='off'>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='名称'
|
||||
name='name'
|
||||
placeholder={'请输入名称'}
|
||||
onChange={handleInputChange}
|
||||
value={name}
|
||||
autoComplete='off'
|
||||
required={!isEdit}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='额度'
|
||||
name='quota'
|
||||
placeholder={'请输入单个兑换码中包含的额度'}
|
||||
onChange={handleInputChange}
|
||||
value={quota}
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
/>
|
||||
</Form.Field>
|
||||
{
|
||||
!isEdit && <>
|
||||
<Form.Field>
|
||||
<Form.Input
|
||||
label='生成数量'
|
||||
name='count'
|
||||
placeholder={'请输入生成数量'}
|
||||
onChange={handleInputChange}
|
||||
value={count}
|
||||
autoComplete='off'
|
||||
type='number'
|
||||
/>
|
||||
</Form.Field>
|
||||
</>
|
||||
}
|
||||
<Button onClick={submit}>提交</Button>
|
||||
</Form>
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditRedemption;
|
14
web/src/pages/Redemption/index.js
Normal file
14
web/src/pages/Redemption/index.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Segment, Header } from 'semantic-ui-react';
|
||||
import RedemptionsTable from '../../components/RedemptionsTable';
|
||||
|
||||
const Redemption = () => (
|
||||
<>
|
||||
<Segment>
|
||||
<Header as='h3'>管理兑换码</Header>
|
||||
<RedemptionsTable/>
|
||||
</Segment>
|
||||
</>
|
||||
);
|
||||
|
||||
export default Redemption;
|
Reference in New Issue
Block a user