feat: add Admin API key management page

This commit is contained in:
QiuSW
2026-09-12 08:44:58 +08:00
parent 3736db1157
commit 1e2a06c8e2
6 changed files with 224 additions and 8 deletions
+34 -8
View File
@@ -9,24 +9,31 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"go-admin/app/admin/models"
"strconv"
"strings"
"time"
"unicode/utf8"
)
type APIKey struct{ api.Api }
func (e APIKey) List(c *gin.Context) {
e.MakeContext(c)
db := primaryDB()
db.AutoMigrate(&models.APIKey{})
var rows []models.APIKey
db.Order("id desc").Find(&rows)
rows := make([]models.APIKey, 0)
if err := db.Order("id desc").Find(&rows).Error; err != nil {
e.Error(500, err, "读取密钥失败,请重试")
return
}
e.OK(rows, "查询成功")
}
func (e APIKey) Create(c *gin.Context) {
e.MakeContext(c)
c.Header("Cache-Control", "no-store")
var in struct {
Name string `json:"name"`
}
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Name) == "" {
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Name) == "" || utf8.RuneCountInString(strings.TrimSpace(in.Name)) > 128 {
e.Error(400, fmt.Errorf("name required"), "参数错误")
return
}
@@ -40,7 +47,6 @@ func (e APIKey) Create(c *gin.Context) {
row := models.APIKey{Name: strings.TrimSpace(in.Name), KeyHash: hex.EncodeToString(h[:]), Enabled: true}
row.CreateBy = user.GetUserId(c)
db := primaryDB()
db.AutoMigrate(&models.APIKey{})
if err := db.Create(&row).Error; err != nil {
e.Error(500, err, "创建失败")
return
@@ -48,22 +54,42 @@ func (e APIKey) Create(c *gin.Context) {
e.OK(gin.H{"id": row.Id, "name": row.Name, "apiKey": plain}, "创建成功")
}
func (e APIKey) Toggle(c *gin.Context) {
e.MakeContext(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
e.Error(400, fmt.Errorf("invalid id"), "无效的密钥编号")
return
}
var row models.APIKey
db := primaryDB()
if db.First(&row, c.Param("id")).Error != nil {
if db.First(&row, id).Error != nil {
e.Error(404, fmt.Errorf("not found"), "不存在")
return
}
row.Enabled = !row.Enabled
db.Save(&row)
if err := db.Save(&row).Error; err != nil {
e.Error(500, err, "更新失败,请刷新后重试")
return
}
e.OK(row, "更新成功")
}
func (e APIKey) Delete(c *gin.Context) {
e.MakeContext(c)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
e.Error(400, fmt.Errorf("invalid id"), "无效的密钥编号")
return
}
db := primaryDB()
if db.Delete(&models.APIKey{}, c.Param("id")).Error != nil {
result := db.Delete(&models.APIKey{}, id)
if result.Error != nil {
e.Error(500, fmt.Errorf("delete failed"), "删除失败")
return
}
if result.RowsAffected == 0 {
e.Error(404, fmt.Errorf("not found"), "密钥不存在")
return
}
e.OK(nil, "删除成功")
}
func ValidateAPIKey(c *gin.Context) bool {
@@ -0,0 +1,31 @@
package version
import (
"fmt"
"go-admin/app/admin/models"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
)
func init() { migration.Migrate.SetVersion("1789171200000", migrateAPIKeyPage) }
func migrateAPIKeyPage(db *gorm.DB, version string) error {
if err := db.AutoMigrate(&models.APIKey{}); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var parent models.SysMenu
if err := tx.Where("title = ? AND menu_type = ?", "系统管理", "M").First(&parent).Error; err != nil {
return err
}
menu := models.SysMenu{MenuName: "APIKeyManagement", Title: "API Key 管理", Icon: "lock", Path: "/admin/api-keys", MenuType: "C", ParentId: parent.MenuId, Component: "/admin/api-key/index", Visible: "0", NoCache: true, Sort: 90, IsFrame: "1"}
if err := tx.Where("menu_name = ?", menu.MenuName).FirstOrCreate(&menu).Error; err != nil {
return err
}
if err := tx.Model(&menu).Update("paths", fmt.Sprintf("%s/%d", parent.Paths, menu.MenuId)).Error; err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
+22
View File
@@ -0,0 +1,22 @@
# LOCAL-005:API Key 管理页面
- 状态:进行中
- 来源:2026-09-12 用户「做005」
- 前置:LOCAL-004 后端已验收
## 范围与方案
沿用 GoAdmin / Element Plus,新增系统管理下的 API Key 页面。
包含列表、名称筛选、创建及一次性密钥展示/复制、启用/禁用、删除确认、创建时间及最后使用时间。
处理加载、空列表和失败状态;不把明文保存到浏览器存储。
必要时修复接口响应和数据库错误处理以完成页面联调。
前端定制文件保存在本仓库 web/,同步至现有 D:/chengma/erpgo-ui 实例。
## 验收
- Admin 菜单和页面可访问。
- 创建后显示一次明文;关闭后列表及刷新不显示明文。
- 启停、删除操作持久化;取消确认不修改记录。
- 空名称有提示;请求失败不产生未捕获异常。
- 接口、前端编译及交互检查通过;只操作 erpgo 服务。
## 证据
实施中。
+7
View File
@@ -0,0 +1,7 @@
# erpgo 前端定制
基线是 D:/github/goadmin/go-admin-ui(Vue 3 / Element Plus)。
本目录仅保存本项目定制文件;运行实例位于 D:/chengma/erpgo-ui。
修改后运行 `Copy-Item web/src/* D:/chengma/erpgo-ui/src/ -Recurse -Force` 同步。
开发服务支持热更新。需要重启时仅操作 erpgo-ui,禁止全局 reload/restart。
菜单由后端数据库迁移建立,完成迁移后重新登录加载菜单。
+6
View File
@@ -0,0 +1,6 @@
import request from '@/utils/request'
export const listKeys = () => request({ url: '/api/v1/api-keys', method: 'get' })
export const createKey = name => request({ url: '/api/v1/api-keys', method: 'post', data: { name } })
export const toggleKey = id => request({ url: `/api/v1/api-keys/${id}/toggle`, method: 'put' })
export const deleteKey = id => request({ url: `/api/v1/api-keys/${id}`, method: 'delete' })
+124
View File
@@ -0,0 +1,124 @@
<template>
<div class="app-container api-key-page">
<h2>API Key 管理</h2>
<p class="hint">为不同调用方创建独立密钥,可随时停用。</p>
<div class="toolbar">
<el-input v-model="search" aria-label="按名称搜索" placeholder="按名称搜索" clearable class="search" />
<el-button :loading="loading" @click="load">刷新</el-button>
<el-button type="primary" @click="openCreate">创建密钥</el-button>
</div>
<el-alert v-if="loadError" title="读取失败,请点击刷新重试" type="error" :closable="false" show-icon />
<el-table v-loading="loading" :data="filteredRows" row-key="id" border :empty-text="loadError ? '数据暂不可用' : search ? '没有匹配的密钥' : '还没有密钥,点击“创建密钥”开始'">
<el-table-column prop="name" label="名称 / 调用方" min-width="180" show-overflow-tooltip />
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '已启用' : '已禁用' }}</el-tag></template></el-table-column>
<el-table-column label="创建时间" min-width="180"><template #default="{ row }">{{ formatDate(row.createdAt) }}</template></el-table-column>
<el-table-column label="最后使用时间" min-width="180"><template #default="{ row }">{{ formatDate(row.lastUsedAt, '从未使用') }}</template></el-table-column>
<el-table-column label="操作" width="200" fixed="right"><template #default="{ row }">
<el-button size="small" :disabled="busy || loading" @click="toggle(row)">{{ row.enabled ? '禁用' : '启用' }}</el-button>
<el-button size="small" type="danger" plain :disabled="busy || loading" @click="remove(row)">删除</el-button>
</template></el-table-column>
</el-table>
<el-dialog v-model="createOpen" title="创建 API Key" width="min(520px, 92vw)" :close-on-click-modal="false" :close-on-press-escape="!saving" :show-close="!saving">
<el-form @submit.prevent="submit">
<el-form-item label="名称 / 调用方" :error="nameError">
<el-input v-model="name" maxlength="128" show-word-limit aria-label="名称 / 调用方" placeholder="例如:商品同步工具" :disabled="saving" />
</el-form-item>
</el-form>
<template #footer><el-button :disabled="saving" @click="createOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="submit">创建</el-button></template>
</el-dialog>
<el-dialog v-model="secretOpen" title="密钥已创建" width="min(620px, 92vw)" :close-on-click-modal="false" :before-close="closeSecret" @closed="secret = ''">
<el-alert title="完整密钥仅显示这一次,请复制并妥善保存。关闭后无法再次查看。" type="warning" :closable="false" show-icon />
<el-input :model-value="secret" type="textarea" :rows="3" readonly aria-label="新密钥(仅显示一次)" class="secret" />
<el-button @click="copySecret">复制密钥</el-button>
<p v-if="copyError" role="alert">复制失败,请在上方文本框中手动选择并复制。</p>
<p class="hint">调用时使用 X-API-Key 请求头,或 apikey 查询参数。</p>
<template #footer><el-button type="primary" @click="closeSecret()">我已保存,关闭</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { listKeys, createKey, toggleKey, deleteKey } from '@/api/admin/api-key'
import { ElMessage, ElMessageBox } from 'element-plus'
export default {
name: 'APIKeyManagement',
data: () => ({ rows: [], search: '', loading: false, loadError: false, busy: false, createOpen: false, saving: false, name: '', nameError: '', secretOpen: false, secret: '', copyError: false }),
computed: {
filteredRows() { return this.rows.filter(row => row.name.toLowerCase().includes(this.search.trim().toLowerCase())) }
},
mounted() { this.load() },
beforeUnmount() { this.secret = '' },
methods: {
formatDate(value, fallback = '—') {
if (!value) return fallback
const date = new Date(value)
return Number.isNaN(date.getTime()) ? fallback : date.toLocaleString('zh-CN', { hour12: false })
},
async load() {
this.loading = true
this.loadError = false
try { const response = await listKeys(); this.rows = response.data || [] } catch (_) { this.loadError = true } finally { this.loading = false }
},
openCreate() { this.name = ''; this.nameError = ''; this.createOpen = true },
async submit() {
if (this.saving) return
this.nameError = this.name.trim() ? '' : '请输入名称或调用方'
if (this.nameError) return
this.saving = true
try {
const response = await createKey(this.name.trim())
this.secret = response.data.apiKey
this.copyError = false
this.createOpen = false
this.secretOpen = true
await this.load()
} catch (_) { this.nameError = '创建失败,请检查连接后重试' } finally { this.saving = false }
},
async copySecret() {
try { await navigator.clipboard.writeText(this.secret); this.copyError = false; ElMessage.success('已复制') } catch (_) { this.copyError = true }
},
async closeSecret(done) {
try {
await ElMessageBox.confirm('关闭后无法再次查看完整密钥,确认已保存?', '确认关闭', { confirmButtonText: '已保存,关闭', cancelButtonText: '继续保存', type: 'warning' })
this.secret = ''
this.secretOpen = false
if (done) done()
} catch (_) { /* 保留一次性展示,继续保存 */ }
},
async toggle(row) {
if (this.busy) return
this.busy = true
try {
if (row.enabled) await ElMessageBox.confirm(`禁用“${row.name}”后,该调用方将无法访问查询接口。`, '确认禁用', { confirmButtonText: '禁用', cancelButtonText: '取消', type: 'warning' })
await toggleKey(row.id)
ElMessage.success(row.enabled ? '已禁用' : '已启用')
await this.load()
} catch (_) { /* 取消无副作用;请求错误由统一拦截器提示 */ } finally { this.busy = false }
},
async remove(row) {
if (this.busy) return
this.busy = true
try {
await ElMessageBox.confirm(`删除“${row.name}”后密钥立即失效且无法恢复。`, '确认删除', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
await deleteKey(row.id)
ElMessage.success('已删除')
await this.load()
} catch (_) { /* 取消无副作用;请求错误由统一拦截器提示 */ } finally { this.busy = false }
}
}
}
</script>
<style scoped>
.api-key-page { padding: 24px; }
.hint { color: var(--el-text-color-regular); line-height: 1.6; }
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; margin: 20px 0; }
.toolbar .el-button { margin-left: 0; }
.search { width: 280px; max-width: 100%; }
.secret { margin: 20px 0 12px; font-family: monospace; }
.el-alert { margin-bottom: 12px; }
@media (max-width: 600px) { .api-key-page { padding: 12px; } }
</style>