GroupKeys.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import { useEffect, useState } from 'react';
  2. import { fetchModelGroups, fetchApiKeys, fetchGroupKeysDetail, fetchGroupPoolSummary, batchAddKeysToGroup, removeKeyFromGroup, updateGroupKey, testGroupKey, testAllGroupKeys } from '../api';
  3. import type { ModelGroup, ApiKey, GroupKey, GroupPoolSummary } from '../api';
  4. import './ApiKeys.css';
  5. export function GroupKeys() {
  6. const [groups, setGroups] = useState<ModelGroup[]>([]);
  7. const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
  8. const [selectedGroup, setSelectedGroup] = useState<ModelGroup | null>(null);
  9. const [groupKeys, setGroupKeys] = useState<GroupKey[]>([]);
  10. const [poolSummary, setPoolSummary] = useState<GroupPoolSummary | null>(null);
  11. const [loading, setLoading] = useState(true);
  12. const [error, setError] = useState<string | null>(null);
  13. // 添加key弹窗
  14. const [showAdd, setShowAdd] = useState(false);
  15. const [selectedKeyIds, setSelectedKeyIds] = useState<number[]>([]);
  16. const [addError, setAddError] = useState<string | null>(null);
  17. const [adding, setAdding] = useState(false);
  18. // 检测状态
  19. const [testing, setTesting] = useState(false);
  20. const [testResult, setTestResult] = useState<{ total: number; active: number; failed: number; health_rate: number } | null>(null);
  21. const loadGroups = () => {
  22. setLoading(true);
  23. fetchModelGroups()
  24. .then(setGroups)
  25. .catch(() => setError('加载分组失败'))
  26. .finally(() => setLoading(false));
  27. };
  28. const loadApiKeys = () => {
  29. fetchApiKeys()
  30. .then(setApiKeys)
  31. .catch(() => console.error('加载API Keys失败'));
  32. };
  33. useEffect(() => {
  34. loadGroups();
  35. loadApiKeys();
  36. }, []);
  37. const selectGroup = async (group: ModelGroup) => {
  38. setSelectedGroup(group);
  39. setTestResult(null);
  40. try {
  41. const [keys, summary] = await Promise.all([
  42. fetchGroupKeysDetail(group.id),
  43. fetchGroupPoolSummary(group.id),
  44. ]);
  45. setGroupKeys(keys);
  46. setPoolSummary(summary);
  47. } catch (e) {
  48. setError(e instanceof Error ? e.message : String(e));
  49. }
  50. };
  51. const handleBatchAdd = async () => {
  52. if (!selectedGroup || selectedKeyIds.length === 0) {
  53. setAddError('请选择至少一个API Key');
  54. return;
  55. }
  56. setAdding(true);
  57. setAddError(null);
  58. try {
  59. await batchAddKeysToGroup(selectedGroup.id, selectedKeyIds);
  60. setShowAdd(false);
  61. setSelectedKeyIds([]);
  62. selectGroup(selectedGroup);
  63. } catch (e) {
  64. setAddError(e instanceof Error ? e.message : String(e));
  65. } finally {
  66. setAdding(false);
  67. }
  68. };
  69. const handleRemove = async (id: number) => {
  70. if (!confirm('确认从号池中移除该Key?')) return;
  71. try {
  72. await removeKeyFromGroup(id);
  73. if (selectedGroup) selectGroup(selectedGroup);
  74. } catch (e) {
  75. setError(e instanceof Error ? e.message : String(e));
  76. }
  77. };
  78. const handleTestAll = async () => {
  79. if (!selectedGroup) return;
  80. setTesting(true);
  81. setTestResult(null);
  82. try {
  83. const result = await testAllGroupKeys(selectedGroup.id);
  84. setTestResult({
  85. ...result,
  86. health_rate: result.total > 0 ? Math.round((result.active / result.total) * 100) : 0,
  87. });
  88. selectGroup(selectedGroup);
  89. } catch (e) {
  90. setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
  91. } finally {
  92. setTesting(false);
  93. }
  94. };
  95. const handleTestKey = async (key: GroupKey) => {
  96. try {
  97. const result = await testGroupKey(key.id);
  98. setTestResult({
  99. total: 1,
  100. active: result.success ? 1 : 0,
  101. failed: result.success ? 0 : 1,
  102. health_rate: result.success ? 100 : 0,
  103. });
  104. if (selectedGroup) selectGroup(selectedGroup);
  105. } catch (e) {
  106. setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
  107. }
  108. };
  109. const handleToggleActive = async (key: GroupKey) => {
  110. try {
  111. await updateGroupKey(key.id, { is_active: !key.is_active });
  112. if (selectedGroup) selectGroup(selectedGroup);
  113. } catch (e) {
  114. setError(e instanceof Error ? e.message : String(e));
  115. }
  116. };
  117. const getStatusBadge = (status: string) => {
  118. const styles: Record<string, string> = {
  119. active: 'ak-status-active',
  120. rate_limited: 'ak-status-warning',
  121. failed: 'ak-status-error',
  122. banned: 'ak-status-banned',
  123. };
  124. const labels: Record<string, string> = {
  125. active: '正常',
  126. rate_limited: '限流',
  127. failed: '失败',
  128. banned: '禁用',
  129. };
  130. return <span className={`ak-status-badge ${styles[status] || ''}`}>{labels[status] || status}</span>;
  131. };
  132. const getHealthColor = (rate: number) => {
  133. if (rate >= 80) return '#10b981';
  134. if (rate >= 50) return '#f59e0b';
  135. return '#ef4444';
  136. };
  137. const formatNumber = (num: number) => {
  138. if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
  139. if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
  140. return num.toString();
  141. };
  142. // 已在当前分组的key ids
  143. const existingKeyIds = new Set(groupKeys.map(k => k.api_key_id));
  144. return (
  145. <div className="ak-page">
  146. <div className="ak-header">
  147. <div>
  148. <div className="ak-title">号池管理</div>
  149. <div className="ak-subtitle">为每个分组配置多个API Key,实现负载均衡和自动轮换</div>
  150. </div>
  151. </div>
  152. {error && <div className="ak-error">{error}</div>}
  153. {testResult && (
  154. <div className="gk-test-result" style={{
  155. padding: '12px 16px',
  156. marginBottom: '16px',
  157. borderRadius: '8px',
  158. background: testResult.health_rate === 100 ? '#065f46' : testResult.health_rate >= 50 ? '#78350f' : '#7f1d1d',
  159. color: '#f3f4f6',
  160. display: 'flex',
  161. gap: '24px',
  162. alignItems: 'center',
  163. }}>
  164. <span style={{fontWeight: 600}}>检测结果</span>
  165. <span>总计: {testResult.total}</span>
  166. <span style={{color: '#10b981'}}>正常: {testResult.active}</span>
  167. <span style={{color: '#ef4444'}}>失败: {testResult.failed}</span>
  168. <span style={{color: testResult.health_rate === 100 ? '#10b981' : testResult.health_rate >= 50 ? '#f59e0b' : '#ef4444', fontWeight: 600}}>健康度: {testResult.health_rate}%</span>
  169. <button onClick={() => setTestResult(null)} style={{marginLeft: 'auto', background: 'transparent', border: '1px solid #6b7280', color: '#f3f4f6', padding: '4px 12px', borderRadius: '4px', cursor: 'pointer'}}>关闭</button>
  170. </div>
  171. )}
  172. <div className="gk-layout">
  173. {/* 左侧:分组列表 */}
  174. <div className="gk-sidebar">
  175. <div className="gk-sidebar-title">分组列表</div>
  176. {groups.map(group => (
  177. <div
  178. key={group.id}
  179. className={`gk-sidebar-item ${selectedGroup?.id === group.id ? 'active' : ''}`}
  180. onClick={() => selectGroup(group)}
  181. >
  182. <div className="gk-sidebar-item-name">{group.name}</div>
  183. <div className="gk-sidebar-item-count">{group.model_count} 个模型</div>
  184. </div>
  185. ))}
  186. {groups.length === 0 && !loading && (
  187. <div className="gk-empty">暂无分组,请先在「模型分组」中创建</div>
  188. )}
  189. </div>
  190. {/* 右侧:号池详情 */}
  191. <div className="gk-content">
  192. {selectedGroup ? (
  193. <>
  194. {/* 号池概览 */}
  195. {poolSummary && (
  196. <div className="gk-summary">
  197. <div className="gk-summary-title">{selectedGroup.name} - 号池概览</div>
  198. <div className="gk-summary-grid">
  199. <div className="gk-summary-item">
  200. <div className="gk-summary-label">总Key数</div>
  201. <div className="gk-summary-value">{poolSummary.total_keys}</div>
  202. </div>
  203. <div className="gk-summary-item">
  204. <div className="gk-summary-label">正常</div>
  205. <div className="gk-summary-value gk-text-green">{poolSummary.active_keys}</div>
  206. </div>
  207. <div className="gk-summary-item">
  208. <div className="gk-summary-label">限流</div>
  209. <div className="gk-summary-value gk-text-yellow">{poolSummary.rate_limited_keys}</div>
  210. </div>
  211. <div className="gk-summary-item">
  212. <div className="gk-summary-label">失败</div>
  213. <div className="gk-summary-value gk-text-red">{poolSummary.failed_keys}</div>
  214. </div>
  215. <div className="gk-summary-item">
  216. <div className="gk-summary-label">健康度</div>
  217. <div className="gk-summary-value" style={{ color: getHealthColor(poolSummary.health_rate) }}>
  218. {poolSummary.health_rate}%
  219. </div>
  220. </div>
  221. <div className="gk-summary-item">
  222. <div className="gk-summary-label">总Token</div>
  223. <div className="gk-summary-value">{formatNumber(poolSummary.total_tokens)}</div>
  224. </div>
  225. </div>
  226. </div>
  227. )}
  228. {/* Key列表 */}
  229. <div className="gk-keys-header">
  230. <span>号池Key列表 ({groupKeys.length})</span>
  231. <div style={{display: 'flex', gap: '8px'}}>
  232. <button
  233. className="ak-add-btn"
  234. onClick={handleTestAll}
  235. disabled={testing || groupKeys.length === 0}
  236. style={{background: testing ? '#6b7280' : '#f59e0b'}}
  237. >
  238. {testing ? '检测中...' : ' 检测全部'}
  239. </button>
  240. <button className="ak-add-btn" onClick={() => setShowAdd(true)}>
  241. + 添加Key
  242. </button>
  243. </div>
  244. </div>
  245. {groupKeys.length === 0 ? (
  246. <div className="gk-empty">该分组暂无Key,点击上方按钮添加</div>
  247. ) : (
  248. <div className="gk-keys-list">
  249. {groupKeys.map(key => (
  250. <div key={key.id} className={`gk-key-item ${!key.is_active ? 'disabled' : ''}`}>
  251. <div className="gk-key-main">
  252. <div className="gk-key-info">
  253. <span className="gk-key-name">{key.api_key_name}</span>
  254. <code className="gk-key-value">{key.api_key_masked}</code>
  255. {getStatusBadge(key.status)}
  256. {!key.is_active && <span className="ak-status-badge ak-status-disabled">已禁用</span>}
  257. </div>
  258. <div className="gk-key-actions">
  259. <button
  260. className="gk-btn-small"
  261. onClick={() => handleTestKey(key)}
  262. disabled={testing}
  263. >
  264. 检测
  265. </button>
  266. <button
  267. className="gk-btn-small"
  268. onClick={() => handleToggleActive(key)}
  269. >
  270. {key.is_active ? '禁用' : '启用'}
  271. </button>
  272. <button
  273. className="gk-btn-small gk-btn-danger"
  274. onClick={() => handleRemove(key.id)}
  275. >
  276. 移除
  277. </button>
  278. </div>
  279. </div>
  280. <div className="gk-key-stats">
  281. <span>优先级: {key.priority}</span>
  282. <span>权重: {key.weight}</span>
  283. <span>请求: {formatNumber(key.request_count)}</span>
  284. <span>Token: {formatNumber(key.total_tokens)}</span>
  285. <span>错误: {key.error_count}</span>
  286. {key.last_used_at && (
  287. <span>最后使用: {new Date(key.last_used_at).toLocaleString()}</span>
  288. )}
  289. </div>
  290. {key.status_msg && (
  291. <div className="gk-key-status-msg">状态说明: {key.status_msg}</div>
  292. )}
  293. </div>
  294. ))}
  295. </div>
  296. )}
  297. </>
  298. ) : (
  299. <div className="gk-empty">请选择一个分组查看号池</div>
  300. )}
  301. </div>
  302. </div>
  303. {/* 添加Key弹窗 */}
  304. {showAdd && (
  305. <div className="ak-modal-overlay" onClick={() => setShowAdd(false)}>
  306. <div className="ak-modal" onClick={e => e.stopPropagation()}>
  307. <div className="ak-modal-header">
  308. <span>添加Key到号池</span>
  309. <button className="ak-modal-close" onClick={() => setShowAdd(false)}>×</button>
  310. </div>
  311. <div className="ak-modal-body">
  312. {addError && <div className="ak-error">{addError}</div>}
  313. <div className="gk-add-keys-list">
  314. {apiKeys
  315. .filter(k => !existingKeyIds.has(k.id))
  316. .map(key => (
  317. <label key={key.id} className="gk-add-key-item">
  318. <input
  319. type="checkbox"
  320. checked={selectedKeyIds.includes(key.id)}
  321. onChange={() => {
  322. setSelectedKeyIds(prev =>
  323. prev.includes(key.id)
  324. ? prev.filter(id => id !== key.id)
  325. : [...prev, key.id]
  326. );
  327. }}
  328. />
  329. <span className="gk-add-key-name">{key.name}</span>
  330. <code className="gk-add-key-value">{key.key_value}</code>
  331. </label>
  332. ))}
  333. {apiKeys.filter(k => !existingKeyIds.has(k.id)).length === 0 && (
  334. <div className="gk-empty">所有Key都已在此号池中</div>
  335. )}
  336. </div>
  337. </div>
  338. <div className="ak-modal-footer">
  339. <button className="ak-btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
  340. <button className="ak-btn-primary" onClick={handleBatchAdd} disabled={adding}>
  341. {adding ? '添加中...' : `添加 ${selectedKeyIds.length} 个Key`}
  342. </button>
  343. </div>
  344. </div>
  345. </div>
  346. )}
  347. <style>{`
  348. .gk-layout { display: flex; gap: 20px; min-height: 500px; }
  349. .gk-sidebar { width: 240px; border: 1px solid #374151; border-radius: 8px; overflow: hidden; background: #1f2937; }
  350. .gk-sidebar-title { padding: 12px 16px; font-weight: 600; background: #111827; border-bottom: 1px solid #374151; color: #f3f4f6; }
  351. .gk-sidebar-item { padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #374151; transition: background 0.2s; }
  352. .gk-sidebar-item:hover { background: #374151; }
  353. .gk-sidebar-item.active { background: #2563eb; border-left: 3px solid #60a5fa; }
  354. .gk-sidebar-item-name { font-weight: 500; color: #f3f4f6; }
  355. .gk-sidebar-item-count { font-size: 12px; color: #9ca3af; margin-top: 4px; }
  356. .gk-content { flex: 1; border: 1px solid #374151; border-radius: 8px; padding: 20px; background: #1f2937; }
  357. .gk-summary { margin-bottom: 20px; padding: 16px; background: #111827; border-radius: 8px; }
  358. .gk-summary-title { font-weight: 600; margin-bottom: 12px; color: #f3f4f6; }
  359. .gk-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; }
  360. .gk-summary-item { text-align: center; }
  361. .gk-summary-label { font-size: 12px; color: #9ca3af; }
  362. .gk-summary-value { font-size: 20px; font-weight: 600; margin-top: 4px; color: #f3f4f6; }
  363. .gk-text-green { color: #10b981; }
  364. .gk-text-yellow { color: #f59e0b; }
  365. .gk-text-red { color: #ef4444; }
  366. .gk-keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 500; color: #f3f4f6; }
  367. .gk-keys-list { display: flex; flex-direction: column; gap: 12px; }
  368. .gk-key-item { padding: 16px; border: 1px solid #374151; border-radius: 8px; transition: border-color 0.2s; background: #111827; }
  369. .gk-key-item:hover { border-color: #2563eb; }
  370. .gk-key-item.disabled { opacity: 0.6; }
  371. .gk-key-main { display: flex; justify-content: space-between; align-items: center; }
  372. .gk-key-info { display: flex; align-items: center; gap: 12px; }
  373. .gk-key-name { font-weight: 500; color: #f3f4f6; }
  374. .gk-key-value { font-size: 12px; color: #9ca3af; background: #1f2937; padding: 2px 8px; border-radius: 4px; }
  375. .gk-key-actions { display: flex; gap: 8px; }
  376. .gk-btn-small { padding: 4px 12px; font-size: 12px; border: 1px solid #374151; border-radius: 4px; background: #1f2937; color: #f3f4f6; cursor: pointer; }
  377. .gk-btn-small:hover { background: #374151; }
  378. .gk-btn-danger { color: #ef4444; border-color: #7f1d1d; }
  379. .gk-btn-danger:hover { background: #7f1d1d; }
  380. .gk-key-stats { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: #9ca3af; flex-wrap: wrap; }
  381. .gk-key-status-msg { margin-top: 8px; font-size: 12px; color: #f59e0b; }
  382. .gk-empty { text-align: center; padding: 40px; color: #6b7280; }
  383. .ak-status-active { background: #065f46; color: #10b981; }
  384. .ak-status-warning { background: #78350f; color: #f59e0b; }
  385. .ak-status-error { background: #7f1d1d; color: #ef4444; }
  386. .ak-status-banned { background: #1f2937; color: #9ca3af; }
  387. .ak-status-disabled { background: #374151; color: #6b7280; }
  388. .ak-status-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
  389. .gk-add-keys-list { max-height: 400px; overflow-y: auto; }
  390. .gk-add-key-item { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #374151; cursor: pointer; }
  391. .gk-add-key-item:hover { background: #374151; }
  392. .gk-add-key-name { font-weight: 500; min-width: 100px; color: #f3f4f6; }
  393. .gk-add-key-value { font-size: 12px; color: #9ca3af; }
  394. .ak-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 100; }
  395. .ak-modal { background: #1f2937; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; }
  396. .ak-modal-header { padding: 16px; border-bottom: 1px solid #374151; display: flex; justify-content: space-between; align-items: center; color: #f3f4f6; }
  397. .ak-modal-body { padding: 16px; overflow-y: auto; }
  398. .ak-modal-footer { padding: 16px; border-top: 1px solid #374151; display: flex; gap: 8px; justify-content: flex-end; }
  399. .ak-btn-primary { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; }
  400. .ak-btn-primary:hover { background: #1d4ed8; }
  401. .ak-btn-secondary { padding: 8px 16px; background: #374151; color: #f3f4f6; border-radius: 4px; border: 1px solid #4b5563; cursor: pointer; }
  402. .ak-add-btn { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; }
  403. .ak-add-btn:hover { background: #1d4ed8; }
  404. `}</style>
  405. </div>
  406. );
  407. }