| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529 |
- /**
- * 开放平台页面
- *
- * 提供API Key管理、调用统计和费用明细功能
- * 需求: 6.1, 6.2, 6.3, 7, 11, 12
- */
- import React, { useState, useEffect } from 'react';
- import { Key, BarChart3, FileText, Plus, Copy, Eye, EyeOff, Trash2, Power, Loader2, CheckCircle2 } from 'lucide-react';
- import { platformApi, ApiKey, ApiKeyCreateResponse, Stats, CallLog, PaginatedResponse, API_BASE_FULL } from '../services/platformApi';
- import { useToast, useConfirm } from '../contexts/NotificationContext';
- import { copyToClipboard } from '../utils/clipboard';
- type TabType = 'api-keys' | 'logs';
- const OpenPlatform: React.FC = () => {
- const { showToast } = useToast();
- const { showConfirm } = useConfirm();
- const [activeTab, setActiveTab] = useState<TabType>('api-keys');
- const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
- const [localKeys, setLocalKeys] = useState<ApiKey[]>([]);
- const [stats, setStats] = useState<Stats | null>(null);
- const [logs, setLogs] = useState<PaginatedResponse<CallLog> | null>(null);
- const [loading, setLoading] = useState(false);
- const [statsLoading, setStatsLoading] = useState(false);
- const [newKeyName, setNewKeyName] = useState('');
- const [newKeyType, setNewKeyType] = useState<'public' | 'local'>('public');
- const [showCreateModal, setShowCreateModal] = useState(false);
- const [newlyCreatedKey, setNewlyCreatedKey] = useState<ApiKeyCreateResponse | null>(null);
- const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
- const [logsPage, setLogsPage] = useState(1);
- const [activeKeyType, setActiveKeyType] = useState<'public' | 'local'>('public');
- useEffect(() => {
- if (activeTab === 'api-keys') {
- loadApiKeys();
- } else if (activeTab === 'logs') {
- loadStats();
- loadLogs();
- }
- }, [activeTab, logsPage, activeKeyType]);
- const loadApiKeys = async () => {
- setLoading(true);
- try {
- const keys = await platformApi.getApiKeys();
- setApiKeys(keys.filter(key => key.key_type !== 'local'));
- setLocalKeys(keys.filter(key => key.key_type === 'local'));
- } catch (error) {
- console.error('加载API Key失败:', error);
- } finally {
- setLoading(false);
- }
- };
- const loadStats = async () => {
- setStatsLoading(true);
- try {
- const data = await platformApi.getStats(7, activeKeyType);
- setStats(data);
- } catch (error) {
- console.error('加载统计数据失败:', error);
- } finally {
- setStatsLoading(false);
- }
- };
- const loadLogs = async () => {
- setLoading(true);
- try {
- const data = await platformApi.getCallLogs({ page: logsPage, page_size: 20, key_type: activeKeyType });
- setLogs(data);
- } catch (error) {
- console.error('加载调用日志失败:', error);
- } finally {
- setLoading(false);
- }
- };
- const handleCreateKey = async () => {
- try {
- const result = await platformApi.createApiKey(newKeyName || undefined, newKeyType);
- setNewlyCreatedKey(result);
- setNewKeyName('');
- setNewKeyType('public');
- loadApiKeys();
- } catch (error: any) {
- showToast('创建失败,请稍后重试', 'error');
- }
- };
- const handleToggleStatus = async (key: ApiKey) => {
- try {
- await platformApi.updateApiKeyStatus(key.id, key.status === 'active' ? 'disabled' : 'active');
- await loadApiKeys();
- showToast(key.status === 'active' ? '已禁用' : '已启用', 'success');
- } catch (error: any) {
- console.error('更新状态失败:', error);
- showToast(error.message || '操作失败,请稍后重试', 'error');
- }
- };
- const handleDeleteKey = async (id: number) => {
- const confirmed = await showConfirm({
- title: '删除确认',
- message: '确定要删除此API Key吗?删除后将无法恢复。',
- confirmText: '删除',
- cancelText: '取消',
- danger: true
- });
- if (!confirmed) return;
- try {
- await platformApi.deleteApiKey(id);
- await loadApiKeys();
- showToast('删除成功', 'success');
- } catch (error: any) {
- console.error('删除失败:', error);
- showToast(error.message || '删除失败,请稍后重试', 'error');
- }
- };
- const handleCopy = async (text: string, keyId: number) => {
- await copyToClipboard(text);
- setCopiedKeyId(keyId);
- setTimeout(() => setCopiedKeyId(null), 2000);
- };
- const tabs = [
- { id: 'api-keys' as TabType, label: 'API Key管理', icon: Key },
- { id: 'logs' as TabType, label: '调用统计', icon: BarChart3 },
- ];
- return (
- <div className="bg-white p-4 sm:p-6 md:p-8 rounded-2xl border border-gray-100 shadow-sm">
- <div className="space-y-4 sm:space-y-6 animate-in fade-in duration-500">
- <div>
- <h2 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">开放平台</h2>
- <p className="text-sm text-gray-500 mt-1">管理API Key,查看调用统计</p>
- </div>
- {/* 标签页 */}
- <div className="flex space-x-1 bg-gray-100/80 p-1 rounded-xl w-fit">
- {tabs.map((tab) => (
- <button
- key={tab.id}
- onClick={() => setActiveTab(tab.id)}
- className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition-all ${
- activeTab === tab.id
- ? 'bg-white shadow-md text-blue-600'
- : 'text-gray-500 hover:text-gray-700'
- }`}
- >
- <tab.icon className="w-4 h-4" />
- {tab.label}
- </button>
- ))}
- </div>
- {/* API Key管理 */}
- {activeTab === 'api-keys' && (
- <div className="space-y-4">
- <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
- <div className="flex flex-wrap items-center gap-2">
- <button
- onClick={() => setActiveKeyType('public')}
- className={`px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- activeKeyType === 'public'
- ? 'bg-blue-600 text-white'
- : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
- }`}
- >
- 公钥(访问云端模型)
- </button>
- <button
- onClick={() => setActiveKeyType('local')}
- className={`px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- activeKeyType === 'local'
- ? 'bg-blue-600 text-white'
- : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
- }`}
- >
- 本地私钥(访问本地模型)
- </button>
- </div>
- <button
- onClick={() => setShowCreateModal(true)}
- disabled={(activeKeyType === 'public' ? apiKeys.length : localKeys.length) >= 5}
- className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex-shrink-0"
- >
- <Plus className="w-4 h-4" />
- 创建API Key
- </button>
- </div>
- {loading ? (
- <div className="flex justify-center py-12">
- <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
- </div>
- ) : (activeKeyType === 'public' ? apiKeys.length === 0 : localKeys.length === 0) ? (
- <div className="text-center py-12 bg-gray-50 rounded-xl">
- <Key className="w-12 h-12 text-gray-300 mx-auto mb-4" />
- <p className="text-gray-500">暂无{activeKeyType === 'public' ? '公钥' : '本地私钥'},点击上方按钮创建</p>
- </div>
- ) : (
- <div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
- <table className="w-full min-w-[640px]">
- <thead className="bg-gray-50">
- <tr>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">名称</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API Key</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Base URL</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">创建时间</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200">
- {(activeKeyType === 'public' ? apiKeys : localKeys).map((key) => (
- <tr key={key.id} className="hover:bg-gray-50">
- <td className="px-6 py-4 text-sm text-gray-900">{key.name || '未命名'}</td>
- <td className="px-6 py-4">
- <code className="text-sm bg-gray-100 px-2 py-1 rounded">{key.api_key_prefix}...</code>
- </td>
- <td className="px-6 py-4">
- <code className="text-sm bg-gray-100 px-2 py-1 rounded break-all">{API_BASE_FULL || '-'}</code>
- </td>
- <td className="px-6 py-4">
- <span className={`px-2 py-1 text-xs rounded-full ${
- key.status === 'active'
- ? 'bg-green-100 text-green-700'
- : 'bg-gray-100 text-gray-500'
- }`}>
- {key.status === 'active' ? '启用' : '禁用'}
- </span>
- </td>
- <td className="px-6 py-4 text-sm text-gray-500">
- {new Date(key.created_at).toLocaleDateString()}
- </td>
- <td className="px-6 py-4">
- <div className="flex items-center gap-2">
- <button
- onClick={() => handleToggleStatus(key)}
- className="p-1.5 hover:bg-gray-100 rounded"
- title={key.status === 'active' ? '禁用' : '启用'}
- >
- <Power className={`w-4 h-4 ${key.status === 'active' ? 'text-green-500' : 'text-gray-400'}`} />
- </button>
- <button
- onClick={() => handleDeleteKey(key.id)}
- className="p-1.5 hover:bg-red-50 rounded"
- title="删除"
- >
- <Trash2 className="w-4 h-4 text-red-500" />
- </button>
- </div>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- )}
- </div>
- )}
- {/* 调用统计与费用明细 */}
- {activeTab === 'logs' && (
- <div className="space-y-6">
- {/* 密钥类型切换 */}
- <div className="flex items-center gap-2">
- <button
- onClick={() => setActiveKeyType('public')}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- activeKeyType === 'public'
- ? 'bg-blue-600 text-white'
- : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
- }`}
- >
- 公钥统计
- </button>
- <button
- onClick={() => setActiveKeyType('local')}
- className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
- activeKeyType === 'local'
- ? 'bg-blue-600 text-white'
- : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
- }`}
- >
- 私钥统计
- </button>
- </div>
- {/* 统计卡片 */}
- {statsLoading ? (
- <div className="flex justify-center py-6">
- <Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
- </div>
- ) : stats ? (
- <>
- <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
- <div className="bg-white rounded-xl border border-gray-200 p-4">
- <p className="text-xs text-gray-500">今日调用</p>
- <p className="text-xl font-bold text-gray-900 mt-1">{stats.today_calls}</p>
- </div>
- <div className="bg-white rounded-xl border border-gray-200 p-4">
- <p className="text-xs text-gray-500">本月调用</p>
- <p className="text-xl font-bold text-gray-900 mt-1">{stats.month_calls}</p>
- </div>
- <div className="bg-white rounded-xl border border-gray-200 p-4">
- <p className="text-xs text-gray-500">总调用</p>
- <p className="text-xl font-bold text-gray-900 mt-1">{stats.total_calls}</p>
- </div>
- </div>
- {stats.model_distribution.length > 0 && (
- <div className="bg-white rounded-xl border border-gray-200 p-4">
- <h3 className="text-sm font-semibold text-gray-900 mb-3">模型调用分布</h3>
- <div className="space-y-2">
- {stats.model_distribution.map((item, index) => (
- <div key={index} className="flex items-center gap-3">
- <span className="text-xs text-gray-600 w-32 truncate">{item.model_name}</span>
- <div className="flex-1 bg-gray-100 rounded-full h-1.5">
- <div
- className="bg-blue-500 h-1.5 rounded-full"
- style={{ width: `${item.percentage}%` }}
- />
- </div>
- <span className="text-xs text-gray-500 w-24 text-right">{item.count}次 ({item.percentage}%)</span>
- </div>
- ))}
- </div>
- </div>
- )}
- </>
- ) : null}
- {/* 调用明细表格 */}
- <div>
- <h3 className="text-sm font-semibold text-gray-900 mb-3">调用明细</h3>
- {loading ? (
- <div className="flex justify-center py-12">
- <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
- </div>
- ) : logs && logs.items.length > 0 ? (
- <>
- <div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
- <table className="w-full min-w-[700px]">
- <thead className="bg-gray-50">
- <tr>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">模型</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">输入Token</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">输出Token</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API Key</th>
- <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
- </tr>
- </thead>
- <tbody className="divide-y divide-gray-200">
- {logs.items.map((log) => (
- <tr key={log.id} className="hover:bg-gray-50">
- <td className="px-6 py-4 text-sm text-gray-500">
- {new Date(log.created_at).toLocaleString()}
- </td>
- <td className="px-6 py-4">
- <div className="flex items-center gap-2">
- <span className="text-sm text-gray-900">
- {log.model_name.startsWith('local_')
- ? log.model_name.split('_').slice(2).join('_')
- : log.model_name}
- </span>
- {log.is_local && (
- <span className="px-1.5 py-0.5 text-xs bg-purple-100 text-purple-600 rounded">本地</span>
- )}
- </div>
- </td>
- <td className="px-6 py-4 text-sm text-gray-500">{log.input_tokens}</td>
- <td className="px-6 py-4 text-sm text-gray-500">{log.output_tokens}</td>
- <td className="px-6 py-4">
- <code className="text-xs bg-gray-100 px-2 py-1 rounded">{log.api_key_prefix}</code>
- </td>
- <td className="px-6 py-4">
- <span className={`px-2 py-1 text-xs rounded-full ${
- log.status === 'success'
- ? 'bg-green-100 text-green-700'
- : 'bg-red-100 text-red-700'
- }`}>
- {log.status === 'success' ? '成功' : '失败'}
- </span>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- {logs.total_pages > 1 && (
- <div className="flex justify-center gap-2 mt-4">
- <button
- onClick={() => setLogsPage(p => Math.max(1, p - 1))}
- disabled={logsPage === 1}
- className="px-4 py-2 border rounded-lg disabled:opacity-50"
- >
- 上一页
- </button>
- <span className="px-4 py-2 text-gray-500">
- {logsPage} / {logs.total_pages}
- </span>
- <button
- onClick={() => setLogsPage(p => Math.min(logs.total_pages, p + 1))}
- disabled={logsPage === logs.total_pages}
- className="px-4 py-2 border rounded-lg disabled:opacity-50"
- >
- 下一页
- </button>
- </div>
- )}
- </>
- ) : (
- <div className="text-center py-12 bg-gray-50 rounded-xl">
- <FileText className="w-12 h-12 text-gray-300 mx-auto mb-4" />
- <p className="text-gray-500">暂无调用记录</p>
- </div>
- )}
- </div>
- </div>
- )}
- {/* 创建API Key模态框 */}
- {showCreateModal && (
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
- <div className="bg-white rounded-2xl p-6 w-full max-w-md">
- <h3 className="text-lg font-semibold text-gray-900 mb-4">创建API Key</h3>
- <div className="space-y-4">
- <input
- type="text"
- value={newKeyName}
- onChange={(e) => setNewKeyName(e.target.value)}
- placeholder="API Key名称(可选)"
- className="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 outline-none"
- />
- <div>
- <label className="block text-sm font-medium text-gray-700 mb-2">密钥类型</label>
- <div className="flex items-center gap-4">
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="radio"
- name="keyType"
- checked={newKeyType === 'public'}
- onChange={() => setNewKeyType('public')}
- className="w-4 h-4 text-blue-600 focus:ring-blue-500/20"
- />
- <span className="text-sm text-gray-700">公钥(访问云端模型)</span>
- </label>
- <label className="flex items-center gap-2 cursor-pointer">
- <input
- type="radio"
- name="keyType"
- checked={newKeyType === 'local'}
- onChange={() => setNewKeyType('local')}
- className="w-4 h-4 text-blue-600 focus:ring-blue-500/20"
- />
- <span className="text-sm text-gray-700">本地私钥(访问本地模型)</span>
- </label>
- </div>
- </div>
- </div>
- <div className="flex justify-end gap-3 mt-6">
- <button
- onClick={() => {
- setShowCreateModal(false);
- setNewKeyName('');
- setNewKeyType('public');
- }}
- className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
- >
- 取消
- </button>
- <button
- onClick={handleCreateKey}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
- >
- 创建
- </button>
- </div>
- </div>
- </div>
- )}
- {/* 显示新创建的API Key */}
- {newlyCreatedKey && (
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
- <div className="bg-white rounded-2xl p-6 w-full max-w-lg">
- <div className="flex items-center gap-3 mb-4">
- <CheckCircle2 className="w-6 h-6 text-green-500" />
- <h3 className="text-lg font-semibold text-gray-900">API Key创建成功</h3>
- </div>
- <p className="text-sm text-red-500 mb-4">请立即复制保存,此密钥仅显示一次!</p>
- <div className="bg-gray-50 p-4 rounded-lg">
- <code className="text-sm break-all">{newlyCreatedKey.api_key}</code>
- </div>
- <div className="flex justify-end gap-3 mt-6">
- <button
- onClick={() => {
- copyToClipboard(newlyCreatedKey.api_key);
- showToast('已复制到剪贴板', 'success');
- }}
- className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50"
- >
- <Copy className="w-4 h-4" />
- 复制
- </button>
- <button
- onClick={() => {
- setNewlyCreatedKey(null);
- setShowCreateModal(false);
- }}
- className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
- >
- 我已保存
- </button>
- </div>
- </div>
- </div>
- )}
- </div>
- </div>
- );
- };
- export default OpenPlatform;
|