Explorar el Código

feat: now user can top up via redemption code (close #9)

JustSong hace 2 años
padre
commit
9e2f2383b9

+ 6 - 0
common/constants.go

@@ -93,6 +93,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 - 0
controller/redemption.go

@@ -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
+}

+ 31 - 0
controller/token.go

@@ -201,3 +201,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
+}

+ 4 - 0
model/main.go

@@ -69,6 +69,10 @@ func InitDB() (err error) {
 		if err != nil {
 			return err
 		}
+		err = db.AutoMigrate(&Redemption{})
+		if err != nil {
+			return err
+		}
 		err = createRootAccountIfNeed()
 		return err
 	} else {

+ 107 - 0
model/redemption.go

@@ -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:"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()
+}

+ 5 - 0
model/token.go

@@ -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
+}

+ 11 - 0
router/api-router.go

@@ -70,10 +70,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)
+		}
 	}
 }

+ 26 - 0
web/src/App.js

@@ -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={

+ 6 - 0
web/src/components/Header.js

@@ -24,6 +24,12 @@ const headerButtons = [
     to: '/token',
     icon: 'key',
   },
+  {
+    name: '兑换',
+    to: '/redemption',
+    icon: 'dollar sign',
+    admin: true,
+  },
   {
     name: '用户',
     to: '/user',

+ 303 - 0
web/src/components/RedemptionsTable.js

@@ -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;

+ 67 - 14
web/src/components/TokensTable.js

@@ -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>
     </>
   );
 };

+ 114 - 0
web/src/pages/Redemption/EditRedemption.js

@@ -0,0 +1,114 @@
+import React, { useEffect, useState } from 'react';
+import { Button, Form, Header, Segment } from 'semantic-ui-react';
+import { useParams } from 'react-router-dom';
+import { API, isAdmin, showError, showSuccess, quotatamp2string } 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 } = res.data;
+    if (success) {
+      if (isEdit) {
+        showSuccess('兑换码更新成功!');
+      } else {
+        showSuccess('兑换码创建成功!');
+        setInputs(originInputs);
+      }
+    } else {
+      showError(message);
+    }
+  };
+
+  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 - 0
web/src/pages/Redemption/index.js

@@ -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;