|
|
@@ -0,0 +1,429 @@
|
|
|
+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);
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+}
|