新增API管理页面
This commit is contained in:
@@ -555,3 +555,120 @@ func (h *APIHandler) ForceLogCleanupHandler(c *gin.Context) {
|
||||
"success": report.Success,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// ============ API Key 管理相关处理器 ============
|
||||
|
||||
// APIKeyListResponse API Key列表响应
|
||||
type APIKeyListResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Key string `json:"key"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetAPIKeysHandler 获取所有API Key列表
|
||||
func (h *APIHandler) GetAPIKeysHandler(c *gin.Context) {
|
||||
var apiKeys []models.APIKey
|
||||
|
||||
// 查询所有API Key
|
||||
if err := h.DB.Order("created_at DESC").Find(&apiKeys).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch API keys"})
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为响应格式
|
||||
response := make([]APIKeyListResponse, len(apiKeys))
|
||||
for i, key := range apiKeys {
|
||||
response[i] = APIKeyListResponse{
|
||||
ID: key.ID,
|
||||
Key: key.Key,
|
||||
CreatedAt: key.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"api_keys": response,
|
||||
"total": len(response),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateAPIKeyRequest 创建API Key的请求结构
|
||||
type CreateAPIKeyRequest struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateAPIKeyHandler 创建新的API Key
|
||||
func (h *APIHandler) CreateAPIKeyHandler(c *gin.Context) {
|
||||
var req CreateAPIKeyRequest
|
||||
|
||||
// 解析请求
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证Key不为空
|
||||
if req.Key == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API key cannot be empty"})
|
||||
return
|
||||
}
|
||||
|
||||
// 检查Key是否已存在
|
||||
var existingKey models.APIKey
|
||||
if err := h.DB.Where("key = ?", req.Key).First(&existingKey).Error; err == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "API key already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
// 创建新的API Key
|
||||
newAPIKey := models.APIKey{
|
||||
Key: req.Key,
|
||||
}
|
||||
|
||||
if err := h.DB.Create(&newAPIKey).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create API key"})
|
||||
return
|
||||
}
|
||||
|
||||
// 返回创建的API Key
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"message": "API key created successfully",
|
||||
"api_key": APIKeyListResponse{
|
||||
ID: newAPIKey.ID,
|
||||
Key: newAPIKey.Key,
|
||||
CreatedAt: newAPIKey.CreatedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteAPIKeyHandler 删除指定的API Key
|
||||
func (h *APIHandler) DeleteAPIKeyHandler(c *gin.Context) {
|
||||
// 获取API Key ID
|
||||
keyID := c.Param("id")
|
||||
if keyID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "API key ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// 查找API Key
|
||||
var apiKey models.APIKey
|
||||
if err := h.DB.First(&apiKey, keyID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "API key not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to find API key"})
|
||||
return
|
||||
}
|
||||
|
||||
// 删除API Key
|
||||
if err := h.DB.Delete(&apiKey).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete API key"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "API key deleted successfully",
|
||||
"id": apiKey.ID,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,6 +118,11 @@ func main() {
|
||||
// Log Cleaner Management
|
||||
api_.GET("/log-cleaner/status", handler.GetLogCleanerStatusHandler)
|
||||
api_.POST("/log-cleaner/force-cleanup", handler.ForceLogCleanupHandler)
|
||||
|
||||
// API Keys Management
|
||||
api_.GET("/api-keys", handler.GetAPIKeysHandler)
|
||||
api_.POST("/api-keys", handler.CreateAPIKeyHandler)
|
||||
api_.DELETE("/api-keys/:id", handler.DeleteAPIKeyHandler)
|
||||
}
|
||||
|
||||
// 设置优雅关闭信号处理
|
||||
|
||||
@@ -3,6 +3,7 @@ import PageLayout from './components/layout/PageLayout';
|
||||
import ProvidersPage from './features/providers';
|
||||
import VirtualModelsPage from './features/virtual-models';
|
||||
import LogsPage from './features/logs';
|
||||
import ApiKeysPage from './features/api-keys';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
@@ -18,6 +19,10 @@ function App() {
|
||||
{
|
||||
label: '请求日志',
|
||||
content: <LogsPage />
|
||||
},
|
||||
{
|
||||
label: 'API Key管理',
|
||||
content: <ApiKeysPage />
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
30
frontend/src/features/api-keys/api/index.js
Normal file
30
frontend/src/features/api-keys/api/index.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import api from '../../../lib/api';
|
||||
|
||||
/**
|
||||
* 获取所有API Key列表
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
export const fetchAPIKeys = async () => {
|
||||
const response = await api.get('/api/api-keys');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建新的API Key
|
||||
* @param {string} key - API Key字符串
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
export const createAPIKey = async (key) => {
|
||||
const response = await api.post('/api/api-keys', { key });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除指定的API Key
|
||||
* @param {number} id - API Key的ID
|
||||
* @returns {Promise} API响应
|
||||
*/
|
||||
export const deleteAPIKey = async (id) => {
|
||||
const response = await api.delete(`/api/api-keys/${id}`);
|
||||
return response.data;
|
||||
};
|
||||
128
frontend/src/features/api-keys/components/ApiKeyForm.jsx
Normal file
128
frontend/src/features/api-keys/components/ApiKeyForm.jsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
const ApiKeyForm = ({ onSave, onCancel }) => {
|
||||
const [key, setKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 生成随机API Key
|
||||
const generateRandomKey = () => {
|
||||
const prefix = 'sk-';
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let randomPart = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
randomPart += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return prefix + randomPart;
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
setKey(generateRandomKey());
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!key.trim()) {
|
||||
setError('请输入API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证API Key格式
|
||||
if (!key.startsWith('sk-')) {
|
||||
setError('API Key必须以 "sk-" 开头');
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.length < 10) {
|
||||
setError('API Key长度不能少于10个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSave({ key: key.trim() });
|
||||
// 成功后会由父组件处理关闭弹窗
|
||||
} catch (err) {
|
||||
setError(err.message || '创建API Key失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
API Key
|
||||
</label>
|
||||
<div className="flex space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
setKey(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors text-sm"
|
||||
disabled={loading}
|
||||
>
|
||||
生成
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
API Key必须以 "sk-" 开头,建议长度不少于32个字符
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start">
|
||||
<svg className="w-5 h-5 text-blue-600 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-semibold mb-1">安全提示:</p>
|
||||
<ul className="space-y-1 list-disc list-inside">
|
||||
<li>API Key是访问AI Gateway的凭证,请妥善保管</li>
|
||||
<li>建议定期更换API Key以确保安全</li>
|
||||
<li>不要在代码中硬编码API Key,使用环境变量</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || !key.trim()}
|
||||
>
|
||||
{loading ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyForm;
|
||||
231
frontend/src/features/api-keys/components/ApiKeyList.jsx
Normal file
231
frontend/src/features/api-keys/components/ApiKeyList.jsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { fetchAPIKeys, createAPIKey, deleteAPIKey } from '../api';
|
||||
import ApiKeyForm from './ApiKeyForm';
|
||||
import Modal from '../../../components/ui/Modal';
|
||||
|
||||
const ApiKeyList = () => {
|
||||
const [apiKeys, setApiKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
|
||||
const fetchKeys = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchAPIKeys();
|
||||
setApiKeys(data.api_keys || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message || '获取API Key列表失败');
|
||||
console.error('Error fetching API keys:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchKeys();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (keyData) => {
|
||||
try {
|
||||
await createAPIKey(keyData.key);
|
||||
setShowCreateForm(false);
|
||||
await fetchKeys();
|
||||
// 显示成功消息
|
||||
alert('API Key创建成功');
|
||||
} catch (err) {
|
||||
setError(err.message || '创建API Key失败');
|
||||
throw err; // 向表单组件传递错误
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除这个API Key吗?删除后将无法使用该Key进行API调用。')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteAPIKey(id);
|
||||
await fetchKeys();
|
||||
alert('API Key删除成功');
|
||||
} catch (err) {
|
||||
setError(err.message || '删除API Key失败');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'N/A';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
alert('API Key已复制到剪贴板');
|
||||
}).catch(err => {
|
||||
console.error('复制失败:', err);
|
||||
alert('复制失败,请手动复制');
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<div className="text-gray-600">加载中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 创建API Key弹窗 */}
|
||||
<Modal
|
||||
isOpen={showCreateForm}
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
title="创建新API Key"
|
||||
size="md"
|
||||
>
|
||||
<ApiKeyForm
|
||||
onSave={handleCreate}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 页面标题和操作按钮 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">API Key 管理</h2>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
管理用于访问AI Gateway的API密钥
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
className="rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
创建API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key统计信息 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<svg className="w-5 h-5 text-blue-600 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="text-sm text-blue-800">
|
||||
<span className="font-semibold">当前共有 {apiKeys.length} 个API Key</span>
|
||||
<span className="ml-2 text-blue-600">
|
||||
· 每个API Key都可以独立用于认证请求
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Key表格 */}
|
||||
<div className="overflow-x-auto bg-white rounded-lg shadow">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
API Key
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{apiKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="4" className="px-6 py-8 text-center">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<svg className="w-12 h-12 text-gray-400 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
<p className="text-gray-500 text-sm">暂无API Key</p>
|
||||
<p className="text-gray-400 text-xs mt-1">点击"创建API Key"按钮添加新的密钥</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
apiKeys.map((apiKey, index) => (
|
||||
<tr key={apiKey.id || index} className="hover:bg-gray-50 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
#{apiKey.id || 'N/A'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="px-3 py-1 bg-gray-100 text-gray-800 rounded text-sm font-mono break-all">
|
||||
{apiKey.key || 'N/A'}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(apiKey.key)}
|
||||
className="flex-shrink-0 text-blue-600 hover:text-blue-800 transition-colors"
|
||||
title="复制到剪贴板"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{formatDate(apiKey.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-red-600 hover:text-red-900 transition-colors"
|
||||
onClick={() => handleDelete(apiKey.id)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">使用说明</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1 list-disc list-inside">
|
||||
<li>API Key用于认证对AI Gateway的所有请求</li>
|
||||
<li>在请求头中添加 <code className="px-1 bg-gray-200 rounded">Authorization: Bearer YOUR_API_KEY</code></li>
|
||||
<li>请妥善保管API Key,避免泄露给未授权人员</li>
|
||||
<li>如果API Key泄露,请立即删除并创建新的密钥</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyList;
|
||||
14
frontend/src/features/api-keys/index.jsx
Normal file
14
frontend/src/features/api-keys/index.jsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import ApiKeyList from './components/ApiKeyList';
|
||||
|
||||
const ApiKeysPage = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<ApiKeyList />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeysPage;
|
||||
Reference in New Issue
Block a user