OpenPlatform.tsx 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. /**
  2. * 开放平台页面
  3. *
  4. * 提供API Key管理、调用统计和费用明细功能
  5. * 需求: 6.1, 6.2, 6.3, 7, 11, 12
  6. */
  7. import React, { useState, useEffect } from 'react';
  8. import { Key, BarChart3, FileText, Plus, Copy, Eye, EyeOff, Trash2, Power, Loader2, CheckCircle2 } from 'lucide-react';
  9. import { platformApi, ApiKey, ApiKeyCreateResponse, Stats, CallLog, PaginatedResponse, API_BASE_FULL } from '../services/platformApi';
  10. import { useToast, useConfirm } from '../contexts/NotificationContext';
  11. type TabType = 'api-keys' | 'logs';
  12. const OpenPlatform: React.FC = () => {
  13. const { showToast } = useToast();
  14. const { showConfirm } = useConfirm();
  15. const [activeTab, setActiveTab] = useState<TabType>('api-keys');
  16. const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
  17. const [localKeys, setLocalKeys] = useState<ApiKey[]>([]);
  18. const [stats, setStats] = useState<Stats | null>(null);
  19. const [logs, setLogs] = useState<PaginatedResponse<CallLog> | null>(null);
  20. const [loading, setLoading] = useState(false);
  21. const [statsLoading, setStatsLoading] = useState(false);
  22. const [newKeyName, setNewKeyName] = useState('');
  23. const [newKeyType, setNewKeyType] = useState<'public' | 'local'>('public');
  24. const [showCreateModal, setShowCreateModal] = useState(false);
  25. const [newlyCreatedKey, setNewlyCreatedKey] = useState<ApiKeyCreateResponse | null>(null);
  26. const [copiedKeyId, setCopiedKeyId] = useState<number | null>(null);
  27. const [logsPage, setLogsPage] = useState(1);
  28. const [activeKeyType, setActiveKeyType] = useState<'public' | 'local'>('public');
  29. useEffect(() => {
  30. if (activeTab === 'api-keys') {
  31. loadApiKeys();
  32. } else if (activeTab === 'logs') {
  33. loadStats();
  34. loadLogs();
  35. }
  36. }, [activeTab, logsPage, activeKeyType]);
  37. const loadApiKeys = async () => {
  38. setLoading(true);
  39. try {
  40. const keys = await platformApi.getApiKeys();
  41. setApiKeys(keys.filter(key => key.key_type !== 'local'));
  42. setLocalKeys(keys.filter(key => key.key_type === 'local'));
  43. } catch (error) {
  44. console.error('加载API Key失败:', error);
  45. } finally {
  46. setLoading(false);
  47. }
  48. };
  49. const loadStats = async () => {
  50. setStatsLoading(true);
  51. try {
  52. const data = await platformApi.getStats(7, activeKeyType);
  53. setStats(data);
  54. } catch (error) {
  55. console.error('加载统计数据失败:', error);
  56. } finally {
  57. setStatsLoading(false);
  58. }
  59. };
  60. const loadLogs = async () => {
  61. setLoading(true);
  62. try {
  63. const data = await platformApi.getCallLogs({ page: logsPage, page_size: 20, key_type: activeKeyType });
  64. setLogs(data);
  65. } catch (error) {
  66. console.error('加载调用日志失败:', error);
  67. } finally {
  68. setLoading(false);
  69. }
  70. };
  71. const handleCreateKey = async () => {
  72. try {
  73. const result = await platformApi.createApiKey(newKeyName || undefined, newKeyType);
  74. setNewlyCreatedKey(result);
  75. setNewKeyName('');
  76. setNewKeyType('public');
  77. loadApiKeys();
  78. } catch (error: any) {
  79. showToast('创建失败,请稍后重试', 'error');
  80. }
  81. };
  82. const handleToggleStatus = async (key: ApiKey) => {
  83. try {
  84. await platformApi.updateApiKeyStatus(key.id, key.status === 'active' ? 'disabled' : 'active');
  85. await loadApiKeys();
  86. showToast(key.status === 'active' ? '已禁用' : '已启用', 'success');
  87. } catch (error: any) {
  88. console.error('更新状态失败:', error);
  89. showToast(error.message || '操作失败,请稍后重试', 'error');
  90. }
  91. };
  92. const handleDeleteKey = async (id: number) => {
  93. const confirmed = await showConfirm({
  94. title: '删除确认',
  95. message: '确定要删除此API Key吗?删除后将无法恢复。',
  96. confirmText: '删除',
  97. cancelText: '取消',
  98. danger: true
  99. });
  100. if (!confirmed) return;
  101. try {
  102. await platformApi.deleteApiKey(id);
  103. await loadApiKeys();
  104. showToast('删除成功', 'success');
  105. } catch (error: any) {
  106. console.error('删除失败:', error);
  107. showToast(error.message || '删除失败,请稍后重试', 'error');
  108. }
  109. };
  110. const copyToClipboard = async (text: string, keyId: number) => {
  111. await navigator.clipboard.writeText(text);
  112. setCopiedKeyId(keyId);
  113. setTimeout(() => setCopiedKeyId(null), 2000);
  114. };
  115. const tabs = [
  116. { id: 'api-keys' as TabType, label: 'API Key管理', icon: Key },
  117. { id: 'logs' as TabType, label: '调用统计', icon: BarChart3 },
  118. ];
  119. return (
  120. <div className="bg-white p-4 sm:p-6 md:p-8 rounded-2xl border border-gray-100 shadow-sm">
  121. <div className="space-y-4 sm:space-y-6 animate-in fade-in duration-500">
  122. <div>
  123. <h2 className="text-xl sm:text-2xl font-bold text-gray-900 tracking-tight">开放平台</h2>
  124. <p className="text-sm text-gray-500 mt-1">管理API Key,查看调用统计</p>
  125. </div>
  126. {/* 标签页 */}
  127. <div className="flex space-x-1 bg-gray-100/80 p-1 rounded-xl w-fit">
  128. {tabs.map((tab) => (
  129. <button
  130. key={tab.id}
  131. onClick={() => setActiveTab(tab.id)}
  132. className={`flex items-center gap-2 px-6 py-2.5 rounded-lg text-sm font-medium transition-all ${
  133. activeTab === tab.id
  134. ? 'bg-white shadow-md text-blue-600'
  135. : 'text-gray-500 hover:text-gray-700'
  136. }`}
  137. >
  138. <tab.icon className="w-4 h-4" />
  139. {tab.label}
  140. </button>
  141. ))}
  142. </div>
  143. {/* API Key管理 */}
  144. {activeTab === 'api-keys' && (
  145. <div className="space-y-4">
  146. <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
  147. <div className="flex flex-wrap items-center gap-2">
  148. <button
  149. onClick={() => setActiveKeyType('public')}
  150. className={`px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
  151. activeKeyType === 'public'
  152. ? 'bg-blue-600 text-white'
  153. : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
  154. }`}
  155. >
  156. 公钥(访问云端模型)
  157. </button>
  158. <button
  159. onClick={() => setActiveKeyType('local')}
  160. className={`px-3 sm:px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
  161. activeKeyType === 'local'
  162. ? 'bg-blue-600 text-white'
  163. : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
  164. }`}
  165. >
  166. 本地私钥(访问本地模型)
  167. </button>
  168. </div>
  169. <button
  170. onClick={() => setShowCreateModal(true)}
  171. disabled={(activeKeyType === 'public' ? apiKeys.length : localKeys.length) >= 5}
  172. 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"
  173. >
  174. <Plus className="w-4 h-4" />
  175. 创建API Key
  176. </button>
  177. </div>
  178. {loading ? (
  179. <div className="flex justify-center py-12">
  180. <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
  181. </div>
  182. ) : (activeKeyType === 'public' ? apiKeys.length === 0 : localKeys.length === 0) ? (
  183. <div className="text-center py-12 bg-gray-50 rounded-xl">
  184. <Key className="w-12 h-12 text-gray-300 mx-auto mb-4" />
  185. <p className="text-gray-500">暂无{activeKeyType === 'public' ? '公钥' : '本地私钥'},点击上方按钮创建</p>
  186. </div>
  187. ) : (
  188. <div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
  189. <table className="w-full min-w-[640px]">
  190. <thead className="bg-gray-50">
  191. <tr>
  192. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">名称</th>
  193. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API Key</th>
  194. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Base URL</th>
  195. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
  196. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">创建时间</th>
  197. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
  198. </tr>
  199. </thead>
  200. <tbody className="divide-y divide-gray-200">
  201. {(activeKeyType === 'public' ? apiKeys : localKeys).map((key) => (
  202. <tr key={key.id} className="hover:bg-gray-50">
  203. <td className="px-6 py-4 text-sm text-gray-900">{key.name || '未命名'}</td>
  204. <td className="px-6 py-4">
  205. <code className="text-sm bg-gray-100 px-2 py-1 rounded">{key.api_key_prefix}...</code>
  206. </td>
  207. <td className="px-6 py-4">
  208. <code className="text-sm bg-gray-100 px-2 py-1 rounded break-all">{API_BASE_FULL || '-'}</code>
  209. </td>
  210. <td className="px-6 py-4">
  211. <span className={`px-2 py-1 text-xs rounded-full ${
  212. key.status === 'active'
  213. ? 'bg-green-100 text-green-700'
  214. : 'bg-gray-100 text-gray-500'
  215. }`}>
  216. {key.status === 'active' ? '启用' : '禁用'}
  217. </span>
  218. </td>
  219. <td className="px-6 py-4 text-sm text-gray-500">
  220. {new Date(key.created_at).toLocaleDateString()}
  221. </td>
  222. <td className="px-6 py-4">
  223. <div className="flex items-center gap-2">
  224. <button
  225. onClick={() => handleToggleStatus(key)}
  226. className="p-1.5 hover:bg-gray-100 rounded"
  227. title={key.status === 'active' ? '禁用' : '启用'}
  228. >
  229. <Power className={`w-4 h-4 ${key.status === 'active' ? 'text-green-500' : 'text-gray-400'}`} />
  230. </button>
  231. <button
  232. onClick={() => handleDeleteKey(key.id)}
  233. className="p-1.5 hover:bg-red-50 rounded"
  234. title="删除"
  235. >
  236. <Trash2 className="w-4 h-4 text-red-500" />
  237. </button>
  238. </div>
  239. </td>
  240. </tr>
  241. ))}
  242. </tbody>
  243. </table>
  244. </div>
  245. )}
  246. </div>
  247. )}
  248. {/* 调用统计与费用明细 */}
  249. {activeTab === 'logs' && (
  250. <div className="space-y-6">
  251. {/* 密钥类型切换 */}
  252. <div className="flex items-center gap-2">
  253. <button
  254. onClick={() => setActiveKeyType('public')}
  255. className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
  256. activeKeyType === 'public'
  257. ? 'bg-blue-600 text-white'
  258. : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
  259. }`}
  260. >
  261. 公钥统计
  262. </button>
  263. <button
  264. onClick={() => setActiveKeyType('local')}
  265. className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
  266. activeKeyType === 'local'
  267. ? 'bg-blue-600 text-white'
  268. : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
  269. }`}
  270. >
  271. 私钥统计
  272. </button>
  273. </div>
  274. {/* 统计卡片 */}
  275. {statsLoading ? (
  276. <div className="flex justify-center py-6">
  277. <Loader2 className="w-6 h-6 text-blue-600 animate-spin" />
  278. </div>
  279. ) : stats ? (
  280. <>
  281. <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
  282. <div className="bg-white rounded-xl border border-gray-200 p-4">
  283. <p className="text-xs text-gray-500">今日调用</p>
  284. <p className="text-xl font-bold text-gray-900 mt-1">{stats.today_calls}</p>
  285. </div>
  286. <div className="bg-white rounded-xl border border-gray-200 p-4">
  287. <p className="text-xs text-gray-500">本月调用</p>
  288. <p className="text-xl font-bold text-gray-900 mt-1">{stats.month_calls}</p>
  289. </div>
  290. <div className="bg-white rounded-xl border border-gray-200 p-4">
  291. <p className="text-xs text-gray-500">总调用</p>
  292. <p className="text-xl font-bold text-gray-900 mt-1">{stats.total_calls}</p>
  293. </div>
  294. </div>
  295. {stats.model_distribution.length > 0 && (
  296. <div className="bg-white rounded-xl border border-gray-200 p-4">
  297. <h3 className="text-sm font-semibold text-gray-900 mb-3">模型调用分布</h3>
  298. <div className="space-y-2">
  299. {stats.model_distribution.map((item, index) => (
  300. <div key={index} className="flex items-center gap-3">
  301. <span className="text-xs text-gray-600 w-32 truncate">{item.model_name}</span>
  302. <div className="flex-1 bg-gray-100 rounded-full h-1.5">
  303. <div
  304. className="bg-blue-500 h-1.5 rounded-full"
  305. style={{ width: `${item.percentage}%` }}
  306. />
  307. </div>
  308. <span className="text-xs text-gray-500 w-24 text-right">{item.count}次 ({item.percentage}%)</span>
  309. </div>
  310. ))}
  311. </div>
  312. </div>
  313. )}
  314. </>
  315. ) : null}
  316. {/* 调用明细表格 */}
  317. <div>
  318. <h3 className="text-sm font-semibold text-gray-900 mb-3">调用明细</h3>
  319. {loading ? (
  320. <div className="flex justify-center py-12">
  321. <Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
  322. </div>
  323. ) : logs && logs.items.length > 0 ? (
  324. <>
  325. <div className="bg-white rounded-xl border border-gray-200 overflow-x-auto">
  326. <table className="w-full min-w-[700px]">
  327. <thead className="bg-gray-50">
  328. <tr>
  329. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">时间</th>
  330. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">模型</th>
  331. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">输入Token</th>
  332. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">输出Token</th>
  333. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">API Key</th>
  334. <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
  335. </tr>
  336. </thead>
  337. <tbody className="divide-y divide-gray-200">
  338. {logs.items.map((log) => (
  339. <tr key={log.id} className="hover:bg-gray-50">
  340. <td className="px-6 py-4 text-sm text-gray-500">
  341. {new Date(log.created_at).toLocaleString()}
  342. </td>
  343. <td className="px-6 py-4">
  344. <div className="flex items-center gap-2">
  345. <span className="text-sm text-gray-900">
  346. {log.model_name.startsWith('local_')
  347. ? log.model_name.split('_').slice(2).join('_')
  348. : log.model_name}
  349. </span>
  350. {log.is_local && (
  351. <span className="px-1.5 py-0.5 text-xs bg-purple-100 text-purple-600 rounded">本地</span>
  352. )}
  353. </div>
  354. </td>
  355. <td className="px-6 py-4 text-sm text-gray-500">{log.input_tokens}</td>
  356. <td className="px-6 py-4 text-sm text-gray-500">{log.output_tokens}</td>
  357. <td className="px-6 py-4">
  358. <code className="text-xs bg-gray-100 px-2 py-1 rounded">{log.api_key_prefix}</code>
  359. </td>
  360. <td className="px-6 py-4">
  361. <span className={`px-2 py-1 text-xs rounded-full ${
  362. log.status === 'success'
  363. ? 'bg-green-100 text-green-700'
  364. : 'bg-red-100 text-red-700'
  365. }`}>
  366. {log.status === 'success' ? '成功' : '失败'}
  367. </span>
  368. </td>
  369. </tr>
  370. ))}
  371. </tbody>
  372. </table>
  373. </div>
  374. {logs.total_pages > 1 && (
  375. <div className="flex justify-center gap-2 mt-4">
  376. <button
  377. onClick={() => setLogsPage(p => Math.max(1, p - 1))}
  378. disabled={logsPage === 1}
  379. className="px-4 py-2 border rounded-lg disabled:opacity-50"
  380. >
  381. 上一页
  382. </button>
  383. <span className="px-4 py-2 text-gray-500">
  384. {logsPage} / {logs.total_pages}
  385. </span>
  386. <button
  387. onClick={() => setLogsPage(p => Math.min(logs.total_pages, p + 1))}
  388. disabled={logsPage === logs.total_pages}
  389. className="px-4 py-2 border rounded-lg disabled:opacity-50"
  390. >
  391. 下一页
  392. </button>
  393. </div>
  394. )}
  395. </>
  396. ) : (
  397. <div className="text-center py-12 bg-gray-50 rounded-xl">
  398. <FileText className="w-12 h-12 text-gray-300 mx-auto mb-4" />
  399. <p className="text-gray-500">暂无调用记录</p>
  400. </div>
  401. )}
  402. </div>
  403. </div>
  404. )}
  405. {/* 创建API Key模态框 */}
  406. {showCreateModal && (
  407. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  408. <div className="bg-white rounded-2xl p-6 w-full max-w-md">
  409. <h3 className="text-lg font-semibold text-gray-900 mb-4">创建API Key</h3>
  410. <div className="space-y-4">
  411. <input
  412. type="text"
  413. value={newKeyName}
  414. onChange={(e) => setNewKeyName(e.target.value)}
  415. placeholder="API Key名称(可选)"
  416. 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"
  417. />
  418. <div>
  419. <label className="block text-sm font-medium text-gray-700 mb-2">密钥类型</label>
  420. <div className="flex items-center gap-4">
  421. <label className="flex items-center gap-2 cursor-pointer">
  422. <input
  423. type="radio"
  424. name="keyType"
  425. checked={newKeyType === 'public'}
  426. onChange={() => setNewKeyType('public')}
  427. className="w-4 h-4 text-blue-600 focus:ring-blue-500/20"
  428. />
  429. <span className="text-sm text-gray-700">公钥(访问云端模型)</span>
  430. </label>
  431. <label className="flex items-center gap-2 cursor-pointer">
  432. <input
  433. type="radio"
  434. name="keyType"
  435. checked={newKeyType === 'local'}
  436. onChange={() => setNewKeyType('local')}
  437. className="w-4 h-4 text-blue-600 focus:ring-blue-500/20"
  438. />
  439. <span className="text-sm text-gray-700">本地私钥(访问本地模型)</span>
  440. </label>
  441. </div>
  442. </div>
  443. </div>
  444. <div className="flex justify-end gap-3 mt-6">
  445. <button
  446. onClick={() => {
  447. setShowCreateModal(false);
  448. setNewKeyName('');
  449. setNewKeyType('public');
  450. }}
  451. className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg"
  452. >
  453. 取消
  454. </button>
  455. <button
  456. onClick={handleCreateKey}
  457. className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
  458. >
  459. 创建
  460. </button>
  461. </div>
  462. </div>
  463. </div>
  464. )}
  465. {/* 显示新创建的API Key */}
  466. {newlyCreatedKey && (
  467. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
  468. <div className="bg-white rounded-2xl p-6 w-full max-w-lg">
  469. <div className="flex items-center gap-3 mb-4">
  470. <CheckCircle2 className="w-6 h-6 text-green-500" />
  471. <h3 className="text-lg font-semibold text-gray-900">API Key创建成功</h3>
  472. </div>
  473. <p className="text-sm text-red-500 mb-4">请立即复制保存,此密钥仅显示一次!</p>
  474. <div className="bg-gray-50 p-4 rounded-lg">
  475. <code className="text-sm break-all">{newlyCreatedKey.api_key}</code>
  476. </div>
  477. <div className="flex justify-end gap-3 mt-6">
  478. <button
  479. onClick={() => {
  480. navigator.clipboard.writeText(newlyCreatedKey.api_key);
  481. showToast('已复制到剪贴板', 'success');
  482. }}
  483. className="flex items-center gap-2 px-4 py-2 border border-gray-200 rounded-lg hover:bg-gray-50"
  484. >
  485. <Copy className="w-4 h-4" />
  486. 复制
  487. </button>
  488. <button
  489. onClick={() => {
  490. setNewlyCreatedKey(null);
  491. setShowCreateModal(false);
  492. }}
  493. className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
  494. >
  495. 我已保存
  496. </button>
  497. </div>
  498. </div>
  499. </div>
  500. )}
  501. </div>
  502. </div>
  503. );
  504. };
  505. export default OpenPlatform;