🗑️ 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:
@@ -258,3 +258,32 @@ func UpdateToken(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
return
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -327,3 +327,37 @@ func CountUserTokens(userId int) (int64, error) {
|
|||||||
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
|
err := DB.Model(&Token{}).Where("user_id = ?", userId).Count(&total).Error
|
||||||
return total, err
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ func SetApiRouter(router *gin.Engine) {
|
|||||||
tokenRoute.POST("/", controller.AddToken)
|
tokenRoute.POST("/", controller.AddToken)
|
||||||
tokenRoute.PUT("/", controller.UpdateToken)
|
tokenRoute.PUT("/", controller.UpdateToken)
|
||||||
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
tokenRoute.DELETE("/:id", controller.DeleteToken)
|
||||||
|
tokenRoute.POST("/batch", controller.DeleteTokenBatch)
|
||||||
}
|
}
|
||||||
redemptionRoute := apiRouter.Group("/redemption")
|
redemptionRoute := apiRouter.Group("/redemption")
|
||||||
redemptionRoute.Use(middleware.AdminAuth())
|
redemptionRoute.Use(middleware.AdminAuth())
|
||||||
|
|||||||
@@ -435,6 +435,7 @@ const TokensTable = () => {
|
|||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
await loadTokens(1);
|
await loadTokens(1);
|
||||||
|
setSelectedKeys([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyText = async (text) => {
|
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 = () => (
|
const renderHeader = () => (
|
||||||
<div className="flex flex-col w-full">
|
<div className="flex flex-col w-full">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@@ -595,12 +619,12 @@ const TokensTable = () => {
|
|||||||
<Divider margin="12px" />
|
<Divider margin="12px" />
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 w-full">
|
<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
|
<Button
|
||||||
theme="light"
|
theme="light"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<IconPlus />}
|
icon={<IconPlus />}
|
||||||
className="!rounded-full w-full md:w-auto"
|
className="!rounded-full flex-1 md:flex-initial"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingToken({
|
setEditingToken({
|
||||||
id: undefined,
|
id: undefined,
|
||||||
@@ -614,7 +638,7 @@ const TokensTable = () => {
|
|||||||
theme="light"
|
theme="light"
|
||||||
type="warning"
|
type="warning"
|
||||||
icon={<IconCopy />}
|
icon={<IconCopy />}
|
||||||
className="!rounded-full w-full md:w-auto"
|
className="!rounded-full flex-1 md:flex-initial"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (selectedKeys.length === 0) {
|
if (selectedKeys.length === 0) {
|
||||||
showError(t('请至少选择一个令牌!'));
|
showError(t('请至少选择一个令牌!'));
|
||||||
@@ -630,6 +654,29 @@ const TokensTable = () => {
|
|||||||
>
|
>
|
||||||
{t('复制所选令牌到剪贴板')}
|
{t('复制所选令牌到剪贴板')}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Form
|
<Form
|
||||||
|
|||||||
@@ -814,6 +814,12 @@
|
|||||||
"请至少选择一个令牌!": "Please select at least one token!",
|
"请至少选择一个令牌!": "Please select at least one token!",
|
||||||
"管理员未设置查询页链接": "The administrator has not set the query page link",
|
"管理员未设置查询页链接": "The administrator has not set the query page link",
|
||||||
"复制所选令牌到剪贴板": "Copy selected token to clipboard",
|
"复制所选令牌到剪贴板": "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",
|
"查看API地址": "View API address",
|
||||||
"打开查询页": "Open query page",
|
"打开查询页": "Open query page",
|
||||||
"时间(仅显示近3天)": "Time (only displays the last 3 days)",
|
"时间(仅显示近3天)": "Time (only displays the last 3 days)",
|
||||||
|
|||||||
Reference in New Issue
Block a user