🗑️ 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.
This commit is contained in:
t0ng7u
2025-06-22 16:35:30 +08:00
parent 5ad9f8d931
commit 093d86040f
5 changed files with 120 additions and 3 deletions

View File

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

View File

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

View File

@@ -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())

View File

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

View File

@@ -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)",