Browse Source

API Key 管理页面编辑功能

TDesign-Vue-Next-1.20.6
wanghanlin 3 weeks ago
parent
commit
5caebfb20f
  1. 3
      frontend/src/api/api-key.ts
  2. 25
      frontend/src/views/ApiKeyManager.vue
  3. 50
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  4. 63
      src/main/java/com/wok/supportbot/service/ApiKeyService.java

3
frontend/src/api/api-key.ts

@ -11,3 +11,6 @@ export function deleteApiKey(id: string): Promise<ApiResponse> { return request.
export function updateApiKeyRoles(id: string, roleIds: string[]): Promise<ApiResponse> { export function updateApiKeyRoles(id: string, roleIds: string[]): Promise<ApiResponse> {
return request.put(`/api-key/${id}/roles`, { roleIds }).then(r => r.data) return request.put(`/api-key/${id}/roles`, { roleIds }).then(r => r.data)
} }
export function updateApiKey(id: string, data: Record<string, any>): Promise<ApiResponse> {
return request.put(`/api-key/${id}`, data).then(r => r.data)
}

25
frontend/src/views/ApiKeyManager.vue

@ -18,6 +18,7 @@
<template #enabled="{row}"><span :style="{color:row.enabled?'var(--td-success-color)':'var(--td-error-color)'}">{{ row.enabled?'✅ 有效':'❌ 已吊销' }}</span></template> <template #enabled="{row}"><span :style="{color:row.enabled?'var(--td-success-color)':'var(--td-error-color)'}">{{ row.enabled?'✅ 有效':'❌ 已吊销' }}</span></template>
<template #op="{row}"> <template #op="{row}">
<t-space :size="4"> <t-space :size="4">
<t-button size="small" variant="text" @click="openEditDialog(row)">编辑</t-button>
<t-button size="small" variant="text" @click="openRoleDialog(row)">绑定角色</t-button> <t-button size="small" variant="text" @click="openRoleDialog(row)">绑定角色</t-button>
<t-button v-if="row.enabled" size="small" variant="text" theme="danger" @click="doRevoke(row.id)">吊销</t-button> <t-button v-if="row.enabled" size="small" variant="text" theme="danger" @click="doRevoke(row.id)">吊销</t-button>
<t-button v-else size="small" variant="text" theme="success" @click="doEnable(row.id)">启用</t-button> <t-button v-else size="small" variant="text" theme="success" @click="doEnable(row.id)">启用</t-button>
@ -47,6 +48,22 @@
</div> </div>
</t-dialog> </t-dialog>
<!-- 编辑弹窗 -->
<t-dialog v-model:visible="showEditDialog" :header="'编辑 — '+editingKeyName" width="520px" :footer="false">
<t-form label-align="top">
<t-form-item label="名称"><t-input v-model="editForm.name" placeholder="Key 名称" /></t-form-item>
<t-form-item label="描述"><t-input v-model="editForm.description" placeholder="用途说明" /></t-form-item>
<t-form-item label="频率限制/分钟"><t-input-number v-model="editForm.rateLimit" :min="1" /></t-form-item>
<t-form-item label="状态">
<t-switch v-model="editForm.enabled" :label="['有效', '已吊销']" />
</t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="showEditDialog=false">取消</t-button>
<t-button theme="primary" @click="doUpdate">保存</t-button>
</div>
</t-dialog>
<!-- 角色绑定弹窗 --> <!-- 角色绑定弹窗 -->
<t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false"> <t-dialog v-model:visible="showRoleDialog" :header="'绑定角色 — '+bindingKeyName" width="480px" :footer="false">
<t-checkbox-group v-model="selectedRoleIds"> <t-checkbox-group v-model="selectedRoleIds">
@ -62,7 +79,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, updateApiKeyRoles } from '@/api/api-key'
import { listApiKeys, createApiKey, revokeApiKey, enableApiKey, deleteApiKey, updateApiKeyRoles, updateApiKey } from '@/api/api-key'
import { getAllRoles } from '@/api/role' import { getAllRoles } from '@/api/role'
import { toast } from '@/utils/toast' import { toast } from '@/utils/toast'
import { useConfirm } from '@/composables/useConfirm' import { useConfirm } from '@/composables/useConfirm'
@ -71,6 +88,9 @@ const { confirm } = useConfirm()
const keys=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(20);const total=ref(0) const keys=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(20);const total=ref(0)
const showCreateDialog=ref(false);const showRoleDialog=ref(false) const showCreateDialog=ref(false);const showRoleDialog=ref(false)
const showEditDialog=ref(false)
const editingKeyId=ref<any>(null);const editingKeyName=ref('')
const editForm=ref({name:'',description:'',rateLimit:60,enabled:true})
const form=ref({name:'',description:'',rateLimit:60}) const form=ref({name:'',description:'',rateLimit:60})
const createdKey=ref('') const createdKey=ref('')
const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('') const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('')
@ -103,6 +123,9 @@ function openRoleDialog(k:any){bindingKeyId.value=k.id;bindingKeyName.value=k.na
async function doBindRoles(){try{const r=await updateApiKeyRoles(bindingKeyId.value,selectedRoleIds.value);if(r.success){toast('保存成功','success');showRoleDialog.value=false;loadList()}else toast(r.message||'保存失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}} async function doBindRoles(){try{const r=await updateApiKeyRoles(bindingKeyId.value,selectedRoleIds.value);if(r.success){toast('保存成功','success');showRoleDialog.value=false;loadList()}else toast(r.message||'保存失败','error')}catch(e:any){toast('保存失败:'+e.message,'error')}}
function openEditDialog(row:any){editingKeyId.value=row.id;editingKeyName.value=row.name||'未命名';editForm.value={name:row.name||'',description:row.description||'',rateLimit:row.rateLimit??60,enabled:row.enabled};showEditDialog.value=true}
async function doUpdate(){try{const r=await updateApiKey(editingKeyId.value,editForm.value);if(r.success){toast('更新成功','success');showEditDialog.value=false;loadList()}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}}
async function copyKey(k:string){try{await navigator.clipboard.writeText(k);toast('已复制','success')}catch{toast('复制失败','error')}} async function copyKey(k:string){try{await navigator.clipboard.writeText(k);toast('已复制','success')}catch{toast('复制失败','error')}}
</script> </script>
<style scoped>.key-code{font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;}.created-key-box{margin-top:12px;padding:12px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;}.created-key-box p{font-size:12px;color:#166534;margin-bottom:6px;}.created-key{font-size:13px;word-break:break-all;display:block;margin-bottom:6px;}</style> <style scoped>.key-code{font-size:12px;background:#f3f4f6;padding:2px 6px;border-radius:4px;}.created-key-box{margin-top:12px;padding:12px;background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;}.created-key-box p{font-size:12px;color:#166534;margin-bottom:6px;}.created-key{font-size:13px;word-break:break-all;display:block;margin-bottom:6px;}</style>

50
src/main/java/com/wok/supportbot/controller/ApiKeyController.java

@ -238,6 +238,56 @@ public class ApiKeyController {
} }
} }
/**
* 通用更新 API Key
* 仅更新传入的字段未传入的字段保持原值不变
*
* @param id Key ID
* @param body 请求体可选字段name, description, rateLimit, maxCalls, expireTime, enabled
*/
@PutMapping("/{id}")
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> update(
@PathVariable Long id,
@RequestBody Map<String, Object> body) {
try {
String name = (String) body.get("name");
String description = (String) body.get("description");
Integer rateLimit = body.containsKey("rateLimit") && body.get("rateLimit") != null
? ((Number) body.get("rateLimit")).intValue() : null;
Long maxCalls = body.containsKey("maxCalls") && body.get("maxCalls") != null
? ((Number) body.get("maxCalls")).longValue() : null;
Boolean enabled = body.containsKey("enabled") ? (Boolean) body.get("enabled") : null;
Date expireTime = null;
if (body.containsKey("expireTime") && body.get("expireTime") != null) {
Object raw = body.get("expireTime");
if (raw instanceof Number num) {
expireTime = num.longValue() == 0 ? new Date(0) : new Date(num.longValue());
} else if (raw instanceof String str && !str.isBlank()) {
expireTime = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm").parse(str);
}
}
apiKeyService.updateKey(id, name, description, rateLimit, maxCalls, expireTime, enabled);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "更新成功"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("更新 API Key 失败", e);
return ResponseEntity.status(500).body(Map.of(
"success", false,
"message", "更新失败:" + e.getMessage()
));
}
}
/** /**
* 解析请求体中的 roleIds 列表 * 解析请求体中的 roleIds 列表
*/ */

63
src/main/java/com/wok/supportbot/service/ApiKeyService.java

@ -207,6 +207,69 @@ public class ApiKeyService {
log.info("API Key 角色绑定已更新: id={}, roleIds={}", id, json); log.info("API Key 角色绑定已更新: id={}, roleIds={}", id, json);
} }
/**
* 通用更新 API Key 的可编辑字段
* 仅更新传入的非 null 字段expireTime 0 表示清除过期时间
* 使用 JdbcTemplate 动态 SQL避免 MyBatis-Plus role_ids JSONB 类型不匹配
*
* @param id Key ID
* @param name Key 名称null 则不更新
* @param description 描述null 则不更新
* @param rateLimit 每分钟频率限制null 则不更新
* @param maxCalls 最大调用次数null 则不更新
* @param expireTime 过期时间null 则不更新0 表示清除
* @param enabled 是否启用null 则不更新
*/
public void updateKey(Long id, String name, String description,
Integer rateLimit, Long maxCalls, Date expireTime,
Boolean enabled) {
// 先验证记录存在
ApiKey existing = apiKeyMapper.selectById(id);
if (existing == null) {
throw new IllegalArgumentException("API Key 不存在,ID:" + id);
}
if (rateLimit != null && rateLimit < 1) {
throw new IllegalArgumentException("频率限制必须大于 0");
}
// 使用 JdbcTemplate 动态构建 SQL避免 role_ids JSONB 类型不匹配
StringBuilder sql = new StringBuilder("UPDATE api_key SET update_time = CURRENT_TIMESTAMP");
List<Object> params = new ArrayList<>();
if (name != null) {
sql.append(", name = ?");
params.add(name);
}
if (description != null) {
sql.append(", description = ?");
params.add(description);
}
if (rateLimit != null) {
sql.append(", rate_limit = ?");
params.add(rateLimit);
}
if (maxCalls != null) {
sql.append(", max_calls = ?");
params.add(maxCalls);
}
if (expireTime != null && expireTime.getTime() == 0) {
sql.append(", expire_time = NULL");
} else if (expireTime != null) {
sql.append(", expire_time = ?");
params.add(expireTime);
}
if (enabled != null) {
sql.append(", enabled = ?");
params.add(enabled);
}
sql.append(" WHERE id = ? AND is_delete = false");
params.add(id);
jdbcTemplate.update(sql.toString(), params.toArray());
log.info("更新 API Key: id={}, name={}", id, name != null ? name : existing.getName());
}
/** /**
* 解析 API Key 绑定的角色 ID 列表 * 解析 API Key 绑定的角色 ID 列表
* *

Loading…
Cancel
Save