瀏覽代碼

🗑️ feat(token): implement batch token deletion API & front-end integration

• Back-end
  • model/token.go
    • Add `BatchDeleteTokens(ids []int, userId int)` – transactional DB removal + async Redis cache cleanup.
  • controller/token.go
    • Introduce `TokenBatch` DTO and `DeleteTokenBatch` handler calling the model layer; returns amount deleted.
  • router/api-router.go
    • Register `POST /api/token/batch` route (user-scoped).

• Front-end (TokensTable.js)
  • Replace per-token deletion loops with single request to `/api/token/batch`.
  • Display dynamic i18n message: “Deleted {{count}} tokens!”.
  • Add modal confirmation:
    • Title “Batch delete token”.
    • Content “Are you sure you want to delete the selected {{count}} tokens?”.
  • UI/UX tweaks
    • Responsive button group (flex-wrap, mobile line-break).
    • Clear `selectedKeys` after refresh / successful deletion to avoid ghost selections.

• i18n
  • Ensure placeholder style matches translation keys (`{{count}}`).

This commit delivers efficient, scalable token management and an improved user experience across devices.
t0ng7u 6 月之前
父節點
當前提交
093d86040f
共有 5 個文件被更改,包括 120 次插入3 次删除
  1. 29 0
      controller/token.go
  2. 34 0
      model/token.go
  3. 1 0
      router/api-router.go
  4. 50 3
      web/src/components/table/TokensTable.js
  5. 6 0
      web/src/i18n/locales/en.json

+ 29 - 0
controller/token.go

@@ -258,3 +258,32 @@ func UpdateToken(c *gin.Context) {
 	})
 	return
 }
+
+type TokenBatch struct {
+	Ids []int `json:"ids"`
+}
+
+func DeleteTokenBatch(c *gin.Context) {
+	tokenBatch := TokenBatch{}
+	if err := c.ShouldBindJSON(&tokenBatch); err != nil || len(tokenBatch.Ids) == 0 {
+		c.JSON(http.StatusOK, gin.H{
+			"success": false,
+			"message": "参数错误",
+		})
+		return
+	}
+	userId := c.GetInt("id")
+	count, err := model.BatchDeleteTokens(tokenBatch.Ids, userId)
+	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":    count,
+	})
+}

+ 34 - 0
model/token.go

@@ -327,3 +327,37 @@ func CountUserTokens(userId int) (int64, error) {
 	err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
 	return total, err
 }
+
+// BatchDeleteTokens 删除指定用户的一组令牌,返回成功删除数量
+func BatchDeleteTokens(ids []int, userId int) (int, error) {
+	if len(ids) == 0 {
+		return 0, errors.New("ids 不能为空!")
+	}
+
+	tx := DB.Begin()
+
+	var tokens []Token
+	if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Find(&tokens).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	if err := tx.Where("user_id = ? AND id IN (?)", userId, ids).Delete(&Token{}).Error; err != nil {
+		tx.Rollback()
+		return 0, err
+	}
+
+	if err := tx.Commit().Error; err != nil {
+		return 0, err
+	}
+
+	if common.RedisEnabled {
+		gopool.Go(func() {
+			for _, t := range tokens {
+				_ = cacheDeleteToken(t.Key)
+			}
+		})
+	}
+
+	return len(tokens), nil
+}

+ 1 - 0
router/api-router.go

@@ -125,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
 			tokenRoute.POST("/", controller.AddToken)
 			tokenRoute.PUT("/", controller.UpdateToken)
 			tokenRoute.DELETE("/:id", controller.DeleteToken)
+			tokenRoute.POST("/batch", controller.DeleteTokenBatch)
 		}
 		redemptionRoute := apiRouter.Group("/redemption")
 		redemptionRoute.Use(middleware.AdminAuth())

+ 50 - 3
web/src/components/table/TokensTable.js

@@ -435,6 +435,7 @@ const TokensTable = () => {
 
   const refresh = async () => {
     await loadTokens(1);
+    setSelectedKeys([]);
   };
 
   const copyText = async (text) => {
@@ -583,6 +584,29 @@ const TokensTable = () => {
     }
   };
 
+  const batchDeleteTokens = async () => {
+    if (selectedKeys.length === 0) {
+      showError(t('请先选择要删除的令牌!'));
+      return;
+    }
+    setLoading(true);
+    try {
+      const ids = selectedKeys.map((token) => token.id);
+      const res = await API.post('/api/token/batch', { ids });
+      if (res?.data?.success) {
+        const count = res.data.data || 0;
+        showSuccess(t('已删除 {{count}} 个令牌!', { count }));
+        await refresh();
+      } else {
+        showError(res?.data?.message || t('删除失败'));
+      }
+    } catch (error) {
+      showError(error.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
   const renderHeader = () => (
     <div className="flex flex-col w-full">
       <div className="mb-2">
@@ -595,12 +619,12 @@ const TokensTable = () => {
       <Divider margin="12px" />
 
       <div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
-        <div className="flex gap-2 w-full md:w-auto order-2 md:order-1">
+        <div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
           <Button
             theme="light"
             type="primary"
             icon={<IconPlus />}
-            className="!rounded-full w-full md:w-auto"
+            className="!rounded-full flex-1 md:flex-initial"
             onClick={() => {
               setEditingToken({
                 id: undefined,
@@ -614,7 +638,7 @@ const TokensTable = () => {
             theme="light"
             type="warning"
             icon={<IconCopy />}
-            className="!rounded-full w-full md:w-auto"
+            className="!rounded-full flex-1 md:flex-initial"
             onClick={async () => {
               if (selectedKeys.length === 0) {
                 showError(t('请至少选择一个令牌!'));
@@ -630,6 +654,29 @@ const TokensTable = () => {
           >
             {t('复制所选令牌到剪贴板')}
           </Button>
+          <div className="w-full md:hidden"></div>
+          <Button
+            theme="light"
+            type="danger"
+            className="!rounded-full w-full md:w-auto"
+            onClick={() => {
+              if (selectedKeys.length === 0) {
+                showError(t('请至少选择一个令牌!'));
+                return;
+              }
+              Modal.confirm({
+                title: t('批量删除令牌'),
+                content: (
+                  <div>
+                    {t('确定要删除所选的 {{count}} 个令牌吗?', { count: selectedKeys.length })}
+                  </div>
+                ),
+                onOk: () => batchDeleteTokens(),
+              });
+            }}
+          >
+            {t('删除所选令牌')}
+          </Button>
         </div>
 
         <Form

+ 6 - 0
web/src/i18n/locales/en.json

@@ -814,6 +814,12 @@
   "请至少选择一个令牌!": "Please select at least one token!",
   "管理员未设置查询页链接": "The administrator has not set the query page link",
   "复制所选令牌到剪贴板": "Copy selected token to clipboard",
+  "批量删除令牌": "Batch delete token",
+  "确定要删除所选的 {{count}} 个令牌吗?": "Are you sure you want to delete the selected {{count}} tokens?",
+  "删除所选令牌": "Delete selected token",
+  "请先选择要删除的令牌!": "Please select the token to be deleted!",
+  "已删除 {{count}} 个令牌!": "Deleted {{count}} tokens!",
+  "删除失败": "Delete failed",
   "查看API地址": "View API address",
   "打开查询页": "Open query page",
   "时间(仅显示近3天)": "Time (only displays the last 3 days)",