feat: add Admin API key management page
This commit is contained in:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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 服务。
|
||||
|
||||
## 证据
|
||||
实施中。
|
||||
@@ -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。
|
||||
菜单由后端数据库迁移建立,完成迁移后重新登录加载菜单。
|
||||
@@ -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' })
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user