OpenPlatform.tsx 23 KB

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