| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432 |
- import { useEffect, useState } from 'react';
- import { fetchModelGroups, fetchApiKeys, fetchGroupKeysDetail, fetchGroupPoolSummary, batchAddKeysToGroup, removeKeyFromGroup, updateGroupKey, testGroupKey, testAllGroupKeys } from '../api';
- import type { ModelGroup, ApiKey, GroupKey, GroupPoolSummary } from '../api';
- import './ApiKeys.css';
- export function GroupKeys() {
- const [groups, setGroups] = useState<ModelGroup[]>([]);
- const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
- const [selectedGroup, setSelectedGroup] = useState<ModelGroup | null>(null);
- const [groupKeys, setGroupKeys] = useState<GroupKey[]>([]);
- const [poolSummary, setPoolSummary] = useState<GroupPoolSummary | null>(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState<string | null>(null);
- // 添加key弹窗
- const [showAdd, setShowAdd] = useState(false);
- const [selectedKeyIds, setSelectedKeyIds] = useState<number[]>([]);
- const [addError, setAddError] = useState<string | null>(null);
- const [adding, setAdding] = useState(false);
- // 检测状态
- const [testing, setTesting] = useState(false);
- const [testResult, setTestResult] = useState<{ total: number; active: number; failed: number; health_rate: number } | null>(null);
- const loadGroups = () => {
- setLoading(true);
- fetchModelGroups()
- .then(setGroups)
- .catch(() => setError('加载分组失败'))
- .finally(() => setLoading(false));
- };
- const loadApiKeys = () => {
- fetchApiKeys()
- .then(setApiKeys)
- .catch(() => console.error('加载API Keys失败'));
- };
- useEffect(() => {
- loadGroups();
- loadApiKeys();
- }, []);
- const selectGroup = async (group: ModelGroup) => {
- setSelectedGroup(group);
- setTestResult(null);
- try {
- const [keys, summary] = await Promise.all([
- fetchGroupKeysDetail(group.id),
- fetchGroupPoolSummary(group.id),
- ]);
- setGroupKeys(keys);
- setPoolSummary(summary);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- }
- };
- const handleBatchAdd = async () => {
- if (!selectedGroup || selectedKeyIds.length === 0) {
- setAddError('请选择至少一个API Key');
- return;
- }
- setAdding(true);
- setAddError(null);
- try {
- await batchAddKeysToGroup(selectedGroup.id, selectedKeyIds);
- setShowAdd(false);
- setSelectedKeyIds([]);
- selectGroup(selectedGroup);
- } catch (e) {
- setAddError(e instanceof Error ? e.message : String(e));
- } finally {
- setAdding(false);
- }
- };
- const handleRemove = async (id: number) => {
- if (!confirm('确认从号池中移除该Key?')) return;
- try {
- await removeKeyFromGroup(id);
- if (selectedGroup) selectGroup(selectedGroup);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- }
- };
- const handleTestAll = async () => {
- if (!selectedGroup) return;
- setTesting(true);
- setTestResult(null);
- try {
- const result = await testAllGroupKeys(selectedGroup.id);
- setTestResult({
- ...result,
- health_rate: result.total > 0 ? Math.round((result.active / result.total) * 100) : 0,
- });
- selectGroup(selectedGroup);
- } catch (e) {
- setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
- } finally {
- setTesting(false);
- }
- };
- const handleTestKey = async (key: GroupKey) => {
- try {
- const result = await testGroupKey(key.id);
- setTestResult({
- total: 1,
- active: result.success ? 1 : 0,
- failed: result.success ? 0 : 1,
- health_rate: result.success ? 100 : 0,
- });
- if (selectedGroup) selectGroup(selectedGroup);
- } catch (e) {
- setError(`检测失败: ${e instanceof Error ? e.message : String(e)}`);
- }
- };
- const handleToggleActive = async (key: GroupKey) => {
- try {
- await updateGroupKey(key.id, { is_active: !key.is_active });
- if (selectedGroup) selectGroup(selectedGroup);
- } catch (e) {
- setError(e instanceof Error ? e.message : String(e));
- }
- };
- const getStatusBadge = (status: string) => {
- const styles: Record<string, string> = {
- active: 'ak-status-active',
- rate_limited: 'ak-status-warning',
- failed: 'ak-status-error',
- banned: 'ak-status-banned',
- };
- const labels: Record<string, string> = {
- active: '正常',
- rate_limited: '限流',
- failed: '失败',
- banned: '禁用',
- };
- return <span className={`ak-status-badge ${styles[status] || ''}`}>{labels[status] || status}</span>;
- };
- const getHealthColor = (rate: number) => {
- if (rate >= 80) return '#10b981';
- if (rate >= 50) return '#f59e0b';
- return '#ef4444';
- };
- const formatNumber = (num: number) => {
- if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M';
- if (num >= 1000) return (num / 1000).toFixed(1) + 'K';
- return num.toString();
- };
- // 已在当前分组的key ids
- const existingKeyIds = new Set(groupKeys.map(k => k.api_key_id));
- return (
- <div className="ak-page">
- <div className="ak-header">
- <div>
- <div className="ak-title">号池管理</div>
- <div className="ak-subtitle">为每个分组配置多个API Key,实现负载均衡和自动轮换</div>
- </div>
- </div>
- {error && <div className="ak-error">{error}</div>}
- {testResult && (
- <div className="gk-test-result" style={{
- padding: '12px 16px',
- marginBottom: '16px',
- borderRadius: '8px',
- background: testResult.health_rate === 100 ? '#065f46' : testResult.health_rate >= 50 ? '#78350f' : '#7f1d1d',
- color: '#f3f4f6',
- display: 'flex',
- gap: '24px',
- alignItems: 'center',
- }}>
- <span style={{fontWeight: 600}}>检测结果</span>
- <span>总计: {testResult.total}</span>
- <span style={{color: '#10b981'}}>正常: {testResult.active}</span>
- <span style={{color: '#ef4444'}}>失败: {testResult.failed}</span>
- <span style={{color: testResult.health_rate === 100 ? '#10b981' : testResult.health_rate >= 50 ? '#f59e0b' : '#ef4444', fontWeight: 600}}>健康度: {testResult.health_rate}%</span>
- <button onClick={() => setTestResult(null)} style={{marginLeft: 'auto', background: 'transparent', border: '1px solid #6b7280', color: '#f3f4f6', padding: '4px 12px', borderRadius: '4px', cursor: 'pointer'}}>关闭</button>
- </div>
- )}
- <div className="gk-layout">
- {/* 左侧:分组列表 */}
- <div className="gk-sidebar">
- <div className="gk-sidebar-title">分组列表</div>
- {groups.map(group => (
- <div
- key={group.id}
- className={`gk-sidebar-item ${selectedGroup?.id === group.id ? 'active' : ''}`}
- onClick={() => selectGroup(group)}
- >
- <div className="gk-sidebar-item-name">{group.name}</div>
- <div className="gk-sidebar-item-count">{group.model_count} 个模型</div>
- </div>
- ))}
- {groups.length === 0 && !loading && (
- <div className="gk-empty">暂无分组,请先在「模型分组」中创建</div>
- )}
- </div>
- {/* 右侧:号池详情 */}
- <div className="gk-content">
- {selectedGroup ? (
- <>
- {/* 号池概览 */}
- {poolSummary && (
- <div className="gk-summary">
- <div className="gk-summary-title">{selectedGroup.name} - 号池概览</div>
- <div className="gk-summary-grid">
- <div className="gk-summary-item">
- <div className="gk-summary-label">总Key数</div>
- <div className="gk-summary-value">{poolSummary.total_keys}</div>
- </div>
- <div className="gk-summary-item">
- <div className="gk-summary-label">正常</div>
- <div className="gk-summary-value gk-text-green">{poolSummary.active_keys}</div>
- </div>
- <div className="gk-summary-item">
- <div className="gk-summary-label">限流</div>
- <div className="gk-summary-value gk-text-yellow">{poolSummary.rate_limited_keys}</div>
- </div>
- <div className="gk-summary-item">
- <div className="gk-summary-label">失败</div>
- <div className="gk-summary-value gk-text-red">{poolSummary.failed_keys}</div>
- </div>
- <div className="gk-summary-item">
- <div className="gk-summary-label">健康度</div>
- <div className="gk-summary-value" style={{ color: getHealthColor(poolSummary.health_rate) }}>
- {poolSummary.health_rate}%
- </div>
- </div>
- <div className="gk-summary-item">
- <div className="gk-summary-label">总Token</div>
- <div className="gk-summary-value">{formatNumber(poolSummary.total_tokens)}</div>
- </div>
- </div>
- </div>
- )}
- {/* Key列表 */}
- <div className="gk-keys-header">
- <span>号池Key列表 ({groupKeys.length})</span>
- <div style={{display: 'flex', gap: '8px'}}>
- <button
- className="ak-add-btn"
- onClick={handleTestAll}
- disabled={testing || groupKeys.length === 0}
- style={{background: testing ? '#6b7280' : '#f59e0b'}}
- >
- {testing ? '检测中...' : ' 检测全部'}
- </button>
- <button className="ak-add-btn" onClick={() => setShowAdd(true)}>
- + 添加Key
- </button>
- </div>
- </div>
- {groupKeys.length === 0 ? (
- <div className="gk-empty">该分组暂无Key,点击上方按钮添加</div>
- ) : (
- <div className="gk-keys-list">
- {groupKeys.map(key => (
- <div key={key.id} className={`gk-key-item ${!key.is_active ? 'disabled' : ''}`}>
- <div className="gk-key-main">
- <div className="gk-key-info">
- <span className="gk-key-name">{key.api_key_name}</span>
- <code className="gk-key-value">{key.api_key_masked}</code>
- {getStatusBadge(key.status)}
- {!key.is_active && <span className="ak-status-badge ak-status-disabled">已禁用</span>}
- </div>
- <div className="gk-key-actions">
- <button
- className="gk-btn-small"
- onClick={() => handleTestKey(key)}
- disabled={testing}
- >
- 检测
- </button>
- <button
- className="gk-btn-small"
- onClick={() => handleToggleActive(key)}
- >
- {key.is_active ? '禁用' : '启用'}
- </button>
- <button
- className="gk-btn-small gk-btn-danger"
- onClick={() => handleRemove(key.id)}
- >
- 移除
- </button>
- </div>
- </div>
- <div className="gk-key-stats">
- <span>优先级: {key.priority}</span>
- <span>权重: {key.weight}</span>
- <span>请求: {formatNumber(key.request_count)}</span>
- <span>Token: {formatNumber(key.total_tokens)}</span>
- <span>错误: {key.error_count}</span>
- {key.last_used_at && (
- <span>最后使用: {new Date(key.last_used_at).toLocaleString()}</span>
- )}
- </div>
- {key.status_msg && (
- <div className="gk-key-status-msg">状态说明: {key.status_msg}</div>
- )}
- </div>
- ))}
- </div>
- )}
- </>
- ) : (
- <div className="gk-empty">请选择一个分组查看号池</div>
- )}
- </div>
- </div>
- {/* 添加Key弹窗 */}
- {showAdd && (
- <div className="ak-modal-overlay" onClick={() => setShowAdd(false)}>
- <div className="ak-modal" onClick={e => e.stopPropagation()}>
- <div className="ak-modal-header">
- <span>添加Key到号池</span>
- <button className="ak-modal-close" onClick={() => setShowAdd(false)}>×</button>
- </div>
- <div className="ak-modal-body">
- {addError && <div className="ak-error">{addError}</div>}
- <div className="gk-add-keys-list">
- {apiKeys
- .filter(k => !existingKeyIds.has(k.id))
- .map(key => (
- <label key={key.id} className="gk-add-key-item">
- <input
- type="checkbox"
- checked={selectedKeyIds.includes(key.id)}
- onChange={() => {
- setSelectedKeyIds(prev =>
- prev.includes(key.id)
- ? prev.filter(id => id !== key.id)
- : [...prev, key.id]
- );
- }}
- />
- <span className="gk-add-key-name">{key.name}</span>
- <code className="gk-add-key-value">{key.key_value}</code>
- </label>
- ))}
- {apiKeys.filter(k => !existingKeyIds.has(k.id)).length === 0 && (
- <div className="gk-empty">所有Key都已在此号池中</div>
- )}
- </div>
- </div>
- <div className="ak-modal-footer">
- <button className="ak-btn-secondary" onClick={() => setShowAdd(false)}>取消</button>
- <button className="ak-btn-primary" onClick={handleBatchAdd} disabled={adding}>
- {adding ? '添加中...' : `添加 ${selectedKeyIds.length} 个Key`}
- </button>
- </div>
- </div>
- </div>
- )}
- <style>{`
- .gk-layout { display: flex; gap: 20px; min-height: 500px; }
- .gk-sidebar { width: 240px; border: 1px solid #374151; border-radius: 8px; overflow: hidden; background: #1f2937; }
- .gk-sidebar-title { padding: 12px 16px; font-weight: 600; background: #111827; border-bottom: 1px solid #374151; color: #f3f4f6; }
- .gk-sidebar-item { padding: 12px 16px; cursor: pointer; border-bottom: 1px solid #374151; transition: background 0.2s; }
- .gk-sidebar-item:hover { background: #374151; }
- .gk-sidebar-item.active { background: #2563eb; border-left: 3px solid #60a5fa; }
- .gk-sidebar-item-name { font-weight: 500; color: #f3f4f6; }
- .gk-sidebar-item-count { font-size: 12px; color: #9ca3af; margin-top: 4px; }
- .gk-content { flex: 1; border: 1px solid #374151; border-radius: 8px; padding: 20px; background: #1f2937; }
- .gk-summary { margin-bottom: 20px; padding: 16px; background: #111827; border-radius: 8px; }
- .gk-summary-title { font-weight: 600; margin-bottom: 12px; color: #f3f4f6; }
- .gk-summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: 12px; }
- .gk-summary-item { text-align: center; }
- .gk-summary-label { font-size: 12px; color: #9ca3af; }
- .gk-summary-value { font-size: 20px; font-weight: 600; margin-top: 4px; color: #f3f4f6; }
- .gk-text-green { color: #10b981; }
- .gk-text-yellow { color: #f59e0b; }
- .gk-text-red { color: #ef4444; }
- .gk-keys-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-weight: 500; color: #f3f4f6; }
- .gk-keys-list { display: flex; flex-direction: column; gap: 12px; }
- .gk-key-item { padding: 16px; border: 1px solid #374151; border-radius: 8px; transition: border-color 0.2s; background: #111827; }
- .gk-key-item:hover { border-color: #2563eb; }
- .gk-key-item.disabled { opacity: 0.6; }
- .gk-key-main { display: flex; justify-content: space-between; align-items: center; }
- .gk-key-info { display: flex; align-items: center; gap: 12px; }
- .gk-key-name { font-weight: 500; color: #f3f4f6; }
- .gk-key-value { font-size: 12px; color: #9ca3af; background: #1f2937; padding: 2px 8px; border-radius: 4px; }
- .gk-key-actions { display: flex; gap: 8px; }
- .gk-btn-small { padding: 4px 12px; font-size: 12px; border: 1px solid #374151; border-radius: 4px; background: #1f2937; color: #f3f4f6; cursor: pointer; }
- .gk-btn-small:hover { background: #374151; }
- .gk-btn-danger { color: #ef4444; border-color: #7f1d1d; }
- .gk-btn-danger:hover { background: #7f1d1d; }
- .gk-key-stats { display: flex; gap: 16px; margin-top: 12px; font-size: 12px; color: #9ca3af; flex-wrap: wrap; }
- .gk-key-status-msg { margin-top: 8px; font-size: 12px; color: #f59e0b; }
- .gk-empty { text-align: center; padding: 40px; color: #6b7280; }
- .ak-status-active { background: #065f46; color: #10b981; }
- .ak-status-warning { background: #78350f; color: #f59e0b; }
- .ak-status-error { background: #7f1d1d; color: #ef4444; }
- .ak-status-banned { background: #1f2937; color: #9ca3af; }
- .ak-status-disabled { background: #374151; color: #6b7280; }
- .ak-status-badge { padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; }
- .gk-add-keys-list { max-height: 400px; overflow-y: auto; }
- .gk-add-key-item { display: flex; align-items: center; gap: 12px; padding: 12px; border-bottom: 1px solid #374151; cursor: pointer; }
- .gk-add-key-item:hover { background: #374151; }
- .gk-add-key-name { font-weight: 500; min-width: 100px; color: #f3f4f6; }
- .gk-add-key-value { font-size: 12px; color: #9ca3af; }
- .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; }
- .ak-modal { background: #1f2937; border-radius: 8px; width: 90%; max-width: 600px; max-height: 80vh; overflow: hidden; display: flex; flex-direction: column; }
- .ak-modal-header { padding: 16px; border-bottom: 1px solid #374151; display: flex; justify-content: space-between; align-items: center; color: #f3f4f6; }
- .ak-modal-body { padding: 16px; overflow-y: auto; }
- .ak-modal-footer { padding: 16px; border-top: 1px solid #374151; display: flex; gap: 8px; justify-content: flex-end; }
- .ak-btn-primary { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; }
- .ak-btn-primary:hover { background: #1d4ed8; }
- .ak-btn-secondary { padding: 8px 16px; background: #374151; color: #f3f4f6; border-radius: 4px; border: 1px solid #4b5563; cursor: pointer; }
- .ak-add-btn { padding: 8px 16px; background: #2563eb; color: white; border-radius: 4px; border: none; cursor: pointer; font-size: 14px; }
- .ak-add-btn:hover { background: #1d4ed8; }
- `}</style>
- </div>
- );
- }
|